docs: план обрезан до порядка, отложенное переехало в беклог

- содержимое шагов не перечисляется: список работ жил в плане и в беклоге и расходился с каждой закрытой задачей
- десять пунктов «Отложено» заведены задачами [idea]; два из них (ретеншен, алерт) уже были в беклоге — в плане лежал дубль
- обоснование порядка расписано по шагам: это единственное, чего беклог структурно не вмещает
This commit is contained in:
av
2026-08-02 07:02:07 +03:00
parent 91e860cfc8
commit 2070ef438c
11 changed files with 181 additions and 95 deletions
+3 -1
View File
@@ -112,7 +112,9 @@ curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}'
придумывать своё
- [docs/architecture.md](docs/architecture.md) — устройство, схема данных, API, принятые решения
- [docs/conventions.md](docs/conventions.md) — как пишем код
- [docs/plan.md](docs/plan.md) — шаги и отложенное
- [docs/plan.md](docs/plan.md) — шаги и обоснование их порядка
- [docs/backlog](docs/backlog/README.md) — что брать следующим, включая
отложенные идеи
- [docs/local-research.md](docs/local-research.md) — что показал реальный поток
Health Auto Export; источник истины по формату, документация приложения
местами расходится с тем, что оно шлёт
+7
View File
@@ -40,4 +40,11 @@
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [Ретеншен сырого архива](retenshen-syrogo-arhiva.md) — Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает
- [Активный алерт «данных нет N часов»](alert-tishina-potoka.md) — Пропажу потока сейчас замечает человек, а не сервис
- [[idea] Порог sealed: с какого возраста час считается запечатанным](porog-sealed.md) — WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
- [[idea] Месячный проход по ручным секциям](mesyachnyj-prohod-ruchnye-sekcii.md) — Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
- [[idea] Человеческие аннотации поверх выведенных схем](annotacii-k-shemam.md) — Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
- [[idea] Отказ от heartbeatSeries](otkaz-ot-heartbeatseries.md) — 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
- [[idea] Выгрузка в parquet отдельной командой](vygruzka-v-parquet.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
- [[idea] NDJSON-поток для больших выборок Read API](ndjson-potok.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
- [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](razvorachivanie-marshrutov.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
+21
View File
@@ -0,0 +1,21 @@
# [idea] Человеческие аннотации поверх выведенных схем
**Приоритет:** низкий
Схема содержимого выводится из данных и говорит **форму** — какие поля есть,
какого типа, с какой заполненностью. Чего она не говорит — что метрика значит,
в каких единицах разумны значения и чем `apple_stand_hour` отличается от
`apple_exercise_time`.
Два пути, и выбор между ними преждевременен:
- **аннотации поверх выведенных схем** — человеческое описание рядом с
машинным выводом, дописывается по мере надобности;
- **рукописный каталог метрик** — полнее, но описывал бы документацию HAE, а не
то, что он реально прислал.
Почему идея, а не задача: выбор зависит от того, насколько стабильным окажется
формат. Меняться он может только с обновлением Health Auto Export, а это
отслеживается — значит ответ придёт сам.
Связано: `docs/architecture.md` → «Самоописание», задача `samoopisanie-shemy`.
@@ -0,0 +1,21 @@
# [idea] Месячный проход по ручным секциям
**Приоритет:** низкий
Окно досчёта не единое, и это измеренное различие, а не предположение.
Количественные метрики (пульс, шаги, энергия) человек руками не правит — они
опаздывают на часы, и недельного глубокого прохода им хватает. Ручные записи
(`symptoms`, `medications`, `stateOfMind`, `cycleTracking`) заводятся задним
числом на недели и месяцы: симптом или приём лекарства можно отметить за
прошлую дату.
Растянуть общий глубокий проход на месяц нельзя: тела запросов доходили до
42 МБ (находка 23), а месяц минутных данных — это десятки мегабайт на каждую
доставку. Отсюда решение: редкий широкий проход **только по ручным секциям**
их единицы записей, и месячное окно там почти ничего не стоит.
Почему идея, а не задача: этих секций в живом потоке ещё не было. Заводить,
когда они появятся, — иначе проход пишется вслепую и проверяется не на чем.
Связано: `docs/architecture.md` → «Досчёт задним числом», задача
`proverka-novyh-sekcij`.
+20
View File
@@ -0,0 +1,20 @@
# [idea] NDJSON-поток для больших выборок Read API
**Приоритет:** низкий
Read API отдаёт ответ одним JSON. Для выборок нижнего слоя за длинный период
это не работает: `heart_rate` в слое `raw` — порядка сотни тысяч координат в
сутки, и месяц такого ряда не влезет ни в память клиента, ни в разумный ответ.
Сейчас проблема закрыта с другой стороны — правилом размера ответа: сервер сам
берёт сетку погрубее, когда разбивка не задана, и отвечает ошибкой со списком
доступных сеток, когда задана явно. Это защищает агента с ограниченным
контекстом, но не помогает клиенту, которому действительно нужен весь ряд —
например, разовой выгрузке в другой инструмент.
Почему идея, а не задача: неизвестно, появится ли такой клиент. Если появится,
выбор между NDJSON-потоком и курсорной пагинацией зависит от того, читает он
последовательно или с возвратами.
Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача
`read-api-tochki`.
+19
View File
@@ -0,0 +1,19 @@
# [idea] Отказ от heartbeatSeries
**Приоритет:** низкий
`heart_rate_variability` приезжает вместе с `heartbeatSeries` — рядом
межударных интервалов внутри точки. Это **93% объёма метрики** (находка 39)
ради данных, которых нет ни в одном из планируемых запросов: ни агент, ни
трекер, ни игра межударными интервалами не оперируют.
Отбросить их означало бы нарушить инвариант «точки хранятся дословно» — и это
не мелочь: срок жизни сырого архива держится ровно на том, что объект является
полной копией. Поэтому вопрос не «выбросить или нет», а «когда цена хранения
нижнего слоя станет заметной».
Почему идея, а не задача: цена пока не измерена в годовом масштабе, а решение
необратимо — выброшенные ряды не вернуть иначе как из экспорта Apple, где их
может не быть вовсе.
Связано: `docs/architecture.md` → «Открытые вопросы», находка 39.
+17
View File
@@ -0,0 +1,17 @@
# [idea] Порог sealed: с какого возраста час считается запечатанным
**Приоритет:** низкий
Флаг `sealed` отмечает часы, которые уже не должны меняться. Механика готова:
изменение запечатанного объекта не отвергается, а пишется `WARN`, и данные
всё равно сохраняются. Не выбрано одно — **с какого возраста** ставить флаг.
Почему идея, а не задача: правильный порог выводится из эксплуатации, а не из
рассуждения. Наблюдалась глубина досчёта до 22 минут (находка 10), но одного
наблюдения мало — ручные секции правятся задним числом на недели, а
количественные метрики опаздывают на часы. Ставить порог сейчас значит угадать.
Что нужно, чтобы стало задачей: статистика `WARN` за несколько недель живого
потока и распределение возраста изменённых часов по классам метрик.
Связано: `docs/architecture.md` → «Часовые объекты метрик».
@@ -0,0 +1,17 @@
# [idea] Разворачивание маршрутов тренировок в отдельную таблицу
**Приоритет:** низкий
Тренировка хранится нераскрытой: заголовок — колонками, всё остальное, включая
маршрут и внутренние ряды, — блобом `payload`. Решение осознанное: структура
тренировки разнородна и избыточна (сводки дублируют ряды, находка 15), и
раскладывать её в таблицы значило бы решить за Apple, что в ней главное.
Разворачивание маршрута в отдельную таблицу точек имело бы смысл для запросов
вида «все пробежки, проходившие через эту область» или «набор высоты по
сегментам» — то есть когда маршрут нужен не целиком, а выборочно.
Почему идея, а не задача: такого клиента нет. Трекер тренировок берёт
тренировку целиком одним пакетом, и этого ему достаточно.
Связано: `docs/architecture.md` → «Тренировки и прочие секции».
+5
View File
@@ -13,6 +13,11 @@
Вывод ограничивается по глубине вложенности, иначе схема тренировки с маршрутом
разрастётся до размеров самих данных.
**Глубину вывода для тренировок выбираем по факту**, когда увидим, как приходят
маршруты: структура тренировки разнородна, и заранее назначенный предел либо
срежет полезное, либо не срежет ничего. Точка маршрута при этом описываться
должна — блоб трека не непрозрачен, это массив однотипных объектов.
Готово, когда клиент по `/api/v1/metrics/{name}/schema` видит поля, их типы и
присутствие, не выкачивая выборку.
+18
View File
@@ -0,0 +1,18 @@
# [idea] Выгрузка в parquet отдельной командой
**Приоритет:** низкий
Отдельная команда, выгружающая хранилище в parquet, — дверь для тяжёлой
аналитики снаружи, без миграции самого хранилища.
Контекст решения: DuckDB рассматривался как основное хранилище и отложен —
чистого Go-драйвера нет, любой требует cgo, а это стоит нам `CGO_ENABLED=0` и
одного статического бинаря. Но дверь при этом осталась открытой: DuckDB читает
и parquet, и файл SQLite напрямую. Значит спешить некуда — выгрузка добавляется
тогда, когда появится тяжёлый аналитический запрос, а не заранее.
Почему идея, а не задача: такого запроса пока нет. Ни один из трёх потребителей
(агент-медик, трекер, игра) в аналитике по всей истории не нуждается.
Связано: `docs/architecture.md` → «Открытые вопросы» → «Хранилище под
аналитику».
+33 -94
View File
@@ -1,121 +1,60 @@
# План
Живой документ: шаги отмечаем по мере готовности, отложенное копится внизу.
Это **порядок и его обоснование**, а не список работ. Единицы работы живут в
[беклоге](backlog/README.md) — одна задача, один файл, свой приоритет. План
отвечает «почему в таком порядке», беклог — «что брать следующим».
Отсюда правило: **содержимое шага здесь не перечисляется.** Шаг — это название
и статус; что именно в нём делается, знает задача. Иначе список работ живёт в
двух местах и расходится с каждой закрытой задачей. Меняется этот файл, когда
меняется порядок, а не когда закрывается задача.
## Ближайшая цель
Метрики разбираются и ложатся в часовые объекты: тела перестали быть
недифференцированной кучей. Блокеры, накопившиеся из ревью, разобраны — их в
беклоге ноль.
Дальше — **`reindex`**, и он сейчас срочнее остального остатка шага 3. После
Дальше — **`reindex`**, и он сейчас срочнее остального остатка разбора. После
миграции 00005 доставки числятся `pending`, а подобрать их некому: код
пересборки не написан. Данные целы (тела в архиве, объекты в витрине), но
учёт честно говорит «этим разбором не смотрели», и так будет, пока пересборки
нет. Тем же кодом закрывается половина задачи «разнести ответ и свёртку».
Потом — остаток шага 3: тренировки и записи со своими `id` (это половина
Потом — остаток разбора: тренировки и записи со своими `id` (это половина
потока: `workouts` и `stateOfMind` принимаются и хранятся, но не разбираются),
словарь категориальных значений.
Разведка закончена: правило вывода слоя, модель идентичности и формы точки
проверены на живом потоке, выводы — в
[local-research.md](local-research.md), 50 находок.
проверены на живом потоке, выводы — в [local-research.md](local-research.md).
## Шаги
- [x] **1. Каркас.** `Taskfile.yml`, `.golangci.yml`, `CLAUDE.md`, TOML-конфиг
с валидацией на старте, логгер, подкоманда `serve` с `/healthz`.
- [x] **2. Приём без разбора.** `POST /api/v1/ingest`: лимит тела, gzip,
запись тела в архив, строка в `delivery`. Разбора ещё нет.
**подключаем телефон по локальной сети**
- [~] **3. Разбор и хранилище.** Метрики — сделано (`bucket`, `internal/hae`,
`internal/canon`, `internal/fold`), вместе с частичным разбором: секции,
которых разбор не покрывает, перечисляются, доставка получает статус
`partial` и список непокрытых ключей. Остались тренировки и записи со
своими `id`, `reindex` и словарь категориальных значений. Миграции,
часовые объекты метрик
(`bucket`, ключ `метрика + слой + час`) + `workout`/`record` по своим
`id`. Вывод слоя из выравнивания меток; развод `sleep_analysis` на два
имени (находка 38). Канонизация с округлением чисел и хеш содержимого
как детектор изменений. Слияние точек: при столкновении выигрывает
**более полная** точка, а не последняя (находка 41). Три формата
времени: локальное со смещением, RFC 3339 Z, Unix-эпоха внутри
`heartbeatSeries` (находка 39). Словарь `(локаль, строка) → код
HealthKit` для переведённых значений (находка 37). `healthlog reindex`.
- [ ] **4. Каталог и род агрегации.** Род (`cumulative`/`instant`/`unknown`)
**измеряется** сверкой слоёв между собой, а не размечается руками
(находка 40). Каталог метрик со слоями, диапазонами и родом.
- [ ] **5. Read API.** Точки метрики из часовых объектов, выбор слоя,
необязательная свёртка по сетке. Огрубление, когда сетка не задана;
ошибка со списком доступных сеток, когда задана явно. Тренировки,
записи.
- [ ] **6. Самоописание.** Выведенные из данных схемы содержимого со
статистикой + статичная схема контракта API.
- [ ] **7. MCP.** Эндпоинт того же процесса, транспорт Streamable HTTP, токен
чтения общий с Read API. Три инструмента: каталог, значения за период,
значения с разбивкой.
- [ ] **8. `healthlog import`.** Родной экспорт Apple Health: разбор
`экспорт.xml` в слой `sample`, маршруты GPX, ЭКГ из CSV. Заливка полной
истории кусками по годам.
- [ ] **9. Устаревание нижнего слоя.** Пометка данных HAE старше проверенного
экспорта. Проверка покрытия — непрерывность по дням и сходимость сумм с
часовым слоем. Пометка ≠ удаление: удаление включаем только после того,
как восстановление из экспорта отработает на живых данных хотя бы раз.
- [ ] **10. Наблюдаемость.** `/stats`: последняя доставка, счётчики, тишина по
потоку, список строк без кода в словаре.
- [ ] **11. Деплой.** `Dockerfile`, сборка образа локально, доставка на
rivendell, конфиг Caddy, поддомены приёма и чтения, токены.
- [x] **1. Каркас.**
- [x] **2. Приём без разбора.****подключаем телефон по локальной сети**
- [~] **3. Разбор и хранилище.** Метрики — сделано; тренировки и записи со
своими `id`, `reindex` и словарь категориальных значений — нет.
- [ ] **4. Каталог и род агрегации.**
- [ ] **5. Read API.**
- [ ] **6. Самоописание.**
- [ ] **7. MCP.**
- [ ] **8. `healthlog import`.**
- [ ] **9. Устаревание нижнего слоя.**
- [ ] **10. Наблюдаемость.**
- [ ] **11. Деплой.**
Порядок неслучаен. Шаг 4 стоит перед Read API, потому что без измеренного рода
свёртка на шаге 5 неотличима от угадывания. Шаг 8 стоит перед 9: пока импорт
экспорта не написан, помечать что-либо устаревшим не на основании чего.
Порядок неслучаен, и это единственное, чего нет в беклоге:
## Отложено
- **Каталог и род агрегации — перед Read API.** Без измеренного рода свёртка в
ответе неотличима от угадывания, а ошибиться здесь дорого: просуммировать
нижний слой значит завысить втрое.
- **`healthlog import` — перед устареванием нижнего слоя.** Пока импорт
экспорта не написан, помечать что-либо устаревшим не на основании чего.
- **Read API — перед MCP.** Адаптер собственной логики не несёт, он переводит
вызовы в те же обработчики; переводить пока нечего.
- ~~Отсев идентичных тел доставок по `sha256`.~~ **Вычеркнуто:** находка 2
показала, что порядок ключей в JSON нестабилен, поэтому два пакета с одними
и теми же данными почти никогда не совпадают побайтно — хеш тела не
сработает. Дедупликация возможна только по канонизированному содержимому,
а это и делает хеш часового объекта. Отдельная механика не нужна.
- ~~Вторая автоматизация без группировки.~~ **Сделано на телефоне:** метрики
здоровья идут в трёх разрезах — несуммированном для несуммируемых метрик,
минутном и часовом для всех.
- ~~Пометка локализованных полей в схемах.~~ **Переросло в шаг 3:** одной
пометки мало, нужен словарь кодов, иначе не сойтись с родным экспортом
(находка 37).
- **Ретеншен сырого архива** — удаление доставок старше последнего
проверенного экспорта (не фиксированный срок: журнал не должен рваться).
Пока архив не подчищается; включить после того, как разбор устоится.
Предусловие снято: статус `partial` и список непокрытых секций готовы, и
ретеншен обязан спрашивать статус, а не считать `parsed` разрешением.
Вместе с ним действует правило — задача, которая начинает разбирать секцию,
тем же изменением переводит `partial`-строки с этим ключом в `pending`.
- **Порог `sealed`** — с какого возраста час считается запечатанным. Ставим по
факту: сначала пишем `WARN` на изменение старых объектов и смотрим, какая
глубина досчёта встречается в жизни (наблюдалось до 22 минут, находка 10).
- **Месячный проход по ручным секциям.** Симптомы и лекарства заводятся задним
числом на недели; количественным метрикам недельного прохода хватает.
Заводить, когда эти секции появятся в потоке живьём.
- **Алерт «данных нет N часов».** Тихо сломавшаяся автоматизация — главный
эксплуатационный риск коллектора. В v1 факт виден в `/stats`; активное
уведомление добавим после.
- **Аннотации к схемам** — человеческие описания метрик поверх выведенных
схем, либо рукописный каталог. Выбор зависит от того, насколько стабильным
окажется формат; меняться он может только с обновлением Health Auto Export,
а это отслеживается.
- **Схема тренировок** — глубину вывода определим по факту, когда увидим,
как приходят маршруты.
- **Отказ от `heartbeatSeries`.** 93% объёма HRV (находка 39) ради данных,
которых нет ни в одном планируемом запросе. Решать, когда станет ясна цена
хранения нижнего слоя за год.
- **Выгрузка в parquet** — отдельной командой, на случай тяжёлой аналитики
снаружи. DuckDB читает и parquet, и файл SQLite напрямую, поэтому спешить
некуда: дверь открыта без миграции.
- **NDJSON-поток** для больших выборок из read API.
- **Разворачивание маршрутов тренировок** в отдельную таблицу — если появится
клиент, которому мало отдачи тренировки одним пакетом.
## Отложенное
Отложенных идей в плане нет: их место — [беклог](backlog/README.md) с пометкой
`[idea]`. Два дома для одной идеи расходятся, и тогда полного списка не даёт ни
один; вопрос «что мы решили отложить» задаётся беклогу.