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:
+10
-10
@@ -2,21 +2,21 @@
|
||||
#
|
||||
# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign,
|
||||
# staticcheck, unused. Сверх него включены линтеры, механизирующие конвенции
|
||||
# из docs/conventions.md: то, что проверяет правило, не остаётся прозой.
|
||||
# из docs/conventions/README.md: то, что проверяет правило, не остаётся прозой.
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- misspell
|
||||
# docs/conventions.md, «Логи»: msg — константная категория, данные — в
|
||||
# docs/conventions/logging.md: msg — константная категория, данные — в
|
||||
# полях, единый стиль ключ-значение.
|
||||
- sloglint
|
||||
# docs/conventions.md: без fmt.Print* (логируем через slog), конфиг только
|
||||
# docs/conventions/README.md: без fmt.Print* (логируем через slog), конфиг только
|
||||
# из TOML (env не используем), время — только store.Now().
|
||||
- forbidigo
|
||||
# docs/conventions.md, «Ошибки»: сравнение через errors.Is/As.
|
||||
# docs/conventions/errors.md: сравнение через errors.Is/As.
|
||||
- errorlint
|
||||
# docs/conventions.md, «Ошибки»: ошибки — только stdlib.
|
||||
# docs/conventions/errors.md: ошибки — только stdlib.
|
||||
- depguard
|
||||
|
||||
settings:
|
||||
@@ -28,11 +28,11 @@ linters:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^fmt\.Print.*$
|
||||
msg: логируем через slog, в stdout напрямую не пишем (docs/conventions.md)
|
||||
msg: логируем через slog, в stdout напрямую не пишем (docs/conventions/logging.md)
|
||||
- pattern: ^os\.Getenv$
|
||||
msg: конфигурация только из TOML, env для конфига не используем (docs/conventions.md)
|
||||
msg: конфигурация только из TOML, env для конфига не используем (docs/conventions/config.md)
|
||||
- pattern: ^time\.Now$
|
||||
msg: время генерирует store.Now() (UTC, единая точка) — docs/conventions.md
|
||||
msg: время генерирует store.Now() (UTC, единая точка) — docs/conventions/storage.md
|
||||
|
||||
errorlint:
|
||||
# Обёртка вида fmt.Errorf("%w: %v", ErrSentinel, err) осознанна: sentinel
|
||||
@@ -46,9 +46,9 @@ linters:
|
||||
main:
|
||||
deny:
|
||||
- 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
|
||||
desc: стек-трейсы избыточны, контекст несёт slog (docs/conventions.md)
|
||||
desc: стек-трейсы избыточны, контекст несёт slog (docs/conventions/errors.md)
|
||||
|
||||
exclusions:
|
||||
generated: lax
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
Памятка для работы над healthlog. Перед задачей прочитай также
|
||||
[docs/passport.md](docs/passport.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`.
|
||||
|
||||
## Инварианты
|
||||
|
||||
- **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде,
|
||||
в каком их прислал HAE. Начнём что-то отбрасывать внутри точки — потеряем
|
||||
безвозвратно.
|
||||
- **Хранилище — свёртка по журналу.** Экспорт Apple это снапшот всей истории,
|
||||
Что нарушать нельзя. `severity` рядом с формулировкой — по ней проходы ревью
|
||||
присваивают вес находке, а не выводят его заново.
|
||||
|
||||
- **Точки хранятся дословно.** `critical`, необратимо. Часовой объект держит
|
||||
точки ровно в том виде, в каком их прислал HAE. Начнём что-то отбрасывать
|
||||
внутри точки — потеряем безвозвратно.
|
||||
- **Хранилище — свёртка по журналу.** `critical`, необратимо.
|
||||
Экспорт Apple это снапшот всей истории,
|
||||
доставки HAE после его даты — события поверх. Состояние всегда пересобираемо:
|
||||
`import(экспорт) + replay(доставки по received_at)`. Поэтому сырой архив
|
||||
живёт до следующего проверенного экспорта (~2 ГБ за квартал), а свёртка
|
||||
обязана быть детерминированной. Что не восстанавливается — `stateOfMind`
|
||||
(его в экспорте нет) и верхние слои за периоды с удалёнными доставками;
|
||||
каталог обязан говорить об этом честно, а не досчитывать молча.
|
||||
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор:
|
||||
битый JSON — 400, непонятое содержимое — 200.
|
||||
- **Ничего не теряем молча.** Идентичность — координаты
|
||||
- **Сохранили — значит приняли.** `critical`, необратимо: отказ приёма теряет
|
||||
доставку навсегда. Код ответа отражает доставку, а не разбор: битый JSON —
|
||||
400, непонятое содержимое — 200.
|
||||
- **Ничего не теряем молча.** `critical`, обратимо пересборкой — но только
|
||||
пока архив жив. Идентичность — координаты
|
||||
(`метрика + слой + начало + конец`), у точки-измерения конец равен началу:
|
||||
под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек —
|
||||
отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он
|
||||
нестабилен. Хеш
|
||||
канонизированного содержимого остался детектором изменений. При
|
||||
нестабилен. Хеш канонизированного содержимого остался детектором изменений. При
|
||||
столкновении выигрывает **более полная** точка, а не последняя. Изменение
|
||||
запечатанного часа — `WARN`, но данные всё равно пишутся.
|
||||
- **Дыры закрываются сами.** Три прохода разной глубины (5 минут / сутки /
|
||||
неделя). Настройки данных у проходов теперь **разные** — намеренно, они
|
||||
- **Дыры закрываются сами.** `major`, обратимо. Три прохода разной глубины
|
||||
(5 минут / сутки / неделя). Настройки данных у проходов теперь **разные** — намеренно, они
|
||||
наполняют разные слои; это безопасно ровно потому, что слой входит в ключ.
|
||||
- **Форма Apple не транслируется.** Значения отдаём как пришли, нормализовано
|
||||
только время (`ts_utc` + офсет исходной зоны). Единственное добавление —
|
||||
- **Форма Apple не транслируется.** `major`, обратимо пересборкой. Значения
|
||||
отдаём как пришли, нормализовано только время (`ts_utc` + офсет исходной зоны). Единственное добавление —
|
||||
стабильный код рядом с переведённой строкой: HAE отдаёт «БДГ» и «Сидячий
|
||||
образ жизни» на языке телефона, а родной экспорт — коды HealthKit, и без
|
||||
словаря эти два источника не сойтись.
|
||||
- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в той
|
||||
подробности, в какой пришла (`sample`/`raw`/`minute`/`hour`); слой выводится
|
||||
- **Своей агрегации в хранении нет — есть слои.** `critical`, обратимо
|
||||
пересборкой. Метрика лежит в той подробности, в какой пришла (`sample`/`raw`/`minute`/`hour`); слой выводится
|
||||
из выравнивания меток, а не из заголовка HAE — тот врёт.
|
||||
- **Агрегация в ответе — только измеренная.** Род свёртки выводится сверкой
|
||||
слоёв между собой (часовое = сумма минутных → накопительная, = среднее →
|
||||
- **Агрегация в ответе — только измеренная.** `critical`, обратимо: ответ не
|
||||
хранится, но потребитель уже принял по нему решение. Род свёртки выводится
|
||||
сверкой слоёв между собой (часовое = сумма минутных → накопительная, = среднее →
|
||||
мгновенная), а не размечается руками. Род неизвестен — свёртки нет. И
|
||||
никогда не суммируем нижний слой HAE: это интерполяция, а не сэмплы.
|
||||
- **Секреты не в логах** — токены приёма и чтения. Данные о здоровье
|
||||
чувствительны: тела запросов только на `DEBUG` и с обрезкой.
|
||||
- **Секреты не в логах.** `critical`, необратимо: утечка не отзывается. Токены
|
||||
приёма и чтения. Данные о здоровье чувствительны: тела запросов только на
|
||||
`DEBUG` и с обрезкой. Периметр и модель угроз — [docs/security.md](docs/security.md).
|
||||
|
||||
## Команды
|
||||
|
||||
@@ -83,37 +94,83 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
||||
- `task tidy` — `go mod tidy`
|
||||
- `task setup` — установка golangci-lint
|
||||
|
||||
## Гейт
|
||||
|
||||
- **Команда:** `task gate` (`BASE=<rev>` — база диффа; без неё берётся
|
||||
`git merge-base HEAD master`). Шаги: сборка, `go vet`, `golangci-lint`,
|
||||
`gofmt`, тесты, флаки, гонки, покрытие изменённых строк, миграции против
|
||||
`docs/database.md`, образцы конфига, секреты и данные о здоровье в индексе,
|
||||
уязвимости, раскладка документов (`docs.py check`).
|
||||
- **Где логи шагов:** `tmp/gate/<шаг>.log` (каталог под `.gitignore`); сводка —
|
||||
в терминале.
|
||||
- **Исходы:** 0 — зелёный; ненулевой — красный, и до его починки опиниативные
|
||||
проходы ревью **не запускаются**.
|
||||
- **Что красит безусловно:** любой файл из `./data` в индексе, любой токен в
|
||||
индексе, непокрытая изменённая строка, миграция без правки `docs/database.md`.
|
||||
Причина одна на все: это ровно те отказы, которые не видны глазами и стоят
|
||||
необратимо.
|
||||
- **Чего в гейте намеренно нет и кто обязан это гонять:**
|
||||
`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/plan.md](docs/plan.md).
|
||||
Задачи — в [docs/tasks/BACKLOG.md](docs/tasks/BACKLOG.md) (один файл на запись,
|
||||
индексы производны), цели — в [docs/tasks/PLAN.md](docs/tasks/PLAN.md). Ведёт их
|
||||
скилл `av-dev-pm:tasks`, спринт и ритуал между спринтами — `av-dev-pm:session`.
|
||||
|
||||
Работа над задачей идёт скиллом `healthlog-task-pipeline`: беклог → `opsx:explore` →
|
||||
`opsx:propose` → ревью спек (профиль `design`) → `opsx:apply` → ревью кода →
|
||||
`opsx:archive` → чистка беклога → коммит. Ревью — скилл `healthlog-review-pipeline`,
|
||||
проходы — агенты `healthlog-review-*`.
|
||||
Работа над задачей идёт скиллом `av-dev-pipeline:task-pipeline`: задача →
|
||||
`opsx:explore` → `opsx:propose` → ревью спек (профиль `design`) → `opsx:apply` →
|
||||
ревью кода → `opsx:archive` → закрытие задачи → коммит. Ревью — скилл
|
||||
`av-dev-pipeline:review-pipeline`, проходы — агенты `av-dev-pipeline:review-*`,
|
||||
проектная настройка конвейера — [docs/review.md](docs/review.md).
|
||||
|
||||
**Действуем автономно.** Умолчание — делать, а не спрашивать. Вопрос, который
|
||||
решать не мне, **вынимается блокером** в секцию `блокеры` беклога, задача
|
||||
переформулируется на остаток, остаток доводится до коммита. Блокеры разбираются
|
||||
пачками; из чего состоит пункт блокера — в
|
||||
[индексе беклога](docs/backlog/README.md). Спрашиваем только про
|
||||
**необратимое**: деплой, выкладку наружу, удаление или перезапись данных в
|
||||
`./data`.
|
||||
решать не мне, **выносится в раздел «Вопросы»** файла задачи и помечается тегом
|
||||
`question`; задача с открытым вопросом в спринт не берётся, а сама работа
|
||||
переформулируется на остаток и доводится до коммита. Спрашиваем немедленно
|
||||
только про **необратимое** — список выше.
|
||||
|
||||
**Развилка или блокер — сперва prior art.** Проект не уникален: прежде чем
|
||||
**Развилка или вопрос — сперва prior art.** Проект не уникален: прежде чем
|
||||
проектировать своё, смотрим, как это решено в референсах
|
||||
[паспорта](docs/passport.md) и в интернете. Готовое решение либо берётся, либо
|
||||
отвергается с названной причиной — и причина идёт в `architecture.md`.
|
||||
|
||||
Гейт блокирует: пока `task gate` красный, опиниативные проходы ревью не
|
||||
запускаются.
|
||||
|
||||
**Поток не останавливается.** Телефон шлёт непрерывно и молча. Сломанный приём,
|
||||
оставленный работать, теряет данные необратимо: доставка, не попавшая в
|
||||
архив, в журнал не попадает вовсе — телефон её не перешлёт.
|
||||
Ничего из `./data` не попадает ни в git, ни в логи выше `DEBUG`, ни в вывод
|
||||
агента.
|
||||
|
||||
## Конвенции
|
||||
|
||||
@@ -123,7 +180,7 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
||||
(`depguard`). Пересказывать эти правила не нужно — линтер скажет точнее.
|
||||
|
||||
Прозой остаётся то, что правилом не выражается:
|
||||
[docs/conventions.md](docs/conventions.md) — уровень лога по адресату,
|
||||
[docs/conventions/README.md](docs/conventions/README.md) — уровень лога по адресату,
|
||||
единственный логирующий чекпоинт на доменной границе, трансляция ошибки на
|
||||
внешней границе, самодокументируемый `config.example.toml`, время в БД в UTC
|
||||
RFC 3339, ULID через `ident`.
|
||||
@@ -132,7 +189,7 @@ RFC 3339, ULID через `ident`.
|
||||
`testdata`. Документация формата тонкая и местами расходится с тем, что
|
||||
приложение реально шлёт, — источником истины служат живые данные.
|
||||
|
||||
Что показал реальный поток — [docs/local-research.md](docs/local-research.md).
|
||||
Что показал реальный поток — [docs/research/apple-health.md](docs/research/apple-health.md).
|
||||
Читать **до** работы над разбором: там же лежат находки, которых нет в
|
||||
документации HAE (поле `source` существует; порядок ключей в JSON нестабилен,
|
||||
поэтому хеш содержимого считается по канонической форме с рекурсивной
|
||||
|
||||
@@ -72,10 +72,10 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
|
||||
открывается. Журнал WAL разбирается фоновым чекпойнтом по таймеру.
|
||||
|
||||
Чего ещё нет: **read API точек**, тренировок и записей — сами данные наружу
|
||||
пока не отдаются. План в [docs/plan.md](docs/plan.md).
|
||||
пока не отдаются. План в [docs/tasks/PLAN.md](docs/tasks/PLAN.md).
|
||||
|
||||
Разведка формата закончена: 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/conventions.md](docs/conventions.md) — как пишем код
|
||||
- [docs/plan.md](docs/plan.md) — шаги и обоснование их порядка
|
||||
- [docs/backlog](docs/backlog/README.md) — что брать следующим, включая
|
||||
- [docs/conventions/](docs/conventions/README.md) — как пишем код
|
||||
- [docs/security.md](docs/security.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; источник истины по формату, документация приложения
|
||||
местами расходится с тем, что оно шлёт
|
||||
|
||||
+20
-1
@@ -10,6 +10,9 @@ vars:
|
||||
PKG: ./cmd/healthlog
|
||||
# Версии инструментов для воспроизводимой установки (см. задачу setup).
|
||||
GOLANGCI_VERSION: v2.12.2
|
||||
# Проверка раскладки документов по канону av-dev-pm. Пусто — путь ищется в
|
||||
# кеше плагинов (версия в пути меняется при обновлении, поэтому не зашита).
|
||||
DOCS_PY: '{{.DOCS_PY | default ""}}'
|
||||
|
||||
tasks:
|
||||
default:
|
||||
@@ -101,9 +104,25 @@ tasks:
|
||||
- docker compose ps
|
||||
|
||||
gate:
|
||||
desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/секреты. BASE=<rev> — база диффа'
|
||||
desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/секреты/раскладка документов. BASE=<rev> — база диффа'
|
||||
cmds:
|
||||
- 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:
|
||||
desc: 'Вход для архитектурного прохода ревью: пакеты, граф зависимостей, инвентарь концепций'
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"canon": 1,
|
||||
"migrations": "internal/store/migrations"
|
||||
}
|
||||
@@ -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`, а не переезд: адаптация
|
||||
раскладки содержания не сочиняет.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Краткий заголовок решения
|
||||
|
||||
- Дата: ГГГГ-ММ-ДД
|
||||
- Источник: openspec/changes/archive/<id>/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Что именно решено — одной фразой.
|
||||
|
||||
## Почему
|
||||
|
||||
Намерение и причина. Цитата из источника, а не пересказ. Пиши так, чтобы через
|
||||
год было понятно без чтения переписки.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` что стало лучше.
|
||||
- `−` чем платим: ограничения, риски, нагрузка на поддержку.
|
||||
+45
-18
@@ -1,5 +1,11 @@
|
||||
# Архитектура
|
||||
|
||||
Обзор: как сложено и где что работает. **Поведение системы здесь не
|
||||
описывается** — его нормативный дом [`openspec/specs/`](../openspec/specs).
|
||||
Разделы, помеченные `<!-- канон: поведение → … -->`, ещё не разнесены:
|
||||
это долг переезда на канон 2026-08-03, он закрывается порциями по ходу
|
||||
задач и гейт от него не краснеет.
|
||||
|
||||
## Назначение
|
||||
|
||||
healthlog принимает выгрузки Apple Health из приложения Health Auto Export
|
||||
@@ -50,6 +56,8 @@ healthlog принимает выгрузки Apple Health из приложен
|
||||
|
||||
## Формат Health Auto Export
|
||||
|
||||
<!-- канон: поведение → openspec/specs/parsing -->
|
||||
|
||||
Документация формата скудная: [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).
|
||||
Ниже — то, на что мы опираемся; всё остальное уточняем по реальным пакетам.
|
||||
@@ -84,7 +92,7 @@ healthlog принимает выгрузки Apple Health из приложен
|
||||
|
||||
Вопреки документации, в точке **есть поле `source`** — какие устройства
|
||||
вложились в значение (составное, через `|`). Что ещё документация описывает
|
||||
неверно и как поток выглядит на самом деле — [local-research.md](local-research.md).
|
||||
неверно и как поток выглядит на самом деле — [research/apple-health.md](research/apple-health.md).
|
||||
|
||||
Даты приходят строкой с офсетом: `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), поэтому идентичность по
|
||||
содержимому работает без оговорок. Заодно сохраняются детали, которые
|
||||
группировка съедает: эпизоды сна и межударные интервалы (находки 6, 19).
|
||||
@@ -205,19 +213,22 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
|
||||
## Компоненты
|
||||
|
||||
| Пакет | Ответственность |
|
||||
| ---------- | ------------------------------------------------------ |
|
||||
| `config` | загрузка и валидация TOML-конфига |
|
||||
| `logging` | сборка slog-логгера |
|
||||
| `ident` | генерация и разбор ULID |
|
||||
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен |
|
||||
| `hae` | разбор формата HAE, канонизация, хеш содержимого |
|
||||
| `ingest` | use-case приёма, общий для HTTP и CLI `import` |
|
||||
| `fold` | свёртка одной доставки в часовые объекты |
|
||||
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт |
|
||||
| `catalog` | каталог разрезов и измерение рода агрегации |
|
||||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи |
|
||||
| `httpapi` | приём и read API |
|
||||
Пакет — это реализация; **что система делает, нормативно сказано в
|
||||
capability**, и здесь стоит ссылка, а не пересказ требований.
|
||||
|
||||
| Пакет | Ответственность | Capability |
|
||||
| ---------- | ------------------------------------------------------ | ---------- |
|
||||
| `config` | загрузка и валидация TOML-конфига | — |
|
||||
| `logging` | сборка slog-логгера | — |
|
||||
| `ident` | генерация и разбор ULID | — |
|
||||
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) |
|
||||
| `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) |
|
||||
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
|
||||
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md) |
|
||||
| `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`: её точки доедут только
|
||||
пересборкой. Окно узкое, и изменение его сужает, а не открывает, — но закрытие
|
||||
предела требует удерживать порядок на самом приёме, и это отдельный вопрос
|
||||
(беклог, блокеры).
|
||||
(задача `journal-order-on-ingest`).
|
||||
|
||||
Остановка формулируется **инвариантом**: приём прекращается раньше воркера, и
|
||||
после остановки не существует доставки, которая числится разобранной, а записана
|
||||
@@ -378,6 +389,8 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
|
||||
### Сырой архив и восстановление состояния
|
||||
|
||||
<!-- канон: поведение → openspec/specs/reindex -->
|
||||
|
||||
`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
|
||||
месяца. Данные HAE в нижнем слое старше последнего экспорта избыточны: тот же
|
||||
период лежит в слое `sample` подробнее и честнее.
|
||||
@@ -683,6 +700,8 @@ Litestream) не взят по названной причине: он двиг
|
||||
|
||||
### Часовые объекты метрик
|
||||
|
||||
<!-- канон: поведение → openspec/specs/storage -->
|
||||
|
||||
Точки метрик хранятся не по одной, а **пачками: один объект = одна метрика за
|
||||
один час 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,
|
||||
`docs/review-journal.md`).
|
||||
`docs/review.md`).
|
||||
|
||||
Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть
|
||||
проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и
|
||||
@@ -928,6 +949,8 @@ hour метки выровнены на час heart_rate 00:00:00
|
||||
|
||||
### Измерение рода агрегации
|
||||
|
||||
<!-- канон: поведение → openspec/specs/catalog -->
|
||||
|
||||
Род метрики — `cumulative`, `instant` или `unknown` — выводится сверкой
|
||||
минутного слоя с часовым. Правило целиком:
|
||||
|
||||
@@ -1062,6 +1085,8 @@ Assistant требует ручного удаления статистики).
|
||||
|
||||
### Категориальные значения
|
||||
|
||||
<!-- канон: поведение → openspec/specs/parsing -->
|
||||
|
||||
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
|
||||
фаза сна приезжает как «БДГ», контекст пульса — как «Сидячий образ жизни», тип
|
||||
тренировки — как «В помещении Ходьба» (машинная калька с `Indoor Walk`). При
|
||||
@@ -1095,6 +1120,8 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с
|
||||
|
||||
### Тренировки и прочие секции
|
||||
|
||||
<!-- канон: поведение → openspec/specs/parsing -->
|
||||
|
||||
Тренировка адресуется своим `id` из HealthKit, запись — парой `род + id`.
|
||||
`record` держит секции с собственными идентификаторами; разбором покрыт пока
|
||||
только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и
|
||||
|
||||
@@ -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 — следующая покрытая секция унаследует слепую зону
|
||||
|
||||
@@ -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`
|
||||
и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела
|
||||
запросов, координаты объектов).
|
||||
@@ -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`, тесты,
|
||||
гонки, покрытие изменённых строк, миграции, образцы конфига, секреты в индексе,
|
||||
данные о здоровье в индексе.
|
||||
|
||||
Непойманное место механизации означает, что проход по конвенциям будет
|
||||
добросовестно проверять уже проверенное.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Конфигурация
|
||||
|
||||
- Только **TOML**, никаких env-переменных: окружение наследуется дочерними
|
||||
процессами и видно через `/proc/<pid>/environ` — для токенов это слабее
|
||||
файла под `0600`.
|
||||
- Грузим один раз при старте в типизированную `Config`; дальше по коду читаем
|
||||
только её. Конфиг неизменяем — смена параметров означает рестарт.
|
||||
- Имя по умолчанию — `config.toml` в рабочей директории, переопределяется
|
||||
`--config=path`.
|
||||
- `config.example.toml` коммитим как единый самодокументируемый справочник:
|
||||
**каждое поле с комментарием**, из которого ясно зачем оно, каков диапазон
|
||||
допустимых значений и в каких единицах. Секретные поля — пустые.
|
||||
- Реальный `config.toml` не коммитится; секреты рендерит деплой.
|
||||
- **Валидация на старте, до приёма трафика.** Невалидный конфиг — `ERROR` и
|
||||
выход с ненулевым кодом. Не стартуем «наполовину».
|
||||
@@ -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-обработчика.
|
||||
- Глушить ошибку без лога — только с однострочным комментарием «почему».
|
||||
@@ -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`. Предел держит само сообщение, а не
|
||||
обрезка на стороне логирующего: обрезка живёт в другом месте и о новой ошибке
|
||||
разбора не узнает.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Тесты
|
||||
|
||||
- Тесты на разбор формата HAE держим на **реальных пакетах**, сложенных в
|
||||
`testdata` (с вычищенными токенами). Документация формата ненадёжна —
|
||||
источником истины служат живые данные.
|
||||
- Проверяем идемпотентность: повторный разбор того же пакета не меняет
|
||||
витрину.
|
||||
- **Где код выбирает между двумя версиями одних данных, тест обязан прогнать
|
||||
обе стороны и хотя бы одну перестановку трёх.** Пример на паре доказывает
|
||||
коммутативность и молчит про ассоциативность, а сломаться правило может
|
||||
именно на ней: полнота — частичный порядок, тай-брейк — тотальный, и их
|
||||
попарная свёртка дала нетранзитивное отношение победы, из-за которого одна
|
||||
и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого
|
||||
не увидело, ревью кода увидело только перебором троек. Правилом линтера не
|
||||
выражается — отсюда проза.
|
||||
- **В тот же перебор обязана входить версия с содержимым, равным одной из уже
|
||||
присланных, и пара «равная каноническая форма, разные байты».** Три версии с
|
||||
разными хешами ветку «содержание равно» не посещают ни разу — а именно на ней
|
||||
устаревал провенанс, и живая витрина расходилась с пересборкой молча. Пара с
|
||||
равной формой ловит другое: неединственный минимум, при котором победителем
|
||||
оказывается просто первый в срезе, то есть порядок элементов на проводе.
|
||||
- **Изменение правила разбора или слияния сопровождается замером на живом
|
||||
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
|
||||
без числа не отличается от предположения, а цена ошибки здесь — необратимое
|
||||
решение о судьбе тел.
|
||||
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
|
||||
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
|
||||
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
|
||||
прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time`
|
||||
и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела
|
||||
запросов, координаты объектов).
|
||||
@@ -155,3 +155,35 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
|
||||
массивов); при равных наборах выигрывает версия из более поздней доставки
|
||||
журнала, а не свёрнутая последней. Подробности и обоснование — в
|
||||
`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
@@ -1,7 +1,7 @@
|
||||
# Паспорт проекта
|
||||
|
||||
Куда мы идём, как выглядит работа проекта в жизни и у кого подсматривать,
|
||||
когда упёрлись. Самый верхний документ: [plan.md](plan.md) отвечает «в каком
|
||||
когда упёрлись. Самый верхний документ: [tasks/PLAN.md](tasks/PLAN.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), а не теряется.
|
||||
@@ -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, то есть аналитика; хранения журнала нет |
|
||||
| [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; модель хранения нам не подходит |
|
||||
|
||||
@@ -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]`. Два дома для одной идеи расходятся, и тогда полного списка не даёт ни
|
||||
один; вопрос «что мы решили отложить» задаётся беклогу.
|
||||
@@ -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. Документация
|
||||
формата ([wiki](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format))
|
||||
Наблюдения за реальным потоком Health Auto Export и за родным экспортом Apple
|
||||
Health. Документация формата HAE
|
||||
([wiki](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format))
|
||||
тонкая и местами расходится с тем, что приложение шлёт на самом деле, поэтому
|
||||
источником истины служит этот файл.
|
||||
источником истины служит этот файл, а не она.
|
||||
|
||||
Пополняется по мере накопления доставок. Каждый вывод — с числами и командой,
|
||||
которой он получен, чтобы его можно было перепроверить.
|
||||
|
||||
## Как снималось
|
||||
|
||||
Сервис запущен локально (`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 '...'
|
||||
```
|
||||
Файл пополняется по мере накопления доставок. Находки нумерованы сквозным
|
||||
номером, и **номер — это ссылка**: на «находку 49» ссылаются спеки,
|
||||
предложения и задачи, поэтому нумерация не пересчитывается и записи не
|
||||
переставляются. Как снималось и каким инструментом — в
|
||||
[README.md](README.md).
|
||||
|
||||
## 1. Поле `source` существует
|
||||
|
||||
@@ -1786,25 +1766,6 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
|
||||
заполненности (`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» неудачную отправку.** Ключевой вопрос для
|
||||
@@ -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`.
|
||||
|
||||
## Журнал дефектов
|
||||
|
||||
Запись на каждый воспроизведённый дефект, сразу, а не ретроспективно: со
|
||||
временем теряется не факт, а причина непоймания.
|
||||
|
||||
Реализованные задачи, находки ревью и решения сюда не пишутся: у них есть
|
||||
коммит, спека и беклог. Здесь только промахи конвейера.
|
||||
коммит, спека и задача. Здесь только промахи конвейера и решения о его составе.
|
||||
|
||||
Форма записи:
|
||||
Форма:
|
||||
|
||||
```
|
||||
## 2026-08-01 — <краткое последствие>
|
||||
<!-- копия: журнал-дефектов-форма из av-dev-pipeline/skills/review-pipeline/references/review-journal.md -->
|
||||
## ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман]
|
||||
|
||||
- **Где:** internal/store/bucket.go:120
|
||||
- **Симптом:** <как обнаружилось, кем и когда>
|
||||
- **Почему не поймали:** <какой проход обязан был найти и что ему помешало>
|
||||
- **Что меняем:** <правило прохода, шаг гейта, конвенция — либо «ничего, цена
|
||||
поимки выше цены дефекта»>
|
||||
```
|
||||
- **Где:** путь:строка либо «конвейер, а не код»
|
||||
- **Симптом:** как обнаружилось, кем и когда
|
||||
- **Причина:** что на самом деле было не так
|
||||
- **Чем воспроизведён:** тест, команда, замер — с числами
|
||||
- **Почему не поймали:** только для проскочивших — какой проход обязан был найти
|
||||
и что ему помешало
|
||||
- **Что меняем:** правило прохода, шаг гейта, конвенция, факт в документе
|
||||
проекта — либо «ничего, цена поимки выше цены дефекта»
|
||||
<!-- /копия: журнал-дефектов-форма -->
|
||||
|
||||
Последний пункт важнее остальных. Вывод «ничего не меняем» — законный исход:
|
||||
не всякий дефект стоит того, чтобы усложнять ради него ревью каждой задачи.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-01 — свёртка не воспроизводилась при пересборке журнала
|
||||
## 2026-08-01 — свёртка не воспроизводилась при пересборке журнала [проскочил]
|
||||
|
||||
- **Где:** `internal/store/delivery.go`, `LastDerivedLayer`
|
||||
- **Симптом:** прогон живого архива (99 доставок) вторым проходом дал 1742
|
||||
@@ -48,7 +198,7 @@
|
||||
на живом архиве (`internal/fold/replay_test.go`) остаётся постоянным —
|
||||
именно он это поймал.
|
||||
|
||||
## 2026-08-02 — прогон живого архива был красным и об этом никто не знал
|
||||
## 2026-08-02 — прогон живого архива был красным и об этом никто не знал [проскочил]
|
||||
|
||||
- **Где:** `internal/fold/replay_test.go` (перенесён в `internal/replay/archive_test.go`)
|
||||
- **Симптом:** первый же запуск `task verify:archive` в задаче про пересборку
|
||||
@@ -74,7 +224,7 @@
|
||||
протухшей константы, а после этой задачи прогон стал ещё и единственным, кто
|
||||
проверяет настоящий проигрыватель журнала.
|
||||
|
||||
## 2026-08-02 — чекпоинт кода прошёл без трёх проходов, и ровно они нашли всё
|
||||
## 2026-08-02 — чекпоинт кода прошёл без трёх проходов, и ровно они нашли всё [проскочил]
|
||||
|
||||
- **Где:** конвейер, а не код: коммит `f8200f7` («тренировки и записи с
|
||||
собственным `id`»), шаг 7 скилла `healthlog-task-pipeline`, профиль `deep`.
|
||||
@@ -111,7 +261,7 @@
|
||||
на их дозакрытие. Состав проходов и профилей при этом не трогаем: они
|
||||
сработали ровно так, как задуманы, — их просто не позвали.
|
||||
|
||||
## 2026-08-02 — тест на утечку значений в лог краснел от хода часов
|
||||
## 2026-08-02 — тест на утечку значений в лог краснел от хода часов [проскочил]
|
||||
|
||||
- **Где:** `internal/fold/log_test.go`, `TestFoldНесравнимыеНаборыДаютWarn`
|
||||
- **Симптом:** гейт задачи про цену читающего маршрута покраснел на чужом
|
||||
@@ -127,7 +277,7 @@
|
||||
выглядит образцовым: он проверяет ровно тот инвариант, который проекту
|
||||
дороже всего («данные о здоровье чувствительнее токенов»). Ни один проход
|
||||
ревью не смотрит на тесты чужих задач.
|
||||
- **Что меняем:** правило в [conventions.md](conventions.md) — проверка «в логе
|
||||
- **Что меняем:** правило в [conventions/testing.md](conventions/testing.md) — проверка «в логе
|
||||
нет значения» разбирает запись и выбрасывает `time`, а не ищет в сыром
|
||||
буфере. Гейт не трогаем: два прогона против однопроцентной флаки не помогут,
|
||||
а десять стоили бы дороже самой находки.
|
||||
@@ -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; подписи
|
||||
тела нет.
|
||||
@@ -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 коммитится — так нельзя выезжать наружу
|
||||
@@ -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` — Заголовок. Причина: … Был приоритет: … -->
|
||||
- 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 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Был приоритет: блокеры.
|
||||
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->
|
||||
- 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 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Была секция: блокеры.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Спринт
|
||||
|
||||
Спринта нет. Цель называет человек, набор собирает агент:
|
||||
`tasks.py sprint start --goal <слаг>`.
|
||||
|
||||
## Набор
|
||||
@@ -1,6 +1,6 @@
|
||||
# Импорт родного экспорта Apple Health
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут · **Теги:** goal:native-export-import
|
||||
|
||||
Слой `sample` пуст: настоящих сэмплов HealthKit в потоке нет вовсе — HAE отдаёт
|
||||
посекундную развёртку, а не измерения (находка 34). Полная история и точные
|
||||
@@ -41,4 +41,3 @@
|
||||
|
||||
Архивы: `/home/av/MediaEverything/HealthData/apple_health/` — девять штук,
|
||||
2021-12 … 2026-08. Старые версии формата годятся как регрессионный набор.
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
# Словарь категориальных значений → коды HealthKit
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить · **Теги:** goal:parsing-and-storage
|
||||
|
||||
HAE отдаёт перечислимые значения строками локали телефона: «БДГ», «Сидячий
|
||||
образ жизни», «В помещении Ходьба». Родной экспорт Apple при этом говорит
|
||||
@@ -37,4 +37,3 @@ HAE отдаёт перечислимые значения строками ло
|
||||
`/stats` показывает строки, для которых кода ещё нет.
|
||||
|
||||
`stateOfMind` в словаре не нуждается — он и так шлёт коды HealthKit.
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
# Умолчания конфига указывают на прежнюю раскладку
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** инфра · **Хук:** Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка · **Теги:** goal:deploy
|
||||
|
||||
Данные переехали в `./data` (база + сырой архив, он же том контейнера), а
|
||||
умолчания в `internal/config` остались прежними: `./healthlog.db` и `./raw`.
|
||||
@@ -14,4 +14,3 @@
|
||||
|
||||
Готово, когда запуск без конфига использует `./data` и не создаёт ничего в
|
||||
корне репозитория. Тогда же снимается предупреждение из `config.example.toml`.
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# Data-миграции не отбирают строки по обрезаемым спискам
|
||||
|
||||
**Приоритет:** низкий
|
||||
**Секция:** ядро · **Хук:** Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону · **Теги:** goal:journal-and-rebuild
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`).
|
||||
@@ -22,7 +22,7 @@ WHERE EXISTS (SELECT 1 FROM json_each(delivery.uncovered_sections)
|
||||
секцией `ecg` за ними даёт список без `ecg`.
|
||||
|
||||
Для `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` обязан
|
||||
означать безусловное пересворачивание — доставка, у которой список обрезан,
|
||||
про своё покрытие ничего достоверного не говорит.
|
||||
- Кандидат в `docs/conventions.md` (раздел про миграции), если форма отбора
|
||||
- Кандидат в `docs/conventions/README.md` (раздел про миграции), если форма отбора
|
||||
окажется общей.
|
||||
|
||||
## Связано
|
||||
|
||||
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) —
|
||||
- [Проверка секций, которых поток ещё не приносил](unseen-sections-check.md) —
|
||||
именно она следующей сделает секцию покрытой и напишет такую миграцию.
|
||||
@@ -1,6 +1,6 @@
|
||||
# [idea] Что считать сутками при смене часового пояса
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено · **Теги:** goal:read-api
|
||||
|
||||
«Шаги за день» — базовый запрос трекера и фитнес-игры. Но точка несёт метку с
|
||||
офсетом исходной зоны, и при перелёте сутки перестают быть однозначными: день
|
||||
@@ -17,4 +17,3 @@ Apple эту неоднозначность не решает, а перекла
|
||||
|
||||
Вопрос не гипотетический: походы и хайкинг из второго сценария — это как раз
|
||||
поездки со сменой зоны.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Предел на размер и число заголовков доставки
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним · **Теги:** goal:limits-and-load
|
||||
|
||||
У тела доставки предел есть (`max_body`), у заголовков — нет ни одного:
|
||||
`MaxHeaderBytes` серверу не задан, а `delivery.headers` пишутся в базу целиком,
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
Чинится дёшево и в двух местах сразу: `MaxHeaderBytes` у `http.Server` и предел
|
||||
на то, что уходит в колонку. Разумно делать одной правкой с
|
||||
[управлением токенами](upravlenie-sekretami.md) — оба пункта про одно и то же:
|
||||
[управлением токенами](token-and-secret-management.md) — оба пункта про одно и то же:
|
||||
приём перестаёт доверять тому, кто с ним говорит.
|
||||
|
||||
Осторожно: это путь приёма, а доставка, не попавшая в архив, теряется навсегда.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Заголовки доставки в архиве рядом с телом
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке · **Теги:** goal:journal-and-rebuild
|
||||
|
||||
Состояние объявлено свёрткой по журналу, а журналом — сырой архив. Но в архиве
|
||||
лежит только **тело**: заголовки запроса (`automation-id`,
|
||||
@@ -1,6 +1,6 @@
|
||||
# Деплой на rivendell
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** инфра · **Хук:** Сервис живёт на рабочей машине — телефон достаёт до него только дома · **Теги:** goal:deploy
|
||||
|
||||
Сервис живёт в контейнере на рабочей машине, телефон достаёт до него только
|
||||
дома. Вне дома экспорт копится и уезжает пачкой при возвращении — работает, но
|
||||
@@ -28,4 +28,3 @@
|
||||
Том стоит смонтировать так, чтобы серверный бекап забирал его без отдельной
|
||||
настройки, и снимать копию SQLite через `VACUUM INTO`, а не `cp`: телефон шлёт
|
||||
непрерывно, и файл под записью копировать нельзя.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# [goal] Деплой
|
||||
|
||||
**Секция:** порядок · **Хук:** Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
|
||||
|
||||
Сервис переезжает на rivendell и становится доступен телефону из любой сети.
|
||||
|
||||
Выведена из шага 11 плана.
|
||||
|
||||
Завершена, когда оба контура закрыты разными токенами, откат релиза имеет
|
||||
названный механизм, а запуск без конфига не заводит базу мимо данных.
|
||||
|
||||
## Завершение
|
||||
|
||||
Оба контура закрыты разными токенами, откат релиза имеет названный механизм,
|
||||
а запуск без конфига не заводит базу мимо данных.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Выведенные из данных схемы содержимого
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке · **Теги:** goal:self-description
|
||||
|
||||
Метрик у Apple больше сотни, формы точек разные, и рукописный каталог описывал
|
||||
бы документацию HAE, а не то, что он реально прислал. Схема содержимого
|
||||
@@ -23,4 +23,3 @@
|
||||
|
||||
Связано: `docs/architecture.md` → «Самоописание». Форму конверта API описывает
|
||||
не эта задача, а OpenAPI.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# [idea] Отказ от heartbeatSeries
|
||||
|
||||
**Приоритет:** низкий
|
||||
**Секция:** ядро · **Хук:** 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе · **Теги:** goal:lower-layer-cleanup
|
||||
|
||||
`heart_rate_variability` приезжает вместе с `heartbeatSeries` — рядом
|
||||
межударных интервалов внутри точки. Это **93% объёма метрики** (находка 39)
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Пределы на размер сущности и потоковый расчёт формы
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе · **Теги:** goal:limits-and-load
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`). Та задача убрала канонизацию приехавшей
|
||||
@@ -58,12 +58,12 @@
|
||||
схлопываются, но различных тело вмещает сколько угодно. Отмена цикл
|
||||
прерывает (дедлайн свёртки снова работает), но доставка при этом уходит в
|
||||
`failed` — то есть отравленное тело стоит полного дедлайна воркера. Тот же
|
||||
вопрос открыт для точек на одной координате: `cena-sliyaniya-na-shirokoj-dostavke.md`,
|
||||
вопрос открыт для точек на одной координате: `merge-cost-wide-delivery.md`,
|
||||
пункт 4.
|
||||
|
||||
## Связано
|
||||
|
||||
- [Цена слияния на широкой доставке](cena-sliyaniya-na-shirokoj-dostavke.md) —
|
||||
- [Цена слияния на широкой доставке](merge-cost-wide-delivery.md) —
|
||||
та же плата со стороны **точек** (`hashPoints` пересчитывает форму всех точек
|
||||
часа). Задачи делать вместе: половина решения общая — `canon`.
|
||||
- Из того же ревью: «хеш без полного прохода по содержимому не посчитать» —
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
# Сущность с id, но неразобранной меткой
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны · **Теги:** goal:parsing-and-storage, question
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`). Та задача сделала мягким чтение заголовка:
|
||||
@@ -18,10 +18,9 @@
|
||||
не молчит — но содержимое всё ещё не хранится.
|
||||
- Достижимость из реального потока: замер на 118 доставках дал **ноль**
|
||||
пропусков всех трёх классов. Дрейф формата дат у HAE при этом
|
||||
задокументирован (`docs/local-research.md`), то есть вход не выдуман.
|
||||
|
||||
## Что решить
|
||||
задокументирован (`docs/research/apple-health.md`), то есть вход не выдуман.
|
||||
|
||||
## Вопросы
|
||||
Хранить ли сущность с разобранным `id` и неразобранной меткой. Цена:
|
||||
|
||||
1. **Хранить с NULL-меткой** — правка схемы (`start_utc`/`ts_utc` становятся
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Проверка целостности собранной витрины перед подменой
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе · **Теги:** goal:journal-and-rebuild
|
||||
|
||||
`healthlog reindex` собирает витрину в отдельный файл и снимает с него
|
||||
отпечаток, а подмену делает человек: остановить сервис, переименовать файл,
|
||||
@@ -0,0 +1,18 @@
|
||||
# [goal] Журнал и пересборка
|
||||
|
||||
**Секция:** темы · **Хук:** Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
|
||||
|
||||
Тема: инвариант «`import` + `replay` даёт то же состояние» и всё, что его
|
||||
держит — архив, ретеншен, отпечаток витрины, расход памяти пересборки.
|
||||
|
||||
В порядок не встаёт: работа приходит находками и растёт вместе с
|
||||
журналом.
|
||||
|
||||
Завершена не бывает: закрывается по мере того, как расхождение витрины с
|
||||
журналом перестаёт быть молчащим.
|
||||
|
||||
## Завершение
|
||||
|
||||
Завершена не бывает — это тема. Закрывается по мере того, как расхождение витрины
|
||||
с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с
|
||||
журналом.
|
||||
+3
-4
@@ -1,18 +1,17 @@
|
||||
# Порядок журнала при конкурентных приёмах
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** Решено: повторы, но после /stats. Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда · **Теги:** goal:journal-and-rebuild, question
|
||||
|
||||
**Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.**
|
||||
До появления наблюдаемости живём вариантом (г) с уже записанным в спеке
|
||||
приёма пределом — иначе повторы лечат болезнь, которую никто не наблюдает.
|
||||
Задача берётся после [наблюдаемости](stats-nablyudaemost.md); ниже — исходная
|
||||
Задача берётся после [наблюдаемости](stats-endpoint.md); ниже — исходная
|
||||
постановка блокера, она же ТЗ.
|
||||
|
||||
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
|
||||
`deep`, враждебный проход, находка с построенным путём и прогоном).
|
||||
|
||||
## Что решить
|
||||
|
||||
## Вопросы
|
||||
Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи
|
||||
тела в архив и до вставки строки учёта. Порядок, в котором строки становятся
|
||||
видимыми воркеру, порядку меток не подчиняется: между выпуском идентификатора и
|
||||
@@ -0,0 +1,16 @@
|
||||
# [goal] Пределы и поведение под объёмом
|
||||
|
||||
**Секция:** темы · **Хук:** Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
|
||||
|
||||
Тема: названные пределы на размер тела, сущности, заголовков и ответа плюс
|
||||
поведение под удерживаемой блокировкой.
|
||||
|
||||
В порядок не встаёт: пределы всплывают замерами, а не планом.
|
||||
|
||||
Завершена не бывает: закрывается по мере того, как каждый вход получает
|
||||
названный предел вместо подразумеваемого.
|
||||
|
||||
## Завершение
|
||||
|
||||
Завершена не бывает — это тема. Закрывается по мере того, как каждый вход получает
|
||||
названный предел вместо подразумеваемого.
|
||||
@@ -0,0 +1,16 @@
|
||||
# [goal] Устаревание нижнего слоя
|
||||
|
||||
**Секция:** порядок · **Хук:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
|
||||
После проверенного экспорта нижний слой HAE избыточен и подлежит чистке.
|
||||
|
||||
Выведена из шага 9 плана. Нижний слой растёт на ~100 тысяч координат в сутки.
|
||||
|
||||
Завершена, когда чистка идёт по правилу, а не по календарю, и решение о
|
||||
удалении опирается на колонку, отличающую ноль от «не измерялось».
|
||||
|
||||
## Завершение
|
||||
|
||||
Чистка идёт по правилу «до следующего проверенного экспорта», а не по
|
||||
календарю, и решение об удалении опирается на колонку, отличающую ноль от
|
||||
«не измерялось».
|
||||
@@ -1,6 +1,6 @@
|
||||
# Устаревание нижнего слоя после экспорта
|
||||
|
||||
**Приоритет:** низкий
|
||||
**Секция:** ядро · **Хук:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен · **Теги:** goal:lower-layer-cleanup
|
||||
|
||||
Нижний слой растёт примерно на 100 тысяч координат в сутки против ~3 700 у
|
||||
минутного и ~100 у часового — разница в три порядка (находка 41). Всё давление
|
||||
@@ -19,4 +19,3 @@
|
||||
станет актуальной, когда нижний слой перевалит за несколько гигабайт.
|
||||
|
||||
Зависит от импорта экспорта Apple — до него помечать нечем.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MCP-сервер поверх Read API
|
||||
|
||||
**Приоритет:** высокий
|
||||
**Секция:** ядро · **Хук:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем · **Теги:** goal:mcp
|
||||
|
||||
Агент-медик — первый заказчик проекта. Ему нужна актуальная сводка, а не срез
|
||||
на дату последнего ручного экспорта.
|
||||
@@ -20,4 +20,3 @@ MCP не даёт ничего, чего не даёт HTTP, и права об
|
||||
неделе» без промежуточного кода.
|
||||
|
||||
Связано: `docs/architecture.md` → «MCP», план → шаг «MCP».
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# [goal] MCP
|
||||
|
||||
**Секция:** порядок · **Хук:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||
|
||||
Агент-медик — первый заказчик проекта — подключается к хранилищу.
|
||||
|
||||
Выведена из шага 7 плана. Идёт после Read API намеренно: адаптер собственной
|
||||
логики не несёт, он переводит вызовы в те же обработчики, и переводить пока
|
||||
нечего.
|
||||
|
||||
Завершена, когда агент читает данные через MCP тем же токеном чтения.
|
||||
|
||||
## Завершение
|
||||
|
||||
Агент читает данные через MCP тем же токеном чтения, и собственной логики
|
||||
адаптер не несёт.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Цена слияния на широкой доставке
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed · **Теги:** goal:limits-and-load
|
||||
|
||||
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный
|
||||
проход и независимая реализация — независимо друг от друга).
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Счётчики слияния переживают ротацию логов
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** инфра · **Хук:** единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего · **Теги:** goal:observability
|
||||
|
||||
Вынуто ревью кода задачи `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) — придёт к вопросу о
|
||||
тай-брейке и потребует эксплуатационной истории, которой без этой задачи не
|
||||
будет: мерить придётся снова по архиву, а он к тому моменту подрезан.
|
||||
@@ -0,0 +1,15 @@
|
||||
# [goal] Прочность слияния и идентичности
|
||||
|
||||
**Секция:** темы · **Хук:** Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
|
||||
|
||||
Тема: правила, по которым две версии одних данных превращаются в одну.
|
||||
В порядок не встаёт — работа приходит находками ревью и замерами на
|
||||
живом корпусе.
|
||||
|
||||
Завершена не бывает: закрывается по мере того, как правила перестают зависеть
|
||||
от порядка на проводе.
|
||||
|
||||
## Завершение
|
||||
|
||||
Завершена не бывает — это тема. Закрывается по мере того, как правила выбора между
|
||||
версиями перестают зависеть от порядка элементов на проводе.
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# [idea] Месячный проход по ручным секциям
|
||||
|
||||
**Приоритет:** низкий
|
||||
**Секция:** ядро · **Хук:** Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит · **Теги:** goal:parsing-and-storage
|
||||
|
||||
Окно досчёта не единое, и это измеренное различие, а не предположение.
|
||||
Количественные метрики (пульс, шаги, энергия) человек руками не правит — они
|
||||
@@ -18,4 +18,4 @@
|
||||
когда они появятся, — иначе проход пишется вслепую и проверяется не на чем.
|
||||
|
||||
Связано: `docs/architecture.md` → «Досчёт задним числом», задача
|
||||
`proverka-novyh-sekcij`.
|
||||
`unseen-sections-check`.
|
||||
@@ -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
|
||||
|
||||
**Приоритет:** низкий
|
||||
**Секция:** ядро · **Хук:** Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация · **Теги:** goal:read-api
|
||||
|
||||
Read API отдаёт ответ одним JSON. Для выборок нижнего слоя за длинный период
|
||||
это не работает: `heart_rate` в слое `raw` — порядка сотни тысяч координат в
|
||||
@@ -17,4 +17,4 @@ Read API отдаёт ответ одним JSON. Для выборок нижн
|
||||
последовательно или с возвратами.
|
||||
|
||||
Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача
|
||||
`read-api-tochki`.
|
||||
`read-api-points`.
|
||||
@@ -0,0 +1,16 @@
|
||||
# [goal] Наблюдаемость
|
||||
|
||||
**Секция:** порядок · **Хук:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
||||
|
||||
Тихо сломавшаяся автоматизация — главный эксплуатационный риск: телефон шлёт
|
||||
молча, и молчание неотличимо от нормы.
|
||||
|
||||
Выведена из шага 10 плана.
|
||||
|
||||
Завершена, когда пропажа потока и расхождение витрины с журналом видны
|
||||
владельцу без чтения логов.
|
||||
|
||||
## Завершение
|
||||
|
||||
Пропажа потока и расхождение витрины с журналом видны владельцу без чтения
|
||||
логов и переживают ротацию логов.
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenAPI-спека и Swagger UI
|
||||
|
||||
**Приоритет:** высокий
|
||||
**Секция:** ядро · **Хук:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате · **Теги:** goal:read-api
|
||||
|
||||
Потребителей три, и один из них — агент, который читает контракт машиной.
|
||||
Пересказывать форму ответа в чате не годится, а самоописание из плана покрывает
|
||||
@@ -21,4 +21,3 @@
|
||||
|
||||
Развилка на решение: спека пишется руками как источник истины или выводится из
|
||||
кода. Для маленького API рукописная спека честнее — но это стоит обсудить.
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
# [idea] Пересекающиеся источники одной метрики
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь · **Теги:** goal:read-api
|
||||
|
||||
Одну метрику пишут несколько источников: сон — часы и стороннее приложение
|
||||
AutoSleep, шаги — часы и телефон одновременно. Поле `source` при этом не
|
||||
@@ -17,4 +17,3 @@ AutoSleep, шаги — часы и телефон одновременно. П
|
||||
|
||||
Для агента-медика вопрос практический: «сколько я спал» не должно давать
|
||||
двойной ответ.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# [idea] Выгрузка в parquet отдельной командой
|
||||
|
||||
**Приоритет:** низкий
|
||||
**Секция:** ядро · **Хук:** Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно · **Теги:** goal:read-api
|
||||
|
||||
Отдельная команда, выгружающая хранилище в parquet, — дверь для тяжёлой
|
||||
аналитики снаружи, без миграции самого хранилища.
|
||||
@@ -0,0 +1,18 @@
|
||||
# [goal] Разбор и хранилище
|
||||
|
||||
**Секция:** порядок · **Хук:** Секции живого потока разобраны все, кроме словаря категориальных значений и пяти невиденных
|
||||
|
||||
Метрики, тренировки и записи со своими `id` разбираются и ложатся в часовые
|
||||
объекты; тела перестали быть недифференцированной кучей.
|
||||
|
||||
Выведена из шага 3 плана. Сделано: разбор метрик в объекты, тренировки и
|
||||
записи, `reindex`. Осталось: словарь категориальных значений и секции, которых
|
||||
поток ещё не приносил.
|
||||
|
||||
Завершена, когда ни одна секция живого потока не числится неразобранной, а
|
||||
категориальные значения имеют стабильный код рядом с переведённой строкой.
|
||||
|
||||
## Завершение
|
||||
|
||||
Ни одна секция живого потока не числится неразобранной, а категориальные
|
||||
значения несут стабильный код рядом с переведённой строкой.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Ретеншен сырого архива
|
||||
|
||||
**Приоритет:** низкий
|
||||
**Секция:** инфра · **Хук:** Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает · **Теги:** goal:journal-and-rebuild
|
||||
|
||||
Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но
|
||||
удаления нет: архив растёт бесконечно. Пока это 16 МБ и проблемой не является.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Read API: точки, выбор слоя, свёртка по сетке
|
||||
|
||||
**Приоритет:** высокий
|
||||
**Секция:** ядро · **Хук:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может · **Теги:** goal:read-api
|
||||
|
||||
Сейчас данные достаются только `sqlite3` на хосте. Все три сценария —
|
||||
агент-медик, трекер тренировок, фитнес-игра — упираются в отсутствие чтения.
|
||||
@@ -74,4 +74,3 @@ WAL и условным запросом): 693 мс и +153 МиБ живой к
|
||||
|
||||
Связано: `docs/architecture.md` → «Read API», «Измерение рода агрегации»,
|
||||
план → шаг «Read API».
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# [goal] Read API
|
||||
|
||||
**Секция:** порядок · **Хук:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
|
||||
Потребители читают точки: выбор слоя, свёртка по сетке, предел размера ответа.
|
||||
|
||||
Выведена из шага 5 плана. Идёт после каталога и рода агрегации намеренно: без
|
||||
измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться здесь
|
||||
дорого — просуммировать нижний слой значит завысить втрое.
|
||||
|
||||
Завершена, когда любой из трёх потребителей получает точки за период без
|
||||
доступа к файлу базы.
|
||||
|
||||
## Завершение
|
||||
|
||||
Любой из трёх потребителей получает точки за период без доступа к файлу базы,
|
||||
и предел размера ответа объявлен, а не подразумевается.
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Сверка живой витрины с пересборкой
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит · **Теги:** goal:journal-and-rebuild
|
||||
|
||||
`healthlog reindex` печатает отпечаток собранной витрины и отпечаток рабочей —
|
||||
то есть данные для сверки уже есть, и **сравнивать их некому**. Расхождение
|
||||
@@ -8,7 +8,7 @@
|
||||
только тем, что кто-то вручную запустил пересборку и посмотрел на два числа.
|
||||
|
||||
Между тем расхождение — не гипотеза. Известный путь к нему записан блокером
|
||||
[«Порядок журнала при конкурентных приёмах»](poryadok-zhurnala-na-priyome.md):
|
||||
[«Порядок журнала при конкурентных приёмах»](journal-order-on-ingest.md):
|
||||
доставка, свёрнутая раньше своей предшественницы, уходит в `failed` навсегда, и
|
||||
живая витрина расходится с пересборкой молча. Пока тот предел не закрыт, сверка
|
||||
— единственный способ узнать, что он сработал.
|
||||
@@ -26,5 +26,5 @@
|
||||
Готово, когда расхождение витрины с пересборкой перестаёт зависеть от того,
|
||||
догадался ли человек посмотреть.
|
||||
|
||||
Связано: `cmd/healthlog/reindex.go`, [наблюдаемость](stats-nablyudaemost.md),
|
||||
Связано: `cmd/healthlog/reindex.go`, [наблюдаемость](stats-endpoint.md),
|
||||
[деплой](deploy-rivendell.md).
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Пересборка держит весь журнал в памяти
|
||||
|
||||
**Приоритет:** низкий
|
||||
**Секция:** ядро · **Хук:** Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет · **Теги:** goal:journal-and-rebuild
|
||||
|
||||
`healthlog reindex` материализует целиком две вещи: учёт доставок из базы и
|
||||
список путей архива. На сегодняшнем объёме (сотня тел) это незаметно, на
|
||||
@@ -17,7 +17,7 @@
|
||||
памяти.
|
||||
|
||||
Сегодня недостижимо, поэтому приоритет низкий. Естественно склеивается с
|
||||
[ретеншеном сырого архива](retenshen-syrogo-arhiva.md): та задача задаёт, где
|
||||
[ретеншеном сырого архива](raw-archive-retention.md): та задача задаёт, где
|
||||
у журнала конец, эта — как его читать, не поднимая целиком.
|
||||
|
||||
Готово, когда пересборка на журнале в десятки тысяч доставок идёт с потреблением
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
# Чем откатывать релиз после наката миграции
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** инфра · **Хук:** Решено: копия файла базы перед накатом. Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем · **Теги:** goal:deploy, question
|
||||
|
||||
**Решение принято владельцем 2026-08-02: вариант (2) — копия файла базы перед
|
||||
накатом.** Entrypoint контейнера копирует файл базы рядом до старта бинаря,
|
||||
@@ -35,8 +35,7 @@
|
||||
синхронизации; для `stateOfMind` не закрывается ничем — у него доставки HAE
|
||||
единственный источник.
|
||||
|
||||
## Варианты и цена
|
||||
|
||||
## Вопросы
|
||||
1. **Подкоманда `healthlog migrate --down-to N`.** Цена: новая поверхность CLI
|
||||
плюс тест на `Down` каждой миграции (сейчас их нет, и `DROP COLUMN` в SQLite
|
||||
ведёт себя не так, как в постгресе). Зато откат становится операцией, а не
|
||||
@@ -1,6 +1,6 @@
|
||||
# [idea] Человеческие аннотации поверх выведенных схем
|
||||
|
||||
**Приоритет:** низкий
|
||||
**Секция:** ядро · **Хук:** Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата · **Теги:** goal:self-description
|
||||
|
||||
Схема содержимого выводится из данных и говорит **форму** — какие поля есть,
|
||||
какого типа, с какой заполненностью. Чего она не говорит — что метрика значит,
|
||||
@@ -18,4 +18,4 @@
|
||||
формат. Меняться он может только с обновлением Health Auto Export, а это
|
||||
отслеживается — значит ответ придёт сам.
|
||||
|
||||
Связано: `docs/architecture.md` → «Самоописание», задача `samoopisanie-shemy`.
|
||||
Связано: `docs/architecture.md` → «Самоописание», задача `derived-content-schemas`.
|
||||
@@ -1,6 +1,6 @@
|
||||
# [idea] Порог sealed: с какого возраста час считается запечатанным
|
||||
|
||||
**Приоритет:** низкий
|
||||
**Секция:** ядро · **Хук:** WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта · **Теги:** goal:merge-robustness
|
||||
|
||||
Флаг `sealed` отмечает часы, которые уже не должны меняться. Механика готова:
|
||||
изменение запечатанного объекта не отвергается, а пишется `WARN`, и данные
|
||||
@@ -0,0 +1,15 @@
|
||||
# [goal] Самоописание
|
||||
|
||||
**Секция:** порядок · **Хук:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
||||
|
||||
Клиент узнаёт форму данных из ответа сервиса, а не угадывает её по выборке.
|
||||
|
||||
Выведена из шага 6 плана.
|
||||
|
||||
Завершена, когда контракт читается машиной, а формы содержимого метрик
|
||||
выведены из данных, а не описаны руками.
|
||||
|
||||
## Завершение
|
||||
|
||||
Контракт читается машиной, а формы содержимого метрик выведены из данных, а не
|
||||
описаны руками.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Остановка и миграция: раздельные бюджеты и следы в логе
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** инфра · **Хук:** Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM · **Теги:** goal:deploy
|
||||
|
||||
Две находки эксплуатационного и идиоматического проходов ревью каталога. Обе
|
||||
существовали и раньше, но достижимыми их сделал первый маршрут чтения:
|
||||
@@ -1,6 +1,6 @@
|
||||
# Наблюдаемость: /stats
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** инфра · **Хук:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах · **Теги:** goal:observability
|
||||
|
||||
Тихо сломавшаяся автоматизация — главный эксплуатационный риск коллектора:
|
||||
данные просто перестают приходить, и заметить это можно только по молчанию.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Активный алерт «данных нет N часов»
|
||||
|
||||
**Приоритет:** низкий
|
||||
**Секция:** инфра · **Хук:** Пропажу потока сейчас замечает человек, а не сервис · **Теги:** goal:observability
|
||||
|
||||
Пропажу потока сейчас замечает человек. `/stats` покажет факт, но только если
|
||||
туда заглянуть — а заглядывают ровно тогда, когда уже что-то заподозрили.
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
# Тай-брейк при равной полноте точек
|
||||
|
||||
**Приоритет:** высокий
|
||||
**Секция:** ядро · **Хук:** Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт · **Теги:** goal:merge-robustness, question
|
||||
|
||||
**Решение принято владельцем 2026-08-02: вариант (б) — брать бо́льшее значение
|
||||
точки.** Ниже — исходная постановка блокера, она же ТЗ; рекомендация в конце
|
||||
@@ -11,15 +11,14 @@
|
||||
остаться полурешёткой: `max` коммутативен, ассоциативен и идемпотентен, поэтому
|
||||
воспроизводимость свёртки не страдает. Род агрегации в правило **не входит**:
|
||||
род есть функция витрины, и правило слияния, читающее собственную выдачу,
|
||||
повторяет дефект наследования слоя «из будущего» (`docs/review-journal.md`,
|
||||
повторяет дефект наследования слоя «из будущего» (`docs/review.md`,
|
||||
2026-08-01).
|
||||
|
||||
Приёмка та, что названа ниже: на прогоне живого архива отпечаток витрины обязан
|
||||
**измениться** (иначе правило не сработало), а число столкновений с равной
|
||||
полнотой — остаться прежним.
|
||||
|
||||
## Что решить
|
||||
|
||||
## Вопросы
|
||||
Какое правило выбирает победителя, когда по одним координатам приехали две точки
|
||||
с **равными** наборами содержательных полей и разными значениями. Структурная
|
||||
часть правила слияния закрыта (`pravilo-sliyaniya-tochek`); открыт только этот
|
||||
@@ -45,7 +44,7 @@
|
||||
зависящим от измеренного рода нельзя**. Род есть функция витрины, витрина —
|
||||
результат слияния, и правило слияния, читающее собственную выдачу, повторяет
|
||||
ровно тот дефект, на котором свёртка уже переставала быть функцией префикса
|
||||
журнала (`docs/review-journal.md`, 2026-08-01, наследование слоя «из будущего»).
|
||||
журнала (`docs/review.md`, 2026-08-01, наследование слоя «из будущего»).
|
||||
|
||||
## Варианты и цена
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
# Управление токенами и секретами
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** инфра · **Хук:** Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу · **Теги:** goal:deploy
|
||||
|
||||
Сейчас проверка токенов выключена сознательно — доверенная локальная сеть, — и
|
||||
`config.docker.toml` коммитится без секретов. Для локальной разработки это
|
||||
@@ -27,4 +27,3 @@
|
||||
|
||||
Готово, когда запуск без токенов возможен только на localhost, а на rivendell
|
||||
оба контура закрыты разными токенами.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Проверка секций, которых поток ещё не приносил
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую · **Теги:** goal:parsing-and-storage
|
||||
|
||||
Разбор пишется по тем данным, что видел поток, а он приносил только `metrics`,
|
||||
`workouts` и `stateOfMind`. Не виденны живьём: `symptoms`, `ecg`,
|
||||
@@ -26,7 +26,7 @@
|
||||
смотреть, когда данные появятся.
|
||||
|
||||
Готово, когда каждая новая секция либо разобрана, либо явно описана в
|
||||
`docs/local-research.md` как не пришедшая, и ни одна не числится в ошибках
|
||||
`docs/research/apple-health.md` как не пришедшая, и ни одна не числится в ошибках
|
||||
разбора.
|
||||
|
||||
## Что уже сделано
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Идентичность тренировок при импорте родного экспорта
|
||||
|
||||
**Приоритет:** средний
|
||||
**Секция:** ядро · **Хук:** В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE · **Теги:** goal:native-export-import
|
||||
|
||||
Тренировка в витрине адресуется своим `id` из HealthKit — его шлёт HAE. В
|
||||
`export.xml` этого идентификатора **нет вовсе**: у элемента `Workout` только
|
||||
@@ -34,4 +34,4 @@ Prior art: `dogsheep/healthkit-to-sqlite` адресует тренировку
|
||||
рядами, а в экспорте маршрут лежит отдельными GPX).
|
||||
|
||||
Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача
|
||||
`import-eksporta-apple`.
|
||||
`apple-export-import`.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# [idea] Разворачивание маршрутов тренировок в отдельную таблицу
|
||||
|
||||
**Приоритет:** низкий
|
||||
**Секция:** ядро · **Хук:** Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом · **Теги:** goal:read-api
|
||||
|
||||
Тренировка хранится нераскрытой: заголовок — колонками, всё остальное, включая
|
||||
маршрут и внутренние ряды, — блобом `payload`. Решение осознанное: структура
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
//
|
||||
// Без округления сравнение бесполезно: 45 507 из 71 730 повторно приехавших
|
||||
// точек различались последним разрядом double при одинаковом измерении — 63%
|
||||
// повторов выглядели новыми (docs/local-research.md, находка 30). Двенадцать
|
||||
// повторов выглядели новыми (docs/research/apple-health.md, находка 30). Двенадцать
|
||||
// цифр отсекают дребезг сериализации и оставляют нетронутым всё, что Apple
|
||||
// реально измеряет: даже доли процента у walking_asymmetry_percentage не
|
||||
// доходят до седьмой значащей цифры.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"git.vakhrushev.me/av/healthlog/internal/canon"
|
||||
)
|
||||
|
||||
// Пары взяты с живого потока (docs/local-research.md, находка 30): те же
|
||||
// Пары взяты с живого потока (docs/research/apple-health.md, находка 30): те же
|
||||
// измерения в двух выгрузках, разошедшиеся последним разрядом double. Без
|
||||
// округления 63% повторов считались бы новыми точками.
|
||||
func TestFormСхлопываетДребезгПоследнегоРазряда(t *testing.T) {
|
||||
|
||||
+1
-1
@@ -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 местами расходится с тем, что приложение шлёт на самом деле, поэтому
|
||||
// источник истины по формату — пакеты в testdata.
|
||||
package hae
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestPointValueФормыТочки(t *testing.T) {
|
||||
}
|
||||
|
||||
// Формы точки берутся из реальных пакетов: документация формата тонкая и
|
||||
// местами расходится с тем, что приложение шлёт (docs/local-research.md).
|
||||
// местами расходится с тем, что приложение шлёт (docs/research/apple-health.md).
|
||||
func TestPointValueНаРеальныхПакетах(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestAcceptStoresBodyVerbatim(t *testing.T) {
|
||||
}
|
||||
|
||||
// Повтор того же тела пока принимается — отсев идентичных доставок отложен
|
||||
// (docs/plan.md). Проверяем, что повтор не ломается и не затирает первую.
|
||||
// (docs/tasks/PLAN.md). Проверяем, что повтор не ломается и не затирает первую.
|
||||
func TestAcceptAllowsRepeatedBody(t *testing.T) {
|
||||
svc, _, st := newService(t)
|
||||
body := []byte(`{"data":{"metrics":[]}}`)
|
||||
|
||||
@@ -139,7 +139,7 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
measureStyles(t, dst)
|
||||
|
||||
// Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним
|
||||
// `date` лежит до трёх записей (docs/local-research.md, находка 47).
|
||||
// `date` лежит до трёх записей (docs/research/apple-health.md, находка 47).
|
||||
//
|
||||
// Проверяется само свойство, а не измеренное когда-то число. Прежняя
|
||||
// редакция сравнивала с константой 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) {
|
||||
t.Helper()
|
||||
|
||||
@@ -187,7 +187,7 @@ JSON-массив имён (`["stateOfMind"]`), пустой список — `[
|
||||
установившееся состояние половины потока (48 доставок из 99). Постоянный `WARN`
|
||||
каждые пять минут обесценивает уровень ровно так же, как обесценило бы
|
||||
сравнение с заголовком `Default`. Момент появления **новой** секции — отдельная
|
||||
задача (`proverka-novyh-sekcij`), и она будет опираться на сохранённый список.
|
||||
задача (`unseen-sections-check`), и она будет опираться на сохранённый список.
|
||||
|
||||
Имена идут структурным атрибутом (`[]string`), а не склейкой в строку: JSON-
|
||||
кодировщик `slog` экранирует управляющие символы, поэтому имя из чужого тела не
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
- `internal/fold` — исход свёртки, статус и атрибут лога.
|
||||
- `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.3 `docs/backlog`: задача снята, остаток (предел ответа, измеренная цена
|
||||
первого запроса, готовая машинерия условного запроса) перенесён в
|
||||
`read-api-tochki.md`; наблюдаемость — в `stats-nablyudaemost.md`, цена
|
||||
ветки исчерпанного бюджета — в `ostanovka-i-migraciya-sledy.md`
|
||||
`read-api-points.md`; наблюдаемость — в `stats-endpoint.md`, цена
|
||||
ветки исчерпанного бюджета — в `shutdown-and-migration-traces.md`
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
- [x] 11.3 `docs/review-journal.md`: запись о чекпоинте кода без трёх проходов.
|
||||
- [x] 11.4 Остатки заведены задачами беклога: NULL-метка; пределы размера
|
||||
сущности и секции с потоковым расчётом; принцип отбора data-миграций;
|
||||
строка про очередь `pending` — в `stats-nablyudaemost.md`.
|
||||
строка про очередь `pending` — в `stats-endpoint.md`.
|
||||
|
||||
## 12. Приёмка
|
||||
|
||||
|
||||
@@ -90,9 +90,9 @@
|
||||
корпусе
|
||||
- [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 Убрать задачу из беклога, обновить индекс
|
||||
|
||||
## 8. Дозакрыто по ревью кода
|
||||
|
||||
@@ -279,7 +279,7 @@ received_at > ? OR (received_at = ? AND id > ?) → SCAN … COVERING INDEX d
|
||||
отвечать `200`.
|
||||
|
||||
Числа — текущая длина `pending`, возраст самой старой неразобранной доставки —
|
||||
это `/stats`, и они уезжают строкой в задачу `stats-nablyudaemost`. Здесь их
|
||||
это `/stats`, и они уезжают строкой в задачу `stats-endpoint`. Здесь их
|
||||
нет намеренно: отдельного механизма счётчиков в проекте пока не существует.
|
||||
|
||||
### 6. Частичный индекс по неразобранным доставкам
|
||||
@@ -388,7 +388,7 @@ write_timeout)`. Тогда `/healthz` и будущий Read API сохраня
|
||||
- **Задолженность после рестарта разбирается не мгновенно** → 116 тел живого
|
||||
архива это порядка минуты работы воркера; всё это время витрина неполна.
|
||||
Названо `INFO`-строкой при старте. `/healthz` этого не отражает — он статичен;
|
||||
отражать будет `/stats`, задача `stats-nablyudaemost`.
|
||||
отражать будет `/stats`, задача `stats-endpoint`.
|
||||
- **Второй процесс на той же базе даёт двух воркеров** → «одна горутина» —
|
||||
свойство процесса, а не файла базы. Порчи витрины ждать не приходится
|
||||
(`_txlock=immediate` и повтор транзакции сериализуют слияние), но наследование
|
||||
@@ -401,7 +401,7 @@ write_timeout)`. Тогда `/healthz` и будущий Read API сохраня
|
||||
только то, что параллельность стала штатной. `busy_timeout`,
|
||||
`_txlock=immediate` и повтор транзакции уже есть, а исчерпание повторов теперь
|
||||
не стирает доставку с полки (решение 4б). Наблюдение за этим — задача
|
||||
`cena-sliyaniya-na-shirokoj-dostavke`.
|
||||
`merge-cost-wide-delivery`.
|
||||
- **Тик даёт проход раз в минуту при пустой очереди** → это один запрос по
|
||||
покрывающему частичному индексу, в котором ноль строк. Цена измеримо нулевая,
|
||||
а без него состояние «работа есть, прогресса нет» невидимо.
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
быстрого прохода, и одна строка `INFO` о размере задолженности при старте.
|
||||
Метка считается на выборке прохода, а сам проход будит не только сигнал, но и
|
||||
тик — иначе «работа есть, прогресса нет» неотличимо от пустого потока.
|
||||
Счётчики в `/stats` — задача `stats-nablyudaemost`, здесь только метки в логе.
|
||||
Счётчики в `/stats` — задача `stats-endpoint`, здесь только метки в логе.
|
||||
- Убирается второй, оставшийся источник молчаливого обрыва: общий `write_timeout`
|
||||
(30 с) меньше `read_timeout` (5 мин), а он покрывает и чтение тела — то есть
|
||||
медленная загрузка 64 МиБ обрывается независимо от свёртки. Длинный бюджет
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
не память; порядок журнала и остановка; классификация «отказ доставки»
|
||||
против «отказ обстоятельств»; бюджет ответа маршрута приёма; отвергнутые
|
||||
варианты с причинами.
|
||||
- [x] 9.2 Строка в задачу беклога `stats-nablyudaemost`: длина `pending`,
|
||||
- [x] 9.2 Строка в задачу беклога `stats-endpoint`: длина `pending`,
|
||||
возраст самой старой неразобранной доставки и то, что `/healthz` их не
|
||||
отражает.
|
||||
|
||||
@@ -177,4 +177,4 @@
|
||||
разбора, добавлен класс «отложено».
|
||||
- [x] 10.11 Остаточный предел порядка при конкурентных приёмах назван в спеке и
|
||||
в `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:**
|
||||
|
||||
- **Отдача наружу.** Read API в проекте нет вовсе; форма конверта, выбор слоя и
|
||||
предел размера ответа проектируются задачей `read-api-tochki`. Два эндпоинта,
|
||||
предел размера ответа проектируются задачей `read-api-points`. Два эндпоинта,
|
||||
введённые раньше конверта, задали бы контракт мимоходом.
|
||||
- **Секции, которых поток не приносил** (`ecg`, `symptoms`, `cycleTracking`,
|
||||
`medications`, `heartRateNotifications`). Модель под них закладывается —
|
||||
таблица `record` ключуется родом секции, — но разбор не пишется вслепую: их
|
||||
формы никто не видел, а задача `proverka-novyh-sekcij` существует ровно про
|
||||
формы никто не видел, а задача `unseen-sections-check` существует ровно про
|
||||
момент, когда они появятся.
|
||||
- **Разворачивание маршрута** в таблицу точек — отдельная идея беклога, у неё нет
|
||||
клиента.
|
||||
@@ -158,7 +158,7 @@ stateOfMind 26 и 26 копий, по 1 содержимому каждая
|
||||
что воркер сворачивает в порядке `(received_at, id)` только среди **видимых**
|
||||
ему доставок, а абсолютного порядка при конкурентных приёмах не обещает
|
||||
(`docs/architecture.md`, «Предел порядка назван вслух»; открытый блокер
|
||||
`poryadok-zhurnala-na-priyome.md`). Доставка с более ранней меткой, свёрнутая
|
||||
`journal-order-on-ingest.md`). Доставка с более ранней меткой, свёрнутая
|
||||
позже, вернула бы витрину к недосчитанной версии — и `reindex` разошёлся бы с
|
||||
живым приёмом **молча**, в содержимом тренировки. Поэтому сущность несёт
|
||||
провенанс — `delivery_id` и `received_at` своей доставки, — а тай-брейк
|
||||
@@ -315,7 +315,7 @@ verify:archive`. Оставить его отпечатком одних час
|
||||
экспорта Apple, состояние разума нет (находка 46). До этой задачи защита
|
||||
работала побочным эффектом непокрытости. Ретеншена в проекте нет, поэтому здесь
|
||||
ничего не ломается сегодня; но предусловие, которое задача
|
||||
`retenshen-syrogo-arhiva` считала снятым, снова открыто, и это записывается в
|
||||
`raw-archive-retention` считала снятым, снова открыто, и это записывается в
|
||||
её файл тем же изменением.
|
||||
|
||||
### 9а. Отказ разбора остаётся «всё или ничего» — теперь и для сущностей
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
записано в спеке хранения.
|
||||
- **Не входит:** отдача тренировок и записей наружу. Read API в проекте пока нет
|
||||
вовсе; его форма (конверт ответа, выбор слоя, предел размера) проектируется
|
||||
задачей `read-api-tochki`, и вводить два эндпоинта раньше конверта значило бы
|
||||
задачей `read-api-points`, и вводить два эндпоинта раньше конверта значило бы
|
||||
задать контракт мимоходом.
|
||||
|
||||
## Capabilities
|
||||
|
||||
@@ -105,11 +105,11 @@
|
||||
переприсылке (числа замера) и пересчёт находки 50 на 118 доставок
|
||||
- [x] 7.4 Беклог: задача про идентичность тренировок при импорте родного
|
||||
экспорта (в `export.xml` `id` нет — `dogsheep` считает hash_id);
|
||||
`retenshen-syrogo-arhiva` — предусловие про `stateOfMind` снова открыто;
|
||||
отметить в `read-api-tochki`, что отдача тренировок и записей входит в
|
||||
неё; уточнить `proverka-novyh-sekcij` — модель заложена, остались пять
|
||||
`raw-archive-retention` — предусловие про `stateOfMind` снова открыто;
|
||||
отметить в `read-api-points`, что отдача тренировок и записей входит в
|
||||
неё; уточнить `unseen-sections-check` — модель заложена, остались пять
|
||||
секций
|
||||
- [x] 7.5 Удалить `docs/backlog/trenirovki-i-zapisi.md` и строку индекса
|
||||
- [x] 7.5 Удалить `docs/tasks/items/trenirovki-i-zapisi.md` и строку индекса
|
||||
|
||||
## 8. Приёмочные критерии (рубрика ревью)
|
||||
|
||||
|
||||
@@ -23,17 +23,15 @@ context: |
|
||||
русифицируем — они несут точную нормативную/структурную семантику.
|
||||
|
||||
Ревью (процесс, не артефакт):
|
||||
- Нетривиальная/архитектурная задача — два чекпоинта: ревью дизайна (после
|
||||
design/specs, ДО кода — дешевле чинить направление) и ревью кода (после
|
||||
apply, до archive).
|
||||
- Тривиальная задача — достаточно одного прохода (код).
|
||||
- Правило выбора профиля и состав проходов здесь не пересказываем: их дом —
|
||||
скилл av-dev-pipeline:review-pipeline, проектная настройка — docs/review.md.
|
||||
|
||||
Конвенции кода (соблюдать при apply):
|
||||
- Механизируемое проверяет `task gate` (линтер, сборка, тесты, race,
|
||||
покрытие изменённых строк, миграции, образцы конфига, секреты и данные о
|
||||
здоровье в индексе). Состав шагов и правил здесь не пересказываем — он
|
||||
растёт, а гейт скажет точнее и всегда актуальнее.
|
||||
- Прозой остаётся то, что правилом не выражается: docs/conventions.md.
|
||||
- Прозой остаётся то, что правилом не выражается: docs/conventions/README.md.
|
||||
Читаем в источнике, а не отсюда — конвенции дописываются по ходу задач.
|
||||
- Безопасность: данные о здоровье чувствительнее токенов. Ни тела запросов,
|
||||
ни значения точек не попадают в логи выше DEBUG; ничего из ./data не
|
||||
@@ -48,10 +46,11 @@ context: |
|
||||
- Развилка или блокер — сперва prior art. Проект не уникален: готовые решения
|
||||
смотрим в референсах паспорта, отвергаем — с названной причиной, и причина
|
||||
идёт в 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. ПРОВЕРЬ ТАМ, прежде чем строить
|
||||
предположение о формате входа: скорее всего вопрос закрыт измерением.
|
||||
- Тесты на разбор формата держим на реальных пакетах (testdata), а не на
|
||||
|
||||
Reference in New Issue
Block a user