diff --git a/openspec/changes/razbor-metrik-v-obekty/.openspec.yaml b/openspec/changes/razbor-metrik-v-obekty/.openspec.yaml new file mode 100644 index 0000000..5849c2d --- /dev/null +++ b/openspec/changes/razbor-metrik-v-obekty/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-01 diff --git a/openspec/changes/razbor-metrik-v-obekty/design.md b/openspec/changes/razbor-metrik-v-obekty/design.md new file mode 100644 index 0000000..54d631e --- /dev/null +++ b/openspec/changes/razbor-metrik-v-obekty/design.md @@ -0,0 +1,157 @@ +## Context + +Приём принимает пакеты Health Auto Export и складывает тела в архив +(`internal/ingest`, `internal/archive`). Разбора нет: в SQLite только строки +`delivery`. Накоплено 89 доставок, 16 МБ архива, поток идёт непрерывно. + +Правила разбора выведены измерением, а не спроектированы: 46 находок в +`docs/local-research.md`. Половина расходится с документацией HAE, поэтому +источник истины по формату — живые пакеты, а не документация. + +Ограничение, определяющее форму решения: **сервис нельзя останавливать**. +Телефон шлёт непрерывно и молча; доставка, не попавшая в архив, не попадает в +журнал вовсе — телефон её не перешлёт. Значит разбор не имеет права уронить +приём. + +## Goals / Non-Goals + +**Goals:** + +- Точки из секции `metrics` попадают в хранилище с правильным слоем. +- Повторные и пересекающиеся доставки не задваивают и не затирают данные. +- Разбор отделён от хранения: импорт родного экспорта Apple будет другим + разбором поверх того же хранилища. +- Ошибка разбора не влияет ни на код ответа приёма, ни на сохранность архива. + +**Non-Goals:** + +- Тренировки и секции с собственными `id` — другая модель хранения. +- `healthlog reindex` — накопленные 89 доставок доедут отдельной задачей. +- Словарь категориальных значений: строка пока хранится дословно и без кода. +- Род агрегации и каталог разрезов. +- Своя агрегация при записи: слои не сводятся друг к другу никогда. + +## Decisions + +### Разбор — отдельный пакет `internal/hae`, а не метод `ingest` + +`ingest` — use-case приёма: сохранить тело, записать доставку. Разбор формата +живёт своей жизнью: у него будет второй потребитель (`reindex`) и второй +источник (родной экспорт Apple, свой пакет). Втянуть разбор в `ingest` значит +получить пакет, который меняется по двум несвязанным причинам. + +Альтернатива — разбор внутри `store`. Отвергнута: `store` не должен знать +формат HAE, иначе импорт из Apple потребует второй реализации хранения. + +Граница: `hae.Parse(body []byte, hdr Meta) (Parsed, error)` возвращает точки с +уже выведенным слоем и нормализованным временем. Дальше их принимает `store`, +который о HAE ничего не знает. + +### Разбор — синхронно в приёме, после записи в архив + +Тело сначала ложится на диск, потом разбирается. Отказ разбора не откатывает +архив: журнал важнее витрины, и восстановить точки из тела можно всегда, а +тело из точек — нет. + +Асинхронный разбор (очередь, воркер) отвергнут: он даёт окно, в котором +доставка принята, но не разобрана, а сервис перезапущен — и мы теряем понимание, +что доразобрать. Синхронный разбор при 42 МБ теле стоит секунд, а `read_timeout` +уже пять минут. + +### Ключ объекта — `(metric, layer, hour_utc)`, содержимое — gzip-BLOB + +Единица хранения — час, а не точка: 30 метрик × 24 часа × 365 ≈ 260 тыс. строк +на слой в год независимо от плотности точек внутри. Строка на точку дала бы +десятки миллионов. + +Плата: внутрь объекта не заглянуть средствами SQL. Для хранилища, отдающего +диапазоны точек, это не потеря; каталог и свёртка получат свои производные +структуры отдельной задачей. + +Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ. + +### Слияние — по полноте, при равенстве — по `received_at` + +Координатный ключ означает перезапись значения. Кто побеждает — решает +полнота: 0.66% координат несут разные содержимые, и разбор выборки показал, +что почти всё это разный **набор полей** при одинаковом `qty`. Правило «последний +победил» стирало бы `start`/`end` у уже сохранённой точки. + +Полнота считается по числу значащих полей точки, `source` в счёт не идёт. +При равной полноте побеждает точка из доставки с большим `received_at` — это +делает свёртку по журналу детерминированной: проигрывание архива обязано дать +то же состояние, что приём в реальном времени. + +### Слой выводится по метрике внутри доставки, а не по доставке целиком + +Правило проверено на всей истории (находка 33) и уже дважды ломалось на живых +данных при более простых формулировках. Классификация доставки целиком +сложила минутные точки с посекундными и удвоила сумму за час; классификация +каждой метрики по отдельности растащила редкие метрики по трём слоям. + +Работающая формулировка: плотная метрика (≥10 точек) — сама по себе, редкая +наследует самый мелкий слой среди плотных. + +### Сводка сна — отдельное имя метрики и фиксированный слой `day` + +Разводить схемы на имена приходится потому, что правило вывода слоя на суточной +сводке даёт `hour` (полночь выровнена по часу), хотя это суточный итог. Имя +`sleep_analysis_summary` — наше, не Apple; инвариант «форма Apple не +транслируется» это не нарушает: переименования полей внутри точки нет, +разделяются только имена метрик, под которыми HAE смешал две схемы. + +### `testdata` — реальные пакеты с вычищенными значениями + +Конвенция требует тестов на реальных пакетах; инвариант запрещает данным о +здоровье попадать под контроль версий. Обе цели совместимы: в фикстурах +сохраняется всё, что важно разбору, — порядок ключей, три формата времени, +неразрывные пробелы в именах устройств, точность чисел, обе схемы сна, +смешанная доставка, — а измеренные величины заменяются. + +Скрипт порождения фикстур из архива лежит в `tmp/research/` и позволяет собрать +их заново, когда поток принесёт новую форму. + +Альтернатива — писать фикстуры руками по документации. Отвергнута ровно тем, +ради чего заводилось исследование: документация врёт. + +## Risks / Trade-offs + +**Разбор роняет приём** → разбор идёт после `f.Sync()` и переименования файла +архива; паника в разборе перехватывается, доставка помечается +`parse_status=failed`, ответ остаётся `200`. + +**Конкурентные доставки правят один час** → read-modify-write без блокировки +теряет точки. Три автоматизации шлют одновременно, и перекрытие часов — норма, +а не край. Запись объекта идёт в транзакции; при `SQLITE_BUSY` — повтор. +Проверяется тестом с параллельной записью в один `hour_utc`. + +**Правило полноты ошибочно для метрики, где меньше полей значит новее** → +таких в потоке не наблюдалось, но допущение не доказано. Помечается как +предположение в спеке; расхождение всплывёт при сверке с экспортом Apple. + +**Вычищенные фикстуры прячут свойство реальных данных** → риск реален: именно +дребезг последнего разряда double едва не увёл модель идентичности не туда. +Смягчение — сохранять точность чисел как в оригинале и держать отдельный тест +на канонизацию с настоящими значениями из находки 30. + +**Объект за час распухает** → в нижнем слое HRV несёт `heartbeatSeries`, 93% +объёма метрики. Порог не выбран, поведение при большом объекте не определено; +наблюдаемость размера объекта уходит в задачу про `/stats`. + +## Migration Plan + +Миграция `00003_bucket.sql` — только добавление таблицы, существующие данные не +трогает. Откат: сервис прежней версии игнорирует новую таблицу, доставки +продолжают приниматься и складываться в архив, разбор просто не происходит. + +Накопленные 89 доставок этой миграцией не разбираются: их подхватит `reindex` +отдельной задачей. До тех пор в хранилище только точки из доставок, пришедших +после выката. + +## Open Questions + +- **Порог `sealed`.** С какого возраста час считается запечатанным — ставим по + факту: сначала `WARN` на изменение старых объектов, потом смотрим, какая + глубина досчёта встречается в жизни (наблюдалось до 22 минут). +- **Поведение при объекте необычного размера.** Отдельного решения пока нет; + ждём наблюдаемости. diff --git a/openspec/changes/razbor-metrik-v-obekty/proposal.md b/openspec/changes/razbor-metrik-v-obekty/proposal.md new file mode 100644 index 0000000..8e2f9f1 --- /dev/null +++ b/openspec/changes/razbor-metrik-v-obekty/proposal.md @@ -0,0 +1,75 @@ +## Why + +Приём работает третьи сутки, но дальше архива данные не идут: 89 доставок лежат +телами `.json.gz`, а в SQLite только строки `delivery`. Ни одна из трёх целей +проекта — агент-медик, трекер тренировок, фитнес-игра — не может прочитать +ничего, потому что читать нечего. Каталог, Read API, MCP и OpenAPI упираются в +эту задачу. + +Правила разбора не надо изобретать: они выведены измерением на живом потоке и +записаны в `docs/local-research.md` (находки 30, 33, 35, 36, 38, 39, 41). +Задача — перенести их в код, а не спроектировать заново. + +## What Changes + +- Секция `metrics` тела доставки разбирается в **точки**: метрика, слой, метка + времени, содержимое как пришло. +- **Слой выводится из выравнивания меток**, а не из заголовка HAE: плотная + метрика (≥10 точек) классифицируется сама, редкая наследует преобладающий + слой доставки. Заголовок `automation-aggregation` непригоден — значение + `Default` соответствует трём разным режимам. +- Точки складываются в **часовые объекты** (`bucket`, ключ `метрика + слой + + час`), содержимое — gzip-BLOB. Запись — read-modify-write со слиянием. +- **Идентичность точки — координаты** (`метрика + слой + метка`). `source` в + ключ не входит: он нестабилен и меняется задним числом. При столкновении + выигрывает **более полная** точка, а не последняя пришедшая. +- **Канонизация с округлением** чисел до ~12 значащих цифр; хеш канонической + формы — детектор изменений, а не ключ. +- `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку — + под одним именем HAE шлёт две несовместимые схемы. +- Три формата времени на входе: локальное со смещением, RFC 3339 Z, + Unix-эпоха внутри `heartbeatSeries`. В хранилище — UTC RFC 3339 плюс офсет + исходной зоны. +- Разбор не влияет на код ответа приёма: непонятое содержимое по-прежнему + даёт 200, исход разбора виден в `delivery.parse_status` и в логе. + +Не входит в изменение (сознательно, чтобы задача мерджилась целиком): + +- **тренировки и секции с собственными `id`** (`workouts`, `stateOfMind`, + прочие `record`) — отдельная задача, у них другая модель хранения; +- **`healthlog reindex`** — отдельная задача. Следствие: разбираются только + доставки, пришедшие после выката; накопленные 89 доедут пересборкой. + Сходимость на них проверяется скриптом поверх архива, а не командой сервиса; +- **словарь категориальных значений** (переведённые строки → коды HealthKit) — + отдельная задача; пока строка хранится дословно и без кода рядом; +- **род агрегации и каталог разрезов** — отдельная задача, она следующая. + +## Capabilities + +### New Capabilities + +- `parsing`: превращение тела доставки Health Auto Export в точки — вывод + слоя, разбор трёх форматов времени, канонизация содержимого, разделение + схем под одним именем метрики. Отдельно от хранения потому, что импорт + родного экспорта Apple будет другим разбором поверх того же хранилища. +- `storage`: идентичность точки, слияние и хранение часовыми объектами — + координатный ключ, правило разрешения столкновений, хеш как детектор + изменений, признак запечатанного часа. + +### Modified Capabilities + +Нет: `openspec/specs/` пуст, приём кодом существует, но спекой не описан и в +этом изменении не трогается. + +## Impact + +- Новые пакеты `internal/hae` (разбор) и расширение `internal/store` (объекты). +- Миграция `internal/store/migrations/00003_bucket.sql` — первая миграция после + приёма; вместе с ней заводится `docs/database.md` (ER-схема), которую требует + шаг гейта `er-schema`. +- `internal/ingest` получает шаг разбора после записи в архив; контракт приёма + не меняется. +- Появляется `testdata` с реальными пакетами HAE. Значения в них **вычищаются**: + структура, порядок ключей, форматы времени, неразрывные пробелы в именах + устройств и точность чисел сохраняются, измеренные величины заменяются — + данные о здоровье не попадают под контроль версий. diff --git a/openspec/changes/razbor-metrik-v-obekty/specs/parsing/spec.md b/openspec/changes/razbor-metrik-v-obekty/specs/parsing/spec.md new file mode 100644 index 0000000..c9b0bf5 --- /dev/null +++ b/openspec/changes/razbor-metrik-v-obekty/specs/parsing/spec.md @@ -0,0 +1,137 @@ +## ADDED Requirements + +### Requirement: Разбор секции метрик + +Система SHALL разбирать секцию `data.metrics` тела доставки Health Auto Export +в точки. Точка несёт имя метрики, единицы, слой, метку времени и содержимое в +том виде, в каком его прислал HAE. + +Незнакомое поле внутри точки MUST сохраняться, а не отбрасываться: сырой архив +недолговечен, и отброшенное поле теряется безвозвратно. + +#### Scenario: Метрика с точками разбирается в точки + +- **WHEN** тело содержит `data.metrics[]` с непустым `data[]` +- **THEN** каждая точка с непустым `date` становится точкой хранилища +- **AND** все поля точки, кроме служебных, сохраняются дословно + +#### Scenario: Незнакомая метрика не ломает разбор + +- **WHEN** приходит метрика с именем, которого разбор не знает +- **THEN** её точки разбираются наравне с остальными +- **AND** разбор не завершается ошибкой + +#### Scenario: Точка без метки времени пропускается + +- **WHEN** точка не содержит `date` либо `date` не разбирается ни одним из + поддерживаемых форматов +- **THEN** точка не попадает в хранилище +- **AND** факт учитывается в итоге разбора доставки + +### Requirement: Вывод слоя гранулярности + +Система SHALL выводить слой точки из **выравнивания меток времени**, а не из +заголовка доставки. Заголовок `automation-aggregation` непригоден: значение +`Default` соответствует трём разным режимам выгрузки. + +Слои: `raw` (метка на произвольной секунде), `minute` (секунды нулевые), +`hour` (секунды и минуты нулевые). + +Классификация MUST быть **по метрике внутри доставки**, а не по доставке +целиком: при перенастройке автоматизации приезжают смешанные доставки, и +отнесение такой доставки к одному слою складывает минутные точки с +посекундными. + +#### Scenario: Плотная метрика классифицируется сама + +- **WHEN** в доставке у метрики не меньше десяти точек с метками +- **THEN** слой определяется выравниванием её собственных меток + +#### Scenario: Редкая метрика наследует преобладающий слой + +- **WHEN** в доставке у метрики меньше десяти точек +- **THEN** она получает самый мелкий слой среди плотных метрик этой доставки +- **AND** её собственное выравнивание во внимание не принимается + +#### Scenario: В доставке нет плотных метрик + +- **WHEN** ни у одной метрики доставки нет десяти точек +- **THEN** слой берётся из заголовка `automation-aggregation` + (`Minutes` → `minute`, `Hours` → `hour`, иначе `raw`) + +#### Scenario: Выведенный слой расходится с заголовком + +- **WHEN** выведенный слой не совпадает с тем, что объявляет заголовок доставки +- **THEN** система пишет запись уровня `WARN` +- **AND** сохраняет точки по выведенному слою, а не по заголовку + +### Requirement: Разбор форматов времени + +Система SHALL понимать три формата времени, встречающиеся в теле доставки, и +приводить их к UTC, сохраняя офсет исходной зоны. + +- `2026-07-31 21:03:51 +0300` — метрики и тренировки; +- `2026-07-31T18:03:51Z` — RFC 3339 в UTC; +- `1785446196.4132624` — Unix-эпоха дробным числом внутри `heartbeatSeries`. + +#### Scenario: Локальное время со смещением + +- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300` +- **THEN** точка получает время в UTC и офсет `+10800` секунд + +#### Scenario: Время внутри серии ударов + +- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries` +- **THEN** элементы серии сохраняются дословно вместе с их эпохой +- **AND** серия не разворачивается в отдельные точки + +### Requirement: Разделение схем под одним именем метрики + +Система SHALL разводить на разные имена метрики те схемы, которые Health Auto +Export шлёт под одним именем, чтобы одно имя означало одну схему. + +Под именем `sleep_analysis` приезжают две несовместимые схемы: поэпизодная +(`start`/`end`/`value`/`qty`) и суточная сводка +(`totalSleep`/`core`/`rem`/`deep`/`awake` с меткой на местной полуночи). Общих +полей, кроме `date` и `source`, у них нет. + +#### Scenario: Поэпизодная запись сна + +- **WHEN** точка `sleep_analysis` содержит поле `value` +- **THEN** она сохраняется под именем `sleep_analysis` + +#### Scenario: Суточная сводка сна + +- **WHEN** точка `sleep_analysis` содержит поле `totalSleep` +- **THEN** она сохраняется под именем `sleep_analysis_summary` +- **AND** её слой фиксирован как `day`, а не выводится из выравнивания + +### Requirement: Канонизация содержимого + +Система SHALL приводить содержимое точки к канонической форме перед сравнением +и хешированием: рекурсивная сортировка ключей и округление чисел до двенадцати +значащих цифр. + +Без округления сравнение бесполезно: 63% повторно приехавших точек различались +последним разрядом double при одинаковом измерении. + +#### Scenario: Повтор с иным порядком ключей опознаётся как тот же + +- **WHEN** та же точка приезжает с другим порядком ключей в JSON +- **THEN** её каноническая форма совпадает с сохранённой + +#### Scenario: Дребезг последнего разряда не считается изменением + +- **WHEN** значение отличается только за пределами двенадцатой значащей цифры +- **THEN** каноническая форма совпадает с сохранённой + +### Requirement: Разбор не влияет на код ответа приёма + +Система MUST сохранять правило «сохранили — значит приняли»: исход разбора не +меняет код ответа на доставку. + +#### Scenario: Содержимое не разобралось + +- **WHEN** тело сохранено в архив, но разбор его содержимого не удался +- **THEN** ответ на приём остаётся `200` +- **AND** исход виден в `delivery.parse_status` и в записи лога diff --git a/openspec/changes/razbor-metrik-v-obekty/specs/storage/spec.md b/openspec/changes/razbor-metrik-v-obekty/specs/storage/spec.md new file mode 100644 index 0000000..b2c25b6 --- /dev/null +++ b/openspec/changes/razbor-metrik-v-obekty/specs/storage/spec.md @@ -0,0 +1,100 @@ +## ADDED Requirements + +### Requirement: Идентичность точки по координатам + +Система SHALL адресовать точку координатами `метрика + слой + метка времени`. +Поле `source` в ключ входить MUST NOT: оно нестабильно — то же измерение с тем +же значением приезжает то как `Apple Watch Ultra 3|iPad (Anton)`, то как +`Apple Watch Ultra 3`, потому что Health переосмысливает атрибуцию задним +числом. + +Идентичность по хешу содержимого проверялась и отвергнута: она задваивала +минутный слой целиком — 120 точек в часе вместо 60. + +#### Scenario: Повторная доставка той же точки ничего не меняет + +- **WHEN** точка с теми же координатами и тем же содержимым приезжает снова +- **THEN** хранилище не изменяется + +#### Scenario: Смена источника не создаёт вторую точку + +- **WHEN** точка с теми же координатами приезжает с другой строкой `source` +- **THEN** она остаётся одной точкой, а не превращается в две + +### Requirement: Разрешение столкновений по полноте + +Когда по одним координатам приходят разные содержимые, система SHALL оставлять +**более полную** точку — ту, у которой больше значащих полей, — а не последнюю +пришедшую. Иначе бедная доставка стирает `start`/`end` у богатой. + +Если полнота равна, а значения различаются, исход определяет порядок +воспроизведения, и он MUST быть по `received_at` доставки: свёртка по журналу +обязана давать то же состояние, что приём в реальном времени. + +#### Scenario: Бедная точка не стирает поля богатой + +- **WHEN** сохранена точка с `qty`, `start` и `end` +- **AND** по тем же координатам приезжает точка только с `qty` +- **THEN** сохранённая точка остаётся с `start` и `end` + +#### Scenario: Одинаково полные точки с разными значениями + +- **WHEN** по одним координатам приходят две одинаково полные точки с разными + значениями +- **THEN** побеждает точка из доставки с большим `received_at` + +### Requirement: Хранение часовыми объектами + +Система SHALL хранить точки часовыми объектами с ключом +`метрика + слой + час (UTC)`. Содержимое объекта — сжатый gzip блоб; точки +внутри упорядочены по времени. + +Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта +MUST NOT удаляться. + +#### Scenario: Точки за один час ложатся в один объект + +- **WHEN** приходят точки одной метрики и слоя за один час UTC +- **THEN** они хранятся одним объектом + +#### Scenario: Дозапись в существующий час + +- **WHEN** приходят новые точки за уже существующий час +- **THEN** объект перечитывается, точки сливаются, объект записывается обратно +- **AND** ранее сохранённые точки остаются в объекте + +### Requirement: Хеш как детектор изменений + +Система SHALL хранить хеш канонической формы объекта и пропускать запись, если +хеш не изменился. Хеш — детектор, а не ключ. + +Это то, что делает широкие проходы синхронизации дешёвыми: глубокий проход +переприсылает неделю, но почти все сравнения сходятся и записи не происходит. + +#### Scenario: Повторная присылка того же часа не пишет в базу + +- **WHEN** приезжает доставка, целиком повторяющая уже сохранённый час +- **THEN** хеш совпадает и запись не выполняется + +### Requirement: Признак запечатанного часа + +Система SHALL отмечать признаком `sealed` часы, которые уже не должны +меняться. Изменение запечатанного объекта — не отказ, а сигнал. + +#### Scenario: Изменение запечатанного часа + +- **WHEN** приходят точки за час, помеченный `sealed` +- **THEN** система пишет запись уровня `WARN` +- **AND** данные всё равно сохраняются + +### Requirement: Значения точек не попадают в логи + +Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения +точек и тела доставок в записи лога уровня выше `DEBUG`. + +#### Scenario: Разбор доставки логируется без значений + +- **WHEN** доставка разобрана +- **THEN** запись лога содержит счётчики (метрик, точек, объектов) и + идентификатор доставки +- **AND** не содержит ни значений точек, ни имён устройств diff --git a/openspec/changes/razbor-metrik-v-obekty/tasks.md b/openspec/changes/razbor-metrik-v-obekty/tasks.md new file mode 100644 index 0000000..6a8c5e3 --- /dev/null +++ b/openspec/changes/razbor-metrik-v-obekty/tasks.md @@ -0,0 +1,43 @@ +## 1. Фикстуры и схема + +- [ ] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени +- [ ] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries` +- [ ] 1.3 Миграция `internal/store/migrations/00003_bucket.sql`: таблица `bucket` (`metric`, `layer`, `hour_utc`, `payload` BLOB, `content_hash`, `points`, `sealed`, `created_at`, `updated_at`), уникальность по `(metric, layer, hour_utc)` +- [ ] 1.4 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций) + +## 2. Разбор — пакет `internal/hae` + +- [ ] 2.1 Разбор трёх форматов времени в UTC с сохранением офсета исходной зоны; неразобранная метка — не паника, а пропуск точки со счётчиком +- [ ] 2.2 Вывод слоя: выравнивание меток, плотная метрика (≥10 точек) сама, редкая наследует преобладающий слой, при отсутствии плотных — заголовок доставки +- [ ] 2.3 Расхождение выведенного слоя с заголовком доставки — запись `WARN` без значений точек +- [ ] 2.4 Разделение `sleep_analysis` на поэпизодную и `sleep_analysis_summary` со слоем `day` +- [ ] 2.5 Канонизация: рекурсивная сортировка ключей, округление чисел до 12 значащих цифр +- [ ] 2.6 `hae.Parse` возвращает точки со слоем, метрикой, меткой и содержимым как пришло; незнакомые поля и метрики сохраняются +- [ ] 2.7 Тесты разбора на фикстурах из 1.2, включая смешанную доставку и обе схемы сна + +## 3. Хранение — часовые объекты + +- [ ] 3.1 Модель точки и объекта в `internal/store`; сериализация содержимого в gzip-BLOB, точки внутри упорядочены по времени +- [ ] 3.2 Слияние: координатный ключ `метрика + слой + метка`, победа более полной точки, при равной полноте — по `received_at` +- [ ] 3.3 Хеш канонической формы объекта как детектор изменений: совпал — записи нет +- [ ] 3.4 Запись объекта в транзакции; повтор при `SQLITE_BUSY` +- [ ] 3.5 Признак `sealed`: изменение запечатанного часа пишет `WARN` и всё равно сохраняет +- [ ] 3.6 Тест конкурентной записи в один `hour_utc`: точки обеих сторон на месте +- [ ] 3.7 Тест идемпотентности: повторное слияние того же набора не меняет ни содержимое, ни хеш + +## 4. Сшивка с приёмом + +- [ ] 4.1 `internal/ingest` вызывает разбор после записи тела в архив и строки `delivery` +- [ ] 4.2 Отказ и паника разбора не меняют код ответа: `200`, `parse_status=failed`, запись лога уровня `ERROR` без значений +- [ ] 4.3 Единственный логирующий чекпоинт на границе: счётчики метрик, точек и объектов, идентификатор доставки; ни значений, ни имён устройств +- [ ] 4.4 Тест приёма: битый JSON — 400, непонятое содержимое — 200 с `parse_status=failed` + +## 5. Сходимость на реальных данных + +- [ ] 5.1 Скрипт `tmp/research/verify_buckets.py`: прогоняет архив через разбор и сверяет суммы по часовому слою с прежней проверкой из разведки +- [ ] 5.2 Прогон на всех накопленных доставках: расхождений по накопительным метрикам нет +- [ ] 5.3 `task gate` зелёный; `task restart` поднимает сервис, новая доставка с телефона разбирается + +## 6. Приёмочные критерии ревью дизайна + + diff --git a/openspec/config.yaml b/openspec/config.yaml index 80dd7d3..d6508f9 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -67,4 +67,5 @@ rules: # Кавычки обязательны: без них YAML обрежет строку на первом '#'. - "Каждое ### Requirement обязано содержать SHALL или MUST (иначе валидация падает)" - "Сценарий — ровно #### (четыре решётки); три или список молча теряются" + - "SHALL/MUST должно стоять в ПЕРВОМ абзаце требования: валидатор смотрит только его, обоснование ниже он не видит (проверено)" - "Заголовки и WHEN/THEN/GIVEN — на английском, остальной текст на русском"