docs: документация переведена на канон av-dev-pm

- беклог и план переехали в docs/tasks (38 задач, 11 целей), слаги
  переименованы с транслита на английские, 85 ссылок поправлены
- conventions.md разобран в docs/conventions/, local-research.md — в
  docs/research/, review-journal.md — в docs/review.md с разделом настройки
  конвейера; заведены security.md, adr/ и .pm.json
- шаг docs.py check добавлен в task gate; поведение в architecture.md помечено
  девятью маркерами долга, database.md получил настройки с числовым значением
This commit is contained in:
av
2026-08-03 17:14:53 +03:00
parent de7b15d48c
commit d79189be18
94 changed files with 1234 additions and 566 deletions
+10 -10
View File
@@ -2,21 +2,21 @@
# #
# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign, # Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign,
# staticcheck, unused. Сверх него включены линтеры, механизирующие конвенции # staticcheck, unused. Сверх него включены линтеры, механизирующие конвенции
# из docs/conventions.md: то, что проверяет правило, не остаётся прозой. # из docs/conventions/README.md: то, что проверяет правило, не остаётся прозой.
version: "2" version: "2"
linters: linters:
enable: enable:
- misspell - misspell
# docs/conventions.md, «Логи»: msg — константная категория, данные — в # docs/conventions/logging.md: msg — константная категория, данные — в
# полях, единый стиль ключ-значение. # полях, единый стиль ключ-значение.
- sloglint - sloglint
# docs/conventions.md: без fmt.Print* (логируем через slog), конфиг только # docs/conventions/README.md: без fmt.Print* (логируем через slog), конфиг только
# из TOML (env не используем), время — только store.Now(). # из TOML (env не используем), время — только store.Now().
- forbidigo - forbidigo
# docs/conventions.md, «Ошибки»: сравнение через errors.Is/As. # docs/conventions/errors.md: сравнение через errors.Is/As.
- errorlint - errorlint
# docs/conventions.md, «Ошибки»: ошибки — только stdlib. # docs/conventions/errors.md: ошибки — только stdlib.
- depguard - depguard
settings: settings:
@@ -28,11 +28,11 @@ linters:
forbidigo: forbidigo:
forbid: forbid:
- pattern: ^fmt\.Print.*$ - pattern: ^fmt\.Print.*$
msg: логируем через slog, в stdout напрямую не пишем (docs/conventions.md) msg: логируем через slog, в stdout напрямую не пишем (docs/conventions/logging.md)
- pattern: ^os\.Getenv$ - pattern: ^os\.Getenv$
msg: конфигурация только из TOML, env для конфига не используем (docs/conventions.md) msg: конфигурация только из TOML, env для конфига не используем (docs/conventions/config.md)
- pattern: ^time\.Now$ - pattern: ^time\.Now$
msg: время генерирует store.Now() (UTC, единая точка) — docs/conventions.md msg: время генерирует store.Now() (UTC, единая точка) — docs/conventions/storage.md
errorlint: errorlint:
# Обёртка вида fmt.Errorf("%w: %v", ErrSentinel, err) осознанна: sentinel # Обёртка вида fmt.Errorf("%w: %v", ErrSentinel, err) осознанна: sentinel
@@ -46,9 +46,9 @@ linters:
main: main:
deny: deny:
- pkg: github.com/pkg/errors - pkg: github.com/pkg/errors
desc: ошибки — только stdlib errors + fmt.Errorf (docs/conventions.md) desc: ошибки — только stdlib errors + fmt.Errorf (docs/conventions/errors.md)
- pkg: github.com/cockroachdb/errors - pkg: github.com/cockroachdb/errors
desc: стек-трейсы избыточны, контекст несёт slog (docs/conventions.md) desc: стек-трейсы избыточны, контекст несёт slog (docs/conventions/errors.md)
exclusions: exclusions:
generated: lax generated: lax
+97 -40
View File
@@ -3,7 +3,11 @@
Памятка для работы над healthlog. Перед задачей прочитай также Памятка для работы над healthlog. Перед задачей прочитай также
[docs/passport.md](docs/passport.md) (цель, сценарии, референсы), [docs/passport.md](docs/passport.md) (цель, сценарии, референсы),
[README.md](README.md), [docs/architecture.md](docs/architecture.md), [README.md](README.md), [docs/architecture.md](docs/architecture.md),
[docs/conventions.md](docs/conventions.md) и [docs/plan.md](docs/plan.md). [docs/conventions/README.md](docs/conventions/README.md),
[docs/security.md](docs/security.md) и [docs/tasks/PLAN.md](docs/tasks/PLAN.md).
Документация ведётся по канону `av-dev-pm` (версия в `docs/.pm.json`);
раскладку проверяет `av-dev-pm:canon`, содержимое ведёт `av-dev-pm:docs`.
## Что это ## Что это
@@ -24,43 +28,50 @@ Module path — `git.vakhrushev.me/av/healthlog`.
## Инварианты ## Инварианты
- **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде, Что нарушать нельзя. `severity` рядом с формулировкой — по ней проходы ревью
в каком их прислал HAE. Начнём что-то отбрасывать внутри точки — потеряем присваивают вес находке, а не выводят его заново.
безвозвратно.
- **Хранилище — свёртка по журналу.** Экспорт Apple это снапшот всей истории, - **Точки хранятся дословно.** `critical`, необратимо. Часовой объект держит
точки ровно в том виде, в каком их прислал HAE. Начнём что-то отбрасывать
внутри точки — потеряем безвозвратно.
- **Хранилище — свёртка по журналу.** `critical`, необратимо.
Экспорт Apple это снапшот всей истории,
доставки HAE после его даты — события поверх. Состояние всегда пересобираемо: доставки HAE после его даты — события поверх. Состояние всегда пересобираемо:
`import(экспорт) + replay(доставки по received_at)`. Поэтому сырой архив `import(экспорт) + replay(доставки по received_at)`. Поэтому сырой архив
живёт до следующего проверенного экспорта (~2 ГБ за квартал), а свёртка живёт до следующего проверенного экспорта (~2 ГБ за квартал), а свёртка
обязана быть детерминированной. Что не восстанавливается — `stateOfMind` обязана быть детерминированной. Что не восстанавливается — `stateOfMind`
(его в экспорте нет) и верхние слои за периоды с удалёнными доставками; (его в экспорте нет) и верхние слои за периоды с удалёнными доставками;
каталог обязан говорить об этом честно, а не досчитывать молча. каталог обязан говорить об этом честно, а не досчитывать молча.
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор: - **Сохранили — значит приняли.** `critical`, необратимо: отказ приёма теряет
битый JSON — 400, непонятое содержимое — 200. доставку навсегда. Код ответа отражает доставку, а не разбор: битый JSON —
- **Ничего не теряем молча.** Идентичность — координаты 400, непонятое содержимое — 200.
- **Ничего не теряем молча.** `critical`, обратимо пересборкой — но только
пока архив жив. Идентичность — координаты
(`метрика + слой + начало + конец`), у точки-измерения конец равен началу: (`метрика + слой + начало + конец`), у точки-измерения конец равен началу:
под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек — под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек —
отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он
нестабилен. Хеш нестабилен. Хеш канонизированного содержимого остался детектором изменений. При
канонизированного содержимого остался детектором изменений. При
столкновении выигрывает **более полная** точка, а не последняя. Изменение столкновении выигрывает **более полная** точка, а не последняя. Изменение
запечатанного часа — `WARN`, но данные всё равно пишутся. запечатанного часа — `WARN`, но данные всё равно пишутся.
- **Дыры закрываются сами.** Три прохода разной глубины (5 минут / сутки / - **Дыры закрываются сами.** `major`, обратимо. Три прохода разной глубины
неделя). Настройки данных у проходов теперь **разные** — намеренно, они (5 минут / сутки / неделя). Настройки данных у проходов теперь **разные** — намеренно, они
наполняют разные слои; это безопасно ровно потому, что слой входит в ключ. наполняют разные слои; это безопасно ровно потому, что слой входит в ключ.
- **Форма Apple не транслируется.** Значения отдаём как пришли, нормализовано - **Форма Apple не транслируется.** `major`, обратимо пересборкой. Значения
только время (`ts_utc` + офсет исходной зоны). Единственное добавление — отдаём как пришли, нормализовано только время (`ts_utc` + офсет исходной зоны). Единственное добавление —
стабильный код рядом с переведённой строкой: HAE отдаёт «БДГ» и «Сидячий стабильный код рядом с переведённой строкой: HAE отдаёт «БДГ» и «Сидячий
образ жизни» на языке телефона, а родной экспорт — коды HealthKit, и без образ жизни» на языке телефона, а родной экспорт — коды HealthKit, и без
словаря эти два источника не сойтись. словаря эти два источника не сойтись.
- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в той - **Своей агрегации в хранении нет — есть слои.** `critical`, обратимо
подробности, в какой пришла (`sample`/`raw`/`minute`/`hour`); слой выводится пересборкой. Метрика лежит в той подробности, в какой пришла (`sample`/`raw`/`minute`/`hour`); слой выводится
из выравнивания меток, а не из заголовка HAE — тот врёт. из выравнивания меток, а не из заголовка HAE — тот врёт.
- **Агрегация в ответе — только измеренная.** Род свёртки выводится сверкой - **Агрегация в ответе — только измеренная.** `critical`, обратимо: ответ не
слоёв между собой (часовое = сумма минутных → накопительная, = среднее → хранится, но потребитель уже принял по нему решение. Род свёртки выводится
сверкой слоёв между собой (часовое = сумма минутных → накопительная, = среднее →
мгновенная), а не размечается руками. Род неизвестен — свёртки нет. И мгновенная), а не размечается руками. Род неизвестен — свёртки нет. И
никогда не суммируем нижний слой HAE: это интерполяция, а не сэмплы. никогда не суммируем нижний слой HAE: это интерполяция, а не сэмплы.
- **Секреты не в логах** — токены приёма и чтения. Данные о здоровье - **Секреты не в логах.** `critical`, необратимо: утечка не отзывается. Токены
чувствительны: тела запросов только на `DEBUG` и с обрезкой. приёма и чтения. Данные о здоровье чувствительны: тела запросов только на
`DEBUG` и с обрезкой. Периметр и модель угроз — [docs/security.md](docs/security.md).
## Команды ## Команды
@@ -83,37 +94,83 @@ Module path — `git.vakhrushev.me/av/healthlog`.
- `task tidy``go mod tidy` - `task tidy``go mod tidy`
- `task setup` — установка golangci-lint - `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`.
Причина одна на все: это ровно те отказы, которые не видны глазами и стоят
необратимо.
- **Чего в гейте намеренно нет и кто обязан это гонять:**
`task verify:archive` (минута прогона, данные есть только на этой машине) и
`task verify:busy` (25 секунд). Гоняет их **человек или оркестратор задачи**
перед любым изменением правила разбора, идентичности или слияния — а не «когда
вспомнит». Прецедент, когда молчащая краснота прожила две задачи, записан в
[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` пройден
целиком **и** критерии приёмки задачи проверены поимённо.
## Процесс ## Процесс
Задачи — в [docs/backlog](docs/backlog/README.md) (один файл на задачу, индекс Задачи — в [docs/tasks/BACKLOG.md](docs/tasks/BACKLOG.md) (один файл на запись,
производен). Порядок и его обоснование — в [docs/plan.md](docs/plan.md). индексы производны), цели — в [docs/tasks/PLAN.md](docs/tasks/PLAN.md). Ведёт их
скилл `av-dev-pm:tasks`, спринт и ритуал между спринтами — `av-dev-pm:session`.
Работа над задачей идёт скиллом `healthlog-task-pipeline`: беклог → `opsx:explore` Работа над задачей идёт скиллом `av-dev-pipeline:task-pipeline`: задача
`opsx:propose` → ревью спек (профиль `design`) → `opsx:apply` ревью кода → `opsx:explore``opsx:propose` → ревью спек (профиль `design`) → `opsx:apply`
`opsx:archive`чистка беклога → коммит. Ревью — скилл `healthlog-review-pipeline`, ревью кода → `opsx:archive`закрытие задачи → коммит. Ревью — скилл
проходы — агенты `healthlog-review-*`. `av-dev-pipeline:review-pipeline`, проходы — агенты `av-dev-pipeline:review-*`,
проектная настройка конвейера — [docs/review.md](docs/review.md).
**Действуем автономно.** Умолчание — делать, а не спрашивать. Вопрос, который **Действуем автономно.** Умолчание — делать, а не спрашивать. Вопрос, который
решать не мне, **вынимается блокером** в секцию `блокеры` беклога, задача решать не мне, **выносится в раздел «Вопросы»** файла задачи и помечается тегом
переформулируется на остаток, остаток доводится до коммита. Блокеры разбираются `question`; задача с открытым вопросом в спринт не берётся, а сама работа
пачками; из чего состоит пункт блокера — в переформулируется на остаток и доводится до коммита. Спрашиваем немедленно
[индексе беклога](docs/backlog/README.md). Спрашиваем только про только про **необратимое** — список выше.
**необратимое**: деплой, выкладку наружу, удаление или перезапись данных в
`./data`.
**Развилка или блокер — сперва prior art.** Проект не уникален: прежде чем **Развилка или вопрос — сперва prior art.** Проект не уникален: прежде чем
проектировать своё, смотрим, как это решено в референсах проектировать своё, смотрим, как это решено в референсах
[паспорта](docs/passport.md) и в интернете. Готовое решение либо берётся, либо [паспорта](docs/passport.md) и в интернете. Готовое решение либо берётся, либо
отвергается с названной причиной — и причина идёт в `architecture.md`. отвергается с названной причиной — и причина идёт в `architecture.md`.
Гейт блокирует: пока `task gate` красный, опиниативные проходы ревью не
запускаются.
**Поток не останавливается.** Телефон шлёт непрерывно и молча. Сломанный приём, **Поток не останавливается.** Телефон шлёт непрерывно и молча. Сломанный приём,
оставленный работать, теряет данные необратимо: доставка, не попавшая в оставленный работать, теряет данные необратимо: доставка, не попавшая в
архив, в журнал не попадает вовсе — телефон её не перешлёт. архив, в журнал не попадает вовсе — телефон её не перешлёт.
Ничего из `./data` не попадает ни в git, ни в логи выше `DEBUG`, ни в вывод
агента.
## Конвенции ## Конвенции
@@ -123,7 +180,7 @@ Module path — `git.vakhrushev.me/av/healthlog`.
(`depguard`). Пересказывать эти правила не нужно — линтер скажет точнее. (`depguard`). Пересказывать эти правила не нужно — линтер скажет точнее.
Прозой остаётся то, что правилом не выражается: Прозой остаётся то, что правилом не выражается:
[docs/conventions.md](docs/conventions.md) — уровень лога по адресату, [docs/conventions/README.md](docs/conventions/README.md) — уровень лога по адресату,
единственный логирующий чекпоинт на доменной границе, трансляция ошибки на единственный логирующий чекпоинт на доменной границе, трансляция ошибки на
внешней границе, самодокументируемый `config.example.toml`, время в БД в UTC внешней границе, самодокументируемый `config.example.toml`, время в БД в UTC
RFC 3339, ULID через `ident`. RFC 3339, ULID через `ident`.
@@ -132,7 +189,7 @@ RFC 3339, ULID через `ident`.
`testdata`. Документация формата тонкая и местами расходится с тем, что `testdata`. Документация формата тонкая и местами расходится с тем, что
приложение реально шлёт, — источником истины служат живые данные. приложение реально шлёт, — источником истины служат живые данные.
Что показал реальный поток — [docs/local-research.md](docs/local-research.md). Что показал реальный поток — [docs/research/apple-health.md](docs/research/apple-health.md).
Читать **до** работы над разбором: там же лежат находки, которых нет в Читать **до** работы над разбором: там же лежат находки, которых нет в
документации HAE (поле `source` существует; порядок ключей в JSON нестабилен, документации HAE (поле `source` существует; порядок ключей в JSON нестабилен,
поэтому хеш содержимого считается по канонической форме с рекурсивной поэтому хеш содержимого считается по канонической форме с рекурсивной
+8 -6
View File
@@ -72,10 +72,10 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
открывается. Журнал WAL разбирается фоновым чекпойнтом по таймеру. открывается. Журнал WAL разбирается фоновым чекпойнтом по таймеру.
Чего ещё нет: **read API точек**, тренировок и записей — сами данные наружу Чего ещё нет: **read API точек**, тренировок и записей — сами данные наружу
пока не отдаются. План в [docs/plan.md](docs/plan.md). пока не отдаются. План в [docs/tasks/PLAN.md](docs/tasks/PLAN.md).
Разведка формата закончена: 50 находок на живом потоке, половина расходится с Разведка формата закончена: 50 находок на живом потоке, половина расходится с
документацией Health Auto Export — [docs/local-research.md](docs/local-research.md). документацией Health Auto Export — [docs/research/apple-health.md](docs/research/apple-health.md).
## Команды ## Команды
@@ -161,10 +161,12 @@ curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
работы, референсы: чужие проекты, у которых смотрим решения, прежде чем работы, референсы: чужие проекты, у которых смотрим решения, прежде чем
придумывать своё придумывать своё
- [docs/architecture.md](docs/architecture.md) — устройство, схема данных, API, принятые решения - [docs/architecture.md](docs/architecture.md) — устройство, схема данных, API, принятые решения
- [docs/conventions.md](docs/conventions.md) — как пишем код - [docs/conventions/](docs/conventions/README.md) — как пишем код
- [docs/plan.md](docs/plan.md) — шаги и обоснование их порядка - [docs/security.md](docs/security.md) — периметр и модель угроз
- [docs/backlog](docs/backlog/README.md) — что брать следующим, включая - [docs/review.md](docs/review.md) — настройка конвейера ревью и журнал дефектов
- [docs/tasks/PLAN.md](docs/tasks/PLAN.md) — цели и обоснование их порядка
- [docs/tasks/BACKLOG.md](docs/tasks/BACKLOG.md) — что брать следующим, включая
отложенные идеи отложенные идеи
- [docs/local-research.md](docs/local-research.md) — что показал реальный поток - [docs/research/apple-health.md](docs/research/apple-health.md) — что показал реальный поток
Health Auto Export; источник истины по формату, документация приложения Health Auto Export; источник истины по формату, документация приложения
местами расходится с тем, что оно шлёт местами расходится с тем, что оно шлёт
+20 -1
View File
@@ -10,6 +10,9 @@ vars:
PKG: ./cmd/healthlog PKG: ./cmd/healthlog
# Версии инструментов для воспроизводимой установки (см. задачу setup). # Версии инструментов для воспроизводимой установки (см. задачу setup).
GOLANGCI_VERSION: v2.12.2 GOLANGCI_VERSION: v2.12.2
# Проверка раскладки документов по канону av-dev-pm. Пусто — путь ищется в
# кеше плагинов (версия в пути меняется при обновлении, поэтому не зашита).
DOCS_PY: '{{.DOCS_PY | default ""}}'
tasks: tasks:
default: default:
@@ -101,9 +104,25 @@ tasks:
- docker compose ps - docker compose ps
gate: gate:
desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/секреты. BASE=<rev> — база диффа' desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/секреты/раскладка документов. BASE=<rev> — база диффа'
cmds: cmds:
- python3 scripts/gate.py {{.BASE}} - python3 scripts/gate.py {{.BASE}}
# Раскладка документов по канону av-dev-pm. Путь переопределяется
# переменной DOCS_PY — переустановка плагина не должна править Taskfile.
# Шаг обязан краснеть внятно, если скрипта нет: молча пропущенная
# проверка раскладки хуже отсутствующей.
- |
ds="{{.DOCS_PY}}"
if [ -z "$ds" ]; then
ds=$(ls -t "$HOME"/.claude/plugins/cache/*/av-dev-pm/*/skills/canon/scripts/docs.py 2>/dev/null | head -1)
fi
if [ -z "$ds" ] || [ ! -f "$ds" ]; then
echo "гейт: docs.py не найден"
echo " плагин av-dev-pm не установлен либо переехал —"
echo " поставь его или задай DOCS_PY=<путь> при вызове task gate"
exit 1
fi
python3 "$ds" check --dir . {{if .BASE}}--base {{.BASE}}{{end}}
review:context: review:context:
desc: 'Вход для архитектурного прохода ревью: пакеты, граф зависимостей, инвентарь концепций' desc: 'Вход для архитектурного прохода ревью: пакеты, граф зависимостей, инвентарь концепций'
+4
View File
@@ -0,0 +1,4 @@
{
"canon": 1,
"migrations": "internal/store/migrations"
}
+42
View File
@@ -0,0 +1,42 @@
# Журнал решений
Одна запись — одно решение. **ADR это промоут поверх архивного `design.md`**,
а не второе сочинение: запись цитирует решение и ссылается на
`openspec/changes/archive/<id>/design.md`.
## Когда заводить
Верно одно из трёх:
<!-- копия: adr-когда-заводить из av-dev-pm/skills/canon/references/canon.md -->
- **дорогой откат** — переделка стоит дороже переписывания одного файла;
- **намеренный отказ** от очевидного подхода;
- **пересмотр прежнего решения** — тогда у старой записи обязателен статус
«заменено на».
<!-- /копия: adr-когда-заводить -->
Не заводить для рутины и для того, что видно из кода и `git log`.
## Соглашения
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, слаг английский, дата — когда решение
реально принято.
- Записи неизменяемы: передумали — новая запись, старой ставится статус.
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
`устарело`.
## Записи
Новые сверху.
| Дата | Запись | Статус |
| --- | --- | --- |
Записей пока нет: каталог заведён переездом на канон 2026-08-03. Сырьё для
промоута накоплено — девять архивных изменений в
`openspec/changes/archive/`, из них решения с дорогим откатом и намеренные
отказы есть как минимум в `2026-08-01-polnota-tochki-mnozhestvom-klyuchey`
(идентичность точки и тай-брейк), `2026-08-02-reindex-iz-arhiva` (подмену базы
делает человек) и `2026-08-02-cena-chitayushchego-marshruta` (чекпойнт WAL по
таймеру). Промоут делает скилл `av-dev-pm:docs`, а не переезд: адаптация
раскладки содержания не сочиняет.
+18
View File
@@ -0,0 +1,18 @@
# Краткий заголовок решения
- Дата: ГГГГ-ММ-ДД
- Источник: openspec/changes/archive/<id>/design.md
## Решение
Что именно решено — одной фразой.
## Почему
Намерение и причина. Цитата из источника, а не пересказ. Пиши так, чтобы через
год было понятно без чтения переписки.
## Последствия
- `+` что стало лучше.
- `` чем платим: ограничения, риски, нагрузка на поддержку.
+45 -18
View File
@@ -1,5 +1,11 @@
# Архитектура # Архитектура
Обзор: как сложено и где что работает. **Поведение системы здесь не
описывается** — его нормативный дом [`openspec/specs/`](../openspec/specs).
Разделы, помеченные `<!-- канон: поведение → … -->`, ещё не разнесены:
это долг переезда на канон 2026-08-03, он закрывается порциями по ходу
задач и гейт от него не краснеет.
## Назначение ## Назначение
healthlog принимает выгрузки Apple Health из приложения Health Auto Export healthlog принимает выгрузки Apple Health из приложения Health Auto Export
@@ -50,6 +56,8 @@ healthlog принимает выгрузки Apple Health из приложен
## Формат Health Auto Export ## Формат Health Auto Export
<!-- канон: поведение → openspec/specs/parsing -->
Документация формата скудная: [help.healthyapps.dev](https://help.healthyapps.dev/en/health-auto-export/automations/rest-api/) Документация формата скудная: [help.healthyapps.dev](https://help.healthyapps.dev/en/health-auto-export/automations/rest-api/)
и [wiki Lybron/health-auto-export](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format). и [wiki Lybron/health-auto-export](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format).
Ниже — то, на что мы опираемся; всё остальное уточняем по реальным пакетам. Ниже — то, на что мы опираемся; всё остальное уточняем по реальным пакетам.
@@ -84,7 +92,7 @@ healthlog принимает выгрузки Apple Health из приложен
Вопреки документации, в точке **есть поле `source`** — какие устройства Вопреки документации, в точке **есть поле `source`** — какие устройства
вложились в значение (составное, через `|`). Что ещё документация описывает вложились в значение (составное, через `|`). Что ещё документация описывает
неверно и как поток выглядит на самом деле — [local-research.md](local-research.md). неверно и как поток выглядит на самом деле — [research/apple-health.md](research/apple-health.md).
Даты приходят строкой с офсетом: `2026-07-31 12:00:00 +0300` — не RFC 3339. Даты приходят строкой с офсетом: `2026-07-31 12:00:00 +0300` — не RFC 3339.
@@ -95,7 +103,7 @@ healthlog принимает выгрузки Apple Health из приложен
данные» выключен, группировка при этом недоступна). Причина — суммированные данные» выключен, группировка при этом недоступна). Причина — суммированные
значения досчитываются задним числом: минутное ведро уезжает неполным и в значения досчитываются задним числом: минутное ведро уезжает неполным и в
следующей доставке приезжает полным следующей доставке приезжает полным
([local-research.md](local-research.md), находка 10). На несуммированных ([research/apple-health.md](research/apple-health.md), находка 10). На несуммированных
данных расхождений не наблюдалось (находка 3), поэтому идентичность по данных расхождений не наблюдалось (находка 3), поэтому идентичность по
содержимому работает без оговорок. Заодно сохраняются детали, которые содержимому работает без оговорок. Заодно сохраняются детали, которые
группировка съедает: эпизоды сна и межударные интервалы (находки 6, 19). группировка съедает: эпизоды сна и межударные интервалы (находки 6, 19).
@@ -205,19 +213,22 @@ HRV); у накопительных — только `date`. Поэтому то
## Компоненты ## Компоненты
| Пакет | Ответственность | Пакет — это реализация; **что система делает, нормативно сказано в
| ---------- | ------------------------------------------------------ | capability**, и здесь стоит ссылка, а не пересказ требований.
| `config` | загрузка и валидация TOML-конфига |
| `logging` | сборка slog-логгера | | Пакет | Ответственность | Capability |
| `ident` | генерация и разбор ULID | | ---------- | ------------------------------------------------------ | ---------- |
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | | `config` | загрузка и валидация TOML-конфига | — |
| `hae` | разбор формата HAE, канонизация, хеш содержимого | | `logging` | сборка slog-логгера | — |
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | | `ident` | генерация и разбор ULID | — |
| `fold` | свёртка одной доставки в часовые объекты | | `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) |
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | | `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) |
| `catalog` | каталог разрезов и измерение рода агрегации | | `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | | `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md) |
| `httpapi` | приём и read API | | `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) |
| `httpapi` | приём и read API | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md) |
## Приём ## Приём
@@ -309,7 +320,7 @@ HRV); у накопительных — только `date`. Поэтому то
предшественницы, слоя не выведет и уйдёт в `failed`: её точки доедут только предшественницы, слоя не выведет и уйдёт в `failed`: её точки доедут только
пересборкой. Окно узкое, и изменение его сужает, а не открывает, — но закрытие пересборкой. Окно узкое, и изменение его сужает, а не открывает, — но закрытие
предела требует удерживать порядок на самом приёме, и это отдельный вопрос предела требует удерживать порядок на самом приёме, и это отдельный вопрос
(беклог, блокеры). (задача `journal-order-on-ingest`).
Остановка формулируется **инвариантом**: приём прекращается раньше воркера, и Остановка формулируется **инвариантом**: приём прекращается раньше воркера, и
после остановки не существует доставки, которая числится разобранной, а записана после остановки не существует доставки, которая числится разобранной, а записана
@@ -378,6 +389,8 @@ HRV); у накопительных — только `date`. Поэтому то
### Сырой архив и восстановление состояния ### Сырой архив и восстановление состояния
<!-- канон: поведение → openspec/specs/reindex -->
`raw/ГГГГ/ММ/ДД/<ulid>.json.gz` — тело запроса как пришло, не редактируется. `raw/ГГГГ/ММ/ДД/<ulid>.json.gz` — тело запроса как пришло, не редактируется.
Два источника вместе образуют **полный журнал событий**, а хранилище — Два источника вместе образуют **полный журнал событий**, а хранилище —
@@ -521,6 +534,8 @@ HAE. Значит для него доставки не хвост журнал
### Версия витрины и обслуживание журнала ### Версия витрины и обслуживание журнала
<!-- канон: поведение → openspec/specs/reindex -->
Два механизма живут рядом и держатся друг за друга: один говорит читателю «в Два механизма живут рядом и держатся друг за друга: один говорит читателю «в
базу никто не коммитил», второй разбирает журнал, в который эти коммиты легли. базу никто не коммитил», второй разбирает журнал, в который эти коммиты легли.
@@ -647,6 +662,8 @@ Litestream) не взят по названной причине: он двиг
### Устаревание нижнего слоя ### Устаревание нижнего слоя
<!-- канон: поведение → openspec/specs/storage -->
Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3 Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3
месяца. Данные HAE в нижнем слое старше последнего экспорта избыточны: тот же месяца. Данные HAE в нижнем слое старше последнего экспорта избыточны: тот же
период лежит в слое `sample` подробнее и честнее. период лежит в слое `sample` подробнее и честнее.
@@ -683,6 +700,8 @@ Litestream) не взят по названной причине: он двиг
### Часовые объекты метрик ### Часовые объекты метрик
<!-- канон: поведение → openspec/specs/storage -->
Точки метрик хранятся не по одной, а **пачками: один объект = одна метрика за Точки метрик хранятся не по одной, а **пачками: один объект = одна метрика за
один час UTC**. один час UTC**.
@@ -719,6 +738,8 @@ record(kind, id, ts_utc, tz_offset, payload BLOB, content_hash,
### Слои гранулярности ### Слои гранулярности
<!-- канон: поведение → openspec/specs/storage -->
Одна и та же метрика может приходить с разной подробностью: несуммированной, Одна и та же метрика может приходить с разной подробностью: несуммированной,
минутной, часовой. Мы **не сводим их к одной** и не агрегируем сами — храним минутной, часовой. Мы **не сводим их к одной** и не агрегируем сами — храним
разрезами и говорим клиенту, какие разрезы есть. разрезами и говорим клиенту, какие разрезы есть.
@@ -779,7 +800,7 @@ hour метки выровнены на час heart_rate 00:00:00
Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от
**префикса журнала**. Наследование от последней доставки вообще делает свёртку **префикса журнала**. Наследование от последней доставки вообще делает свёртку
зависящей от истории, и пересборка даёт не то состояние, что живой приём — зависящей от истории, и пересборка даёт не то состояние, что живой приём —
поймано прогоном архива, 1737 объектов против 1742 (docs/review-journal.md). поймано прогоном архива, 1737 объектов против 1742 (docs/review.md).
Классифицировать доставку целиком нельзя: при перенастройке автоматизации Классифицировать доставку целиком нельзя: при перенастройке автоматизации
приезжают **смешанные доставки**, где часть метрик уже минутная, а часть ещё приезжают **смешанные доставки**, где часть метрик уже минутная, а часть ещё
@@ -810,7 +831,7 @@ hour метки выровнены на час heart_rate 00:00:00
причина держать сырой архив. Точнее она именно этим, а не тем, что видит более причина держать сырой архив. Точнее она именно этим, а не тем, что видит более
длинный ряд: слой обязан оставаться функцией **префикса** журнала, и наследование длинный ряд: слой обязан оставаться функцией **префикса** журнала, и наследование
«от последней доставки вообще» уже ловили дефектом (1737 объектов против 1742, «от последней доставки вообще» уже ловили дефектом (1737 объектов против 1742,
`docs/review-journal.md`). `docs/review.md`).
Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть
проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и
@@ -928,6 +949,8 @@ hour метки выровнены на час heart_rate 00:00:00
### Измерение рода агрегации ### Измерение рода агрегации
<!-- канон: поведение → openspec/specs/catalog -->
Род метрики — `cumulative`, `instant` или `unknown` — выводится сверкой Род метрики — `cumulative`, `instant` или `unknown` — выводится сверкой
минутного слоя с часовым. Правило целиком: минутного слоя с часовым. Правило целиком:
@@ -1062,6 +1085,8 @@ Assistant требует ручного удаления статистики).
### Категориальные значения ### Категориальные значения
<!-- канон: поведение → openspec/specs/parsing -->
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами: HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
фаза сна приезжает как «БДГ», контекст пульса — как «Сидячий образ жизни», тип фаза сна приезжает как «БДГ», контекст пульса — как «Сидячий образ жизни», тип
тренировки — как «В помещении Ходьба» (машинная калька с `Indoor Walk`). При тренировки — как «В помещении Ходьба» (машинная калька с `Indoor Walk`). При
@@ -1095,6 +1120,8 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с
### Тренировки и прочие секции ### Тренировки и прочие секции
<!-- канон: поведение → openspec/specs/parsing -->
Тренировка адресуется своим `id` из HealthKit, запись — парой `род + id`. Тренировка адресуется своим `id` из HealthKit, запись — парой `род + id`.
`record` держит секции с собственными идентификаторами; разбором покрыт пока `record` держит секции с собственными идентификаторами; разбором покрыт пока
только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и
-65
View File
@@ -1,65 +0,0 @@
# Беклог
Одна задача = один файл `<slug>.md` + строка в этом индексе.
Приоритет — грубая оценка «ценность / стоимость». Спекулятивные
задачи помечены `[idea]` в заголовке. Ведётся скиллом `backlog`.
**Блокеры** — вопросы, вынутые из задач. Работа над задачей идёт автономно; если
внутри обнаружился вопрос, который решать не мне, он **вынимается** отдельным
пунктом сюда, а сама задача переформулируется на остаток и продолжается. Пункт
блокера отвечает на четыре вопроса: что именно решить, какие есть варианты с
ценой каждого, что заблокировано пока решения нет, и какая **рекомендация**
без неё вопрос перекладывается целиком, а решать его всё равно с тем же
контекстом. Разбираются пачками, а не по одному: прерывать поток ради каждого
дороже, чем накопить.
Варианты ищутся **не с нуля**: сперва prior art — как это решено в референсах
[паспорта](../passport.md) и в интернете, — и только потом своё. Готовое решение
либо берётся, либо отвергается с названной причиной.
## блокеры
## высокий
- [Тай-брейк при равной полноте точек](taj-brejk-pri-ravnoj-polnote.md) — Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
## средний
- [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
- [Выведенные из данных схемы содержимого](samoopisanie-shemy.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [Импорт родного экспорта Apple Health](import-eksporta-apple.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [Идентичность тренировок при импорте родного экспорта](identichnost-trenirovok-pri-importe.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) — Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую
- [Наблюдаемость: /stats](stats-nablyudaemost.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- [Проверка целостности собранной витрины перед подменой](celostnost-pered-podmenoj.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
- [Чем откатывать релиз после наката миграции](otkat-reliza-posle-migracii.md) — Решено: копия файла базы перед накатом. Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем
- [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — Решено: повторы, но после /stats. Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда
- [Деплой на rivendell](deploy-rivendell.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома
- [Управление токенами и секретами](upravlenie-sekretami.md) — Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
- [[idea] Что считать сутками при смене часового пояса](sutki-i-chasovoj-poyas.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- [[idea] Пересекающиеся источники одной метрики](peresekayushchiesya-istochniki.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
- [Умолчания конфига указывают на прежнюю раскладку](umolchaniya-konfiga-data.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
- [Счётчики слияния переживают ротацию логов](nablyudenie-za-sliyaniem-v-bd.md) — единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- [Цена слияния на широкой доставке](cena-sliyaniya-na-shirokoj-dostavke.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
- [Заголовки доставки в архиве рядом с телом](zagolovki-dostavki-v-arhive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
- [Предел на размер и число заголовков доставки](predel-na-zagolovki-dostavki.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
- [Сверка живой витрины с пересборкой](sverka-vitriny-s-peresborkoj.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- [Сущность с id, но неразобранной меткой](hranenie-sushchnosti-bez-metki.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
- [Пределы на размер сущности и потоковый расчёт формы](predely-razmera-sushchnosti.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
- [Остановка и миграция: раздельные бюджеты и следы в логе](ostanovka-i-migraciya-sledy.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
## низкий
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [Ретеншен сырого архива](retenshen-syrogo-arhiva.md) — Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает
- [Пересборка держит весь журнал в памяти](pereborka-ne-vlezaet-v-pamyat.md) — Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
- [Активный алерт «данных нет 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) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
- [Data-миграции не отбирают строки по обрезаемым спискам](otbor-strok-data-migraciyami.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
-169
View File
@@ -1,169 +0,0 @@
# Конвенции кода
Как пишем код (How), а не что система делает (What — в
[architecture.md](architecture.md)). Перенесено из jellybit и сжато под
масштаб этого проекта.
## Язык
- Документация, комментарии, сообщения коммитов — **русский**.
- Код и идентификаторы — **английский**.
## Ошибки
- Только стандартный `errors` + `fmt.Errorf`. Сторонних пакетов ошибок нет:
контекст несёт `slog`, стек-трейсы для домашнего сервиса избыточны.
- Контекст добавляем обёрткой `%w` — это дефолт, чтобы `errors.Is`/`As`
работали сквозь слои. `%v` — только когда причину сознательно не
раскрываем.
- Стиль сообщения: со строчной, без точки, без «failed to». Контекст —
операция или субъект (`"open archive: %w"`), каждый слой добавляет **свой**
смысл, не повторяя нижний.
- Граничные ошибки транслируем в доменные у источника: `sql.ErrNoRows`
`store.ErrNotFound` внутри `store`, чтобы выше не торчал `database/sql`.
- **Sentinel** (`var ErrNotFound = errors.New(...)`) — для условий, на которые
ветвится код. **Типизированная ошибка** — когда вызывающему нужны данные
ошибки. Не плодим типы там, где хватает sentinel.
- Наружу (HTTP) отдаём человекочитаемое сообщение по доменной ошибке, не
сырой `err.Error()`. Маппинг доменная ошибка → статус живёт в одной точке
в `httpapi`; новая штатная ветвь отказа заводится sentinel'ом и
добавляется туда, иначе `default` отдаст 500 на нормальный конфликт.
- Собрать независимые ошибки (валидация конфига — все проблемы разом) —
`errors.Join`.
- `panic` — только невосстановимое: нарушенный инвариант, сбой инициализации.
`recover` — на верхней границе HTTP-обработчика.
- Глушить ошибку без лога — только с однострочным комментарием «почему».
## Логи
Структурированный JSON (`log/slog`) в stdout, один формат для dev и prod.
Сбор и ротацию делает окружение.
- `msg` — короткая константа в нижнем регистре, категория события
(`delivery accepted`, `parse failed`). Данные — атрибутами, не в тексте.
Подсистему выносим в поле `capability` (`ingest`/`parse`/`query`), не в
префикс сообщения.
- **Уровень — это адресат, а не громкость поломки:**
| Уровень | Кому | Примеры |
|---|---|---|
| `DEBUG` | разработчику при отладке | `/healthz`, тела запросов, шаги разбора |
| `INFO` | владельцу, аудит постфактум | принята доставка, разбор завершён, старт |
| `WARN` | владельцу, «может стать проблемой» | точка не разобрана, незнакомая форма метрики |
| `ERROR` | владельцу, в разбор | не записался архив, сбой БД |
- Невалидный ввод от отправителя — `DEBUG`, а не `ERROR`: это норма, разбирать
нечего. `WARN` ≠ «ничего страшного», `WARN` = «может стать проблемой».
- Событийное → `INFO`, рутинно-частое (healthcheck, поллинг) → `DEBUG`.
- **Либо лог, либо возврат, не оба.** Промежуточные слои только оборачивают и
возвращают. Ошибка логируется **один раз**, на границе доменного слоя,
которая определяет исход операции (`ingest`) — не в транспорте. Транспорт
переводит ошибку в ответ и не логирует повторно.
- Ошибка — атрибутом: `log.Error("parse failed", "error", err, "delivery_id", id)`.
- Время в логах — UTC, RFC 3339 с долями секунды.
- Корреляция — по `delivery_id` (ULID), отдельный `trace_id` не заводим.
- **Секреты в логи не попадают**: токены приёма и чтения, `Authorization`.
При сомнении логируем факт наличия, не значение.
- Данные о здоровье — чувствительные. Тела запросов пишем только на `DEBUG`
и с обрезкой по длине.
- **Текст ошибки разбора не содержит значений из входа** — только род токена
(словарём JSON, не именем типа языка) и смещение. Инвариант выше обходится
одним `fmt.Errorf("%v", tok)`: тело в 8 МиБ дало текст ошибки в 8 МиБ, и он
уехал атрибутом `error` на уровень `WARN`. Предел держит само сообщение, а не
обрезка на стороне логирующего: обрезка живёт в другом месте и о новой ошибке
разбора не узнает.
## Конфигурация
- Только **TOML**, никаких env-переменных: окружение наследуется дочерними
процессами и видно через `/proc/<pid>/environ` — для токенов это слабее
файла под `0600`.
- Грузим один раз при старте в типизированную `Config`; дальше по коду читаем
только её. Конфиг неизменяем — смена параметров означает рестарт.
- Имя по умолчанию — `config.toml` в рабочей директории, переопределяется
`--config=path`.
- `config.example.toml` коммитим как единый самодокументируемый справочник:
**каждое поле с комментарием**, из которого ясно зачем оно, каков диапазон
допустимых значений и в каких единицах. Секретные поля — пустые.
- Реальный `config.toml` не коммитится; секреты рендерит деплой.
- **Валидация на старте, до приёма трафика.** Невалидный конфиг — `ERROR` и
выход с ненулевым кодом. Не стартуем «наполовину».
## База данных и идентификаторы
- Первичные ключи сущностей — **TEXT ULID**, генерируется приложением
(`internal/ident`). Сортируется по времени создания, удобен в логах и URL.
Разбор внешнего id — `ident.Parse` на входной границе; синтаксически
невалидный id — 404 без похода в БД.
- Естественный ключ вместо ULID там, где он есть по природе данных: `workout`
по `id` из HealthKit, `record` — по паре `род секции + id` (форму
идентификатора у пяти из шести секций живьём никто не видел, и несквозной `id`
в двух секциях затёр бы одну запись другой молча).
- Новая единица хранения тем же изменением входит в **отпечаток витрины** и в
счётчики отчёта пересборки. Отпечаток отвечает «да/нет» за витрину целиком, и
единица, которой нет в счётчиках, делает расхождение безадресным: человек
видит «не совпало» при неизменившемся числе объектов и принимает по этому
необратимое решение о подмене базы.
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
множества версий**, либо явно **функцией порядка журнала** — третьего
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
порядок свёртки порядку журнала не равен, и живая витрина расходится с
пересборкой молча.
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
названный предел длины (имена непокрытых секций, `id` сущности).
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не
измерялось».** Миграция, добавляющая такую колонку, не подставляет ноль
историческим строкам: ноль означает «проверено, пусто», а не «не знаем», и
подстановка выдаёт неизмеренное за измеренное — с видом измерения. Пример:
`delivery.skipped_entities`, по которому ретеншен решает, можно ли удалить
тело.
- **Метка изменения строки меняется только при изменении содержимого.** Апдейт,
трогающий одни метаданные (провенанс, ссылки), `updated_at` не двигает — иначе
она становится меткой касания, и запрос «что изменилось с момента X» получает
столько ложных изменений, сколько раз источник переприслал то же самое (у
тренировки — двадцать шесть).
- **Новая производная от разбора колонка в момент появления вносится в перечень
того, что пересборка не переносит.** Перечень — единственное место, где это
сказано, и следующий автор решает по нему; поле, не внесённое туда, однажды
перенесут «для полноты учёта», и витрина снова станет функцией предыдущего
прогона.
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
падать громко.
- Enum-поля — обычный `TEXT` без `CHECK`, допустимые значения держит код.
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
структуры обновляем схему в [architecture.md](architecture.md) тем же
изменением.
## Тесты
- Тесты на разбор формата HAE держим на **реальных пакетах**, сложенных в
`testdata` (с вычищенными токенами). Документация формата ненадёжна —
источником истины служат живые данные.
- Проверяем идемпотентность: повторный разбор того же пакета не меняет
витрину.
- **Где код выбирает между двумя версиями одних данных, тест обязан прогнать
обе стороны и хотя бы одну перестановку трёх.** Пример на паре доказывает
коммутативность и молчит про ассоциативность, а сломаться правило может
именно на ней: полнота — частичный порядок, тай-брейк — тотальный, и их
попарная свёртка дала нетранзитивное отношение победы, из-за которого одна
и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого
не увидело, ревью кода увидело только перебором троек. Правилом линтера не
выражается — отсюда проза.
- **В тот же перебор обязана входить версия с содержимым, равным одной из уже
присланных, и пара «равная каноническая форма, разные байты».** Три версии с
разными хешами ветку «содержание равно» не посещают ни разу — а именно на ней
устаревал провенанс, и живая витрина расходилась с пересборкой молча. Пара с
равной формой ловит другое: неединственный минимум, при котором победителем
оказывается просто первый в срезе, то есть порядок элементов на проводе.
- **Изменение правила разбора или слияния сопровождается замером на живом
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
без числа не отличается от предположения, а цена ошибки здесь — необратимое
решение о судьбе тел.
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time`
и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела
запросов, координаты объектов).
+46
View File
@@ -0,0 +1,46 @@
# Конвенции кода
Как мы пишем код — в отличие от `openspec/specs/`, который описывает, что
система делает, и [architecture.md](../architecture.md), который описывает, как
она сложена.
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
правилом линтера, отсюда удаляется и переезжает в перечень «Механизировано».
Язык документации и кода — в [CLAUDE.md](../../CLAUDE.md): это правило шире
кода, оно касается и коммитов, и документов.
## Записи
- [errors.md](errors.md) — ошибки: обёртка, sentinel против типа, трансляция на
границе, что глушим.
- [logging.md](logging.md) — логи: уровень по адресату, единственный логирующий
чекпоинт, что не попадает в лог никогда.
- [config.md](config.md) — конфигурация: TOML, валидация на старте,
самодокументируемый образец.
- [storage.md](storage.md) — база и идентификаторы: ULID и естественные ключи,
время в UTC, правило выбора между версиями, отпечаток витрины, миграции.
- [testing.md](testing.md) — тесты: реальные пакеты в `testdata`,
идемпотентность, перебор версий, замер на живом архиве.
## Механизировано
Проверяет `task lint` по [.golangci.yml](../../.golangci.yml). Пересказывать эти
правила прозой не нужно — линтер скажет точнее и всегда актуальнее.
| Правило | Где механизировано |
| --- | --- |
| `msg` лога — константная категория, данные атрибутами | `sloglint` |
| Без `fmt.Print*` — логируем через `slog` | `forbidigo` |
| Конфигурация только из TOML, без `os.Getenv` | `forbidigo` |
| Время генерирует `store.Now()`, не `time.Now()` | `forbidigo` |
| Сравнение ошибок через `errors.Is`/`As`, не `==` | `errorlint` |
| Ошибки только stdlib `errors` + `fmt.Errorf` | `depguard` |
| Стек-трейсы избыточны — контекст несёт `slog` | `depguard` |
Плюс шаги [`task gate`](../../Taskfile.yml): сборка, `go vet`, `gofmt`, тесты,
гонки, покрытие изменённых строк, миграции, образцы конфига, секреты в индексе,
данные о здоровье в индексе.
Непойманное место механизации означает, что проход по конвенциям будет
добросовестно проверять уже проверенное.
+15
View File
@@ -0,0 +1,15 @@
# Конфигурация
- Только **TOML**, никаких env-переменных: окружение наследуется дочерними
процессами и видно через `/proc/<pid>/environ` — для токенов это слабее
файла под `0600`.
- Грузим один раз при старте в типизированную `Config`; дальше по коду читаем
только её. Конфиг неизменяем — смена параметров означает рестарт.
- Имя по умолчанию — `config.toml` в рабочей директории, переопределяется
`--config=path`.
- `config.example.toml` коммитим как единый самодокументируемый справочник:
**каждое поле с комментарием**, из которого ясно зачем оно, каков диапазон
допустимых значений и в каких единицах. Секретные поля — пустые.
- Реальный `config.toml` не коммитится; секреты рендерит деплой.
- **Валидация на старте, до приёма трафика.** Невалидный конфиг — `ERROR` и
выход с ненулевым кодом. Не стартуем «наполовину».
+24
View File
@@ -0,0 +1,24 @@
# Ошибки
- Только стандартный `errors` + `fmt.Errorf`. Сторонних пакетов ошибок нет:
контекст несёт `slog`, стек-трейсы для домашнего сервиса избыточны.
- Контекст добавляем обёрткой `%w` — это дефолт, чтобы `errors.Is`/`As`
работали сквозь слои. `%v` — только когда причину сознательно не
раскрываем.
- Стиль сообщения: со строчной, без точки, без «failed to». Контекст —
операция или субъект (`"open archive: %w"`), каждый слой добавляет **свой**
смысл, не повторяя нижний.
- Граничные ошибки транслируем в доменные у источника: `sql.ErrNoRows`
`store.ErrNotFound` внутри `store`, чтобы выше не торчал `database/sql`.
- **Sentinel** (`var ErrNotFound = errors.New(...)`) — для условий, на которые
ветвится код. **Типизированная ошибка** — когда вызывающему нужны данные
ошибки. Не плодим типы там, где хватает sentinel.
- Наружу (HTTP) отдаём человекочитаемое сообщение по доменной ошибке, не
сырой `err.Error()`. Маппинг доменная ошибка → статус живёт в одной точке
в `httpapi`; новая штатная ветвь отказа заводится sentinel'ом и
добавляется туда, иначе `default` отдаст 500 на нормальный конфликт.
- Собрать независимые ошибки (валидация конфига — все проблемы разом) —
`errors.Join`.
- `panic` — только невосстановимое: нарушенный инвариант, сбой инициализации.
`recover` — на верхней границе HTTP-обработчика.
- Глушить ошибку без лога — только с однострочным комментарием «почему».
+38
View File
@@ -0,0 +1,38 @@
# Логи
Структурированный JSON (`log/slog`) в stdout, один формат для dev и prod.
Сбор и ротацию делает окружение.
- `msg` — короткая константа в нижнем регистре, категория события
(`delivery accepted`, `parse failed`). Данные — атрибутами, не в тексте.
Подсистему выносим в поле `capability` (`ingest`/`parse`/`query`), не в
префикс сообщения.
- **Уровень — это адресат, а не громкость поломки:**
| Уровень | Кому | Примеры |
|---|---|---|
| `DEBUG` | разработчику при отладке | `/healthz`, тела запросов, шаги разбора |
| `INFO` | владельцу, аудит постфактум | принята доставка, разбор завершён, старт |
| `WARN` | владельцу, «может стать проблемой» | точка не разобрана, незнакомая форма метрики |
| `ERROR` | владельцу, в разбор | не записался архив, сбой БД |
- Невалидный ввод от отправителя — `DEBUG`, а не `ERROR`: это норма, разбирать
нечего. `WARN` ≠ «ничего страшного», `WARN` = «может стать проблемой».
- Событийное → `INFO`, рутинно-частое (healthcheck, поллинг) → `DEBUG`.
- **Либо лог, либо возврат, не оба.** Промежуточные слои только оборачивают и
возвращают. Ошибка логируется **один раз**, на границе доменного слоя,
которая определяет исход операции (`ingest`) — не в транспорте. Транспорт
переводит ошибку в ответ и не логирует повторно.
- Ошибка — атрибутом: `log.Error("parse failed", "error", err, "delivery_id", id)`.
- Время в логах — UTC, RFC 3339 с долями секунды.
- Корреляция — по `delivery_id` (ULID), отдельный `trace_id` не заводим.
- **Секреты в логи не попадают**: токены приёма и чтения, `Authorization`.
При сомнении логируем факт наличия, не значение.
- Данные о здоровье — чувствительные. Тела запросов пишем только на `DEBUG`
и с обрезкой по длине.
- **Текст ошибки разбора не содержит значений из входа** — только род токена
(словарём JSON, не именем типа языка) и смещение. Инвариант выше обходится
одним `fmt.Errorf("%v", tok)`: тело в 8 МиБ дало текст ошибки в 8 МиБ, и он
уехал атрибутом `error` на уровень `WARN`. Предел держит само сообщение, а не
обрезка на стороне логирующего: обрезка живёт в другом месте и о новой ошибке
разбора не узнает.
+49
View File
@@ -0,0 +1,49 @@
# База данных и идентификаторы
Схема как таковая — в [database.md](../database.md); здесь только правила, по
которым она пишется.
- Первичные ключи сущностей — **TEXT ULID**, генерируется приложением
(`internal/ident`). Сортируется по времени создания, удобен в логах и URL.
Разбор внешнего id — `ident.Parse` на входной границе; синтаксически
невалидный id — 404 без похода в БД.
- Естественный ключ вместо ULID там, где он есть по природе данных: `workout`
по `id` из HealthKit, `record` — по паре `род секции + id` (форму
идентификатора у пяти из шести секций живьём никто не видел, и несквозной `id`
в двух секциях затёр бы одну запись другой молча).
- Новая единица хранения тем же изменением входит в **отпечаток витрины** и в
счётчики отчёта пересборки. Отпечаток отвечает «да/нет» за витрину целиком, и
единица, которой нет в счётчиках, делает расхождение безадресным: человек
видит «не совпало» при неизменившемся числе объектов и принимает по этому
необратимое решение о подмене базы.
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
множества версий**, либо явно **функцией порядка журнала** — третьего
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
порядок свёртки порядку журнала не равен, и живая витрина расходится с
пересборкой молча.
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
названный предел длины (имена непокрытых секций, `id` сущности).
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не
измерялось».** Миграция, добавляющая такую колонку, не подставляет ноль
историческим строкам: ноль означает «проверено, пусто», а не «не знаем», и
подстановка выдаёт неизмеренное за измеренное — с видом измерения. Пример:
`delivery.skipped_entities`, по которому ретеншен решает, можно ли удалить
тело.
- **Метка изменения строки меняется только при изменении содержимого.** Апдейт,
трогающий одни метаданные (провенанс, ссылки), `updated_at` не двигает — иначе
она становится меткой касания, и запрос «что изменилось с момента X» получает
столько ложных изменений, сколько раз источник переприслал то же самое (у
тренировки — двадцать шесть).
- **Новая производная от разбора колонка в момент появления вносится в перечень
того, что пересборка не переносит.** Перечень — единственное место, где это
сказано, и следующий автор решает по нему; поле, не внесённое туда, однажды
перенесут «для полноты учёта», и витрина снова станет функцией предыдущего
прогона.
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
падать громко.
- Enum-поля — обычный `TEXT` без `CHECK`, допустимые значения держит код.
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
структуры обновляем схему в [database.md](../database.md) тем же изменением —
это проверяет `task gate`.
+31
View File
@@ -0,0 +1,31 @@
# Тесты
- Тесты на разбор формата HAE держим на **реальных пакетах**, сложенных в
`testdata` (с вычищенными токенами). Документация формата ненадёжна —
источником истины служат живые данные.
- Проверяем идемпотентность: повторный разбор того же пакета не меняет
витрину.
- **Где код выбирает между двумя версиями одних данных, тест обязан прогнать
обе стороны и хотя бы одну перестановку трёх.** Пример на паре доказывает
коммутативность и молчит про ассоциативность, а сломаться правило может
именно на ней: полнота — частичный порядок, тай-брейк — тотальный, и их
попарная свёртка дала нетранзитивное отношение победы, из-за которого одна
и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого
не увидело, ревью кода увидело только перебором троек. Правилом линтера не
выражается — отсюда проза.
- **В тот же перебор обязана входить версия с содержимым, равным одной из уже
присланных, и пара «равная каноническая форма, разные байты».** Три версии с
разными хешами ветку «содержание равно» не посещают ни разу — а именно на ней
устаревал провенанс, и живая витрина расходилась с пересборкой молча. Пара с
равной формой ловит другое: неединственный минимум, при котором победителем
оказывается просто первый в срезе, то есть порядок элементов на проводе.
- **Изменение правила разбора или слияния сопровождается замером на живом
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
без числа не отличается от предположения, а цена ошибки здесь — необратимое
решение о судьбе тел.
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time`
и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела
запросов, координаты объектов).
+32
View File
@@ -155,3 +155,35 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
массивов); при равных наборах выигрывает версия из более поздней доставки массивов); при равных наборах выигрывает версия из более поздней доставки
журнала, а не свёрнутая последней. Подробности и обоснование — в журнала, а не свёрнутая последней. Подробности и обоснование — в
`architecture.md`, раздел «Тренировки и прочие секции». `architecture.md`, раздел «Тренировки и прочие секции».
## Представление данных
- **Точки часового объекта лежат сжатым BLOB** (`gzip`) в колонке `payload`.
Чтение объекта распаковывает его **целиком**: частичного доступа к точке нет,
и любая правка — read-modify-write всей пачки. Отсюда цена широкой доставки:
63 МиБ на одной координате держат транзакцию 5.15 с, а тело 40 МиБ давало
768 МиБ пика кучи, пока канонизация шла внутри транзакции.
- Таблицы часовых объектов — `WITHOUT ROWID`: строка целиком, вместе со сжатым
`payload`, живёт в дереве первичного ключа. Поэтому агрегатные запросы идут
по покрывающему индексу `bucket_catalog`, а не по таблице.
- Тела доставок в базе не лежат вовсе — они в сыром архиве
(`<archive_dir>/raw/ГГГГ/ММ/ДД/<ulid>.json.gz`); в `delivery` только учёт.
## Настройки с числовым значением
Без них замер не превращается в находку: пик памяти — аномалия только рядом со
строкой «запись лежит сжатой и распаковывается целиком».
| Настройка | Значение | Где задана |
| --- | --- | --- |
| `journal_mode` | `WAL` | `internal/store/store.go`, строка соединения |
| `busy_timeout` | `5000` мс | там же; на устаревший снимок транзакции **не** действует |
| `foreign_keys` | `on` | там же |
| `journal_size_limit` | 64 МиБ | `internal/store/store.go`, `journalSizeLimit` |
| чекпойнт WAL по таймеру | 1 мин | `cmd/healthlog/checkpoint.go`, `checkpointInterval` |
| предел тела запроса | 64 МиБ (`ingest.max_body_mb`) | конфиг; **ретроактивен** — тем же пределом читаются тела из архива при пересборке |
| таймаут чтения запроса | 5 мин (`server.read_timeout`) | конфиг; щедро: экспорт истории по мобильной сети |
| таймаут отправки ответа | 30 с (`server.write_timeout`) | конфиг; маршрут приёма держит собственный бюджет |
| бюджет остановки | 30 с | `cmd/healthlog/serve.go`, `shutdownTimeout` |
| ретеншен сырого архива | до следующего проверенного экспорта (~2 ГБ за квартал) | правило, а не число; не реализован — задача `raw-archive-retention` |
| предела на одну сущность | **нет** | задача `entity-size-limits` |
+4 -4
View File
@@ -1,7 +1,7 @@
# Паспорт проекта # Паспорт проекта
Куда мы идём, как выглядит работа проекта в жизни и у кого подсматривать, Куда мы идём, как выглядит работа проекта в жизни и у кого подсматривать,
когда упёрлись. Самый верхний документ: [plan.md](plan.md) отвечает «в каком когда упёрлись. Самый верхний документ: [tasks/PLAN.md](tasks/PLAN.md) отвечает «в каком
порядке», [architecture.md](architecture.md) — «как устроено», паспорт — порядке», [architecture.md](architecture.md) — «как устроено», паспорт —
**«зачем и для кого»**. **«зачем и для кого»**.
@@ -51,7 +51,7 @@
## Типовые сценарии ## Типовые сценарии
Ситуации, ради которых всё написано. В скобках — шаги [plan.md](plan.md), Ситуации, ради которых всё написано. В скобках — шаги [tasks/PLAN.md](tasks/PLAN.md),
которыми сценарий закрывается; названы, а не пронумерованы, потому что план которыми сценарий закрывается; названы, а не пронумерованы, потому что план
живой и нумерация в нём поедет. живой и нумерация в нём поедет.
@@ -117,7 +117,7 @@ HAE), а сырой архив получает право быть подчищ
Отсюда правило работы: Отсюда правило работы:
> **Развилка или блокер — сперва prior art.** Прежде чем проектировать своё, > **Развилка или вопрос — сперва prior art.** Прежде чем проектировать своё,
> посмотреть, как это сделано в проектах ниже и в интернете. Готовое решение > посмотреть, как это сделано в проектах ниже и в интернете. Готовое решение
> либо берётся, либо отвергается **с названной причиной** — и тогда причина > либо берётся, либо отвергается **с названной причиной** — и тогда причина
> идёт в [architecture.md](architecture.md), а не теряется. > идёт в [architecture.md](architecture.md), а не теряется.
@@ -131,7 +131,7 @@ HAE), а сырой архив получает право быть подчищ
| Проект | Что смотреть | Оговорка | | Проект | Что смотреть | Оговорка |
| --- | --- | --- | | --- | --- | --- |
| [Lybron/health-auto-export](https://github.com/Lybron/health-auto-export) | Документация формата от автора приложения — единственная, что есть | Местами расходится с тем, что приложение шлёт: см. [local-research.md](local-research.md) | | [Lybron/health-auto-export](https://github.com/Lybron/health-auto-export) | Документация формата от автора приложения — единственная, что есть | Местами расходится с тем, что приложение шлёт: см. [research/apple-health.md](research/apple-health.md) |
| [HealthyApps/health-auto-export-server](https://github.com/HealthyApps/health-auto-export-server) | Как приём видят сами авторы HAE: какие поля считают опорными | Их цель — Grafana, то есть аналитика; хранения журнала нет | | [HealthyApps/health-auto-export-server](https://github.com/HealthyApps/health-auto-export-server) | Как приём видят сами авторы HAE: какие поля считают опорными | Их цель — Grafana, то есть аналитика; хранения журнала нет |
| [irvinlim/apple-health-ingester](https://github.com/irvinlim/apple-health-ingester) | **Ближайший по стеку**: Go, HTTP-приём HAE, конфиг, токен, разведение бэкендов | Приём синхронный, слияние по метке при записи, сырого журнала нет — ровно тот дизайн, от которого мы ушли осознанно | | [irvinlim/apple-health-ingester](https://github.com/irvinlim/apple-health-ingester) | **Ближайший по стеку**: Go, HTTP-приём HAE, конфиг, токен, разведение бэкендов | Приём синхронный, слияние по метке при записи, сырого журнала нет — ровно тот дизайн, от которого мы ушли осознанно |
| [po4yka/apple-health-export-automation-backup](https://github.com/po4yka/apple-health-export-automation-backup) | Заявлены дедупликация, tombstones и dead-letter queue — смотреть, когда встанет вопрос «куда девать неразобранную доставку» | Python/FastAPI + InfluxDB; модель хранения нам не подходит | | [po4yka/apple-health-export-automation-backup](https://github.com/po4yka/apple-health-export-automation-backup) | Заявлены дедупликация, tombstones и dead-letter queue — смотреть, когда встанет вопрос «куда девать неразобранную доставку» | Python/FastAPI + InfluxDB; модель хранения нам не подходит |
-66
View File
@@ -1,66 +0,0 @@
# План
Это **порядок и его обоснование**, а не список работ. Единицы работы живут в
[беклоге](backlog/README.md) — одна задача, один файл, свой приоритет. План
отвечает «почему в таком порядке», беклог — «что брать следующим».
Отсюда правило: **содержимое шага здесь не перечисляется.** Шаг — это название
и статус; что именно в нём делается, знает задача. Иначе список работ живёт в
двух местах и расходится с каждой закрытой задачей. Меняется этот файл, когда
меняется порядок, а не когда закрывается задача.
## Ближайшая цель
Метрики разбираются и ложатся в часовые объекты: тела перестали быть
недифференцированной кучей. Приём отвечает `200`, не дожидаясь свёртки: её ведёт
фоновый воркер, для которого очередью служит сама таблица доставок.
**`reindex` сделан**: журнал проигрывается в свежую витрину, отпечатки
сравниваются, повторный прогон ничего не меняет. Доставки, числящиеся `pending`
после миграции 00005, подбираются им же — но применяется результат подменой
базы, а её делает человек при остановленном сервисе. Тем же кодом закрывается
половина задачи «разнести ответ и свёртку»: проигрывание журнала теперь готовая
операция.
Тренировки и записи со своими `id` разбираются: `workouts` и `stateOfMind`
половина потока — перестали лежать неразобранными. От разбора остался словарь
категориальных значений.
**Род агрегации измерен**: сверка минутного слоя с часовым разложила метрики
живого корпуса на накопительные и мгновенные, не сойдясь ни на одной. Каталог
разрезов отдаётся первым маршрутом чтения — дальше Read API, которому теперь
есть на чём строить свёртку.
Разведка закончена: правило вывода слоя, модель идентичности и формы точки
проверены на живом потоке, выводы — в [local-research.md](local-research.md).
## Шаги
- [x] **1. Каркас.**
- [x] **2. Приём без разбора.** ← **подключаем телефон по локальной сети**
- [~] **3. Разбор и хранилище.** Метрики, тренировки и записи со своими `id`,
`reindex` — сделано; словарь категориальных значений — нет.
- [x] **4. Каталог и род агрегации.**
- [ ] **5. Read API.**
- [ ] **6. Самоописание.**
- [ ] **7. MCP.**
- [ ] **8. `healthlog import`.**
- [ ] **9. Устаревание нижнего слоя.**
- [ ] **10. Наблюдаемость.**
- [ ] **11. Деплой.**
Порядок неслучаен, и это единственное, чего нет в беклоге:
- **Каталог и род агрегации — перед Read API.** Без измеренного рода свёртка в
ответе неотличима от угадывания, а ошибиться здесь дорого: просуммировать
нижний слой значит завысить втрое.
- **`healthlog import` — перед устареванием нижнего слоя.** Пока импорт
экспорта не написан, помечать что-либо устаревшим не на основании чего.
- **Read API — перед MCP.** Адаптер собственной логики не несёт, он переводит
вызовы в те же обработчики; переводить пока нечего.
## Отложенное
Отложенных идей в плане нет: их место — [беклог](backlog/README.md) с пометкой
`[idea]`. Два дома для одной идеи расходятся, и тогда полного списка не даёт ни
один; вопрос «что мы решили отложить» задаётся беклогу.
+77
View File
@@ -0,0 +1,77 @@
# Разведка
Наблюдения за внешним миром: что реально шлёт источник, чем документация
формата расходится с практикой. Источник истины — этот каталог, а не чужая
документация.
**Каждый вывод — с числами и командой, которой получен**, чтобы его можно было
перепроверить. Число без источника читается как условие, а не как замер.
## Как снималось
Сервис запущен локально (`task run`), телефон шлёт по локальной сети на IP
машины. Автоматизация — REST API, JSON, интервал 5 минут.
Накоплено к 2026-08-01: **42 доставки, 165 МБ тел, 6,6 МБ архива**.
Три автоматизации, режимы менялись по ходу разведки:
| автоматизация | что шлёт | режимы, которые прошли |
|---|---|---|
| `BC99C8A3` | показатели здоровья | суммирование посекундно → поминутно → **выключено**, период Today → Default → **Since Last Sync** |
| `37A43AE1` | тренировки (сперва ошибочно показатели) | период Default |
| `F4458FA4` | состояние разума | период Default |
За это время снято: суммированные данные обеих гранулярностей,
несуммированные, тренировка в помещении и уличная с геотреком, состояния
разума, ночь целиком. Позже к этому добавился родной экспорт Apple Health —
второй источник, снятый разово выгрузкой из приложения «Здоровье».
Разбор — командами вида:
```
gzip -dc raw/2026/07/31/<id>.json.gz | jq -r '...'
```
плюс скриптом `tmp/research/hl.py` (Python 3, только стандартная библиотека,
каталог под `.gitignore`):
```
python3 tmp/research/hl.py deliveries что приехало
python3 tmp/research/hl.py metrics --period 'Since Last Sync'
python3 tmp/research/hl.py shapes формы точки
python3 tmp/research/hl.py sources источники, с показом невидимых символов
python3 tmp/research/hl.py points step_count точки, инфляция серий
python3 tmp/research/hl.py sleep разбор ночи
python3 tmp/research/hl.py diff <id1> <id2> что изменилось между доставками
python3 tmp/research/hl.py workouts тренировки, ряды, маршрут
```
Он канонизирует JSON перед сравнением и показывает невидимые символы — те две
грабли, на которых разбор оболочкой ломался молча.
## Записи
- [apple-health.md](apple-health.md) — 53 находки на живом потоке Health Auto
Export и на родном экспорте Apple.
Записи нумерованы сквозным номером внутри файла, и **на номер ссылаются
снаружи**: спеки, предложения и задачи говорят «находка 49». Поэтому нумерация
не пересчитывается, записи не переставляются, новая получает следующий номер.
Тематический указатель по номерам находок:
| Тема | Находки |
| --- | --- |
| Форма точки, схемы, типы значений | 4, 21, 38, 39, 44 |
| Слой и гранулярность, режимы автоматизации | 5, 6, 13, 19, 20, 23, 33, 41 |
| Идентичность, столкновения, слияние, полнота | 11, 14, 36, 47, 49 |
| Досчёт задним числом и стабильность значений | 3, 10, 30, 48, 51 |
| Локализация и категориальные значения | 8, 24, 37, 43 |
| Секции потока и их состав | 9, 15, 16, 17, 22, 34, 50, 52 |
| Заголовки доставки и мета-информация | 12, 31, 32 |
| Родной экспорт Apple как второй источник | 34, 42, 43, 45, 46 |
| Объём, цена, что чистить | 7, 23, 41 |
| Поведение приложения и потери данных | 18, 26, 27, 28, 29 |
| Род агрегации | 40, 53 |
| Разбор ночи, сон | 25, 35, 38, 47 |
| Источник точки (`source`) | 1, 2, 36 |
@@ -1,36 +1,16 @@
# Разведка на живых данных # Apple Health: наблюдения на живых данных
Журнал наблюдений за реальным потоком Health Auto Export. Документация Наблюдения за реальным потоком Health Auto Export и за родным экспортом Apple
формата ([wiki](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format)) Health. Документация формата HAE
([wiki](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format))
тонкая и местами расходится с тем, что приложение шлёт на самом деле, поэтому тонкая и местами расходится с тем, что приложение шлёт на самом деле, поэтому
источником истины служит этот файл. источником истины служит этот файл, а не она.
Пополняется по мере накопления доставок. Каждый вывод — с числами и командой, Файл пополняется по мере накопления доставок. Находки нумерованы сквозным
которой он получен, чтобы его можно было перепроверить. номером, и **номер — это ссылка**: на «находку 49» ссылаются спеки,
предложения и задачи, поэтому нумерация не пересчитывается и записи не
## Как снималось переставляются. Как снималось и каким инструментом — в
[README.md](README.md).
Сервис запущен локально (`task run`), телефон шлёт по локальной сети на IP
машины. Автоматизация — REST API, JSON, интервал 5 минут.
Накоплено к 2026-08-01: **42 доставки, 165 МБ тел, 6,6 МБ архива**.
Три автоматизации, режимы менялись по ходу разведки:
| автоматизация | что шлёт | режимы, которые прошли |
|---|---|---|
| `BC99C8A3` | показатели здоровья | суммирование посекундно → поминутно → **выключено**, период Today → Default → **Since Last Sync** |
| `37A43AE1` | тренировки (сперва ошибочно показатели) | период Default |
| `F4458FA4` | состояние разума | период Default |
За это время снято: суммированные данные обеих гранулярностей,
несуммированные, тренировка в помещении и уличная с геотреком, состояния
разума, ночь целиком.
Разбор — командами вида:
```
gzip -dc raw/2026/07/31/<id>.json.gz | jq -r '...'
```
## 1. Поле `source` существует ## 1. Поле `source` существует
@@ -1786,25 +1766,6 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
заполненности (`xFilesFactor`) измерению не нужно: две конкурирующие гипотезы заполненности (`xFilesFactor`) измерению не нужно: две конкурирующие гипотезы
отсеивают неполный час сами. отсеивают неполный час сами.
## Инструмент
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
библиотека, каталог под `.gitignore`):
```
python3 tmp/research/hl.py deliveries что приехало
python3 tmp/research/hl.py metrics --period 'Since Last Sync'
python3 tmp/research/hl.py shapes формы точки
python3 tmp/research/hl.py sources источники, с показом невидимых символов
python3 tmp/research/hl.py points step_count точки, инфляция серий
python3 tmp/research/hl.py sleep разбор ночи
python3 tmp/research/hl.py diff <id1> <id2> что изменилось между доставками
python3 tmp/research/hl.py workouts тренировки, ряды, маршрут
```
Он канонизирует JSON перед сравнением и показывает невидимые символы — те две
грабли, на которых разбор оболочкой ломался молча.
## Открытые вопросы ## Открытые вопросы
- **Переживает ли «Since Last Sync» неудачную отправку.** Ключевой вопрос для - **Переживает ли «Since Last Sync» неудачную отправку.** Ключевой вопрос для
+169 -19
View File
@@ -1,30 +1,180 @@
# Журнал проскочивших дефектов # Ревью: настройка и журнал
Сюда попадает дефект, который **прошёл ревью и всплыл позже**. Записывается Конвейер — скилл `av-dev-pipeline:review-pipeline`, проходы — агенты
сразу, а не ретроспективно: со временем теряется не сам факт, а причина `av-dev-pipeline:review-*`. Здесь только проектная часть: чем этот проект
непоймания — единственное, ради чего журнал существует. отличается от умолчаний конвейера и что в нём уже проскакивало.
## Как настроен конвейер
### Типовые узлы
Рода узлов проекта и свойства, по которым судится каждый. Рода, а не инвентарь
пакетов: род, который проект задумал, но ещё не написал, включён намеренно.
**Разбор пакета HAE** (`internal/hae`)
- Точка сохраняется дословно; ничего внутри неё не отбрасывается и не
переименовывается.
- Непонятое содержимое не роняет доставку: она принята, непокрытое названо.
- Слой выводится из выравнивания меток, а не из заголовка HAE — тот врёт.
- Текст ошибки не содержит значений из входа — род токена и смещение.
- Тест гоняется на реальном пакете из `testdata`, а не на выдуманном.
**HTTP-обработчик приёма** (`internal/httpapi`)
- Код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое
содержимое — 200.
- Тело попадает в архив раньше, чем в разбор; потеря архива необратима.
- Тело и заголовки в лог выше `DEBUG` не уезжают, токены — никогда.
- Есть названный предел на размер тела и на заголовки.
**Свёртка и репозиторий часовых объектов** (`internal/store`, `internal/fold`)
- Результат — функция **префикса** журнала: узел не читает состояние, которое
сам же меняет, без границы по `received_at` разбираемой доставки.
- Правило выбора между версиями — функция множества версий либо явно функция
порядка журнала; третьего состояния нет.
- Столкновение разрешается полнотой, а не свежестью; изменение запечатанного
часа пишется `WARN`, но данные пишутся.
- Транзакция не держит блокировку дольше `busy_timeout`: канонизация и
сжатие — вне её.
**Файловый архив и ретеншен** (`internal/archive`)
- Путь строится из значений, которых отправитель не контролирует.
- Удаление тела опирается на колонку, отличающую ноль от «не измерялось».
- Место на диске и рост каталога названы числом.
**Проигрыватель журнала и CLI** (`internal/replay`, `cmd/`)
- Повторный прогон даёт то же состояние и тот же отпечаток.
- Новая единица хранения входит в отпечаток и в счётчики отчёта.
- Расход памяти не растёт вместе с длиной журнала.
- Подмена базы — решение человека при остановленном сервисе, не команды.
**Обработчик чтения и адаптер MCP** (Read API, MCP — ещё не написаны)
- Агрегат считается только там, где род свёртки измерен; нижний слой HAE не
суммируется никогда.
- Ответ имеет предел размера, и предел объявлен, а не подразумевается.
- Адаптер MCP собственной логики не несёт — те же обработчики.
### Типовые ложноположительные
Находки, которые здесь выглядят убедительно и всегда неверны.
- **«Ответ 200 на непонятое содержимое проглатывает ошибку.»** Не дефект:
инвариант «сохранили — значит приняли». Телефон шлёт молча и не
перешлёт — код ответа отражает доставку, а не разбор.
- **«`source` не входит в ключ — идентичность неполна.»** Не дефект: поле
измерено нестабильным (разведка, находка 36), включение его в ключ задваивает
точки.
- **«Точка хранится избыточно, поля дублируются.»** Не дефект: точки хранятся
дословно, инвариант прямой. Экономия здесь необратима.
- **«Часовой объект не считает агрегат при записи.»** Не дефект: своей
агрегации в хранении нет, род свёртки выводится сверкой слоёв в ответе.
- **«У одной метки три записи сна — дубликат.»** Не дефект: у точки-измерения
конец равен началу, под одной меткой лежит до трёх записей.
- **«Русские строки в значениях — незакрытая локализация.»** Наполовину: строки
приходят на языке телефона, и это факт источника; дефектом является только
отсутствие стабильного кода рядом с переводом.
### Вопросы к проходам
Форма: `<имя прохода>: <вопрос> (<провенанс>)`. Задаются дополнительно к
обязательным.
- `ops`: читает ли узел состояние, которое сам же меняет, и остаётся ли
результат функцией от **префикса** журнала (запись 2026-08-01, свёртка не
воспроизводилась при пересборке).
- `ops`: поведение библиотеки, драйвера и `PRAGMA` измерено или вычитано из
документации; что возвращается в **вырожденном** случае и отличим ли этот
ответ от штатного (запись 2026-08-02 про упразднение `idiom`; прецедент
`-1 >= -1` — 1492 тика из 5502).
- `ops`: хватит ли сигналов владельцу, когда поток оборвётся ночью (переселено
из упразднённого `negative`).
- `architecture`: не изобретаем ли то, что уже есть в стандартной библиотеке —
своя абстракция, повторяющая форму существующей (переселено из `idiom`).
- `architecture`: что опытный человек отсюда удалил бы (переселено из
`negative`).
- `rubric`: пришпилено ли утверждение теста к числу, производному от размера
корпуса — корпус растёт с каждой доставкой (запись 2026-08-02, прогон живого
архива был красным).
- `adversary`: доводится ли значение точки или тело доставки до лога выше
`DEBUG` хотя бы одним путём (запись 2026-08-02, тело 8 МиБ в тексте ошибки).
- `triage`: перечислены ли запущенные проходы поимённо и с исходом; непущенный
проход идёт в границы покрытия строкой «не запускался» (запись 2026-08-02,
чекпоинт кода прошёл без трёх проходов).
### Триггеры профиля
Уточняет умолчания конвейера, не отменяет их.
- **`deep`** — изменения в правиле разбора, идентичности, слияния или вывода
слоя; миграции схемы; всё, что трогает `internal/store`, `internal/fold`,
`internal/replay`.
- **«Поведение, видимое снаружи»** здесь — код ответа приёма, форма ответа
чтения, содержимое архива и **состояние, которое даёт пересборка**: витрина
наблюдаема через пересборку, поэтому расхождение с журналом — внешнее
поведение, а не внутренняя деталь.
- **`reimpl`** запускается по триггеру «новое правило слияния, идентичности или
разбора». Единственный раз, когда триаж назвал его отсутствие дырой
покрытия, — это была задача с новым правилом слияния сущностей.
- **`quick`** — правка документов, конфигурации, сообщений; ничего, что меняет
хранимое.
### Недоступно проверке
**Не проверит ни один проход.** Реальный профиль нагрузки: телефон шлёт молча и
непрерывно, объём и частота меряются только по факту. Поведение приложения HAE
за пределами наблюдённого — расписание автоматизаций пожелание, а не гарантия
(разведка, находка 28). Полнота словаря переводов после обновления iOS.
Секции, которых поток ещё не приносил: `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications` — разбор писался
вслепую, и проход может судить только форму кода, не соответствие реальности.
**Перестали проверять сознательно.**
- Прогон живого архива (`task verify:archive`) и свёртка под удерживаемой
блокировкой (`task verify:busy`) в гейт не входят: минута и 25 секунд
соответственно, плюс данные, которых нет ни на какой другой машине. Гоняет их
человек перед задачей, трогающей разбор или слияние (запись 2026-08-02,
прогон живого архива был красным и об этом никто не знал).
- Класс «в Go так не пишут» — поимённая сверка с Effective Go, Go Code Review
Comments, стайлгайдами Uber и Google — не покрыт вовсе после упразднения
`idiom`. Класс обратимый, портит форму кода, а не данные, но признавать это
надо в границах покрытия, а не считать проверенным (запись 2026-08-02).
- Класс «чего нет в зрелой реализации такого узла» — вне профиля `design`.
## Журнал дефектов
Запись на каждый воспроизведённый дефект, сразу, а не ретроспективно: со
временем теряется не факт, а причина непоймания.
Реализованные задачи, находки ревью и решения сюда не пишутся: у них есть Реализованные задачи, находки ревью и решения сюда не пишутся: у них есть
коммит, спека и беклог. Здесь только промахи конвейера. коммит, спека и задача. Здесь только промахи конвейера и решения о его составе.
Форма записи: Форма:
``` <!-- копия: журнал-дефектов-форма из av-dev-pipeline/skills/review-pipeline/references/review-journal.md -->
## 2026-08-01 — <краткое последствие> ## ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман]
- **Где:** internal/store/bucket.go:120 - **Где:** путь:строка либо «конвейер, а не код»
- **Симптом:** <как обнаружилось, кем и когда> - **Симптом:** как обнаружилось, кем и когда
- **Почему не поймали:** <какой проход обязан был найти и что ему помешало> - **Причина:** что на самом деле было не так
- **Что меняем:** <правило прохода, шаг гейта, конвенция — либо «ничего, цена - **Чем воспроизведён:** тест, команда, замер — с числами
поимки выше цены дефекта»> - **Почему не поймали:** только для проскочивших — какой проход обязан был найти
``` и что ему помешало
- **Что меняем:** правило прохода, шаг гейта, конвенция, факт в документе
проекта — либо «ничего, цена поимки выше цены дефекта»
<!-- /копия: журнал-дефектов-форма -->
Последний пункт важнее остальных. Вывод «ничего не меняем» — законный исход: Последний пункт важнее остальных. Вывод «ничего не меняем» — законный исход:
не всякий дефект стоит того, чтобы усложнять ради него ревью каждой задачи. не всякий дефект стоит того, чтобы усложнять ради него ревью каждой задачи.
--- ---
## 2026-08-01 — свёртка не воспроизводилась при пересборке журнала ## 2026-08-01 — свёртка не воспроизводилась при пересборке журнала [проскочил]
- **Где:** `internal/store/delivery.go`, `LastDerivedLayer` - **Где:** `internal/store/delivery.go`, `LastDerivedLayer`
- **Симптом:** прогон живого архива (99 доставок) вторым проходом дал 1742 - **Симптом:** прогон живого архива (99 доставок) вторым проходом дал 1742
@@ -48,7 +198,7 @@
на живом архиве (`internal/fold/replay_test.go`) остаётся постоянным — на живом архиве (`internal/fold/replay_test.go`) остаётся постоянным —
именно он это поймал. именно он это поймал.
## 2026-08-02 — прогон живого архива был красным и об этом никто не знал ## 2026-08-02 — прогон живого архива был красным и об этом никто не знал [проскочил]
- **Где:** `internal/fold/replay_test.go` (перенесён в `internal/replay/archive_test.go`) - **Где:** `internal/fold/replay_test.go` (перенесён в `internal/replay/archive_test.go`)
- **Симптом:** первый же запуск `task verify:archive` в задаче про пересборку - **Симптом:** первый же запуск `task verify:archive` в задаче про пересборку
@@ -74,7 +224,7 @@
протухшей константы, а после этой задачи прогон стал ещё и единственным, кто протухшей константы, а после этой задачи прогон стал ещё и единственным, кто
проверяет настоящий проигрыватель журнала. проверяет настоящий проигрыватель журнала.
## 2026-08-02 — чекпоинт кода прошёл без трёх проходов, и ровно они нашли всё ## 2026-08-02 — чекпоинт кода прошёл без трёх проходов, и ровно они нашли всё [проскочил]
- **Где:** конвейер, а не код: коммит `f8200f7` («тренировки и записи с - **Где:** конвейер, а не код: коммит `f8200f7` («тренировки и записи с
собственным `id`»), шаг 7 скилла `healthlog-task-pipeline`, профиль `deep`. собственным `id`»), шаг 7 скилла `healthlog-task-pipeline`, профиль `deep`.
@@ -111,7 +261,7 @@
на их дозакрытие. Состав проходов и профилей при этом не трогаем: они на их дозакрытие. Состав проходов и профилей при этом не трогаем: они
сработали ровно так, как задуманы, — их просто не позвали. сработали ровно так, как задуманы, — их просто не позвали.
## 2026-08-02 — тест на утечку значений в лог краснел от хода часов ## 2026-08-02 — тест на утечку значений в лог краснел от хода часов [проскочил]
- **Где:** `internal/fold/log_test.go`, `TestFoldНесравнимыеНаборыДаютWarn` - **Где:** `internal/fold/log_test.go`, `TestFoldНесравнимыеНаборыДаютWarn`
- **Симптом:** гейт задачи про цену читающего маршрута покраснел на чужом - **Симптом:** гейт задачи про цену читающего маршрута покраснел на чужом
@@ -127,7 +277,7 @@
выглядит образцовым: он проверяет ровно тот инвариант, который проекту выглядит образцовым: он проверяет ровно тот инвариант, который проекту
дороже всего («данные о здоровье чувствительнее токенов»). Ни один проход дороже всего («данные о здоровье чувствительнее токенов»). Ни один проход
ревью не смотрит на тесты чужих задач. ревью не смотрит на тесты чужих задач.
- **Что меняем:** правило в [conventions.md](conventions.md) — проверка «в логе - **Что меняем:** правило в [conventions/testing.md](conventions/testing.md) — проверка «в логе
нет значения» разбирает запись и выбрасывает `time`, а не ищет в сыром нет значения» разбирает запись и выбрасывает `time`, а не ищет в сыром
буфере. Гейт не трогаем: два прогона против однопроцентной флаки не помогут, буфере. Гейт не трогаем: два прогона против однопроцентной флаки не помогут,
а десять стоили бы дороже самой находки. а десять стоили бы дороже самой находки.
+109
View File
@@ -0,0 +1,109 @@
# Модель угроз
## Периметр
**Находки строятся против целевого периметра: сервис открыт в публичный
интернет.** Целевой контур — VPS **rivendell** (Timeweb) за **Caddy**, который
терминирует TLS; сам сервис слушает plain HTTP на localhost контейнера. Наружу
открыты два контура на разных поддоменах: **приём** (телефон, токен записи) и
**чтение вместе с MCP** (агенты и приложения, токен чтения). Отдельного контура
у MCP нет.
**Сегодняшний контур другой, и это переходное состояние, а не модель.** Сервис
живёт на рабочей машине, телефон достаёт до него только по локальной сети,
проверка токенов **выключена сознательно**, `config.docker.toml` коммитится без
секретов. Сервис предупреждает на старте обоими сообщениями (`write auth
disabled`, `read auth disabled`), но стартовать не отказывается.
Отсюда правило для проходов ревью: **выключенная сегодня проверка токенов — не
дефект, а объявленное состояние**; дефектом является путь, который остаётся
открытым и после включения токенов. Закрытие сегодняшнего контура — задача
«Управление токенами и секретами», решается перед деплоем.
Цена контуров разная и определяет ранжирование: открытый приём означает мусор
во входе, открытое чтение — **выгрузку всей истории здоровья** любому, кто нашёл
порт.
## Недоверенный вход
Отправитель контролирует целиком:
- **Тело доставки** — JSON от Health Auto Export: имена метрик, единицы,
значения, метки времени, имена источников и устройств, имена секций, `id`
тренировок и записей, содержимое маршрута.
- **Заголовки доставки** — включая `automation-id`, `automation-aggregation`,
`User-Agent`, `Accept-Language`, `Upload-Complete`; они пишутся в `delivery` и
участвуют в выводе слоя. Заголовки полуправдивы: `automation-aggregation`
реальной гранулярности не описывает (разведка, находка 33).
- **Размер тела** — предела на одну сущность нет; наблюдалось 63 МиБ на одной
координате и 768 МиБ пика кучи на теле 40 МиБ.
Позже к этому добавится **содержимое родного экспорта Apple** — zip-архив с
`export.xml`, который выбирает человек, но формируется он устройством и по
объёму (3,6 млн записей) глазами не проверяется.
Ответы внешних систем в недоверенный вход не входят: исходящих вызовов у
сервиса нет.
## Из чего строятся пути и ключи
- **Путь в архиве**`<storage.archive_dir>/raw/ГГГГ/ММ/ДД/<ulid>.json.gz`.
Дата берётся из времени приёма, имя файла — из ULID, сгенерированного нами.
**Ни один сегмент пути не берётся из тела или заголовков доставки** — это и
есть защита от выхода за пределы каталога, и она держится ровно на этом.
- **Координатный ключ точки**`метрика + слой + начало + конец`. Имя метрики
приходит из тела и в путь на диске не попадает, но попадает в ключ, в лог и в
ответ каталога. Любое значение из чужого JSON, попадающее в ключ, в лог или в
отчёт, имеет названный предел длины.
- **Ключ сущности**`род секции + id` из HealthKit для `record`, `id` для
`workout`. `id` приходит из тела.
- **Файл базы и каталог архива** — из конфига, не из запроса.
## Что разграничивает доступ
Статический токен в заголовке `Authorization: Bearer …`; список допустимых
токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно.
Токены **раздельные**: запись (приём) и чтение. Клиент, читающий данные, писать
не может. MCP пользуется токеном чтения. Ролей, пользователей и сессий нет —
данные одного человека, разграничение только по контурам.
Конфиг с токенами лежит отдельным томом под `0600`; реальный `config.toml` не
коммитится, секреты рендерит деплой.
## Что чувствительнее чего
По убыванию:
1. **Данные о здоровье** — значения точек, тела доставок, содержимое архива.
Утечка необратима и невосполнима: это история конкретного человека за годы.
2. **Токен чтения** — открывает всю ту же историю целиком.
3. **Токен записи** — открывает загрязнение витрины; лечится пересборкой
журнала, то есть обратимо.
4. **Метаданные потока** — имена устройств, `automation-id`, объёмы и время
доставок. Выдают распорядок дня и модель телефона.
Отсюда правило логов: тела запросов и значения точек — только на `DEBUG` и с
обрезкой; токены — никогда, ни на каком уровне. Ничего из `./data` не попадает
ни в git, ни в логи выше `DEBUG`, ни в вывод агента — это проверяет `task gate`.
## Что вне модели
Перечислено явно, чтобы враждебный проход не выдумывал угрозу сам.
- **Компрометация самой машины rivendell и её оператора.** Получивший shell
получает и базу, и архив, и конфиг; шифрования на покое нет.
- **Компрометация телефона и учётной записи Apple.** Источник данных доверенный
по построению.
- **TLS, сертификаты и защита от сетевых атак** — целиком на Caddy; сервис
слушает plain HTTP и об этом знает.
- **DoS и исчерпание ресурсов как злонамеренное действие.** Пределы на размер
тела и заголовков нужны против **своего же телефона**, который шлёт 63 МиБ
честно; сценарий «злоумышленник выкачивает диск» не рассматривается — контур
приёма закрыт токеном, а токен есть только у одного устройства.
- **Многопользовательность, ролевая модель, аудит доступа.** Данные одного
человека; журнала обращений к чтению нет и не планируется.
- **Стойкость статического токена к подбору.** Токен длинный и генерируется
вне сервиса; ограничения частоты запросов нет.
- **Подмена содержимого доставки в пути.** Закрывается TLS на Caddy; подписи
тела нет.
+52
View File
@@ -0,0 +1,52 @@
# Беклог
Что **можно взять**. Одна задача = один файл `items/<slug>.md`
+ строка здесь. Целей тут нет — они в [PLAN.md](PLAN.md): беклог — то, что берут,
план — то, подо что берут. Порядка внутри секции нет: «что делать
дальше» отвечает набор спринта. Ведётся скиллом `tasks`.
Секции «блокеры» здесь нет и не заводится: блокер — это состояние
(спринт не может продолжаться ни одной задачей), оно живёт до ответа
человека, а его следы — вопросами в файлах задач.
## ядро
- [[idea] Человеческие аннотации поверх выведенных схем](items/schema-annotations.md) — Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
- [Проверка целостности собранной витрины перед подменой](items/integrity-before-swap.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
- [Цена слияния на широкой доставке](items/merge-cost-wide-delivery.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
- [Сущность с id, но неразобранной меткой](items/entity-without-parsed-label.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
- [Идентичность тренировок при импорте родного экспорта](items/workout-identity-on-import.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
- [Импорт родного экспорта Apple Health](items/apple-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [MCP-сервер поверх Read API](items/mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [[idea] Месячный проход по ручным секциям](items/monthly-manual-sections-pass.md) — Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
- [[idea] NDJSON-поток для больших выборок Read API](items/ndjson-stream.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
- [OpenAPI-спека и Swagger UI](items/openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [Data-миграции не отбирают строки по обрезаемым спискам](items/data-migration-row-selection.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
- [[idea] Отказ от heartbeatSeries](items/drop-heartbeat-series.md) — 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
- [Пересборка держит весь журнал в памяти](items/rebuild-memory-footprint.md) — Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
- [[idea] Пересекающиеся источники одной метрики](items/overlapping-sources.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
- [[idea] Порог sealed: с какого возраста час считается запечатанным](items/sealed-threshold.md) — WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
- [Порядок журнала при конкурентных приёмах](items/journal-order-on-ingest.md) — Решено: повторы, но после /stats. Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда
- [Предел на размер и число заголовков доставки](items/delivery-header-limits.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
- [Пределы на размер сущности и потоковый расчёт формы](items/entity-size-limits.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
- [Проверка секций, которых поток ещё не приносил](items/unseen-sections-check.md) — Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую
- [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](items/workout-routes-table.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
- [Read API: точки, выбор слоя, свёртка по сетке](items/read-api-points.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [Выведенные из данных схемы содержимого](items/derived-content-schemas.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [Словарь категориальных значений → коды HealthKit](items/categorical-value-dictionary.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
- [[idea] Что считать сутками при смене часового пояса](items/day-boundary-timezone.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- [Сверка живой витрины с пересборкой](items/rebuild-comparison-check.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- [Тай-брейк при равной полноте точек](items/tie-break-equal-completeness.md) — Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт
- [Устаревание нижнего слоя после экспорта](items/lower-layer-expiry.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [[idea] Выгрузка в parquet отдельной командой](items/parquet-export.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
- [Заголовки доставки в архиве рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
## инфра
- [Активный алерт «данных нет N часов»](items/stream-silence-alert.md) — Пропажу потока сейчас замечает человек, а не сервис
- [Деплой на rivendell](items/deploy-rivendell.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома
- [Счётчики слияния переживают ротацию логов](items/merge-counters-in-db.md) — единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- [Остановка и миграция: раздельные бюджеты и следы в логе](items/shutdown-and-migration-traces.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
- [Чем откатывать релиз после наката миграции](items/release-rollback-after-migration.md) — Решено: копия файла базы перед накатом. Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем
- [Ретеншен сырого архива](items/raw-archive-retention.md) — Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает
- [Наблюдаемость: /stats](items/stats-endpoint.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- [Умолчания конфига указывают на прежнюю раскладку](items/config-defaults-data-dir.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
- [Управление токенами и секретами](items/token-and-secret-management.md) — Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
+45
View File
@@ -0,0 +1,45 @@
# План
Оглавление целей. Цель — файл `[goal]` в `items/`; её задачи
здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.
В первой секции («порядок») очередь значима и обосновывается
прозой; в остальных порядка нет — это тематические цели.
## Что уже пройдено
Каркас и приём без разбора закрыты. Метрики, тренировки и записи со своими `id`
разбираются и ложатся в часовые объекты. `reindex` проигрывает журнал в свежую
витрину, отпечатки сравниваются, повторный прогон ничего не меняет. Род
агрегации **измерен**: сверка минутного слоя с часовым разложила метрики живого
корпуса на накопительные и мгновенные, не сойдясь ни на одной, и каталог
разрезов отдаётся первым маршрутом чтения. Разведка закончена — правило вывода
слоя, модель идентичности и формы точки проверены на живом потоке
([research/apple-health.md](../research/apple-health.md)).
Эти звенья целями не заведены: закрытая цель записи не оставляет, ей хватает
коммита и спеки.
## Почему в таком порядке
- **Каталог и род агрегации — перед Read API.** Без измеренного рода свёртка в
ответе неотличима от угадывания, а ошибиться здесь дорого: просуммировать
нижний слой значит завысить втрое. Это звено уже закрыто.
- **`healthlog import` — перед устареванием нижнего слоя.** Пока импорт
экспорта не написан, помечать что-либо устаревшим не на основании чего.
- **Read API — перед MCP.** Адаптер собственной логики не несёт, он переводит
вызовы в те же обработчики; переводить пока нечего.
## порядок
- [[goal] Разбор и хранилище](items/parsing-and-storage.md) — Секции живого потока разобраны все, кроме словаря категориальных значений и пяти невиденных
- [[goal] Read API](items/read-api.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [[goal] Самоописание](items/self-description.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [[goal] MCP](items/mcp.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [[goal] Импорт родного экспорта Apple](items/native-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [[goal] Устаревание нижнего слоя](items/lower-layer-cleanup.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [[goal] Наблюдаемость](items/observability.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- [[goal] Деплой](items/deploy.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
## темы
- [[goal] Прочность слияния и идентичности](items/merge-robustness.md) — Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
- [[goal] Журнал и пересборка](items/journal-and-rebuild.md) — Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
- [[goal] Пределы и поведение под объёмом](items/limits-and-load.md) — Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
@@ -1,8 +1,10 @@
# Кладбище беклога # Ушедшее без реализации
Задачи, покинувшие беклог без реализации. Пишется `backlog.py close`. Задачи, покинувшие беклог **без реализации**, с причиной и датой.
Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них
есть коммит. Это первое место, куда смотрит дедупликация при заведении.
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Был приоритет: … --> <!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->
- 2026-08-01 `bekap-dannyh` — Резервное копирование ./data. Причина: бекап обеспечивает готовый механизм на сервере пет-проектов — своего заводить не нужно, задача снимается деплоем. Был приоритет: высокий. - 2026-08-01 `bekap-dannyh` — Резервное копирование ./data. Причина: бекап обеспечивает готовый механизм на сервере пет-проектов — своего заводить не нужно, задача снимается деплоем. Была секция: высокий.
- 2026-08-01 `identichnost-epizodnyh-metrik` — Идентичность эпизодных метрик. Причина: решён измерением и prior art: ключ эпизода — метрика+слой+start+end (находка 47), вариант А; вернулся в scope razbor-metrik-v-obekty. Был приоритет: блокеры. - 2026-08-01 `identichnost-epizodnyh-metrik` — Идентичность эпизодных метрик. Причина: решён измерением и prior art: ключ эпизода — метрика+слой+start+end (находка 47), вариант А; вернулся в scope razbor-metrik-v-obekty. Была секция: блокеры.
- 2026-08-01 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Был приоритет: блокеры. - 2026-08-01 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Была секция: блокеры.
+6
View File
@@ -0,0 +1,6 @@
# Спринт
Спринта нет. Цель называет человек, набор собирает агент:
`tasks.py sprint start --goal <слаг>`.
## Набор
@@ -1,6 +1,6 @@
# Импорт родного экспорта Apple Health # Импорт родного экспорта Apple Health
**Приоритет:** средний **Секция:** ядро · **Хук:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут · **Теги:** goal:native-export-import
Слой `sample` пуст: настоящих сэмплов HealthKit в потоке нет вовсе — HAE отдаёт Слой `sample` пуст: настоящих сэмплов HealthKit в потоке нет вовсе — HAE отдаёт
посекундную развёртку, а не измерения (находка 34). Полная история и точные посекундную развёртку, а не измерения (находка 34). Полная история и точные
@@ -41,4 +41,3 @@
Архивы: `/home/av/MediaEverything/HealthData/apple_health/` — девять штук, Архивы: `/home/av/MediaEverything/HealthData/apple_health/` — девять штук,
2021-12 … 2026-08. Старые версии формата годятся как регрессионный набор. 2021-12 … 2026-08. Старые версии формата годятся как регрессионный набор.
@@ -1,6 +1,6 @@
# Словарь категориальных значений → коды HealthKit # Словарь категориальных значений → коды HealthKit
**Приоритет:** средний **Секция:** ядро · **Хук:** Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить · **Теги:** goal:parsing-and-storage
HAE отдаёт перечислимые значения строками локали телефона: «БДГ», «Сидячий HAE отдаёт перечислимые значения строками локали телефона: «БДГ», «Сидячий
образ жизни», «В помещении Ходьба». Родной экспорт Apple при этом говорит образ жизни», «В помещении Ходьба». Родной экспорт Apple при этом говорит
@@ -37,4 +37,3 @@ HAE отдаёт перечислимые значения строками ло
`/stats` показывает строки, для которых кода ещё нет. `/stats` показывает строки, для которых кода ещё нет.
`stateOfMind` в словаре не нуждается — он и так шлёт коды HealthKit. `stateOfMind` в словаре не нуждается — он и так шлёт коды HealthKit.
@@ -1,6 +1,6 @@
# Умолчания конфига указывают на прежнюю раскладку # Умолчания конфига указывают на прежнюю раскладку
**Приоритет:** средний **Секция:** инфра · **Хук:** Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка · **Теги:** goal:deploy
Данные переехали в `./data` (база + сырой архив, он же том контейнера), а Данные переехали в `./data` (база + сырой архив, он же том контейнера), а
умолчания в `internal/config` остались прежними: `./healthlog.db` и `./raw`. умолчания в `internal/config` остались прежними: `./healthlog.db` и `./raw`.
@@ -14,4 +14,3 @@
Готово, когда запуск без конфига использует `./data` и не создаёт ничего в Готово, когда запуск без конфига использует `./data` и не создаёт ничего в
корне репозитория. Тогда же снимается предупреждение из `config.example.toml`. корне репозитория. Тогда же снимается предупреждение из `config.example.toml`.
@@ -1,6 +1,6 @@
# Data-миграции не отбирают строки по обрезаемым спискам # Data-миграции не отбирают строки по обрезаемым спискам
**Приоритет:** низкий **Секция:** ядро · **Хук:** Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону · **Теги:** goal:journal-and-rebuild
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`). `dozakryt-nahodki-sushchnostej`).
@@ -22,7 +22,7 @@ WHERE EXISTS (SELECT 1 FROM json_each(delivery.uncovered_sections)
секцией `ecg` за ними даёт список без `ecg`. секцией `ecg` за ними даёт список без `ecg`.
Для `00007` дефект **пустой**: HAE шлёт одну секцию за доставку Для `00007` дефект **пустой**: HAE шлёт одну секцию за доставку
(`docs/local-research.md`, находка 50), секций восемь, тела с 32 незнакомыми (`docs/research/apple-health.md`, находка 50), секций восемь, тела с 32 незнакомыми
ключами в архиве не существует. Но следующая покрытая секция унаследует ту же ключами в архиве не существует. Но следующая покрытая секция унаследует ту же
слепую зону, а к тому времени причину никто не вспомнит. слепую зону, а к тому времени причину никто не вспомнит.
@@ -36,10 +36,10 @@ WHERE EXISTS (SELECT 1 FROM json_each(delivery.uncovered_sections)
- Практическое следствие для существующего кода: `UncoveredDropped > 0` обязан - Практическое следствие для существующего кода: `UncoveredDropped > 0` обязан
означать безусловное пересворачивание — доставка, у которой список обрезан, означать безусловное пересворачивание — доставка, у которой список обрезан,
про своё покрытие ничего достоверного не говорит. про своё покрытие ничего достоверного не говорит.
- Кандидат в `docs/conventions.md` (раздел про миграции), если форма отбора - Кандидат в `docs/conventions/README.md` (раздел про миграции), если форма отбора
окажется общей. окажется общей.
## Связано ## Связано
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) — - [Проверка секций, которых поток ещё не приносил](unseen-sections-check.md) —
именно она следующей сделает секцию покрытой и напишет такую миграцию. именно она следующей сделает секцию покрытой и напишет такую миграцию.
@@ -1,6 +1,6 @@
# [idea] Что считать сутками при смене часового пояса # [idea] Что считать сутками при смене часового пояса
**Приоритет:** средний **Секция:** ядро · **Хук:** Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено · **Теги:** goal:read-api
«Шаги за день» — базовый запрос трекера и фитнес-игры. Но точка несёт метку с «Шаги за день» — базовый запрос трекера и фитнес-игры. Но точка несёт метку с
офсетом исходной зоны, и при перелёте сутки перестают быть однозначными: день офсетом исходной зоны, и при перелёте сутки перестают быть однозначными: день
@@ -17,4 +17,3 @@ Apple эту неоднозначность не решает, а перекла
Вопрос не гипотетический: походы и хайкинг из второго сценария — это как раз Вопрос не гипотетический: походы и хайкинг из второго сценария — это как раз
поездки со сменой зоны. поездки со сменой зоны.
@@ -1,6 +1,6 @@
# Предел на размер и число заголовков доставки # Предел на размер и число заголовков доставки
**Приоритет:** средний **Секция:** ядро · **Хук:** MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним · **Теги:** goal:limits-and-load
У тела доставки предел есть (`max_body`), у заголовков — нет ни одного: У тела доставки предел есть (`max_body`), у заголовков — нет ни одного:
`MaxHeaderBytes` серверу не задан, а `delivery.headers` пишутся в базу целиком, `MaxHeaderBytes` серверу не задан, а `delivery.headers` пишутся в базу целиком,
@@ -14,7 +14,7 @@
Чинится дёшево и в двух местах сразу: `MaxHeaderBytes` у `http.Server` и предел Чинится дёшево и в двух местах сразу: `MaxHeaderBytes` у `http.Server` и предел
на то, что уходит в колонку. Разумно делать одной правкой с на то, что уходит в колонку. Разумно делать одной правкой с
[управлением токенами](upravlenie-sekretami.md) — оба пункта про одно и то же: [управлением токенами](token-and-secret-management.md) — оба пункта про одно и то же:
приём перестаёт доверять тому, кто с ним говорит. приём перестаёт доверять тому, кто с ним говорит.
Осторожно: это путь приёма, а доставка, не попавшая в архив, теряется навсегда. Осторожно: это путь приёма, а доставка, не попавшая в архив, теряется навсегда.
@@ -1,6 +1,6 @@
# Заголовки доставки в архиве рядом с телом # Заголовки доставки в архиве рядом с телом
**Приоритет:** средний **Секция:** ядро · **Хук:** Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке · **Теги:** goal:journal-and-rebuild
Состояние объявлено свёрткой по журналу, а журналом — сырой архив. Но в архиве Состояние объявлено свёрткой по журналу, а журналом — сырой архив. Но в архиве
лежит только **тело**: заголовки запроса (`automation-id`, лежит только **тело**: заголовки запроса (`automation-id`,
@@ -1,6 +1,6 @@
# Деплой на rivendell # Деплой на rivendell
**Приоритет:** средний **Секция:** инфра · **Хук:** Сервис живёт на рабочей машине — телефон достаёт до него только дома · **Теги:** goal:deploy
Сервис живёт в контейнере на рабочей машине, телефон достаёт до него только Сервис живёт в контейнере на рабочей машине, телефон достаёт до него только
дома. Вне дома экспорт копится и уезжает пачкой при возвращении — работает, но дома. Вне дома экспорт копится и уезжает пачкой при возвращении — работает, но
@@ -28,4 +28,3 @@
Том стоит смонтировать так, чтобы серверный бекап забирал его без отдельной Том стоит смонтировать так, чтобы серверный бекап забирал его без отдельной
настройки, и снимать копию SQLite через `VACUUM INTO`, а не `cp`: телефон шлёт настройки, и снимать копию SQLite через `VACUUM INTO`, а не `cp`: телефон шлёт
непрерывно, и файл под записью копировать нельзя. непрерывно, и файл под записью копировать нельзя.
+15
View File
@@ -0,0 +1,15 @@
# [goal] Деплой
**Секция:** порядок · **Хук:** Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
Сервис переезжает на rivendell и становится доступен телефону из любой сети.
Выведена из шага 11 плана.
Завершена, когда оба контура закрыты разными токенами, откат релиза имеет
названный механизм, а запуск без конфига не заводит базу мимо данных.
## Завершение
Оба контура закрыты разными токенами, откат релиза имеет названный механизм,
а запуск без конфига не заводит базу мимо данных.
@@ -1,6 +1,6 @@
# Выведенные из данных схемы содержимого # Выведенные из данных схемы содержимого
**Приоритет:** средний **Секция:** ядро · **Хук:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке · **Теги:** goal:self-description
Метрик у Apple больше сотни, формы точек разные, и рукописный каталог описывал Метрик у Apple больше сотни, формы точек разные, и рукописный каталог описывал
бы документацию HAE, а не то, что он реально прислал. Схема содержимого бы документацию HAE, а не то, что он реально прислал. Схема содержимого
@@ -23,4 +23,3 @@
Связано: `docs/architecture.md` → «Самоописание». Форму конверта API описывает Связано: `docs/architecture.md` → «Самоописание». Форму конверта API описывает
не эта задача, а OpenAPI. не эта задача, а OpenAPI.
@@ -1,6 +1,6 @@
# [idea] Отказ от heartbeatSeries # [idea] Отказ от heartbeatSeries
**Приоритет:** низкий **Секция:** ядро · **Хук:** 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе · **Теги:** goal:lower-layer-cleanup
`heart_rate_variability` приезжает вместе с `heartbeatSeries` — рядом `heart_rate_variability` приезжает вместе с `heartbeatSeries` — рядом
межударных интервалов внутри точки. Это **93% объёма метрики** (находка 39) межударных интервалов внутри точки. Это **93% объёма метрики** (находка 39)
@@ -1,6 +1,6 @@
# Пределы на размер сущности и потоковый расчёт формы # Пределы на размер сущности и потоковый расчёт формы
**Приоритет:** средний **Секция:** ядро · **Хук:** Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе · **Теги:** goal:limits-and-load
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`). Та задача убрала канонизацию приехавшей `dozakryt-nahodki-sushchnostej`). Та задача убрала канонизацию приехавшей
@@ -58,12 +58,12 @@
схлопываются, но различных тело вмещает сколько угодно. Отмена цикл схлопываются, но различных тело вмещает сколько угодно. Отмена цикл
прерывает (дедлайн свёртки снова работает), но доставка при этом уходит в прерывает (дедлайн свёртки снова работает), но доставка при этом уходит в
`failed` — то есть отравленное тело стоит полного дедлайна воркера. Тот же `failed` — то есть отравленное тело стоит полного дедлайна воркера. Тот же
вопрос открыт для точек на одной координате: `cena-sliyaniya-na-shirokoj-dostavke.md`, вопрос открыт для точек на одной координате: `merge-cost-wide-delivery.md`,
пункт 4. пункт 4.
## Связано ## Связано
- [Цена слияния на широкой доставке](cena-sliyaniya-na-shirokoj-dostavke.md) — - [Цена слияния на широкой доставке](merge-cost-wide-delivery.md) —
та же плата со стороны **точек** (`hashPoints` пересчитывает форму всех точек та же плата со стороны **точек** (`hashPoints` пересчитывает форму всех точек
часа). Задачи делать вместе: половина решения общая — `canon`. часа). Задачи делать вместе: половина решения общая — `canon`.
- Из того же ревью: «хеш без полного прохода по содержимому не посчитать» — - Из того же ревью: «хеш без полного прохода по содержимому не посчитать» —
@@ -1,6 +1,6 @@
# Сущность с id, но неразобранной меткой # Сущность с id, но неразобранной меткой
**Приоритет:** средний **Секция:** ядро · **Хук:** Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны · **Теги:** goal:parsing-and-storage, question
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`). Та задача сделала мягким чтение заголовка: `dozakryt-nahodki-sushchnostej`). Та задача сделала мягким чтение заголовка:
@@ -18,10 +18,9 @@
не молчит — но содержимое всё ещё не хранится. не молчит — но содержимое всё ещё не хранится.
- Достижимость из реального потока: замер на 118 доставках дал **ноль** - Достижимость из реального потока: замер на 118 доставках дал **ноль**
пропусков всех трёх классов. Дрейф формата дат у HAE при этом пропусков всех трёх классов. Дрейф формата дат у HAE при этом
задокументирован (`docs/local-research.md`), то есть вход не выдуман. задокументирован (`docs/research/apple-health.md`), то есть вход не выдуман.
## Что решить
## Вопросы
Хранить ли сущность с разобранным `id` и неразобранной меткой. Цена: Хранить ли сущность с разобранным `id` и неразобранной меткой. Цена:
1. **Хранить с NULL-меткой** — правка схемы (`start_utc`/`ts_utc` становятся 1. **Хранить с NULL-меткой** — правка схемы (`start_utc`/`ts_utc` становятся
@@ -1,6 +1,6 @@
# Проверка целостности собранной витрины перед подменой # Проверка целостности собранной витрины перед подменой
**Приоритет:** средний **Секция:** ядро · **Хук:** Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе · **Теги:** goal:journal-and-rebuild
`healthlog reindex` собирает витрину в отдельный файл и снимает с него `healthlog reindex` собирает витрину в отдельный файл и снимает с него
отпечаток, а подмену делает человек: остановить сервис, переименовать файл, отпечаток, а подмену делает человек: остановить сервис, переименовать файл,
+18
View File
@@ -0,0 +1,18 @@
# [goal] Журнал и пересборка
**Секция:** темы · **Хук:** Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
Тема: инвариант «`import` + `replay` даёт то же состояние» и всё, что его
держит — архив, ретеншен, отпечаток витрины, расход памяти пересборки.
В порядок не встаёт: работа приходит находками и растёт вместе с
журналом.
Завершена не бывает: закрывается по мере того, как расхождение витрины с
журналом перестаёт быть молчащим.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как расхождение витрины
с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с
журналом.
@@ -1,18 +1,17 @@
# Порядок журнала при конкурентных приёмах # Порядок журнала при конкурентных приёмах
**Приоритет:** средний **Секция:** ядро · **Хук:** Решено: повторы, но после /stats. Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда · **Теги:** goal:journal-and-rebuild, question
**Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.** **Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.**
До появления наблюдаемости живём вариантом (г) с уже записанным в спеке До появления наблюдаемости живём вариантом (г) с уже записанным в спеке
приёма пределом — иначе повторы лечат болезнь, которую никто не наблюдает. приёма пределом — иначе повторы лечат болезнь, которую никто не наблюдает.
Задача берётся после [наблюдаемости](stats-nablyudaemost.md); ниже — исходная Задача берётся после [наблюдаемости](stats-endpoint.md); ниже — исходная
постановка блокера, она же ТЗ. постановка блокера, она же ТЗ.
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
`deep`, враждебный проход, находка с построенным путём и прогоном). `deep`, враждебный проход, находка с построенным путём и прогоном).
## Что решить ## Вопросы
Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи
тела в архив и до вставки строки учёта. Порядок, в котором строки становятся тела в архив и до вставки строки учёта. Порядок, в котором строки становятся
видимыми воркеру, порядку меток не подчиняется: между выпуском идентификатора и видимыми воркеру, порядку меток не подчиняется: между выпуском идентификатора и
+16
View File
@@ -0,0 +1,16 @@
# [goal] Пределы и поведение под объёмом
**Секция:** темы · **Хук:** Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
Тема: названные пределы на размер тела, сущности, заголовков и ответа плюс
поведение под удерживаемой блокировкой.
В порядок не встаёт: пределы всплывают замерами, а не планом.
Завершена не бывает: закрывается по мере того, как каждый вход получает
названный предел вместо подразумеваемого.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как каждый вход получает
названный предел вместо подразумеваемого.
+16
View File
@@ -0,0 +1,16 @@
# [goal] Устаревание нижнего слоя
**Секция:** порядок · **Хук:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
После проверенного экспорта нижний слой HAE избыточен и подлежит чистке.
Выведена из шага 9 плана. Нижний слой растёт на ~100 тысяч координат в сутки.
Завершена, когда чистка идёт по правилу, а не по календарю, и решение о
удалении опирается на колонку, отличающую ноль от «не измерялось».
## Завершение
Чистка идёт по правилу «до следующего проверенного экспорта», а не по
календарю, и решение об удалении опирается на колонку, отличающую ноль от
«не измерялось».
@@ -1,6 +1,6 @@
# Устаревание нижнего слоя после экспорта # Устаревание нижнего слоя после экспорта
**Приоритет:** низкий **Секция:** ядро · **Хук:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен · **Теги:** goal:lower-layer-cleanup
Нижний слой растёт примерно на 100 тысяч координат в сутки против ~3 700 у Нижний слой растёт примерно на 100 тысяч координат в сутки против ~3 700 у
минутного и ~100 у часового — разница в три порядка (находка 41). Всё давление минутного и ~100 у часового — разница в три порядка (находка 41). Всё давление
@@ -19,4 +19,3 @@
станет актуальной, когда нижний слой перевалит за несколько гигабайт. станет актуальной, когда нижний слой перевалит за несколько гигабайт.
Зависит от импорта экспорта Apple — до него помечать нечем. Зависит от импорта экспорта Apple — до него помечать нечем.
@@ -1,6 +1,6 @@
# MCP-сервер поверх Read API # MCP-сервер поверх Read API
**Приоритет:** высокий **Секция:** ядро · **Хук:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем · **Теги:** goal:mcp
Агент-медик — первый заказчик проекта. Ему нужна актуальная сводка, а не срез Агент-медик — первый заказчик проекта. Ему нужна актуальная сводка, а не срез
на дату последнего ручного экспорта. на дату последнего ручного экспорта.
@@ -20,4 +20,3 @@ MCP не даёт ничего, чего не даёт HTTP, и права об
неделе» без промежуточного кода. неделе» без промежуточного кода.
Связано: `docs/architecture.md` → «MCP», план → шаг «MCP». Связано: `docs/architecture.md` → «MCP», план → шаг «MCP».
+16
View File
@@ -0,0 +1,16 @@
# [goal] MCP
**Секция:** порядок · **Хук:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
Агент-медик — первый заказчик проекта — подключается к хранилищу.
Выведена из шага 7 плана. Идёт после Read API намеренно: адаптер собственной
логики не несёт, он переводит вызовы в те же обработчики, и переводить пока
нечего.
Завершена, когда агент читает данные через MCP тем же токеном чтения.
## Завершение
Агент читает данные через MCP тем же токеном чтения, и собственной логики
адаптер не несёт.
@@ -1,6 +1,6 @@
# Цена слияния на широкой доставке # Цена слияния на широкой доставке
**Приоритет:** средний **Секция:** ядро · **Хук:** 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed · **Теги:** goal:limits-and-load
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный
проход и независимая реализация — независимо друг от друга). проход и независимая реализация — независимо друг от друга).
@@ -1,6 +1,6 @@
# Счётчики слияния переживают ротацию логов # Счётчики слияния переживают ротацию логов
**Приоритет:** средний **Секция:** инфра · **Хук:** единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего · **Теги:** goal:observability
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, проход Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, проход
негативного пространства, подтверждено эксплуатационным). негативного пространства, подтверждено эксплуатационным).
@@ -36,7 +36,7 @@
## Связано ## Связано
- [stats-nablyudaemost](stats-nablyudaemost.md) — то же наблюдение нужно и там. - [stats-endpoint](stats-endpoint.md) — то же наблюдение нужно и там.
- [rod-agregacii-i-katalog](rod-agregacii-i-katalog.md) — придёт к вопросу о - [rod-agregacii-i-katalog](rod-agregacii-i-katalog.md) — придёт к вопросу о
тай-брейке и потребует эксплуатационной истории, которой без этой задачи не тай-брейке и потребует эксплуатационной истории, которой без этой задачи не
будет: мерить придётся снова по архиву, а он к тому моменту подрезан. будет: мерить придётся снова по архиву, а он к тому моменту подрезан.
+15
View File
@@ -0,0 +1,15 @@
# [goal] Прочность слияния и идентичности
**Секция:** темы · **Хук:** Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
Тема: правила, по которым две версии одних данных превращаются в одну.
В порядок не встаёт — работа приходит находками ревью и замерами на
живом корпусе.
Завершена не бывает: закрывается по мере того, как правила перестают зависеть
от порядка на проводе.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как правила выбора между
версиями перестают зависеть от порядка элементов на проводе.
@@ -1,6 +1,6 @@
# [idea] Месячный проход по ручным секциям # [idea] Месячный проход по ручным секциям
**Приоритет:** низкий **Секция:** ядро · **Хук:** Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит · **Теги:** goal:parsing-and-storage
Окно досчёта не единое, и это измеренное различие, а не предположение. Окно досчёта не единое, и это измеренное различие, а не предположение.
Количественные метрики (пульс, шаги, энергия) человек руками не правит — они Количественные метрики (пульс, шаги, энергия) человек руками не правит — они
@@ -18,4 +18,4 @@
когда они появятся, — иначе проход пишется вслепую и проверяется не на чем. когда они появятся, — иначе проход пишется вслепую и проверяется не на чем.
Связано: `docs/architecture.md` → «Досчёт задним числом», задача Связано: `docs/architecture.md` → «Досчёт задним числом», задача
`proverka-novyh-sekcij`. `unseen-sections-check`.
+17
View File
@@ -0,0 +1,17 @@
# [goal] Импорт родного экспорта Apple
**Секция:** порядок · **Хук:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
`healthlog import`: снапшот всей истории из родного экспорта Apple Health
ложится в хранилище перед проигрыванием хвоста доставок.
Выведена из шага 8 плана. Идёт перед устареванием нижнего слоя намеренно: пока
импорт экспорта не написан, помечать что-либо устаревшим не на основании чего.
Завершена, когда слой `sample` наполнен историей с 2019 года, а повторный
импорт того же экспорта ничего не меняет.
## Завершение
Слой `sample` наполнен историей с 2019 года, повторный импорт того же экспорта
ничего не меняет, а тренировки из экспорта не задваивают приехавшие от HAE.
@@ -1,6 +1,6 @@
# [idea] NDJSON-поток для больших выборок Read API # [idea] NDJSON-поток для больших выборок Read API
**Приоритет:** низкий **Секция:** ядро · **Хук:** Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация · **Теги:** goal:read-api
Read API отдаёт ответ одним JSON. Для выборок нижнего слоя за длинный период Read API отдаёт ответ одним JSON. Для выборок нижнего слоя за длинный период
это не работает: `heart_rate` в слое `raw` — порядка сотни тысяч координат в это не работает: `heart_rate` в слое `raw` — порядка сотни тысяч координат в
@@ -17,4 +17,4 @@ Read API отдаёт ответ одним JSON. Для выборок нижн
последовательно или с возвратами. последовательно или с возвратами.
Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача
`read-api-tochki`. `read-api-points`.
+16
View File
@@ -0,0 +1,16 @@
# [goal] Наблюдаемость
**Секция:** порядок · **Хук:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
Тихо сломавшаяся автоматизация — главный эксплуатационный риск: телефон шлёт
молча, и молчание неотличимо от нормы.
Выведена из шага 10 плана.
Завершена, когда пропажа потока и расхождение витрины с журналом видны
владельцу без чтения логов.
## Завершение
Пропажа потока и расхождение витрины с журналом видны владельцу без чтения
логов и переживают ротацию логов.
@@ -1,6 +1,6 @@
# OpenAPI-спека и Swagger UI # OpenAPI-спека и Swagger UI
**Приоритет:** высокий **Секция:** ядро · **Хук:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате · **Теги:** goal:read-api
Потребителей три, и один из них — агент, который читает контракт машиной. Потребителей три, и один из них — агент, который читает контракт машиной.
Пересказывать форму ответа в чате не годится, а самоописание из плана покрывает Пересказывать форму ответа в чате не годится, а самоописание из плана покрывает
@@ -21,4 +21,3 @@
Развилка на решение: спека пишется руками как источник истины или выводится из Развилка на решение: спека пишется руками как источник истины или выводится из
кода. Для маленького API рукописная спека честнее — но это стоит обсудить. кода. Для маленького API рукописная спека честнее — но это стоит обсудить.
@@ -1,6 +1,6 @@
# [idea] Пересекающиеся источники одной метрики # [idea] Пересекающиеся источники одной метрики
**Приоритет:** средний **Секция:** ядро · **Хук:** Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь · **Теги:** goal:read-api
Одну метрику пишут несколько источников: сон — часы и стороннее приложение Одну метрику пишут несколько источников: сон — часы и стороннее приложение
AutoSleep, шаги — часы и телефон одновременно. Поле `source` при этом не AutoSleep, шаги — часы и телефон одновременно. Поле `source` при этом не
@@ -17,4 +17,3 @@ AutoSleep, шаги — часы и телефон одновременно. П
Для агента-медика вопрос практический: «сколько я спал» не должно давать Для агента-медика вопрос практический: «сколько я спал» не должно давать
двойной ответ. двойной ответ.
@@ -1,6 +1,6 @@
# [idea] Выгрузка в parquet отдельной командой # [idea] Выгрузка в parquet отдельной командой
**Приоритет:** низкий **Секция:** ядро · **Хук:** Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно · **Теги:** goal:read-api
Отдельная команда, выгружающая хранилище в parquet, — дверь для тяжёлой Отдельная команда, выгружающая хранилище в parquet, — дверь для тяжёлой
аналитики снаружи, без миграции самого хранилища. аналитики снаружи, без миграции самого хранилища.
+18
View File
@@ -0,0 +1,18 @@
# [goal] Разбор и хранилище
**Секция:** порядок · **Хук:** Секции живого потока разобраны все, кроме словаря категориальных значений и пяти невиденных
Метрики, тренировки и записи со своими `id` разбираются и ложатся в часовые
объекты; тела перестали быть недифференцированной кучей.
Выведена из шага 3 плана. Сделано: разбор метрик в объекты, тренировки и
записи, `reindex`. Осталось: словарь категориальных значений и секции, которых
поток ещё не приносил.
Завершена, когда ни одна секция живого потока не числится неразобранной, а
категориальные значения имеют стабильный код рядом с переведённой строкой.
## Завершение
Ни одна секция живого потока не числится неразобранной, а категориальные
значения несут стабильный код рядом с переведённой строкой.
@@ -1,6 +1,6 @@
# Ретеншен сырого архива # Ретеншен сырого архива
**Приоритет:** низкий **Секция:** инфра · **Хук:** Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает · **Теги:** goal:journal-and-rebuild
Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но
удаления нет: архив растёт бесконечно. Пока это 16 МБ и проблемой не является. удаления нет: архив растёт бесконечно. Пока это 16 МБ и проблемой не является.
@@ -1,6 +1,6 @@
# Read API: точки, выбор слоя, свёртка по сетке # Read API: точки, выбор слоя, свёртка по сетке
**Приоритет:** высокий **Секция:** ядро · **Хук:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может · **Теги:** goal:read-api
Сейчас данные достаются только `sqlite3` на хосте. Все три сценария — Сейчас данные достаются только `sqlite3` на хосте. Все три сценария —
агент-медик, трекер тренировок, фитнес-игра — упираются в отсутствие чтения. агент-медик, трекер тренировок, фитнес-игра — упираются в отсутствие чтения.
@@ -74,4 +74,3 @@ WAL и условным запросом): 693 мс и +153 МиБ живой к
Связано: `docs/architecture.md` → «Read API», «Измерение рода агрегации», Связано: `docs/architecture.md` → «Read API», «Измерение рода агрегации»,
план → шаг «Read API». план → шаг «Read API».
+17
View File
@@ -0,0 +1,17 @@
# [goal] Read API
**Секция:** порядок · **Хук:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
Потребители читают точки: выбор слоя, свёртка по сетке, предел размера ответа.
Выведена из шага 5 плана. Идёт после каталога и рода агрегации намеренно: без
измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться здесь
дорого — просуммировать нижний слой значит завысить втрое.
Завершена, когда любой из трёх потребителей получает точки за период без
доступа к файлу базы.
## Завершение
Любой из трёх потребителей получает точки за период без доступа к файлу базы,
и предел размера ответа объявлен, а не подразумевается.
@@ -1,6 +1,6 @@
# Сверка живой витрины с пересборкой # Сверка живой витрины с пересборкой
**Приоритет:** средний **Секция:** ядро · **Хук:** reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит · **Теги:** goal:journal-and-rebuild
`healthlog reindex` печатает отпечаток собранной витрины и отпечаток рабочей — `healthlog reindex` печатает отпечаток собранной витрины и отпечаток рабочей —
то есть данные для сверки уже есть, и **сравнивать их некому**. Расхождение то есть данные для сверки уже есть, и **сравнивать их некому**. Расхождение
@@ -8,7 +8,7 @@
только тем, что кто-то вручную запустил пересборку и посмотрел на два числа. только тем, что кто-то вручную запустил пересборку и посмотрел на два числа.
Между тем расхождение — не гипотеза. Известный путь к нему записан блокером Между тем расхождение — не гипотеза. Известный путь к нему записан блокером
[«Порядок журнала при конкурентных приёмах»](poryadok-zhurnala-na-priyome.md): [«Порядок журнала при конкурентных приёмах»](journal-order-on-ingest.md):
доставка, свёрнутая раньше своей предшественницы, уходит в `failed` навсегда, и доставка, свёрнутая раньше своей предшественницы, уходит в `failed` навсегда, и
живая витрина расходится с пересборкой молча. Пока тот предел не закрыт, сверка живая витрина расходится с пересборкой молча. Пока тот предел не закрыт, сверка
— единственный способ узнать, что он сработал. — единственный способ узнать, что он сработал.
@@ -26,5 +26,5 @@
Готово, когда расхождение витрины с пересборкой перестаёт зависеть от того, Готово, когда расхождение витрины с пересборкой перестаёт зависеть от того,
догадался ли человек посмотреть. догадался ли человек посмотреть.
Связано: `cmd/healthlog/reindex.go`, [наблюдаемость](stats-nablyudaemost.md), Связано: `cmd/healthlog/reindex.go`, [наблюдаемость](stats-endpoint.md),
[деплой](deploy-rivendell.md). [деплой](deploy-rivendell.md).
@@ -1,6 +1,6 @@
# Пересборка держит весь журнал в памяти # Пересборка держит весь журнал в памяти
**Приоритет:** низкий **Секция:** ядро · **Хук:** Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет · **Теги:** goal:journal-and-rebuild
`healthlog reindex` материализует целиком две вещи: учёт доставок из базы и `healthlog reindex` материализует целиком две вещи: учёт доставок из базы и
список путей архива. На сегодняшнем объёме (сотня тел) это незаметно, на список путей архива. На сегодняшнем объёме (сотня тел) это незаметно, на
@@ -17,7 +17,7 @@
памяти. памяти.
Сегодня недостижимо, поэтому приоритет низкий. Естественно склеивается с Сегодня недостижимо, поэтому приоритет низкий. Естественно склеивается с
[ретеншеном сырого архива](retenshen-syrogo-arhiva.md): та задача задаёт, где [ретеншеном сырого архива](raw-archive-retention.md): та задача задаёт, где
у журнала конец, эта — как его читать, не поднимая целиком. у журнала конец, эта — как его читать, не поднимая целиком.
Готово, когда пересборка на журнале в десятки тысяч доставок идёт с потреблением Готово, когда пересборка на журнале в десятки тысяч доставок идёт с потреблением
@@ -1,6 +1,6 @@
# Чем откатывать релиз после наката миграции # Чем откатывать релиз после наката миграции
**Приоритет:** средний **Секция:** инфра · **Хук:** Решено: копия файла базы перед накатом. Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем · **Теги:** goal:deploy, question
**Решение принято владельцем 2026-08-02: вариант (2) — копия файла базы перед **Решение принято владельцем 2026-08-02: вариант (2) — копия файла базы перед
накатом.** Entrypoint контейнера копирует файл базы рядом до старта бинаря, накатом.** Entrypoint контейнера копирует файл базы рядом до старта бинаря,
@@ -35,8 +35,7 @@
синхронизации; для `stateOfMind` не закрывается ничем — у него доставки HAE синхронизации; для `stateOfMind` не закрывается ничем — у него доставки HAE
единственный источник. единственный источник.
## Варианты и цена ## Вопросы
1. **Подкоманда `healthlog migrate --down-to N`.** Цена: новая поверхность CLI 1. **Подкоманда `healthlog migrate --down-to N`.** Цена: новая поверхность CLI
плюс тест на `Down` каждой миграции (сейчас их нет, и `DROP COLUMN` в SQLite плюс тест на `Down` каждой миграции (сейчас их нет, и `DROP COLUMN` в SQLite
ведёт себя не так, как в постгресе). Зато откат становится операцией, а не ведёт себя не так, как в постгресе). Зато откат становится операцией, а не
@@ -1,6 +1,6 @@
# [idea] Человеческие аннотации поверх выведенных схем # [idea] Человеческие аннотации поверх выведенных схем
**Приоритет:** низкий **Секция:** ядро · **Хук:** Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата · **Теги:** goal:self-description
Схема содержимого выводится из данных и говорит **форму** — какие поля есть, Схема содержимого выводится из данных и говорит **форму** — какие поля есть,
какого типа, с какой заполненностью. Чего она не говорит — что метрика значит, какого типа, с какой заполненностью. Чего она не говорит — что метрика значит,
@@ -18,4 +18,4 @@
формат. Меняться он может только с обновлением Health Auto Export, а это формат. Меняться он может только с обновлением Health Auto Export, а это
отслеживается — значит ответ придёт сам. отслеживается — значит ответ придёт сам.
Связано: `docs/architecture.md` → «Самоописание», задача `samoopisanie-shemy`. Связано: `docs/architecture.md` → «Самоописание», задача `derived-content-schemas`.
@@ -1,6 +1,6 @@
# [idea] Порог sealed: с какого возраста час считается запечатанным # [idea] Порог sealed: с какого возраста час считается запечатанным
**Приоритет:** низкий **Секция:** ядро · **Хук:** WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта · **Теги:** goal:merge-robustness
Флаг `sealed` отмечает часы, которые уже не должны меняться. Механика готова: Флаг `sealed` отмечает часы, которые уже не должны меняться. Механика готова:
изменение запечатанного объекта не отвергается, а пишется `WARN`, и данные изменение запечатанного объекта не отвергается, а пишется `WARN`, и данные
+15
View File
@@ -0,0 +1,15 @@
# [goal] Самоописание
**Секция:** порядок · **Хук:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
Клиент узнаёт форму данных из ответа сервиса, а не угадывает её по выборке.
Выведена из шага 6 плана.
Завершена, когда контракт читается машиной, а формы содержимого метрик
выведены из данных, а не описаны руками.
## Завершение
Контракт читается машиной, а формы содержимого метрик выведены из данных, а не
описаны руками.
@@ -1,6 +1,6 @@
# Остановка и миграция: раздельные бюджеты и следы в логе # Остановка и миграция: раздельные бюджеты и следы в логе
**Приоритет:** средний **Секция:** инфра · **Хук:** Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM · **Теги:** goal:deploy
Две находки эксплуатационного и идиоматического проходов ревью каталога. Обе Две находки эксплуатационного и идиоматического проходов ревью каталога. Обе
существовали и раньше, но достижимыми их сделал первый маршрут чтения: существовали и раньше, но достижимыми их сделал первый маршрут чтения:
@@ -1,6 +1,6 @@
# Наблюдаемость: /stats # Наблюдаемость: /stats
**Приоритет:** средний **Секция:** инфра · **Хук:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах · **Теги:** goal:observability
Тихо сломавшаяся автоматизация — главный эксплуатационный риск коллектора: Тихо сломавшаяся автоматизация — главный эксплуатационный риск коллектора:
данные просто перестают приходить, и заметить это можно только по молчанию. данные просто перестают приходить, и заметить это можно только по молчанию.
@@ -1,6 +1,6 @@
# Активный алерт «данных нет N часов» # Активный алерт «данных нет N часов»
**Приоритет:** низкий **Секция:** инфра · **Хук:** Пропажу потока сейчас замечает человек, а не сервис · **Теги:** goal:observability
Пропажу потока сейчас замечает человек. `/stats` покажет факт, но только если Пропажу потока сейчас замечает человек. `/stats` покажет факт, но только если
туда заглянуть — а заглядывают ровно тогда, когда уже что-то заподозрили. туда заглянуть — а заглядывают ровно тогда, когда уже что-то заподозрили.
@@ -1,6 +1,6 @@
# Тай-брейк при равной полноте точек # Тай-брейк при равной полноте точек
**Приоритет:** высокий **Секция:** ядро · **Хук:** Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт · **Теги:** goal:merge-robustness, question
**Решение принято владельцем 2026-08-02: вариант (б) — брать бо́льшее значение **Решение принято владельцем 2026-08-02: вариант (б) — брать бо́льшее значение
точки.** Ниже — исходная постановка блокера, она же ТЗ; рекомендация в конце точки.** Ниже — исходная постановка блокера, она же ТЗ; рекомендация в конце
@@ -11,15 +11,14 @@
остаться полурешёткой: `max` коммутативен, ассоциативен и идемпотентен, поэтому остаться полурешёткой: `max` коммутативен, ассоциативен и идемпотентен, поэтому
воспроизводимость свёртки не страдает. Род агрегации в правило **не входит**: воспроизводимость свёртки не страдает. Род агрегации в правило **не входит**:
род есть функция витрины, и правило слияния, читающее собственную выдачу, род есть функция витрины, и правило слияния, читающее собственную выдачу,
повторяет дефект наследования слоя «из будущего» (`docs/review-journal.md`, повторяет дефект наследования слоя «из будущего» (`docs/review.md`,
2026-08-01). 2026-08-01).
Приёмка та, что названа ниже: на прогоне живого архива отпечаток витрины обязан Приёмка та, что названа ниже: на прогоне живого архива отпечаток витрины обязан
**измениться** (иначе правило не сработало), а число столкновений с равной **измениться** (иначе правило не сработало), а число столкновений с равной
полнотой — остаться прежним. полнотой — остаться прежним.
## Что решить ## Вопросы
Какое правило выбирает победителя, когда по одним координатам приехали две точки Какое правило выбирает победителя, когда по одним координатам приехали две точки
с **равными** наборами содержательных полей и разными значениями. Структурная с **равными** наборами содержательных полей и разными значениями. Структурная
часть правила слияния закрыта (`pravilo-sliyaniya-tochek`); открыт только этот часть правила слияния закрыта (`pravilo-sliyaniya-tochek`); открыт только этот
@@ -45,7 +44,7 @@
зависящим от измеренного рода нельзя**. Род есть функция витрины, витрина — зависящим от измеренного рода нельзя**. Род есть функция витрины, витрина —
результат слияния, и правило слияния, читающее собственную выдачу, повторяет результат слияния, и правило слияния, читающее собственную выдачу, повторяет
ровно тот дефект, на котором свёртка уже переставала быть функцией префикса ровно тот дефект, на котором свёртка уже переставала быть функцией префикса
журнала (`docs/review-journal.md`, 2026-08-01, наследование слоя «из будущего»). журнала (`docs/review.md`, 2026-08-01, наследование слоя «из будущего»).
## Варианты и цена ## Варианты и цена
@@ -1,6 +1,6 @@
# Управление токенами и секретами # Управление токенами и секретами
**Приоритет:** средний **Секция:** инфра · **Хук:** Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу · **Теги:** goal:deploy
Сейчас проверка токенов выключена сознательно — доверенная локальная сеть, — и Сейчас проверка токенов выключена сознательно — доверенная локальная сеть, — и
`config.docker.toml` коммитится без секретов. Для локальной разработки это `config.docker.toml` коммитится без секретов. Для локальной разработки это
@@ -27,4 +27,3 @@
Готово, когда запуск без токенов возможен только на localhost, а на rivendell Готово, когда запуск без токенов возможен только на localhost, а на rivendell
оба контура закрыты разными токенами. оба контура закрыты разными токенами.
@@ -1,6 +1,6 @@
# Проверка секций, которых поток ещё не приносил # Проверка секций, которых поток ещё не приносил
**Приоритет:** средний **Секция:** ядро · **Хук:** Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую · **Теги:** goal:parsing-and-storage
Разбор пишется по тем данным, что видел поток, а он приносил только `metrics`, Разбор пишется по тем данным, что видел поток, а он приносил только `metrics`,
`workouts` и `stateOfMind`. Не виденны живьём: `symptoms`, `ecg`, `workouts` и `stateOfMind`. Не виденны живьём: `symptoms`, `ecg`,
@@ -26,7 +26,7 @@
смотреть, когда данные появятся. смотреть, когда данные появятся.
Готово, когда каждая новая секция либо разобрана, либо явно описана в Готово, когда каждая новая секция либо разобрана, либо явно описана в
`docs/local-research.md` как не пришедшая, и ни одна не числится в ошибках `docs/research/apple-health.md` как не пришедшая, и ни одна не числится в ошибках
разбора. разбора.
## Что уже сделано ## Что уже сделано
@@ -1,6 +1,6 @@
# Идентичность тренировок при импорте родного экспорта # Идентичность тренировок при импорте родного экспорта
**Приоритет:** средний **Секция:** ядро · **Хук:** В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE · **Теги:** goal:native-export-import
Тренировка в витрине адресуется своим `id` из HealthKit — его шлёт HAE. В Тренировка в витрине адресуется своим `id` из HealthKit — его шлёт HAE. В
`export.xml` этого идентификатора **нет вовсе**: у элемента `Workout` только `export.xml` этого идентификатора **нет вовсе**: у элемента `Workout` только
@@ -34,4 +34,4 @@ Prior art: `dogsheep/healthkit-to-sqlite` адресует тренировку
рядами, а в экспорте маршрут лежит отдельными GPX). рядами, а в экспорте маршрут лежит отдельными GPX).
Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача
`import-eksporta-apple`. `apple-export-import`.
@@ -1,6 +1,6 @@
# [idea] Разворачивание маршрутов тренировок в отдельную таблицу # [idea] Разворачивание маршрутов тренировок в отдельную таблицу
**Приоритет:** низкий **Секция:** ядро · **Хук:** Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом · **Теги:** goal:read-api
Тренировка хранится нераскрытой: заголовок — колонками, всё остальное, включая Тренировка хранится нераскрытой: заголовок — колонками, всё остальное, включая
маршрут и внутренние ряды, — блобом `payload`. Решение осознанное: структура маршрут и внутренние ряды, — блобом `payload`. Решение осознанное: структура
+1 -1
View File
@@ -31,7 +31,7 @@ import (
// //
// Без округления сравнение бесполезно: 45 507 из 71 730 повторно приехавших // Без округления сравнение бесполезно: 45 507 из 71 730 повторно приехавших
// точек различались последним разрядом double при одинаковом измерении — 63% // точек различались последним разрядом double при одинаковом измерении — 63%
// повторов выглядели новыми (docs/local-research.md, находка 30). Двенадцать // повторов выглядели новыми (docs/research/apple-health.md, находка 30). Двенадцать
// цифр отсекают дребезг сериализации и оставляют нетронутым всё, что Apple // цифр отсекают дребезг сериализации и оставляют нетронутым всё, что Apple
// реально измеряет: даже доли процента у walking_asymmetry_percentage не // реально измеряет: даже доли процента у walking_asymmetry_percentage не
// доходят до седьмой значащей цифры. // доходят до седьмой значащей цифры.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"git.vakhrushev.me/av/healthlog/internal/canon" "git.vakhrushev.me/av/healthlog/internal/canon"
) )
// Пары взяты с живого потока (docs/local-research.md, находка 30): те же // Пары взяты с живого потока (docs/research/apple-health.md, находка 30): те же
// измерения в двух выгрузках, разошедшиеся последним разрядом double. Без // измерения в двух выгрузках, разошедшиеся последним разрядом double. Без
// округления 63% повторов считались бы новыми точками. // округления 63% повторов считались бы новыми точками.
func TestFormСхлопываетДребезгПоследнегоРазряда(t *testing.T) { func TestFormСхлопываетДребезгПоследнегоРазряда(t *testing.T) {
+1 -1
View File
@@ -7,7 +7,7 @@
// заголовков. // заголовков.
// //
// Правила разбора выведены измерением живого потока, а не спроектированы: // Правила разбора выведены измерением живого потока, а не спроектированы:
// docs/local-research.md, находки 2, 30, 33, 35, 36, 38, 39, 47. Документация // docs/research/apple-health.md, находки 2, 30, 33, 35, 36, 38, 39, 47. Документация
// HAE местами расходится с тем, что приложение шлёт на самом деле, поэтому // HAE местами расходится с тем, что приложение шлёт на самом деле, поэтому
// источник истины по формату — пакеты в testdata. // источник истины по формату — пакеты в testdata.
package hae package hae
+1 -1
View File
@@ -48,7 +48,7 @@ func TestPointValueФормыТочки(t *testing.T) {
} }
// Формы точки берутся из реальных пакетов: документация формата тонкая и // Формы точки берутся из реальных пакетов: документация формата тонкая и
// местами расходится с тем, что приложение шлёт (docs/local-research.md). // местами расходится с тем, что приложение шлёт (docs/research/apple-health.md).
func TestPointValueНаРеальныхПакетах(t *testing.T) { func TestPointValueНаРеальныхПакетах(t *testing.T) {
t.Parallel() t.Parallel()
+1 -1
View File
@@ -65,7 +65,7 @@ func TestAcceptStoresBodyVerbatim(t *testing.T) {
} }
// Повтор того же тела пока принимается — отсев идентичных доставок отложен // Повтор того же тела пока принимается — отсев идентичных доставок отложен
// (docs/plan.md). Проверяем, что повтор не ломается и не затирает первую. // (docs/tasks/PLAN.md). Проверяем, что повтор не ломается и не затирает первую.
func TestAcceptAllowsRepeatedBody(t *testing.T) { func TestAcceptAllowsRepeatedBody(t *testing.T) {
svc, _, st := newService(t) svc, _, st := newService(t)
body := []byte(`{"data":{"metrics":[]}}`) body := []byte(`{"data":{"metrics":[]}}`)
+2 -2
View File
@@ -139,7 +139,7 @@ func TestReplayЖивогоАрхива(t *testing.T) {
measureStyles(t, dst) measureStyles(t, dst)
// Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним // Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним
// `date` лежит до трёх записей (docs/local-research.md, находка 47). // `date` лежит до трёх записей (docs/research/apple-health.md, находка 47).
// //
// Проверяется само свойство, а не измеренное когда-то число. Прежняя // Проверяется само свойство, а не измеренное когда-то число. Прежняя
// редакция сравнивала с константой 174, снятой на 94 доставках, и покраснела // редакция сравнивала с константой 174, снятой на 94 доставках, и покраснела
@@ -163,7 +163,7 @@ func TestReplayЖивогоАрхива(t *testing.T) {
// //
// Утверждаются СВОЙСТВА, а не числа: корпус растёт с каждой доставкой, а прогон // Утверждаются СВОЙСТВА, а не числа: корпус растёт с каждой доставкой, а прогон
// живого архива в гейт не входит, так что константа, производная от размера // живого архива в гейт не входит, так что константа, производная от размера
// корпуса, покраснела бы молча (docs/review-journal.md, 2026-08-02). Измеренные // корпуса, покраснела бы молча (docs/review.md, 2026-08-02). Измеренные
// числа печатаются. // числа печатаются.
func measureStyles(t *testing.T, st *store.Store) { func measureStyles(t *testing.T, st *store.Store) {
t.Helper() t.Helper()
@@ -187,7 +187,7 @@ JSON-массив имён (`["stateOfMind"]`), пустой список — `[
установившееся состояние половины потока (48 доставок из 99). Постоянный `WARN` установившееся состояние половины потока (48 доставок из 99). Постоянный `WARN`
каждые пять минут обесценивает уровень ровно так же, как обесценило бы каждые пять минут обесценивает уровень ровно так же, как обесценило бы
сравнение с заголовком `Default`. Момент появления **новой** секции — отдельная сравнение с заголовком `Default`. Момент появления **новой** секции — отдельная
задача (`proverka-novyh-sekcij`), и она будет опираться на сохранённый список. задача (`unseen-sections-check`), и она будет опираться на сохранённый список.
Имена идут структурным атрибутом (`[]string`), а не склейкой в строку: JSON- Имена идут структурным атрибутом (`[]string`), а не склейкой в строку: JSON-
кодировщик `slog` экранирует управляющие символы, поэтому имя из чужого тела не кодировщик `slog` экранирует управляющие символы, поэтому имя из чужого тела не
@@ -54,5 +54,5 @@
- `internal/fold` — исход свёртки, статус и атрибут лога. - `internal/fold` — исход свёртки, статус и атрибут лога.
- `docs/database.md`, `docs/architecture.md`, `docs/local-research.md` - `docs/database.md`, `docs/architecture.md`, `docs/local-research.md`
схема, статусы и находка о наборах секций в живом потоке. схема, статусы и находка о наборах секций в живом потоке.
- Ретеншен сырого архива (задача `retenshen-syrogo-arhiva`) получает признак, - Ретеншен сырого архива (задача `raw-archive-retention`) получает признак,
на который ему можно опираться. на который ему можно опираться.
@@ -62,5 +62,5 @@
- [x] 6.2 `README.md`: строка про условный запрос в примерах чтения - [x] 6.2 `README.md`: строка про условный запрос в примерах чтения
- [x] 6.3 `docs/backlog`: задача снята, остаток (предел ответа, измеренная цена - [x] 6.3 `docs/backlog`: задача снята, остаток (предел ответа, измеренная цена
первого запроса, готовая машинерия условного запроса) перенесён в первого запроса, готовая машинерия условного запроса) перенесён в
`read-api-tochki.md`; наблюдаемость — в `stats-nablyudaemost.md`, цена `read-api-points.md`; наблюдаемость — в `stats-endpoint.md`, цена
ветки исчерпанного бюджета — в `ostanovka-i-migraciya-sledy.md` ветки исчерпанного бюджета — в `shutdown-and-migration-traces.md`
@@ -135,7 +135,7 @@
- [x] 11.3 `docs/review-journal.md`: запись о чекпоинте кода без трёх проходов. - [x] 11.3 `docs/review-journal.md`: запись о чекпоинте кода без трёх проходов.
- [x] 11.4 Остатки заведены задачами беклога: NULL-метка; пределы размера - [x] 11.4 Остатки заведены задачами беклога: NULL-метка; пределы размера
сущности и секции с потоковым расчётом; принцип отбора data-миграций; сущности и секции с потоковым расчётом; принцип отбора data-миграций;
строка про очередь `pending` — в `stats-nablyudaemost.md`. строка про очередь `pending` — в `stats-endpoint.md`.
## 12. Приёмка ## 12. Приёмка
@@ -90,9 +90,9 @@
корпусе корпусе
- [x] 7.4 Блокер «тай-брейк при равной полноте точек» в беклог, с вариантами, - [x] 7.4 Блокер «тай-брейк при равной полноте точек» в беклог, с вариантами,
ценой и рекомендацией ценой и рекомендацией
- [x] 7.5 Пометка в `docs/backlog/read-api-tochki.md`: порог неполного ведра, - [x] 7.5 Пометка в `docs/tasks/items/read-api-points.md`: порог неполного ведра,
его полярность и предел размера ответа решаются там его полярность и предел размера ответа решаются там
- [x] 7.6 Пометка в `docs/backlog/upravlenie-sekretami.md`: контуров теперь два - [x] 7.6 Пометка в `docs/tasks/items/token-and-secret-management.md`: контуров теперь два
- [x] 7.7 Убрать задачу из беклога, обновить индекс - [x] 7.7 Убрать задачу из беклога, обновить индекс
## 8. Дозакрыто по ревью кода ## 8. Дозакрыто по ревью кода
@@ -279,7 +279,7 @@ received_at > ? OR (received_at = ? AND id > ?) → SCAN … COVERING INDEX d
отвечать `200`. отвечать `200`.
Числа — текущая длина `pending`, возраст самой старой неразобранной доставки — Числа — текущая длина `pending`, возраст самой старой неразобранной доставки —
это `/stats`, и они уезжают строкой в задачу `stats-nablyudaemost`. Здесь их это `/stats`, и они уезжают строкой в задачу `stats-endpoint`. Здесь их
нет намеренно: отдельного механизма счётчиков в проекте пока не существует. нет намеренно: отдельного механизма счётчиков в проекте пока не существует.
### 6. Частичный индекс по неразобранным доставкам ### 6. Частичный индекс по неразобранным доставкам
@@ -388,7 +388,7 @@ write_timeout)`. Тогда `/healthz` и будущий Read API сохраня
- **Задолженность после рестарта разбирается не мгновенно** → 116 тел живого - **Задолженность после рестарта разбирается не мгновенно** → 116 тел живого
архива это порядка минуты работы воркера; всё это время витрина неполна. архива это порядка минуты работы воркера; всё это время витрина неполна.
Названо `INFO`-строкой при старте. `/healthz` этого не отражает — он статичен; Названо `INFO`-строкой при старте. `/healthz` этого не отражает — он статичен;
отражать будет `/stats`, задача `stats-nablyudaemost`. отражать будет `/stats`, задача `stats-endpoint`.
- **Второй процесс на той же базе даёт двух воркеров** → «одна горутина» — - **Второй процесс на той же базе даёт двух воркеров** → «одна горутина» —
свойство процесса, а не файла базы. Порчи витрины ждать не приходится свойство процесса, а не файла базы. Порчи витрины ждать не приходится
(`_txlock=immediate` и повтор транзакции сериализуют слияние), но наследование (`_txlock=immediate` и повтор транзакции сериализуют слияние), но наследование
@@ -401,7 +401,7 @@ write_timeout)`. Тогда `/healthz` и будущий Read API сохраня
только то, что параллельность стала штатной. `busy_timeout`, только то, что параллельность стала штатной. `busy_timeout`,
`_txlock=immediate` и повтор транзакции уже есть, а исчерпание повторов теперь `_txlock=immediate` и повтор транзакции уже есть, а исчерпание повторов теперь
не стирает доставку с полки (решение 4б). Наблюдение за этим — задача не стирает доставку с полки (решение 4б). Наблюдение за этим — задача
`cena-sliyaniya-na-shirokoj-dostavke`. `merge-cost-wide-delivery`.
- **Тик даёт проход раз в минуту при пустой очереди** → это один запрос по - **Тик даёт проход раз в минуту при пустой очереди** → это один запрос по
покрывающему частичному индексу, в котором ноль строк. Цена измеримо нулевая, покрывающему частичному индексу, в котором ноль строк. Цена измеримо нулевая,
а без него состояние «работа есть, прогресса нет» невидимо. а без него состояние «работа есть, прогресса нет» невидимо.
@@ -40,7 +40,7 @@
быстрого прохода, и одна строка `INFO` о размере задолженности при старте. быстрого прохода, и одна строка `INFO` о размере задолженности при старте.
Метка считается на выборке прохода, а сам проход будит не только сигнал, но и Метка считается на выборке прохода, а сам проход будит не только сигнал, но и
тик — иначе «работа есть, прогресса нет» неотличимо от пустого потока. тик — иначе «работа есть, прогресса нет» неотличимо от пустого потока.
Счётчики в `/stats` — задача `stats-nablyudaemost`, здесь только метки в логе. Счётчики в `/stats` — задача `stats-endpoint`, здесь только метки в логе.
- Убирается второй, оставшийся источник молчаливого обрыва: общий `write_timeout` - Убирается второй, оставшийся источник молчаливого обрыва: общий `write_timeout`
(30 с) меньше `read_timeout` (5 мин), а он покрывает и чтение тела — то есть (30 с) меньше `read_timeout` (5 мин), а он покрывает и чтение тела — то есть
медленная загрузка 64 МиБ обрывается независимо от свёртки. Длинный бюджет медленная загрузка 64 МиБ обрывается независимо от свёртки. Длинный бюджет
@@ -141,7 +141,7 @@
не память; порядок журнала и остановка; классификация «отказ доставки» не память; порядок журнала и остановка; классификация «отказ доставки»
против «отказ обстоятельств»; бюджет ответа маршрута приёма; отвергнутые против «отказ обстоятельств»; бюджет ответа маршрута приёма; отвергнутые
варианты с причинами. варианты с причинами.
- [x] 9.2 Строка в задачу беклога `stats-nablyudaemost`: длина `pending`, - [x] 9.2 Строка в задачу беклога `stats-endpoint`: длина `pending`,
возраст самой старой неразобранной доставки и то, что `/healthz` их не возраст самой старой неразобранной доставки и то, что `/healthz` их не
отражает. отражает.
@@ -177,4 +177,4 @@
разбора, добавлен класс «отложено». разбора, добавлен класс «отложено».
- [x] 10.11 Остаточный предел порядка при конкурентных приёмах назван в спеке и - [x] 10.11 Остаточный предел порядка при конкурентных приёмах назван в спеке и
в `docs/architecture.md`, вынут блокером в `docs/architecture.md`, вынут блокером
(`docs/backlog/poryadok-zhurnala-na-priyome.md`). (`docs/tasks/items/journal-order-on-ingest.md`).
@@ -50,12 +50,12 @@ stateOfMind 26 и 26 копий, по 1 содержимому каждая
**Non-Goals:** **Non-Goals:**
- **Отдача наружу.** Read API в проекте нет вовсе; форма конверта, выбор слоя и - **Отдача наружу.** Read API в проекте нет вовсе; форма конверта, выбор слоя и
предел размера ответа проектируются задачей `read-api-tochki`. Два эндпоинта, предел размера ответа проектируются задачей `read-api-points`. Два эндпоинта,
введённые раньше конверта, задали бы контракт мимоходом. введённые раньше конверта, задали бы контракт мимоходом.
- **Секции, которых поток не приносил** (`ecg`, `symptoms`, `cycleTracking`, - **Секции, которых поток не приносил** (`ecg`, `symptoms`, `cycleTracking`,
`medications`, `heartRateNotifications`). Модель под них закладывается — `medications`, `heartRateNotifications`). Модель под них закладывается —
таблица `record` ключуется родом секции, — но разбор не пишется вслепую: их таблица `record` ключуется родом секции, — но разбор не пишется вслепую: их
формы никто не видел, а задача `proverka-novyh-sekcij` существует ровно про формы никто не видел, а задача `unseen-sections-check` существует ровно про
момент, когда они появятся. момент, когда они появятся.
- **Разворачивание маршрута** в таблицу точек — отдельная идея беклога, у неё нет - **Разворачивание маршрута** в таблицу точек — отдельная идея беклога, у неё нет
клиента. клиента.
@@ -158,7 +158,7 @@ stateOfMind 26 и 26 копий, по 1 содержимому каждая
что воркер сворачивает в порядке `(received_at, id)` только среди **видимых** что воркер сворачивает в порядке `(received_at, id)` только среди **видимых**
ему доставок, а абсолютного порядка при конкурентных приёмах не обещает ему доставок, а абсолютного порядка при конкурентных приёмах не обещает
(`docs/architecture.md`, «Предел порядка назван вслух»; открытый блокер (`docs/architecture.md`, «Предел порядка назван вслух»; открытый блокер
`poryadok-zhurnala-na-priyome.md`). Доставка с более ранней меткой, свёрнутая `journal-order-on-ingest.md`). Доставка с более ранней меткой, свёрнутая
позже, вернула бы витрину к недосчитанной версии — и `reindex` разошёлся бы с позже, вернула бы витрину к недосчитанной версии — и `reindex` разошёлся бы с
живым приёмом **молча**, в содержимом тренировки. Поэтому сущность несёт живым приёмом **молча**, в содержимом тренировки. Поэтому сущность несёт
провенанс — `delivery_id` и `received_at` своей доставки, — а тай-брейк провенанс — `delivery_id` и `received_at` своей доставки, — а тай-брейк
@@ -315,7 +315,7 @@ verify:archive`. Оставить его отпечатком одних час
экспорта Apple, состояние разума нет (находка 46). До этой задачи защита экспорта Apple, состояние разума нет (находка 46). До этой задачи защита
работала побочным эффектом непокрытости. Ретеншена в проекте нет, поэтому здесь работала побочным эффектом непокрытости. Ретеншена в проекте нет, поэтому здесь
ничего не ломается сегодня; но предусловие, которое задача ничего не ломается сегодня; но предусловие, которое задача
`retenshen-syrogo-arhiva` считала снятым, снова открыто, и это записывается в `raw-archive-retention` считала снятым, снова открыто, и это записывается в
её файл тем же изменением. её файл тем же изменением.
### 9а. Отказ разбора остаётся «всё или ничего» — теперь и для сущностей ### 9а. Отказ разбора остаётся «всё или ничего» — теперь и для сущностей
@@ -39,7 +39,7 @@
записано в спеке хранения. записано в спеке хранения.
- **Не входит:** отдача тренировок и записей наружу. Read API в проекте пока нет - **Не входит:** отдача тренировок и записей наружу. Read API в проекте пока нет
вовсе; его форма (конверт ответа, выбор слоя, предел размера) проектируется вовсе; его форма (конверт ответа, выбор слоя, предел размера) проектируется
задачей `read-api-tochki`, и вводить два эндпоинта раньше конверта значило бы задачей `read-api-points`, и вводить два эндпоинта раньше конверта значило бы
задать контракт мимоходом. задать контракт мимоходом.
## Capabilities ## Capabilities
@@ -105,11 +105,11 @@
переприсылке (числа замера) и пересчёт находки 50 на 118 доставок переприсылке (числа замера) и пересчёт находки 50 на 118 доставок
- [x] 7.4 Беклог: задача про идентичность тренировок при импорте родного - [x] 7.4 Беклог: задача про идентичность тренировок при импорте родного
экспорта (в `export.xml` `id` нет — `dogsheep` считает hash_id); экспорта (в `export.xml` `id` нет — `dogsheep` считает hash_id);
`retenshen-syrogo-arhiva` — предусловие про `stateOfMind` снова открыто; `raw-archive-retention` — предусловие про `stateOfMind` снова открыто;
отметить в `read-api-tochki`, что отдача тренировок и записей входит в отметить в `read-api-points`, что отдача тренировок и записей входит в
неё; уточнить `proverka-novyh-sekcij` — модель заложена, остались пять неё; уточнить `unseen-sections-check` — модель заложена, остались пять
секций секций
- [x] 7.5 Удалить `docs/backlog/trenirovki-i-zapisi.md` и строку индекса - [x] 7.5 Удалить `docs/tasks/items/trenirovki-i-zapisi.md` и строку индекса
## 8. Приёмочные критерии (рубрика ревью) ## 8. Приёмочные критерии (рубрика ревью)
+6 -7
View File
@@ -23,17 +23,15 @@ context: |
русифицируем — они несут точную нормативную/структурную семантику. русифицируем — они несут точную нормативную/структурную семантику.
Ревью (процесс, не артефакт): Ревью (процесс, не артефакт):
- Нетривиальная/архитектурная задача — два чекпоинта: ревью дизайна (после - Правило выбора профиля и состав проходов здесь не пересказываем: их дом —
design/specs, ДО кода — дешевле чинить направление) и ревью кода (после скилл av-dev-pipeline:review-pipeline, проектная настройка — docs/review.md.
apply, до archive).
- Тривиальная задача — достаточно одного прохода (код).
Конвенции кода (соблюдать при apply): Конвенции кода (соблюдать при apply):
- Механизируемое проверяет `task gate` (линтер, сборка, тесты, race, - Механизируемое проверяет `task gate` (линтер, сборка, тесты, race,
покрытие изменённых строк, миграции, образцы конфига, секреты и данные о покрытие изменённых строк, миграции, образцы конфига, секреты и данные о
здоровье в индексе). Состав шагов и правил здесь не пересказываем — он здоровье в индексе). Состав шагов и правил здесь не пересказываем — он
растёт, а гейт скажет точнее и всегда актуальнее. растёт, а гейт скажет точнее и всегда актуальнее.
- Прозой остаётся то, что правилом не выражается: docs/conventions.md. - Прозой остаётся то, что правилом не выражается: docs/conventions/README.md.
Читаем в источнике, а не отсюда — конвенции дописываются по ходу задач. Читаем в источнике, а не отсюда — конвенции дописываются по ходу задач.
- Безопасность: данные о здоровье чувствительнее токенов. Ни тела запросов, - Безопасность: данные о здоровье чувствительнее токенов. Ни тела запросов,
ни значения точек не попадают в логи выше DEBUG; ничего из ./data не ни значения точек не попадают в логи выше DEBUG; ничего из ./data не
@@ -48,10 +46,11 @@ context: |
- Развилка или блокер — сперва prior art. Проект не уникален: готовые решения - Развилка или блокер — сперва prior art. Проект не уникален: готовые решения
смотрим в референсах паспорта, отвергаем — с названной причиной, и причина смотрим в референсах паспорта, отвергаем — с названной причиной, и причина
идёт в architecture.md. идёт в architecture.md.
- Инварианты целиком — в CLAUDE.md, архитектура — в docs/architecture.md. - Инварианты целиком — в CLAUDE.md, архитектура — в docs/architecture.md,
периметр и модель угроз — в docs/security.md, схема — в docs/database.md.
Разведка уже проведена, догадки о формате не нужны: Разведка уже проведена, догадки о формате не нужны:
- docs/local-research.md — находки на живом потоке, во многом расходящиеся с - docs/research/apple-health.md — находки на живом потоке, во многом расходящиеся с
документацией Health Auto Export. ПРОВЕРЬ ТАМ, прежде чем строить документацией Health Auto Export. ПРОВЕРЬ ТАМ, прежде чем строить
предположение о формате входа: скорее всего вопрос закрыт измерением. предположение о формате входа: скорее всего вопрос закрыт измерением.
- Тесты на разбор формата держим на реальных пакетах (testdata), а не на - Тесты на разбор формата держим на реальных пакетах (testdata), а не на