diff --git a/.golangci.yml b/.golangci.yml index 1400ab9..18080c3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 1cf1f6b..5c2cef4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=` — база диффа; без неё берётся + `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 нестабилен, поэтому хеш содержимого считается по канонической форме с рекурсивной diff --git a/README.md b/README.md index d3e9dbd..7e0ad5a 100644 --- a/README.md +++ b/README.md @@ -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; источник истины по формату, документация приложения местами расходится с тем, что оно шлёт diff --git a/Taskfile.yml b/Taskfile.yml index 080b3d4..441e0a6 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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= — база диффа' + desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/секреты/раскладка документов. BASE= — база диффа' 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: 'Вход для архитектурного прохода ревью: пакеты, граф зависимостей, инвентарь концепций' diff --git a/docs/.pm.json b/docs/.pm.json new file mode 100644 index 0000000..24ddc2c --- /dev/null +++ b/docs/.pm.json @@ -0,0 +1,4 @@ +{ + "canon": 1, + "migrations": "internal/store/migrations" +} diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..37fad10 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,42 @@ +# Журнал решений + +Одна запись — одно решение. **ADR это промоут поверх архивного `design.md`**, +а не второе сочинение: запись цитирует решение и ссылается на +`openspec/changes/archive//design.md`. + +## Когда заводить + +Верно одно из трёх: + + +- **дорогой откат** — переделка стоит дороже переписывания одного файла; +- **намеренный отказ** от очевидного подхода; +- **пересмотр прежнего решения** — тогда у старой записи обязателен статус + «заменено на». + + +Не заводить для рутины и для того, что видно из кода и `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`, а не переезд: адаптация +раскладки содержания не сочиняет. diff --git a/docs/adr/template.md b/docs/adr/template.md new file mode 100644 index 0000000..9146305 --- /dev/null +++ b/docs/adr/template.md @@ -0,0 +1,18 @@ +# Краткий заголовок решения + +- Дата: ГГГГ-ММ-ДД +- Источник: openspec/changes/archive//design.md + +## Решение + +Что именно решено — одной фразой. + +## Почему + +Намерение и причина. Цитата из источника, а не пересказ. Пиши так, чтобы через +год было понятно без чтения переписки. + +## Последствия + +- `+` что стало лучше. +- `−` чем платим: ограничения, риски, нагрузка на поддержку. diff --git a/docs/architecture.md b/docs/architecture.md index a2e8e73..4bc21b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 + + Документация формата скудная: [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`. Поэтому то ### Сырой архив и восстановление состояния + + `raw/ГГГГ/ММ/ДД/.json.gz` — тело запроса как пришло, не редактируется. Два источника вместе образуют **полный журнал событий**, а хранилище — @@ -521,6 +534,8 @@ HAE. Значит для него доставки не хвост журнал ### Версия витрины и обслуживание журнала + + Два механизма живут рядом и держатся друг за друга: один говорит читателю «в базу никто не коммитил», второй разбирает журнал, в который эти коммиты легли. @@ -647,6 +662,8 @@ Litestream) не взят по названной причине: он двиг ### Устаревание нижнего слоя + + Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3 месяца. Данные HAE в нижнем слое старше последнего экспорта избыточны: тот же период лежит в слое `sample` подробнее и честнее. @@ -683,6 +700,8 @@ Litestream) не взят по названной причине: он двиг ### Часовые объекты метрик + + Точки метрик хранятся не по одной, а **пачками: один объект = одна метрика за один час UTC**. @@ -719,6 +738,8 @@ record(kind, id, ts_utc, tz_offset, payload BLOB, content_hash, ### Слои гранулярности + + Одна и та же метрика может приходить с разной подробностью: несуммированной, минутной, часовой. Мы **не сводим их к одной** и не агрегируем сами — храним разрезами и говорим клиенту, какие разрезы есть. @@ -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 ### Измерение рода агрегации + + Род метрики — `cumulative`, `instant` или `unknown` — выводится сверкой минутного слоя с часовым. Правило целиком: @@ -1062,6 +1085,8 @@ Assistant требует ручного удаления статистики). ### Категориальные значения + + HAE отдаёт перечислимые значения строками из локали телефона, а не кодами: фаза сна приезжает как «БДГ», контекст пульса — как «Сидячий образ жизни», тип тренировки — как «В помещении Ходьба» (машинная калька с `Indoor Walk`). При @@ -1095,6 +1120,8 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с ### Тренировки и прочие секции + + Тренировка адресуется своим `id` из HealthKit, запись — парой `род + id`. `record` держит секции с собственными идентификаторами; разбором покрыт пока только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и diff --git a/docs/backlog/README.md b/docs/backlog/README.md deleted file mode 100644 index a047507..0000000 --- a/docs/backlog/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# Беклог - -Одна задача = один файл `.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 — следующая покрытая секция унаследует слепую зону - diff --git a/docs/conventions.md b/docs/conventions.md deleted file mode 100644 index 1ecd43d..0000000 --- a/docs/conventions.md +++ /dev/null @@ -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//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` - и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела - запросов, координаты объектов). diff --git a/docs/conventions/README.md b/docs/conventions/README.md new file mode 100644 index 0000000..1ae0329 --- /dev/null +++ b/docs/conventions/README.md @@ -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`, тесты, +гонки, покрытие изменённых строк, миграции, образцы конфига, секреты в индексе, +данные о здоровье в индексе. + +Непойманное место механизации означает, что проход по конвенциям будет +добросовестно проверять уже проверенное. diff --git a/docs/conventions/config.md b/docs/conventions/config.md new file mode 100644 index 0000000..ee7935e --- /dev/null +++ b/docs/conventions/config.md @@ -0,0 +1,15 @@ +# Конфигурация + +- Только **TOML**, никаких env-переменных: окружение наследуется дочерними + процессами и видно через `/proc//environ` — для токенов это слабее + файла под `0600`. +- Грузим один раз при старте в типизированную `Config`; дальше по коду читаем + только её. Конфиг неизменяем — смена параметров означает рестарт. +- Имя по умолчанию — `config.toml` в рабочей директории, переопределяется + `--config=path`. +- `config.example.toml` коммитим как единый самодокументируемый справочник: + **каждое поле с комментарием**, из которого ясно зачем оно, каков диапазон + допустимых значений и в каких единицах. Секретные поля — пустые. +- Реальный `config.toml` не коммитится; секреты рендерит деплой. +- **Валидация на старте, до приёма трафика.** Невалидный конфиг — `ERROR` и + выход с ненулевым кодом. Не стартуем «наполовину». diff --git a/docs/conventions/errors.md b/docs/conventions/errors.md new file mode 100644 index 0000000..cd0bcbc --- /dev/null +++ b/docs/conventions/errors.md @@ -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-обработчика. +- Глушить ошибку без лога — только с однострочным комментарием «почему». diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md new file mode 100644 index 0000000..39571ad --- /dev/null +++ b/docs/conventions/logging.md @@ -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`. Предел держит само сообщение, а не + обрезка на стороне логирующего: обрезка живёт в другом месте и о новой ошибке + разбора не узнает. diff --git a/docs/conventions/storage.md b/docs/conventions/storage.md new file mode 100644 index 0000000..6b90873 --- /dev/null +++ b/docs/conventions/storage.md @@ -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`. diff --git a/docs/conventions/testing.md b/docs/conventions/testing.md new file mode 100644 index 0000000..1c9b560 --- /dev/null +++ b/docs/conventions/testing.md @@ -0,0 +1,31 @@ +# Тесты + +- Тесты на разбор формата HAE держим на **реальных пакетах**, сложенных в + `testdata` (с вычищенными токенами). Документация формата ненадёжна — + источником истины служат живые данные. +- Проверяем идемпотентность: повторный разбор того же пакета не меняет + витрину. +- **Где код выбирает между двумя версиями одних данных, тест обязан прогнать + обе стороны и хотя бы одну перестановку трёх.** Пример на паре доказывает + коммутативность и молчит про ассоциативность, а сломаться правило может + именно на ней: полнота — частичный порядок, тай-брейк — тотальный, и их + попарная свёртка дала нетранзитивное отношение победы, из-за которого одна + и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого + не увидело, ревью кода увидело только перебором троек. Правилом линтера не + выражается — отсюда проза. +- **В тот же перебор обязана входить версия с содержимым, равным одной из уже + присланных, и пара «равная каноническая форма, разные байты».** Три версии с + разными хешами ветку «содержание равно» не посещают ни разу — а именно на ней + устаревал провенанс, и живая витрина расходилась с пересборкой молча. Пара с + равной формой ловит другое: неединственный минимум, при котором победителем + оказывается просто первый в срезе, то есть порядок элементов на проводе. +- **Изменение правила разбора или слияния сопровождается замером на живом + архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение + без числа не отличается от предположения, а цена ошибки здесь — необратимое + решение о судьбе тел. +- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой + буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока + находится в ней сама: проверка на «5.1» краснела примерно раз на сотню + прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time` + и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела + запросов, координаты объектов). diff --git a/docs/database.md b/docs/database.md index 656a4d6..4f61069 100644 --- a/docs/database.md +++ b/docs/database.md @@ -155,3 +155,35 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж массивов); при равных наборах выигрывает версия из более поздней доставки журнала, а не свёрнутая последней. Подробности и обоснование — в `architecture.md`, раздел «Тренировки и прочие секции». + +## Представление данных + +- **Точки часового объекта лежат сжатым BLOB** (`gzip`) в колонке `payload`. + Чтение объекта распаковывает его **целиком**: частичного доступа к точке нет, + и любая правка — read-modify-write всей пачки. Отсюда цена широкой доставки: + 63 МиБ на одной координате держат транзакцию 5.15 с, а тело 40 МиБ давало + 768 МиБ пика кучи, пока канонизация шла внутри транзакции. +- Таблицы часовых объектов — `WITHOUT ROWID`: строка целиком, вместе со сжатым + `payload`, живёт в дереве первичного ключа. Поэтому агрегатные запросы идут + по покрывающему индексу `bucket_catalog`, а не по таблице. +- Тела доставок в базе не лежат вовсе — они в сыром архиве + (`/raw/ГГГГ/ММ/ДД/.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` | diff --git a/docs/passport.md b/docs/passport.md index 4c4fccb..e499ec6 100644 --- a/docs/passport.md +++ b/docs/passport.md @@ -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; модель хранения нам не подходит | diff --git a/docs/plan.md b/docs/plan.md deleted file mode 100644 index 5997792..0000000 --- a/docs/plan.md +++ /dev/null @@ -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]`. Два дома для одной идеи расходятся, и тогда полного списка не даёт ни -один; вопрос «что мы решили отложить» задаётся беклогу. diff --git a/docs/research/README.md b/docs/research/README.md new file mode 100644 index 0000000..7a9eae7 --- /dev/null +++ b/docs/research/README.md @@ -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/.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 что изменилось между доставками +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 | diff --git a/docs/local-research.md b/docs/research/apple-health.md similarity index 97% rename from docs/local-research.md rename to docs/research/apple-health.md index 5d5bed8..187dbad 100644 --- a/docs/local-research.md +++ b/docs/research/apple-health.md @@ -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/.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 что изменилось между доставками -python3 tmp/research/hl.py workouts тренировки, ряды, маршрут -``` - -Он канонизирует JSON перед сравнением и показывает невидимые символы — те две -грабли, на которых разбор оболочкой ломался молча. - ## Открытые вопросы - **Переживает ли «Since Last Sync» неудачную отправку.** Ключевой вопрос для diff --git a/docs/review-journal.md b/docs/review.md similarity index 59% rename from docs/review-journal.md rename to docs/review.md index b2be1b5..262f1e0 100644 --- a/docs/review-journal.md +++ b/docs/review.md @@ -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 — <краткое последствие> + +## ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман] -- **Где:** 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`, а не ищет в сыром буфере. Гейт не трогаем: два прогона против однопроцентной флаки не помогут, а десять стоили бы дороже самой находки. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..01b346d --- /dev/null +++ b/docs/security.md @@ -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 млн записей) глазами не проверяется. + +Ответы внешних систем в недоверенный вход не входят: исходящих вызовов у +сервиса нет. + +## Из чего строятся пути и ключи + +- **Путь в архиве** — `/raw/ГГГГ/ММ/ДД/.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; подписи + тела нет. diff --git a/docs/tasks/BACKLOG.md b/docs/tasks/BACKLOG.md new file mode 100644 index 0000000..df8eeb9 --- /dev/null +++ b/docs/tasks/BACKLOG.md @@ -0,0 +1,52 @@ +# Беклог + +Что **можно взять**. Одна задача = один файл `items/.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 коммитится — так нельзя выезжать наружу diff --git a/docs/tasks/PLAN.md b/docs/tasks/PLAN.md new file mode 100644 index 0000000..2b87bfb --- /dev/null +++ b/docs/tasks/PLAN.md @@ -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 diff --git a/docs/backlog/CLOSED.md b/docs/tasks/REJECTED.md similarity index 62% rename from docs/backlog/CLOSED.md rename to docs/tasks/REJECTED.md index 66adb54..7255bda 100644 --- a/docs/backlog/CLOSED.md +++ b/docs/tasks/REJECTED.md @@ -1,8 +1,10 @@ -# Кладбище беклога +# Ушедшее без реализации -Задачи, покинувшие беклог без реализации. Пишется `backlog.py close`. +Задачи, покинувшие беклог **без реализации**, с причиной и датой. +Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них +есть коммит. Это первое место, куда смотрит дедупликация при заведении. - -- 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 + счётчик» делает событие наблюдаемым. Был приоритет: блокеры. + +- 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 + счётчик» делает событие наблюдаемым. Была секция: блокеры. diff --git a/docs/tasks/SPRINT.md b/docs/tasks/SPRINT.md new file mode 100644 index 0000000..3638c73 --- /dev/null +++ b/docs/tasks/SPRINT.md @@ -0,0 +1,6 @@ +# Спринт + +Спринта нет. Цель называет человек, набор собирает агент: +`tasks.py sprint start --goal <слаг>`. + +## Набор diff --git a/docs/backlog/import-eksporta-apple.md b/docs/tasks/items/apple-export-import.md similarity index 93% rename from docs/backlog/import-eksporta-apple.md rename to docs/tasks/items/apple-export-import.md index 6272683..6771f0e 100644 --- a/docs/backlog/import-eksporta-apple.md +++ b/docs/tasks/items/apple-export-import.md @@ -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. Старые версии формата годятся как регрессионный набор. - diff --git a/docs/backlog/slovar-kategorialnyh-znachenij.md b/docs/tasks/items/categorical-value-dictionary.md similarity index 92% rename from docs/backlog/slovar-kategorialnyh-znachenij.md rename to docs/tasks/items/categorical-value-dictionary.md index 9bbdd16..de348f5 100644 --- a/docs/backlog/slovar-kategorialnyh-znachenij.md +++ b/docs/tasks/items/categorical-value-dictionary.md @@ -1,6 +1,6 @@ # Словарь категориальных значений → коды HealthKit -**Приоритет:** средний +**Секция:** ядро · **Хук:** Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить · **Теги:** goal:parsing-and-storage HAE отдаёт перечислимые значения строками локали телефона: «БДГ», «Сидячий образ жизни», «В помещении Ходьба». Родной экспорт Apple при этом говорит @@ -37,4 +37,3 @@ HAE отдаёт перечислимые значения строками ло `/stats` показывает строки, для которых кода ещё нет. `stateOfMind` в словаре не нуждается — он и так шлёт коды HealthKit. - diff --git a/docs/backlog/umolchaniya-konfiga-data.md b/docs/tasks/items/config-defaults-data-dir.md similarity index 84% rename from docs/backlog/umolchaniya-konfiga-data.md rename to docs/tasks/items/config-defaults-data-dir.md index 0c181d0..9dd3d15 100644 --- a/docs/backlog/umolchaniya-konfiga-data.md +++ b/docs/tasks/items/config-defaults-data-dir.md @@ -1,6 +1,6 @@ # Умолчания конфига указывают на прежнюю раскладку -**Приоритет:** средний +**Секция:** инфра · **Хук:** Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка · **Теги:** goal:deploy Данные переехали в `./data` (база + сырой архив, он же том контейнера), а умолчания в `internal/config` остались прежними: `./healthlog.db` и `./raw`. @@ -14,4 +14,3 @@ Готово, когда запуск без конфига использует `./data` и не создаёт ничего в корне репозитория. Тогда же снимается предупреждение из `config.example.toml`. - diff --git a/docs/backlog/otbor-strok-data-migraciyami.md b/docs/tasks/items/data-migration-row-selection.md similarity index 81% rename from docs/backlog/otbor-strok-data-migraciyami.md rename to docs/tasks/items/data-migration-row-selection.md index e232759..0ded7d2 100644 --- a/docs/backlog/otbor-strok-data-migraciyami.md +++ b/docs/tasks/items/data-migration-row-selection.md @@ -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) — именно она следующей сделает секцию покрытой и напишет такую миграцию. diff --git a/docs/backlog/sutki-i-chasovoj-poyas.md b/docs/tasks/items/day-boundary-timezone.md similarity index 86% rename from docs/backlog/sutki-i-chasovoj-poyas.md rename to docs/tasks/items/day-boundary-timezone.md index 4169c25..64be339 100644 --- a/docs/backlog/sutki-i-chasovoj-poyas.md +++ b/docs/tasks/items/day-boundary-timezone.md @@ -1,6 +1,6 @@ # [idea] Что считать сутками при смене часового пояса -**Приоритет:** средний +**Секция:** ядро · **Хук:** Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено · **Теги:** goal:read-api «Шаги за день» — базовый запрос трекера и фитнес-игры. Но точка несёт метку с офсетом исходной зоны, и при перелёте сутки перестают быть однозначными: день @@ -17,4 +17,3 @@ Apple эту неоднозначность не решает, а перекла Вопрос не гипотетический: походы и хайкинг из второго сценария — это как раз поездки со сменой зоны. - diff --git a/docs/backlog/predel-na-zagolovki-dostavki.md b/docs/tasks/items/delivery-header-limits.md similarity index 84% rename from docs/backlog/predel-na-zagolovki-dostavki.md rename to docs/tasks/items/delivery-header-limits.md index c3241d4..d0688dc 100644 --- a/docs/backlog/predel-na-zagolovki-dostavki.md +++ b/docs/tasks/items/delivery-header-limits.md @@ -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) — оба пункта про одно и то же: приём перестаёт доверять тому, кто с ним говорит. Осторожно: это путь приёма, а доставка, не попавшая в архив, теряется навсегда. diff --git a/docs/backlog/zagolovki-dostavki-v-arhive.md b/docs/tasks/items/delivery-headers-in-archive.md similarity index 93% rename from docs/backlog/zagolovki-dostavki-v-arhive.md rename to docs/tasks/items/delivery-headers-in-archive.md index d25c22e..6fa43ae 100644 --- a/docs/backlog/zagolovki-dostavki-v-arhive.md +++ b/docs/tasks/items/delivery-headers-in-archive.md @@ -1,6 +1,6 @@ # Заголовки доставки в архиве рядом с телом -**Приоритет:** средний +**Секция:** ядро · **Хук:** Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке · **Теги:** goal:journal-and-rebuild Состояние объявлено свёрткой по журналу, а журналом — сырой архив. Но в архиве лежит только **тело**: заголовки запроса (`automation-id`, diff --git a/docs/backlog/deploy-rivendell.md b/docs/tasks/items/deploy-rivendell.md similarity index 91% rename from docs/backlog/deploy-rivendell.md rename to docs/tasks/items/deploy-rivendell.md index c4bee42..fbe596d 100644 --- a/docs/backlog/deploy-rivendell.md +++ b/docs/tasks/items/deploy-rivendell.md @@ -1,6 +1,6 @@ # Деплой на rivendell -**Приоритет:** средний +**Секция:** инфра · **Хук:** Сервис живёт на рабочей машине — телефон достаёт до него только дома · **Теги:** goal:deploy Сервис живёт в контейнере на рабочей машине, телефон достаёт до него только дома. Вне дома экспорт копится и уезжает пачкой при возвращении — работает, но @@ -28,4 +28,3 @@ Том стоит смонтировать так, чтобы серверный бекап забирал его без отдельной настройки, и снимать копию SQLite через `VACUUM INTO`, а не `cp`: телефон шлёт непрерывно, и файл под записью копировать нельзя. - diff --git a/docs/tasks/items/deploy.md b/docs/tasks/items/deploy.md new file mode 100644 index 0000000..a0e6133 --- /dev/null +++ b/docs/tasks/items/deploy.md @@ -0,0 +1,15 @@ +# [goal] Деплой + +**Секция:** порядок · **Хук:** Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты + +Сервис переезжает на rivendell и становится доступен телефону из любой сети. + +Выведена из шага 11 плана. + +Завершена, когда оба контура закрыты разными токенами, откат релиза имеет +названный механизм, а запуск без конфига не заводит базу мимо данных. + +## Завершение + +Оба контура закрыты разными токенами, откат релиза имеет названный механизм, +а запуск без конфига не заводит базу мимо данных. diff --git a/docs/backlog/samoopisanie-shemy.md b/docs/tasks/items/derived-content-schemas.md similarity index 88% rename from docs/backlog/samoopisanie-shemy.md rename to docs/tasks/items/derived-content-schemas.md index f0130e7..e07cee4 100644 --- a/docs/backlog/samoopisanie-shemy.md +++ b/docs/tasks/items/derived-content-schemas.md @@ -1,6 +1,6 @@ # Выведенные из данных схемы содержимого -**Приоритет:** средний +**Секция:** ядро · **Хук:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке · **Теги:** goal:self-description Метрик у Apple больше сотни, формы точек разные, и рукописный каталог описывал бы документацию HAE, а не то, что он реально прислал. Схема содержимого @@ -23,4 +23,3 @@ Связано: `docs/architecture.md` → «Самоописание». Форму конверта API описывает не эта задача, а OpenAPI. - diff --git a/docs/backlog/otkaz-ot-heartbeatseries.md b/docs/tasks/items/drop-heartbeat-series.md similarity index 87% rename from docs/backlog/otkaz-ot-heartbeatseries.md rename to docs/tasks/items/drop-heartbeat-series.md index d21f530..1213231 100644 --- a/docs/backlog/otkaz-ot-heartbeatseries.md +++ b/docs/tasks/items/drop-heartbeat-series.md @@ -1,6 +1,6 @@ # [idea] Отказ от heartbeatSeries -**Приоритет:** низкий +**Секция:** ядро · **Хук:** 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе · **Теги:** goal:lower-layer-cleanup `heart_rate_variability` приезжает вместе с `heartbeatSeries` — рядом межударных интервалов внутри точки. Это **93% объёма метрики** (находка 39) diff --git a/docs/backlog/predely-razmera-sushchnosti.md b/docs/tasks/items/entity-size-limits.md similarity index 94% rename from docs/backlog/predely-razmera-sushchnosti.md rename to docs/tasks/items/entity-size-limits.md index db14b60..f8a68f5 100644 --- a/docs/backlog/predely-razmera-sushchnosti.md +++ b/docs/tasks/items/entity-size-limits.md @@ -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`. - Из того же ревью: «хеш без полного прохода по содержимому не посчитать» — diff --git a/docs/backlog/hranenie-sushchnosti-bez-metki.md b/docs/tasks/items/entity-without-parsed-label.md similarity index 88% rename from docs/backlog/hranenie-sushchnosti-bez-metki.md rename to docs/tasks/items/entity-without-parsed-label.md index 5bd2cdb..2c750d8 100644 --- a/docs/backlog/hranenie-sushchnosti-bez-metki.md +++ b/docs/tasks/items/entity-without-parsed-label.md @@ -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` становятся diff --git a/docs/backlog/celostnost-pered-podmenoj.md b/docs/tasks/items/integrity-before-swap.md similarity index 89% rename from docs/backlog/celostnost-pered-podmenoj.md rename to docs/tasks/items/integrity-before-swap.md index a9bd13b..a53b913 100644 --- a/docs/backlog/celostnost-pered-podmenoj.md +++ b/docs/tasks/items/integrity-before-swap.md @@ -1,6 +1,6 @@ # Проверка целостности собранной витрины перед подменой -**Приоритет:** средний +**Секция:** ядро · **Хук:** Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе · **Теги:** goal:journal-and-rebuild `healthlog reindex` собирает витрину в отдельный файл и снимает с него отпечаток, а подмену делает человек: остановить сервис, переименовать файл, diff --git a/docs/tasks/items/journal-and-rebuild.md b/docs/tasks/items/journal-and-rebuild.md new file mode 100644 index 0000000..3a0d63b --- /dev/null +++ b/docs/tasks/items/journal-and-rebuild.md @@ -0,0 +1,18 @@ +# [goal] Журнал и пересборка + +**Секция:** темы · **Хук:** Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому + +Тема: инвариант «`import` + `replay` даёт то же состояние» и всё, что его +держит — архив, ретеншен, отпечаток витрины, расход памяти пересборки. + +В порядок не встаёт: работа приходит находками и растёт вместе с +журналом. + +Завершена не бывает: закрывается по мере того, как расхождение витрины с +журналом перестаёт быть молчащим. + +## Завершение + +Завершена не бывает — это тема. Закрывается по мере того, как расхождение витрины +с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с +журналом. diff --git a/docs/backlog/poryadok-zhurnala-na-priyome.md b/docs/tasks/items/journal-order-on-ingest.md similarity index 94% rename from docs/backlog/poryadok-zhurnala-na-priyome.md rename to docs/tasks/items/journal-order-on-ingest.md index d646a3c..43d1ee1 100644 --- a/docs/backlog/poryadok-zhurnala-na-priyome.md +++ b/docs/tasks/items/journal-order-on-ingest.md @@ -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 — **до** записи тела в архив и до вставки строки учёта. Порядок, в котором строки становятся видимыми воркеру, порядку меток не подчиняется: между выпуском идентификатора и diff --git a/docs/tasks/items/limits-and-load.md b/docs/tasks/items/limits-and-load.md new file mode 100644 index 0000000..9824695 --- /dev/null +++ b/docs/tasks/items/limits-and-load.md @@ -0,0 +1,16 @@ +# [goal] Пределы и поведение под объёмом + +**Секция:** темы · **Хук:** Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed + +Тема: названные пределы на размер тела, сущности, заголовков и ответа плюс +поведение под удерживаемой блокировкой. + +В порядок не встаёт: пределы всплывают замерами, а не планом. + +Завершена не бывает: закрывается по мере того, как каждый вход получает +названный предел вместо подразумеваемого. + +## Завершение + +Завершена не бывает — это тема. Закрывается по мере того, как каждый вход получает +названный предел вместо подразумеваемого. diff --git a/docs/tasks/items/lower-layer-cleanup.md b/docs/tasks/items/lower-layer-cleanup.md new file mode 100644 index 0000000..8266a41 --- /dev/null +++ b/docs/tasks/items/lower-layer-cleanup.md @@ -0,0 +1,16 @@ +# [goal] Устаревание нижнего слоя + +**Секция:** порядок · **Хук:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен + +После проверенного экспорта нижний слой HAE избыточен и подлежит чистке. + +Выведена из шага 9 плана. Нижний слой растёт на ~100 тысяч координат в сутки. + +Завершена, когда чистка идёт по правилу, а не по календарю, и решение о +удалении опирается на колонку, отличающую ноль от «не измерялось». + +## Завершение + +Чистка идёт по правилу «до следующего проверенного экспорта», а не по +календарю, и решение об удалении опирается на колонку, отличающую ноль от +«не измерялось». diff --git a/docs/backlog/ustarevanie-nizhnego-sloya.md b/docs/tasks/items/lower-layer-expiry.md similarity index 86% rename from docs/backlog/ustarevanie-nizhnego-sloya.md rename to docs/tasks/items/lower-layer-expiry.md index 36761df..3739dcc 100644 --- a/docs/backlog/ustarevanie-nizhnego-sloya.md +++ b/docs/tasks/items/lower-layer-expiry.md @@ -1,6 +1,6 @@ # Устаревание нижнего слоя после экспорта -**Приоритет:** низкий +**Секция:** ядро · **Хук:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен · **Теги:** goal:lower-layer-cleanup Нижний слой растёт примерно на 100 тысяч координат в сутки против ~3 700 у минутного и ~100 у часового — разница в три порядка (находка 41). Всё давление @@ -19,4 +19,3 @@ станет актуальной, когда нижний слой перевалит за несколько гигабайт. Зависит от импорта экспорта Apple — до него помечать нечем. - diff --git a/docs/backlog/mcp-server.md b/docs/tasks/items/mcp-server.md similarity index 88% rename from docs/backlog/mcp-server.md rename to docs/tasks/items/mcp-server.md index f955c5f..39342f9 100644 --- a/docs/backlog/mcp-server.md +++ b/docs/tasks/items/mcp-server.md @@ -1,6 +1,6 @@ # MCP-сервер поверх Read API -**Приоритет:** высокий +**Секция:** ядро · **Хук:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем · **Теги:** goal:mcp Агент-медик — первый заказчик проекта. Ему нужна актуальная сводка, а не срез на дату последнего ручного экспорта. @@ -20,4 +20,3 @@ MCP не даёт ничего, чего не даёт HTTP, и права об неделе» без промежуточного кода. Связано: `docs/architecture.md` → «MCP», план → шаг «MCP». - diff --git a/docs/tasks/items/mcp.md b/docs/tasks/items/mcp.md new file mode 100644 index 0000000..f4ded69 --- /dev/null +++ b/docs/tasks/items/mcp.md @@ -0,0 +1,16 @@ +# [goal] MCP + +**Секция:** порядок · **Хук:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем + +Агент-медик — первый заказчик проекта — подключается к хранилищу. + +Выведена из шага 7 плана. Идёт после Read API намеренно: адаптер собственной +логики не несёт, он переводит вызовы в те же обработчики, и переводить пока +нечего. + +Завершена, когда агент читает данные через MCP тем же токеном чтения. + +## Завершение + +Агент читает данные через MCP тем же токеном чтения, и собственной логики +адаптер не несёт. diff --git a/docs/backlog/cena-sliyaniya-na-shirokoj-dostavke.md b/docs/tasks/items/merge-cost-wide-delivery.md similarity index 94% rename from docs/backlog/cena-sliyaniya-na-shirokoj-dostavke.md rename to docs/tasks/items/merge-cost-wide-delivery.md index 572f351..81418d2 100644 --- a/docs/backlog/cena-sliyaniya-na-shirokoj-dostavke.md +++ b/docs/tasks/items/merge-cost-wide-delivery.md @@ -1,6 +1,6 @@ # Цена слияния на широкой доставке -**Приоритет:** средний +**Секция:** ядро · **Хук:** 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed · **Теги:** goal:limits-and-load Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный проход и независимая реализация — независимо друг от друга). diff --git a/docs/backlog/nablyudenie-za-sliyaniem-v-bd.md b/docs/tasks/items/merge-counters-in-db.md similarity index 87% rename from docs/backlog/nablyudenie-za-sliyaniem-v-bd.md rename to docs/tasks/items/merge-counters-in-db.md index d3071de..a524d69 100644 --- a/docs/backlog/nablyudenie-za-sliyaniem-v-bd.md +++ b/docs/tasks/items/merge-counters-in-db.md @@ -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) — придёт к вопросу о тай-брейке и потребует эксплуатационной истории, которой без этой задачи не будет: мерить придётся снова по архиву, а он к тому моменту подрезан. diff --git a/docs/tasks/items/merge-robustness.md b/docs/tasks/items/merge-robustness.md new file mode 100644 index 0000000..f67b5d8 --- /dev/null +++ b/docs/tasks/items/merge-robustness.md @@ -0,0 +1,15 @@ +# [goal] Прочность слияния и идентичности + +**Секция:** темы · **Хук:** Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе + +Тема: правила, по которым две версии одних данных превращаются в одну. +В порядок не встаёт — работа приходит находками ревью и замерами на +живом корпусе. + +Завершена не бывает: закрывается по мере того, как правила перестают зависеть +от порядка на проводе. + +## Завершение + +Завершена не бывает — это тема. Закрывается по мере того, как правила выбора между +версиями перестают зависеть от порядка элементов на проводе. diff --git a/docs/backlog/mesyachnyj-prohod-ruchnye-sekcii.md b/docs/tasks/items/monthly-manual-sections-pass.md similarity index 84% rename from docs/backlog/mesyachnyj-prohod-ruchnye-sekcii.md rename to docs/tasks/items/monthly-manual-sections-pass.md index cb42fa4..5b3cbd6 100644 --- a/docs/backlog/mesyachnyj-prohod-ruchnye-sekcii.md +++ b/docs/tasks/items/monthly-manual-sections-pass.md @@ -1,6 +1,6 @@ # [idea] Месячный проход по ручным секциям -**Приоритет:** низкий +**Секция:** ядро · **Хук:** Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит · **Теги:** goal:parsing-and-storage Окно досчёта не единое, и это измеренное различие, а не предположение. Количественные метрики (пульс, шаги, энергия) человек руками не правит — они @@ -18,4 +18,4 @@ когда они появятся, — иначе проход пишется вслепую и проверяется не на чем. Связано: `docs/architecture.md` → «Досчёт задним числом», задача -`proverka-novyh-sekcij`. +`unseen-sections-check`. diff --git a/docs/tasks/items/native-export-import.md b/docs/tasks/items/native-export-import.md new file mode 100644 index 0000000..184a9ed --- /dev/null +++ b/docs/tasks/items/native-export-import.md @@ -0,0 +1,17 @@ +# [goal] Импорт родного экспорта Apple + +**Секция:** порядок · **Хук:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут + +`healthlog import`: снапшот всей истории из родного экспорта Apple Health +ложится в хранилище перед проигрыванием хвоста доставок. + +Выведена из шага 8 плана. Идёт перед устареванием нижнего слоя намеренно: пока +импорт экспорта не написан, помечать что-либо устаревшим не на основании чего. + +Завершена, когда слой `sample` наполнен историей с 2019 года, а повторный +импорт того же экспорта ничего не меняет. + +## Завершение + +Слой `sample` наполнен историей с 2019 года, повторный импорт того же экспорта +ничего не меняет, а тренировки из экспорта не задваивают приехавшие от HAE. diff --git a/docs/backlog/ndjson-potok.md b/docs/tasks/items/ndjson-stream.md similarity index 86% rename from docs/backlog/ndjson-potok.md rename to docs/tasks/items/ndjson-stream.md index 16bae90..d35e5a0 100644 --- a/docs/backlog/ndjson-potok.md +++ b/docs/tasks/items/ndjson-stream.md @@ -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`. diff --git a/docs/tasks/items/observability.md b/docs/tasks/items/observability.md new file mode 100644 index 0000000..0113556 --- /dev/null +++ b/docs/tasks/items/observability.md @@ -0,0 +1,16 @@ +# [goal] Наблюдаемость + +**Секция:** порядок · **Хук:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах + +Тихо сломавшаяся автоматизация — главный эксплуатационный риск: телефон шлёт +молча, и молчание неотличимо от нормы. + +Выведена из шага 10 плана. + +Завершена, когда пропажа потока и расхождение витрины с журналом видны +владельцу без чтения логов. + +## Завершение + +Пропажа потока и расхождение витрины с журналом видны владельцу без чтения +логов и переживают ротацию логов. diff --git a/docs/backlog/openapi-swagger.md b/docs/tasks/items/openapi-swagger.md similarity index 86% rename from docs/backlog/openapi-swagger.md rename to docs/tasks/items/openapi-swagger.md index 71dbd26..01074f1 100644 --- a/docs/backlog/openapi-swagger.md +++ b/docs/tasks/items/openapi-swagger.md @@ -1,6 +1,6 @@ # OpenAPI-спека и Swagger UI -**Приоритет:** высокий +**Секция:** ядро · **Хук:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате · **Теги:** goal:read-api Потребителей три, и один из них — агент, который читает контракт машиной. Пересказывать форму ответа в чате не годится, а самоописание из плана покрывает @@ -21,4 +21,3 @@ Развилка на решение: спека пишется руками как источник истины или выводится из кода. Для маленького API рукописная спека честнее — но это стоит обсудить. - diff --git a/docs/backlog/peresekayushchiesya-istochniki.md b/docs/tasks/items/overlapping-sources.md similarity index 87% rename from docs/backlog/peresekayushchiesya-istochniki.md rename to docs/tasks/items/overlapping-sources.md index 52f34d1..3f34ebb 100644 --- a/docs/backlog/peresekayushchiesya-istochniki.md +++ b/docs/tasks/items/overlapping-sources.md @@ -1,6 +1,6 @@ # [idea] Пересекающиеся источники одной метрики -**Приоритет:** средний +**Секция:** ядро · **Хук:** Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь · **Теги:** goal:read-api Одну метрику пишут несколько источников: сон — часы и стороннее приложение AutoSleep, шаги — часы и телефон одновременно. Поле `source` при этом не @@ -17,4 +17,3 @@ AutoSleep, шаги — часы и телефон одновременно. П Для агента-медика вопрос практический: «сколько я спал» не должно давать двойной ответ. - diff --git a/docs/backlog/vygruzka-v-parquet.md b/docs/tasks/items/parquet-export.md similarity index 83% rename from docs/backlog/vygruzka-v-parquet.md rename to docs/tasks/items/parquet-export.md index a298ac6..cf611ed 100644 --- a/docs/backlog/vygruzka-v-parquet.md +++ b/docs/tasks/items/parquet-export.md @@ -1,6 +1,6 @@ # [idea] Выгрузка в parquet отдельной командой -**Приоритет:** низкий +**Секция:** ядро · **Хук:** Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно · **Теги:** goal:read-api Отдельная команда, выгружающая хранилище в parquet, — дверь для тяжёлой аналитики снаружи, без миграции самого хранилища. diff --git a/docs/tasks/items/parsing-and-storage.md b/docs/tasks/items/parsing-and-storage.md new file mode 100644 index 0000000..f2672c1 --- /dev/null +++ b/docs/tasks/items/parsing-and-storage.md @@ -0,0 +1,18 @@ +# [goal] Разбор и хранилище + +**Секция:** порядок · **Хук:** Секции живого потока разобраны все, кроме словаря категориальных значений и пяти невиденных + +Метрики, тренировки и записи со своими `id` разбираются и ложатся в часовые +объекты; тела перестали быть недифференцированной кучей. + +Выведена из шага 3 плана. Сделано: разбор метрик в объекты, тренировки и +записи, `reindex`. Осталось: словарь категориальных значений и секции, которых +поток ещё не приносил. + +Завершена, когда ни одна секция живого потока не числится неразобранной, а +категориальные значения имеют стабильный код рядом с переведённой строкой. + +## Завершение + +Ни одна секция живого потока не числится неразобранной, а категориальные +значения несут стабильный код рядом с переведённой строкой. diff --git a/docs/backlog/retenshen-syrogo-arhiva.md b/docs/tasks/items/raw-archive-retention.md similarity index 96% rename from docs/backlog/retenshen-syrogo-arhiva.md rename to docs/tasks/items/raw-archive-retention.md index 7df8476..9f77d81 100644 --- a/docs/backlog/retenshen-syrogo-arhiva.md +++ b/docs/tasks/items/raw-archive-retention.md @@ -1,6 +1,6 @@ # Ретеншен сырого архива -**Приоритет:** низкий +**Секция:** инфра · **Хук:** Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает · **Теги:** goal:journal-and-rebuild Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но удаления нет: архив растёт бесконечно. Пока это 16 МБ и проблемой не является. diff --git a/docs/backlog/read-api-tochki.md b/docs/tasks/items/read-api-points.md similarity index 96% rename from docs/backlog/read-api-tochki.md rename to docs/tasks/items/read-api-points.md index 57b3bba..5ecce87 100644 --- a/docs/backlog/read-api-tochki.md +++ b/docs/tasks/items/read-api-points.md @@ -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». - diff --git a/docs/tasks/items/read-api.md b/docs/tasks/items/read-api.md new file mode 100644 index 0000000..c8b8d07 --- /dev/null +++ b/docs/tasks/items/read-api.md @@ -0,0 +1,17 @@ +# [goal] Read API + +**Секция:** порядок · **Хук:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может + +Потребители читают точки: выбор слоя, свёртка по сетке, предел размера ответа. + +Выведена из шага 5 плана. Идёт после каталога и рода агрегации намеренно: без +измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться здесь +дорого — просуммировать нижний слой значит завысить втрое. + +Завершена, когда любой из трёх потребителей получает точки за период без +доступа к файлу базы. + +## Завершение + +Любой из трёх потребителей получает точки за период без доступа к файлу базы, +и предел размера ответа объявлен, а не подразумевается. diff --git a/docs/backlog/sverka-vitriny-s-peresborkoj.md b/docs/tasks/items/rebuild-comparison-check.md similarity index 88% rename from docs/backlog/sverka-vitriny-s-peresborkoj.md rename to docs/tasks/items/rebuild-comparison-check.md index d7cbb18..86e40b7 100644 --- a/docs/backlog/sverka-vitriny-s-peresborkoj.md +++ b/docs/tasks/items/rebuild-comparison-check.md @@ -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). diff --git a/docs/backlog/pereborka-ne-vlezaet-v-pamyat.md b/docs/tasks/items/rebuild-memory-footprint.md similarity index 81% rename from docs/backlog/pereborka-ne-vlezaet-v-pamyat.md rename to docs/tasks/items/rebuild-memory-footprint.md index 264aa5e..f342364 100644 --- a/docs/backlog/pereborka-ne-vlezaet-v-pamyat.md +++ b/docs/tasks/items/rebuild-memory-footprint.md @@ -1,6 +1,6 @@ # Пересборка держит весь журнал в памяти -**Приоритет:** низкий +**Секция:** ядро · **Хук:** Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет · **Теги:** goal:journal-and-rebuild `healthlog reindex` материализует целиком две вещи: учёт доставок из базы и список путей архива. На сегодняшнем объёме (сотня тел) это незаметно, на @@ -17,7 +17,7 @@ памяти. Сегодня недостижимо, поэтому приоритет низкий. Естественно склеивается с -[ретеншеном сырого архива](retenshen-syrogo-arhiva.md): та задача задаёт, где +[ретеншеном сырого архива](raw-archive-retention.md): та задача задаёт, где у журнала конец, эта — как его читать, не поднимая целиком. Готово, когда пересборка на журнале в десятки тысяч доставок идёт с потреблением diff --git a/docs/backlog/otkat-reliza-posle-migracii.md b/docs/tasks/items/release-rollback-after-migration.md similarity index 94% rename from docs/backlog/otkat-reliza-posle-migracii.md rename to docs/tasks/items/release-rollback-after-migration.md index a9ddcb3..6eac011 100644 --- a/docs/backlog/otkat-reliza-posle-migracii.md +++ b/docs/tasks/items/release-rollback-after-migration.md @@ -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 ведёт себя не так, как в постгресе). Зато откат становится операцией, а не diff --git a/docs/backlog/annotacii-k-shemam.md b/docs/tasks/items/schema-annotations.md similarity index 80% rename from docs/backlog/annotacii-k-shemam.md rename to docs/tasks/items/schema-annotations.md index 6572d24..b625d1a 100644 --- a/docs/backlog/annotacii-k-shemam.md +++ b/docs/tasks/items/schema-annotations.md @@ -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`. diff --git a/docs/backlog/porog-sealed.md b/docs/tasks/items/sealed-threshold.md similarity index 82% rename from docs/backlog/porog-sealed.md rename to docs/tasks/items/sealed-threshold.md index a154526..0a39b9a 100644 --- a/docs/backlog/porog-sealed.md +++ b/docs/tasks/items/sealed-threshold.md @@ -1,6 +1,6 @@ # [idea] Порог sealed: с какого возраста час считается запечатанным -**Приоритет:** низкий +**Секция:** ядро · **Хук:** WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта · **Теги:** goal:merge-robustness Флаг `sealed` отмечает часы, которые уже не должны меняться. Механика готова: изменение запечатанного объекта не отвергается, а пишется `WARN`, и данные diff --git a/docs/tasks/items/self-description.md b/docs/tasks/items/self-description.md new file mode 100644 index 0000000..ad51991 --- /dev/null +++ b/docs/tasks/items/self-description.md @@ -0,0 +1,15 @@ +# [goal] Самоописание + +**Секция:** порядок · **Хук:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке + +Клиент узнаёт форму данных из ответа сервиса, а не угадывает её по выборке. + +Выведена из шага 6 плана. + +Завершена, когда контракт читается машиной, а формы содержимого метрик +выведены из данных, а не описаны руками. + +## Завершение + +Контракт читается машиной, а формы содержимого метрик выведены из данных, а не +описаны руками. diff --git a/docs/backlog/ostanovka-i-migraciya-sledy.md b/docs/tasks/items/shutdown-and-migration-traces.md similarity index 94% rename from docs/backlog/ostanovka-i-migraciya-sledy.md rename to docs/tasks/items/shutdown-and-migration-traces.md index 2aa4056..01a215e 100644 --- a/docs/backlog/ostanovka-i-migraciya-sledy.md +++ b/docs/tasks/items/shutdown-and-migration-traces.md @@ -1,6 +1,6 @@ # Остановка и миграция: раздельные бюджеты и следы в логе -**Приоритет:** средний +**Секция:** инфра · **Хук:** Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM · **Теги:** goal:deploy Две находки эксплуатационного и идиоматического проходов ревью каталога. Обе существовали и раньше, но достижимыми их сделал первый маршрут чтения: diff --git a/docs/backlog/stats-nablyudaemost.md b/docs/tasks/items/stats-endpoint.md similarity index 95% rename from docs/backlog/stats-nablyudaemost.md rename to docs/tasks/items/stats-endpoint.md index 63b9034..0badf6d 100644 --- a/docs/backlog/stats-nablyudaemost.md +++ b/docs/tasks/items/stats-endpoint.md @@ -1,6 +1,6 @@ # Наблюдаемость: /stats -**Приоритет:** средний +**Секция:** инфра · **Хук:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах · **Теги:** goal:observability Тихо сломавшаяся автоматизация — главный эксплуатационный риск коллектора: данные просто перестают приходить, и заметить это можно только по молчанию. diff --git a/docs/backlog/alert-tishina-potoka.md b/docs/tasks/items/stream-silence-alert.md similarity index 92% rename from docs/backlog/alert-tishina-potoka.md rename to docs/tasks/items/stream-silence-alert.md index 951626a..77dd1bd 100644 --- a/docs/backlog/alert-tishina-potoka.md +++ b/docs/tasks/items/stream-silence-alert.md @@ -1,6 +1,6 @@ # Активный алерт «данных нет N часов» -**Приоритет:** низкий +**Секция:** инфра · **Хук:** Пропажу потока сейчас замечает человек, а не сервис · **Теги:** goal:observability Пропажу потока сейчас замечает человек. `/stats` покажет факт, но только если туда заглянуть — а заглядывают ровно тогда, когда уже что-то заподозрили. diff --git a/docs/backlog/taj-brejk-pri-ravnoj-polnote.md b/docs/tasks/items/tie-break-equal-completeness.md similarity index 93% rename from docs/backlog/taj-brejk-pri-ravnoj-polnote.md rename to docs/tasks/items/tie-break-equal-completeness.md index 1f2dceb..ffb2720 100644 --- a/docs/backlog/taj-brejk-pri-ravnoj-polnote.md +++ b/docs/tasks/items/tie-break-equal-completeness.md @@ -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, наследование слоя «из будущего»). ## Варианты и цена diff --git a/docs/backlog/upravlenie-sekretami.md b/docs/tasks/items/token-and-secret-management.md similarity index 90% rename from docs/backlog/upravlenie-sekretami.md rename to docs/tasks/items/token-and-secret-management.md index 8f88fa7..22a3cab 100644 --- a/docs/backlog/upravlenie-sekretami.md +++ b/docs/tasks/items/token-and-secret-management.md @@ -1,6 +1,6 @@ # Управление токенами и секретами -**Приоритет:** средний +**Секция:** инфра · **Хук:** Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу · **Теги:** goal:deploy Сейчас проверка токенов выключена сознательно — доверенная локальная сеть, — и `config.docker.toml` коммитится без секретов. Для локальной разработки это @@ -27,4 +27,3 @@ Готово, когда запуск без токенов возможен только на localhost, а на rivendell оба контура закрыты разными токенами. - diff --git a/docs/backlog/proverka-novyh-sekcij.md b/docs/tasks/items/unseen-sections-check.md similarity index 90% rename from docs/backlog/proverka-novyh-sekcij.md rename to docs/tasks/items/unseen-sections-check.md index db859b4..b388bc8 100644 --- a/docs/backlog/proverka-novyh-sekcij.md +++ b/docs/tasks/items/unseen-sections-check.md @@ -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` как не пришедшая, и ни одна не числится в ошибках разбора. ## Что уже сделано diff --git a/docs/backlog/identichnost-trenirovok-pri-importe.md b/docs/tasks/items/workout-identity-on-import.md similarity index 92% rename from docs/backlog/identichnost-trenirovok-pri-importe.md rename to docs/tasks/items/workout-identity-on-import.md index 1406a31..a1daeff 100644 --- a/docs/backlog/identichnost-trenirovok-pri-importe.md +++ b/docs/tasks/items/workout-identity-on-import.md @@ -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`. diff --git a/docs/backlog/razvorachivanie-marshrutov.md b/docs/tasks/items/workout-routes-table.md similarity index 82% rename from docs/backlog/razvorachivanie-marshrutov.md rename to docs/tasks/items/workout-routes-table.md index 9f99254..35e10ea 100644 --- a/docs/backlog/razvorachivanie-marshrutov.md +++ b/docs/tasks/items/workout-routes-table.md @@ -1,6 +1,6 @@ # [idea] Разворачивание маршрутов тренировок в отдельную таблицу -**Приоритет:** низкий +**Секция:** ядро · **Хук:** Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом · **Теги:** goal:read-api Тренировка хранится нераскрытой: заголовок — колонками, всё остальное, включая маршрут и внутренние ряды, — блобом `payload`. Решение осознанное: структура diff --git a/internal/canon/canon.go b/internal/canon/canon.go index bd2fbe4..c0a09fb 100644 --- a/internal/canon/canon.go +++ b/internal/canon/canon.go @@ -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 не // доходят до седьмой значащей цифры. diff --git a/internal/canon/canon_test.go b/internal/canon/canon_test.go index a837f0f..231997b 100644 --- a/internal/canon/canon_test.go +++ b/internal/canon/canon_test.go @@ -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) { diff --git a/internal/hae/hae.go b/internal/hae/hae.go index 2c04d41..2bb8f6a 100644 --- a/internal/hae/hae.go +++ b/internal/hae/hae.go @@ -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 diff --git a/internal/hae/value_test.go b/internal/hae/value_test.go index dc6694b..b97eac6 100644 --- a/internal/hae/value_test.go +++ b/internal/hae/value_test.go @@ -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() diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index 338268e..fe3fd28 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -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":[]}}`) diff --git a/internal/replay/archive_test.go b/internal/replay/archive_test.go index eb7c827..a6f631f 100644 --- a/internal/replay/archive_test.go +++ b/internal/replay/archive_test.go @@ -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() diff --git a/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/design.md b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/design.md index 53320fa..4e59a75 100644 --- a/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/design.md +++ b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/design.md @@ -187,7 +187,7 @@ JSON-массив имён (`["stateOfMind"]`), пустой список — `[ установившееся состояние половины потока (48 доставок из 99). Постоянный `WARN` каждые пять минут обесценивает уровень ровно так же, как обесценило бы сравнение с заголовком `Default`. Момент появления **новой** секции — отдельная -задача (`proverka-novyh-sekcij`), и она будет опираться на сохранённый список. +задача (`unseen-sections-check`), и она будет опираться на сохранённый список. Имена идут структурным атрибутом (`[]string`), а не склейкой в строку: JSON- кодировщик `slog` экранирует управляющие символы, поэтому имя из чужого тела не diff --git a/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/proposal.md b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/proposal.md index 3c9084f..7cd01bd 100644 --- a/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/proposal.md +++ b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/proposal.md @@ -54,5 +54,5 @@ - `internal/fold` — исход свёртки, статус и атрибут лога. - `docs/database.md`, `docs/architecture.md`, `docs/local-research.md` — схема, статусы и находка о наборах секций в живом потоке. -- Ретеншен сырого архива (задача `retenshen-syrogo-arhiva`) получает признак, +- Ретеншен сырого архива (задача `raw-archive-retention`) получает признак, на который ему можно опираться. diff --git a/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/tasks.md b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/tasks.md index fcf8aea..0f09842 100644 --- a/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/tasks.md +++ b/openspec/changes/archive/2026-08-02-cena-chitayushchego-marshruta/tasks.md @@ -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` diff --git a/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/tasks.md b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/tasks.md index 50e5212..0a4ebb8 100644 --- a/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/tasks.md +++ b/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/tasks.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. Приёмка diff --git a/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/tasks.md b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/tasks.md index 43c0938..aa884ee 100644 --- a/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/tasks.md +++ b/openspec/changes/archive/2026-08-02-katalog-i-rod-agregacii/tasks.md @@ -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. Дозакрыто по ревью кода diff --git a/openspec/changes/archive/2026-08-02-otvet-i-svyortka/design.md b/openspec/changes/archive/2026-08-02-otvet-i-svyortka/design.md index acb7592..0d7f47c 100644 --- a/openspec/changes/archive/2026-08-02-otvet-i-svyortka/design.md +++ b/openspec/changes/archive/2026-08-02-otvet-i-svyortka/design.md @@ -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`. - **Тик даёт проход раз в минуту при пустой очереди** → это один запрос по покрывающему частичному индексу, в котором ноль строк. Цена измеримо нулевая, а без него состояние «работа есть, прогресса нет» невидимо. diff --git a/openspec/changes/archive/2026-08-02-otvet-i-svyortka/proposal.md b/openspec/changes/archive/2026-08-02-otvet-i-svyortka/proposal.md index 293bd00..1a0fb8a 100644 --- a/openspec/changes/archive/2026-08-02-otvet-i-svyortka/proposal.md +++ b/openspec/changes/archive/2026-08-02-otvet-i-svyortka/proposal.md @@ -40,7 +40,7 @@ быстрого прохода, и одна строка `INFO` о размере задолженности при старте. Метка считается на выборке прохода, а сам проход будит не только сигнал, но и тик — иначе «работа есть, прогресса нет» неотличимо от пустого потока. - Счётчики в `/stats` — задача `stats-nablyudaemost`, здесь только метки в логе. + Счётчики в `/stats` — задача `stats-endpoint`, здесь только метки в логе. - Убирается второй, оставшийся источник молчаливого обрыва: общий `write_timeout` (30 с) меньше `read_timeout` (5 мин), а он покрывает и чтение тела — то есть медленная загрузка 64 МиБ обрывается независимо от свёртки. Длинный бюджет diff --git a/openspec/changes/archive/2026-08-02-otvet-i-svyortka/tasks.md b/openspec/changes/archive/2026-08-02-otvet-i-svyortka/tasks.md index baafdf1..3d37cbf 100644 --- a/openspec/changes/archive/2026-08-02-otvet-i-svyortka/tasks.md +++ b/openspec/changes/archive/2026-08-02-otvet-i-svyortka/tasks.md @@ -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`). diff --git a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/design.md b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/design.md index 68061cc..3a65ae4 100644 --- a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/design.md +++ b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/design.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а. Отказ разбора остаётся «всё или ничего» — теперь и для сущностей diff --git a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/proposal.md b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/proposal.md index 2fbe62b..8b88442 100644 --- a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/proposal.md +++ b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/proposal.md @@ -39,7 +39,7 @@ записано в спеке хранения. - **Не входит:** отдача тренировок и записей наружу. Read API в проекте пока нет вовсе; его форма (конверт ответа, выбор слоя, предел размера) проектируется - задачей `read-api-tochki`, и вводить два эндпоинта раньше конверта значило бы + задачей `read-api-points`, и вводить два эндпоинта раньше конверта значило бы задать контракт мимоходом. ## Capabilities diff --git a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/tasks.md b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/tasks.md index c67d6be..e2a3818 100644 --- a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/tasks.md +++ b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/tasks.md @@ -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. Приёмочные критерии (рубрика ревью) diff --git a/openspec/config.yaml b/openspec/config.yaml index d8079da..bd8d124 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -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), а не на