хранилище описано как свёртка по журналу событий

- экспорт Apple это снапшот всей истории, доставки после его даты — события
  поверх; состояние пересобирается как import(экспорт) + replay(доставки)
- отсюда ретеншен архива меняется с произвольных 14 дней на «до следующего
  проверенного экспорта» (~2 ГБ за квартал, измерено), а свёртка обязана быть
  детерминированной — воспроизведение строго по received_at
- названы границы модели: stateOfMind в экспорт не попадает вовсе, а верхние
  слои за периоды с удалёнными доставками не воскресают и досчитываться не
  должны — каталог обязан говорить это честно
This commit is contained in:
av
2026-08-01 14:27:13 +03:00
parent 7ee55057e0
commit b2885c79e3
10 changed files with 162 additions and 46 deletions
+3 -2
View File
@@ -34,8 +34,9 @@ description: Конвейер ревью изменений healthlog — дет
Инварианты, нарушение которых — по умолчанию `critical` (подробно —
`CLAUDE.md`, `docs/architecture.md`):
- **Точка хранится дословно.** Всё, что теряет поле внутри точки, необратимо:
сырой архив живёт 14 дней, дальше истина — сами точки.
- **Точка хранится дословно.** Хранилище — свёртка по журналу
(`import(экспорт) + replay(доставки)`), поэтому разобранное пересобираемо, а
вот не принятое — нет: доставка мимо архива теряется навсегда.
- **Идентичность по координатам** (`метрика + слой + метка`). `source` в ключ
не входит. Неверное правило слияния портит историю молча — заметить это
можно только сверкой с родным экспортом Apple, то есть месяцами позже.
+6 -4
View File
@@ -24,13 +24,15 @@ healthlog — хранилище данных о здоровье, у котор
(`task up` / `task restart`), данные в `./data`. Перезапуск на пару секунд
безопасен — дыру закроют средний и глубокий проходы синхронизации; а вот
сломанный приём, оставленный работать, теряет данные необратимо.
- **Потерянная точка не восстанавливается.** Сырой архив живёт 14 дней. Любая
правка разбора, слияния или вывода слоя — это `deep`-профиль ревью, без
исключений.
- **Потерянная доставка не восстанавливается.** Тело, не попавшее в архив, в
журнал не попадает вовсе: телефон его не перешлёт. Разобранное же всегда
пересобираемо свёрткой, поэтому цена ошибки разбора и цена ошибки приёма
различаются на порядок. Любая правка разбора, слияния или вывода слоя — это
`deep`-профиль ревью, без исключений.
- **Данные чувствительны.** Ничего из `./data` не попадает ни в git, ни в
логи выше `DEBUG`, ни в вывод агента. Гейт проверяет первое механически
(`no-health-data`), остальное — на тебе.
- **Разведка уже проведена.** `docs/local-research.md` — 41 находка на живом
- **Разведка уже проведена.** `docs/local-research.md` — 46 находок на живом
потоке, половина расходится с документацией HAE. Проверь там, прежде чем
строить догадку о формате: скорее всего вопрос уже закрыт измерением.
+11 -4
View File
@@ -24,9 +24,15 @@ Module path — `git.vakhrushev.me/av/healthlog`.
## Инварианты
- **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде,
в каком их прислал HAE. На этом инварианте держится всё остальное: сырой
архив живёт лишь 14 дней, дальше истина — сами объекты. Начнём что-то
отбрасывать внутри точки — срок хранения архива станет сроком жизни данных.
в каком их прислал HAE. Начнём что-то отбрасывать внутри точки — потеряем
безвозвратно.
- **Хранилище — свёртка по журналу.** Экспорт Apple это снапшот всей истории,
доставки HAE после его даты — события поверх. Состояние всегда пересобираемо:
`import(экспорт) + replay(доставки по received_at)`. Поэтому сырой архив
живёт до следующего проверенного экспорта (~2 ГБ за квартал), а свёртка
обязана быть детерминированной. Что не восстанавливается — `stateOfMind`
(его в экспорте нет) и верхние слои за периоды с удалёнными доставками;
каталог обязан говорить об этом честно, а не досчитывать молча.
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор:
битый JSON — 400, непонятое содержимое — 200.
- **Ничего не теряем молча.** Идентичность — координаты
@@ -82,7 +88,8 @@ Module path — `git.vakhrushev.me/av/healthlog`.
запускаются.
**Поток не останавливается.** Телефон шлёт непрерывно и молча. Сломанный приём,
оставленный работать, теряет данные необратимо — сырой архив живёт 14 дней.
оставленный работать, теряет данные необратимо: доставка, не попавшая в
архив, в журнал не попадает вовсе — телефон её не перешлёт.
Ничего из `./data` не попадает ни в git, ни в логи выше `DEBUG`, ни в вывод
агента.
+11 -11
View File
@@ -34,22 +34,22 @@ healthlog делает это один раз. Телефон шлёт данн
## Как устроено
```
iPhone ──HTTPS POST──► healthlog ──► сырой архив (.json.gz, 14 дней)
экспорт Apple ────────┐ снапшот всей истории, раз в 2–3 месяца
iPhone ──HTTPS POST──► healthlog ──► журнал доставок (.json.gz)
│ │
│ └── страховка разбора, не склад
│ └── события поверх снапшота
SQLite ──┬──► HTTP read API ──► мои приложения
(точки по
слоям) └──► MCP ───────────► агенты
экспорт Apple ──────────────┘ нижний слой, раз в 2–3 месяца
(свёртка по │
журналу) └──► MCP ───────────► агенты
```
Приём сначала кладёт тело запроса на диск как есть и только потом разбирает.
Значит, ошибка в нашем разборе не может привести к потере данных: хранилище
пересобирается из архива командой `healthlog reindex`. Архив при этом
недолговечен — дальше истина в самих точках, и потому точки хранятся
дословно.
Значит, ошибка в разборе не теряет данные: состояние всегда пересобирается
свёрткой `import(экспорт) + replay(доставки)`. Отсюда и главный инвариант —
точки хранятся дословно: журнал, из которого что-то выброшено, перестаёт быть
журналом.
Подробности — [docs/architecture.md](docs/architecture.md).
@@ -59,7 +59,7 @@ iPhone ──HTTPS POST──► healthlog ──► сырой архив (.jso
сырой архив. Разбора, хранилища и read API ещё нет — план в
[docs/plan.md](docs/plan.md).
Разведка формата закончена: 41 находка на живом потоке, половина расходится с
Разведка формата закончена: 46 находок на живом потоке, половина расходится с
документацией Health Auto Export — [docs/local-research.md](docs/local-research.md).
## Команды
+60 -12
View File
@@ -15,11 +15,12 @@ healthlog принимает выгрузки Apple Health из приложен
в каком их прислал HAE — без переименований, пересчётов и отбрасывания
незнакомых полей. Поэтому хранилище само по себе является полной копией
данных, а не производной выжимкой.
- **Сырой архив — страховка разбора, а не вечный склад.** Тело запроса ложится
на диск до разбора и живёт ограниченный срок (по умолчанию две недели):
этого хватает, чтобы пережить ошибку в нашем разборе и пересобрать
хранилище (`healthlog reindex`). Дальше источником истины остаются часовые
объекты — прямое следствие пункта выше, см. «Хранилище».
- **Хранилище — свёртка по журналу, а не единственная копия.** Экспорт Apple
это снапшот всей истории, доставки HAE после его даты — события поверх
снапшота. Состояние всегда пересобираемо: `import(экспорт) + replay(доставки)`.
Отсюда срок жизни сырого архива — до следующего проверенного экспорта, а не
произвольные две недели. Исключение названо вслух: `stateOfMind` в экспорт не
попадает, см. «Хранилище».
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
(см. «Приём»).
- **Ничего не теряем молча.** Идентичность — устойчивые координаты
@@ -236,16 +237,63 @@ HRV); у накопительных — только `date`. Поэтому то
## Хранилище
### Сырой архив — короткая страховка
### Сырой архив и восстановление состояния
`raw/ГГГГ/ММ/ДД/<ulid>.json.gz` — тело запроса как пришло, не редактируется.
Живёт **ограниченный срок** (`storage.raw_retention`, по умолчанию 14 дней),
после чего удаляется.
Смысл срока: архив нужен, чтобы пережить ошибку в **нашем** разборе и
пересобрать хранилище (`healthlog reindex`). Двух недель на это заведомо
хватает. Вечно хранить его незачем — часовые объекты держат те же точки
дословно, так что архив дублировал бы данные, а не страховал их.
Два источника вместе образуют **полный журнал событий**, а хранилище
свёртку по нему:
```
состояние = import(последний проверенный экспорт) ← снапшот всей истории
+ replay(доставки после его даты) ← хвост событий
```
Экспорт Apple — не просто «источник истины для нижнего слоя», а снапшот: он
содержит всю историю целиком (3.6 млн записей с 2019 года). Доставки HAE после
его даты — события поверх снапшота. Значит любое повреждение хранилища,
включая ошибку в нашем разборе любой давности, лечится пересборкой, а не
восстановлением из бекапа.
Отсюда три следствия, каждое из которых меняет реализацию.
**Срок жизни архива определяется циклом экспорта, а не календарём.** Прежние
14 дней были произвольным числом. Правильное правило: доставки хранятся **до
следующего проверенного экспорта**, иначе в журнале появится дыра между концом
ретеншена и датой снапшота. Цена измерена: поток даёт ~23 МБ архива в сутки,
то есть ~2 ГБ за квартал между экспортами. Это дёшево за возможность
пересобрать что угодно.
**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же
состояние, что и приём в реальном времени. Слияние «выигрывает более полная
точка» коммутативно и порядка не требует; но когда две одинаково полные точки
несут разные значения, исход решает порядок — поэтому воспроизведение идёт
строго по `received_at`, а не по порядку файлов в каталоге.
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
существует, она просто вырожденный случай с пустым снапшотом.
#### Что не восстанавливается, и это сказано вслух
Модель почти полна, но не полностью — умолчать об этом опаснее, чем признать.
**`stateOfMind` в экспорте отсутствует вовсе.** Проверено на свежем экспорте:
ни одного типа со словом `StateOfMind` (есть только `MindfulSession` — это
минуты осознанности, другое). Состояние разума живёт **только** в доставках
HAE. Значит для него доставки не хвост журнала, а единственный источник: либо
они не удаляются никогда, либо его история держится на самих сохранённых
записях и восстановлению не подлежит.
**Верхние слои за периоды с удалёнными доставками.** После проигрывания
снапшота у старого периода будет только слой `sample`; `minute` и `hour` за
него не воскреснут. Посчитать их вниз из `sample` технически можно — и нельзя
по инварианту: это была бы **наша** агрегация под видом присланной.
Поэтому правило: **восстановление не обязано быть побайтным, оно обязано быть
честным.** Каталог разрезов показывает, какие слои есть за какой период; после
пересборки старый период честно объявляет один слой вместо трёх, а не
притворяется, что ничего не изменилось.
### Устаревание нижнего слоя
+1 -1
View File
@@ -7,7 +7,7 @@
## высокий
- [Разбор метрик в часовые объекты](razbor-metrik-v-obekty.md) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
- [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Разбор будет ошибаться, а окно на исправление — 14 дней жизни архива
- [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Ошибка разбора без пересборки становится потерей данных — исправленный код не применится к уже разобранному
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
+16 -4
View File
@@ -3,14 +3,26 @@
**Приоритет:** высокий
Разбор пишется по реальным данным и будет ошибаться — это норма, а не риск.
Риск в другом: сырой архив живёт 14 дней, и окно на исправление ошибки равно
этому сроку. Без `reindex` ошибка разбора становится потерей данных.
Риск в другом: без пересборки ошибка разбора становится потерей данных —
исправленный код не применится к тому, что уже разобрано неверно.
Пересчёт по всей истории сразу ещё и **точнее** приёма: вывод слоя и род
агрегации на полном ряду доставок надёжнее, чем на одной.
Готово, когда `healthlog reindex` пересобирает хранилище с нуля из `data/raw`
и результат совпадает с накопленным приёмом.
Проектировать это надо сразу как **свёртку по журналу**, а не как разовую
утилиту: состояние есть `import(снапшот экспорта) + replay(доставки после его
даты)`, и пересборка из архива — вырожденный случай с пустым снапшотом. Тогда
`reindex` и `import` окажутся одной операцией с разным входом, а не двумя
похожими.
Отсюда требование, которое легко упустить: **свёртка обязана быть
детерминированной.** Проигрывание должно давать то же состояние, что приём в
реальном времени. Слияние «выигрывает более полная точка» коммутативно, но две
одинаково полные точки с разными значениями разрешает порядок — значит
воспроизведение идёт строго по `received_at`, а не по порядку файлов в каталоге.
Готово, когда пересборка с нуля даёт состояние, совпадающее с накопленным
приёмом, и повторный прогон ничего не меняет.
Связано: план шаг 3, `docs/architecture.md` → «Сырой архив».
+18 -4
View File
@@ -5,10 +5,24 @@
Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но
удаления нет: архив растёт бесконечно. Пока это 16 МБ и проблемой не является.
Включать **после** того, как разбор устоится и `reindex` докажет, что
хранилище действительно пересобирается: иначе страховка исчезнет раньше, чем
**Само правило изменилось.** Экспорт Apple — снапшот всей истории, доставки
после его даты — события поверх снапшота, и состояние всегда пересобираемо
свёрткой. Значит доставки должны жить **до следующего проверенного экспорта**,
а не фиксированные две недели: иначе между концом ретеншена и датой снапшота
образуется дыра в журнале, и пересобрать этот отрезок будет нечем.
Цена измерена: ~23 МБ архива в сутки, то есть ~2 ГБ за квартал между
экспортами. Дёшево за возможность пересобрать что угодно.
Отдельное исключение: `stateOfMind` в экспорт не попадает вовсе (проверено на
свежем архиве). Для него доставки — не хвост журнала, а единственный источник,
и под общее правило удаления он не подпадает.
Включать **после** того, как разбор устоится и пересборка докажет, что
хранилище действительно восстанавливается: иначе страховка исчезнет раньше, чем
перестанет быть нужна.
Готово, когда старые тела удаляются по расписанию, а `/stats` показывает
глубину архива в днях.
Готово, когда удаляются только доставки старше последнего проверенного
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
глубину архива и дату снапшота, до которой он подрезан.
+32
View File
@@ -1427,6 +1427,38 @@ RFC3339 Z 20 data.stateOfMind[].end = 2026-07-31T18:03:51
То есть правило «экспорт говорит кодами» верно для типов записей и категориальных
значений, но не для всего документа.
## 46. `stateOfMind` в экспорт не попадает — единственная дыра в журнале
Экспорт плюс доставки после его даты образуют полный журнал событий: состояние
пересобирается свёрткой `import(снапшот) + replay(хвост)`. Проверка показала
ровно одно исключение.
В свежем экспорте **нет ни одного типа со словом `StateOfMind`**:
```
grep -oiE 'type="[^"]*(mind|mood|emotion)[^"]*"' → только
type="HKCategoryTypeIdentifierMindfulSession" ← минуты осознанности, другое
type="HKWorkoutEventTypeMotionPaused|Resumed" ← совпадение по подстроке
```
При этом HAE состояние разума шлёт исправно, и шлёт правильно — стабильными
кодами HealthKit (`momentary_emotion`, `slightly_pleasant`, `drained`), в
отличие от переведённых фаз сна (находка 37).
**Следствие:** для `stateOfMind` доставки HAE — не хвост журнала, а
единственный источник. Под общее правило ретеншена он не подпадает: удалив
доставки, мы потеряем возможность восстановить его историю навсегда.
### Цена журнала
Поток даёт **~23 МБ сырого архива в сутки** (89 доставок за 16.4 часа дали
15.6 МБ). При экспорте раз в 2–3 месяца это ~2 ГБ между снапшотами — дёшево за
возможность пересобрать хранилище с любой точки.
Прежние 14 дней ретеншена были произвольным числом; правильный срок — до
следующего проверенного экспорта, иначе между концом ретеншена и датой
снапшота в журнале образуется дыра.
## Инструмент
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
+4 -4
View File
@@ -14,7 +14,7 @@
Разведка закончена: правило вывода слоя, модель идентичности и формы точки
проверены на живом потоке, выводы — в
[local-research.md](local-research.md), 41 находка.
[local-research.md](local-research.md), 46 находок.
## Шаги
@@ -73,9 +73,9 @@
- ~~Пометка локализованных полей в схемах.~~ **Переросло в шаг 3:** одной
пометки мало, нужен словарь кодов, иначе не сойтись с родным экспортом
(находка 37).
- **Ретеншен сырого архива** (`storage.raw_retention`, 14 дней) — удаление
старых тел. Пока архив не подчищается; включить после того, как разбор
устоится, иначе страховка исчезнет раньше, чем перестанет быть нужна.
- **Ретеншен сырого архива** — удаление доставок старше последнего
проверенного экспорта (не фиксированный срок: журнал не должен рваться).
Пока архив не подчищается; включить после того, как разбор устоится.
- **Порог `sealed`** — с какого возраста час считается запечатанным. Ставим по
факту: сначала пишем `WARN` на изменение старых объектов и смотрим, какая
глубина досчёта встречается в жизни (наблюдалось до 22 минут, находка 10).