- каждая запись каталога задач получила тип вместо тега kind: и префикса заголовка; секция роадмапа «Разработка» стала «Сопровождением», порядок секций канонический - поправлены протухшие факты: нереализованные маршруты Read API, MCP и `healthlog import`, словарь слоёв в инварианте, семантика гейта по покрытию диффа, периметр перестал дублировать security.md - замер слияния переведён с находки 49 на находку 54, заполнены Purpose спек storage и parsing
200 lines
17 KiB
Markdown
200 lines
17 KiB
Markdown
# CLAUDE.md
|
||
|
||
Памятка для работы над healthlog. Перед задачей прочитай также
|
||
[docs/passport.md](docs/passport.md) (цель, сценарии, референсы),
|
||
[README.md](README.md), [docs/architecture.md](docs/architecture.md),
|
||
[docs/conventions/README.md](docs/conventions/README.md),
|
||
[docs/security.md](docs/security.md) и [docs/tasks/ROADMAP.md](docs/tasks/ROADMAP.md).
|
||
|
||
Документация ведётся по канону `av-dev-pm` (версия в `docs/.pm.json`);
|
||
раскладку проверяет `av-dev-pm:canon`, содержимое ведёт `av-dev-pm:docs`.
|
||
|
||
## Что это
|
||
|
||
Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export и
|
||
родного экспорта Apple, хранит их и отдаёт другим моим проектам — через HTTP
|
||
API и, в планах, через MCP. Это **хранилище, а не аналитика**: принять, дедуплицировать,
|
||
сохранить, отдать. Не переименовывать поля Apple, не интерпретировать
|
||
значения. Агрегат считается только в ответе на запрос и только там, где род
|
||
метрики измерен.
|
||
|
||
## Стек
|
||
|
||
Go, один статический бинарь (`CGO_ENABLED=0`). SQLite (`modernc.org/sqlite`,
|
||
чистый Go), `chi`, `sqlx`, `goose` (миграции), `pelletier/go-toml/v2`,
|
||
`log/slog`, ULID (`github.com/oklog/ulid/v2`) через `internal/ident`.
|
||
|
||
Module path — `git.vakhrushev.me/av/healthlog`.
|
||
|
||
## Инварианты
|
||
|
||
Что нарушать нельзя. `severity` рядом с формулировкой — по ней проходы ревью
|
||
присваивают вес находке, а не выводят его заново.
|
||
|
||
- **Точки хранятся дословно.** `critical`, необратимо. Часовой объект держит
|
||
точки ровно в том виде, в каком их прислал HAE. Начнём что-то отбрасывать
|
||
внутри точки — потеряем безвозвратно.
|
||
- **Хранилище — свёртка по журналу.** `critical`, необратимо.
|
||
Экспорт Apple это снапшот всей истории,
|
||
доставки HAE после его даты — события поверх. Состояние всегда пересобираемо:
|
||
`import(экспорт) + replay(доставки по received_at)`. Поэтому сырой архив
|
||
живёт до следующего проверенного экспорта (~2 ГБ за квартал), а свёртка
|
||
обязана быть детерминированной. Что не восстанавливается — `stateOfMind`
|
||
(его в экспорте нет) и верхние слои за периоды с удалёнными доставками;
|
||
каталог обязан говорить об этом честно, а не досчитывать молча.
|
||
- **Сохранили — значит приняли.** `critical`, необратимо: отказ приёма теряет
|
||
доставку навсегда. Код ответа отражает доставку, а не разбор: битый JSON —
|
||
400, непонятое содержимое — 200.
|
||
- **Ничего не теряем молча.** `critical`, обратимо пересборкой — но только
|
||
пока архив жив. Идентичность — координаты
|
||
(`метрика + слой + начало + конец`), у точки-измерения конец равен началу:
|
||
под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек —
|
||
отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он
|
||
нестабилен. Хеш канонизированного содержимого остался детектором изменений. При
|
||
столкновении выигрывает **более полная** точка, а при равной полноте —
|
||
**стоящая позже в журнале** (внутри одной доставки — порядок канонических
|
||
форм). Второе означает, что содержимое витрины есть функция **порядка**
|
||
свёртки, и порядок этот обязан равняться журнальному. Изменение
|
||
запечатанного часа — `WARN`, но данные всё равно пишутся.
|
||
- **Дыры закрываются сами.** `major`, обратимо. Три прохода разной глубины
|
||
(5 минут / сутки / неделя). Настройки данных у проходов теперь **разные** — намеренно, они
|
||
наполняют разные слои; это безопасно ровно потому, что слой входит в ключ.
|
||
- **Форма Apple не транслируется.** `major`, обратимо пересборкой. Значения
|
||
отдаём как пришли, нормализовано только время (`ts_utc` + офсет исходной зоны). Единственное добавление —
|
||
стабильный код рядом с переведённой строкой: HAE отдаёт «БДГ» и «Сидячий
|
||
образ жизни» на языке телефона, а родной экспорт — коды HealthKit, и без
|
||
словаря эти два источника не сойтись.
|
||
- **Своей агрегации в хранении нет — есть слои.** `critical`, обратимо
|
||
пересборкой. Метрика лежит в той подробности, в какой пришла
|
||
(`sample`/`raw`/`minute`/`hour`/`day`); слой выводится
|
||
из выравнивания меток, а не из заголовка HAE — тот врёт. Перечень слоёв один и
|
||
лежит в [docs/database.md](docs/database.md), таблица `bucket`.
|
||
- **Агрегация в ответе — только измеренная.** `critical`, обратимо: ответ не
|
||
хранится, но потребитель уже принял по нему решение. Род свёртки выводится
|
||
сверкой слоёв между собой (часовое = сумма минутных → накопительная, = среднее →
|
||
мгновенная), а не размечается руками. Род неизвестен — свёртки нет. И
|
||
никогда не суммируем нижний слой HAE: это интерполяция, а не сэмплы.
|
||
- **Секреты не в логах.** `critical`, необратимо: утечка не отзывается. Токены
|
||
приёма и чтения. Данные о здоровье чувствительны: тела запросов только на
|
||
`DEBUG` и с обрезкой. Периметр и модель угроз — [docs/security.md](docs/security.md).
|
||
|
||
## Команды
|
||
|
||
Запуск через [Task](https://taskfile.dev) (`task --list` — полный список):
|
||
|
||
- `task up` / `task restart` / `task down` — сервис в контейнере; **основной
|
||
способ запуска**, данные в `./data` переживают пересборку
|
||
- `task logs` / `task ps` — что происходит с сервисом
|
||
- `task gate` — детерминированный гейт ревью (build/vet/lint/test/race/
|
||
покрытие диффа/миграции/образцы конфига/секреты/данные в индексе)
|
||
- `task review:context` — вход для архитектурного прохода ревью
|
||
- `task run` — запуск из исходников, без контейнера
|
||
- `task build` — статический бинарь linux/amd64
|
||
- `task test` / `task lint` — тесты и golangci-lint
|
||
- `task verify:archive` — сходимость на живом архиве: весь `./data/raw` через
|
||
разбор, повтор обязан дать то же состояние. В гейт не входит намеренно —
|
||
минута прогона и данные, которых нет ни на какой другой машине
|
||
- `task verify:busy` — свёртка под удерживаемой блокировкой базы: занятость
|
||
обязана оставить доставку в очереди, а отложенная доставка не должна развести
|
||
живую витрину с пересборкой. В гейт не входит: около 50 секунд на прогон
|
||
- `task tidy` — `go mod tidy`
|
||
- `task setup` — установка golangci-lint
|
||
|
||
## Гейт
|
||
|
||
- **Команда:** `task gate` (`BASE=<rev>` — база диффа; без неё берётся
|
||
`git merge-base HEAD master`). Шаги: сборка, `go vet`, `golangci-lint`,
|
||
`gofmt`, тесты, флаки, гонки, покрытие изменённых строк, миграции против
|
||
`docs/database.md`, образцы конфига, секреты и данные о здоровье в индексе,
|
||
уязвимости, раскладка документов (`docs.py check`).
|
||
- **Где логи шагов:** `tmp/gate/<шаг>.log` (каталог под `.gitignore`); сводка —
|
||
в терминале.
|
||
- **Исходы:** 0 — зелёный; ненулевой — красный, и до его починки опиниативные
|
||
проходы ревью **не запускаются**.
|
||
- **Что красит безусловно:** любой файл из `./data` в индексе, любой токен в
|
||
индексе, миграция без правки `docs/database.md`. Причина одна на все: это
|
||
ровно те отказы, которые не видны глазами и стоят необратимо. Покрытие
|
||
изменённых строк задумано тем же классом, но сегодня гейт от него **не
|
||
краснеет**: `scripts/diff-coverage.py` всегда возвращает `0`, и шаг печатает
|
||
`OK` при любом покрытии — разбор непокрытых строк остаётся человеку или
|
||
проходу ревью. Запись 2026-08-04 в [docs/review.md](docs/review.md).
|
||
- **Чего в гейте намеренно нет и кто обязан это гонять:**
|
||
`task verify:archive` (минута прогона, данные есть только на этой машине) и
|
||
`task verify:busy` (около 50 секунд). Гоняет их **человек или оркестратор задачи**
|
||
перед любым изменением правила разбора, идентичности или слияния — а не «когда
|
||
вспомнит». Прецедент, когда молчащая краснота прожила две задачи, записан в
|
||
[docs/review.md](docs/review.md).
|
||
|
||
## Запреты
|
||
|
||
- **Не запускать сервис против `./data`** мимо `task up` / `task run`: это
|
||
рабочая база `./data/healthlog.db` и рабочий архив `./data/raw`, других копий
|
||
нет ни на какой машине.
|
||
- **Не удалять и не перезаписывать `./data`** — ни файл базы, ни каталог
|
||
архива, ни отдельные тела. Подмена базы после пересборки — действие человека
|
||
при остановленном сервисе.
|
||
- **Ничего из `./data` не попадает** ни в git, ни в логи выше `DEBUG`, ни в
|
||
вывод агента.
|
||
- **Не ходить в rivendell** и вообще наружу: деплой и выкладка спрашиваются
|
||
всегда.
|
||
- `testdata` — `internal/hae/testdata`: реальные пакеты HAE с вычищенными
|
||
токенами. Временное — в `./tmp` (под `.gitignore`).
|
||
|
||
## Работа
|
||
|
||
- **Основная ветка:** `master`. От неё считается база диффа
|
||
(`git merge-base HEAD master`), в неё вливает батч, от неё ветвятся задачи.
|
||
- **Необратимое** (спрашивается у человека всегда): деплой, выкладка наружу,
|
||
удаление или перезапись чего-либо в `./data`, подмена файла базы результатом
|
||
пересборки.
|
||
- **Общий станок:** `task verify:archive`. Покраснев, он врывается в
|
||
замороженный спринт: сходимость журнала — тот инвариант, ради которого
|
||
существует архив, и жить с красным прогоном нельзя.
|
||
- **Ориентир по размеру спринта:** 5–8 задач. Ориентир, а не закон.
|
||
- **Что такое «сделана»:** пайплайн `av-dev-pipeline:task-pipeline` пройден
|
||
целиком **и** критерии приёмки задачи проверены поимённо.
|
||
|
||
## Как здесь принято работать
|
||
|
||
Задачи и спринт ведёт `av-dev-pm`, работу над задачей — `av-dev-pipeline`.
|
||
Порядок шагов, состав проходов ревью и правила ведения задач здесь **не
|
||
пересказываются**: их дом — сами скиллы, а проектная настройка конвейера —
|
||
[docs/review.md](docs/review.md). Пересказ разъедется на первой же правке
|
||
скилла, и разойдётся молча.
|
||
|
||
Проектного здесь три вещи:
|
||
|
||
**Действуем автономно.** Умолчание — делать, а не спрашивать. Немедленно
|
||
спрашиваем только про **необратимое** — список выше. Остальное, что решать не
|
||
мне, уходит вопросом в файл задачи, а работа переформулируется на остаток и
|
||
доводится до коммита.
|
||
|
||
**Развилка или вопрос — сперва prior art.** Проект не уникален; правило и
|
||
референсы — [docs/passport.md](docs/passport.md), раздел «Мы не делаем
|
||
уникального». Отвергли готовое решение — причина идёт в `design.md` изменения,
|
||
а оттуда промоутом в [docs/adr/](docs/adr/README.md). В `architecture.md`
|
||
обоснования больше не пишем: он переопределён как обзор.
|
||
|
||
**Поток не останавливается.** Телефон шлёт непрерывно и молча. Сломанный приём,
|
||
оставленный работать, теряет данные необратимо: доставка, не попавшая в
|
||
архив, в журнал не попадает вовсе — телефон её не перешлёт.
|
||
|
||
## Конвенции
|
||
|
||
Механизируемое проверяет `task lint` по `.golangci.yml`, прозой остаётся то,
|
||
что правилом не выражается — [docs/conventions/](docs/conventions/README.md).
|
||
Перечень правил и перечень записей есть в обоих файлах; здесь они не
|
||
дублируются.
|
||
|
||
Отдельно, потому что это решает, каким тестам верить: **тесты на разбор формата
|
||
HAE держим на реальных пакетах** в `testdata`. Документация формата тонкая и
|
||
местами расходится с тем, что приложение реально шлёт, — источником истины
|
||
служат живые данные, [docs/research/apple-health.md](docs/research/apple-health.md).
|
||
Читать **до** работы над разбором: скорее всего вопрос о формате уже закрыт
|
||
измерением.
|
||
|
||
## Язык
|
||
|
||
- Документация, комментарии, сообщения коммитов — **русский**.
|
||
- Код и идентификаторы — английский.
|