Compare commits

...
23 Commits
Author SHA1 Message Date
av 3d24248075 docs: документация приведена к канону av-dev-pm 4
- каждая запись каталога задач получила тип вместо тега kind: и префикса
  заголовка; секция роадмапа «Разработка» стала «Сопровождением», порядок
  секций канонический
- поправлены протухшие факты: нереализованные маршруты Read API, MCP и
  `healthlog import`, словарь слоёв в инварианте, семантика гейта по покрытию
  диффа, периметр перестал дублировать security.md
- замер слияния переведён с находки 49 на находку 54, заполнены Purpose спек
  storage и parsing
2026-08-05 19:09:35 +03:00
av e4f62785d8 settings: зарегистрирован маркетплейс av-dev-skills 2026-08-05 19:09:22 +03:00
av d33f37249c docs: документация переведена на канон av-dev-pm 3
- роадмап отвечает «что умеет и чего не умеет»: PLAN.md → ROADMAP.md, четыре
  канонические секции, достигнутые звенья строками в «Готово», цели
  переформулированы возможностями приложения
- задачи: род работы и «Затрагивает» набору спринта, 34 заголовка в форму
  действия, «Завершение» целей перечнями со ссылкой из каждой задачи
- вычитка проходами task-form и doc-wording, починены протухшие факты в README,
  паспорте и review.md
2026-08-04 20:48:30 +03:00
av b1d3b25827 закрыта задача read-api-points-period 2026-08-04 18:47:16 +03:00
av 29ca8d415c httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
2026-08-04 18:46:45 +03:00
av b819b77f62 закрыта задача read-api-wire-format 2026-08-04 16:18:43 +03:00
av a834d10415 httpapi: форма провода читающих маршрутов объявлена транспортом
- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы
  metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте,
  тело отказа тоже получило объявленный тип — байты ответа не изменились
- заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до
  сериализации, плюс требование json-тега на полях транспортных структур и
  заведомо красные случаи к обоим правилам
- решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в
  гейте получил свой кеш — общий на машину красил прогон находками из чужого
  worktree
2026-08-04 16:18:05 +03:00
av bd832337df tasks: задачи спринта раздроблены до восьми гранулярных
- конверт и точки разложены на форму провода, точки за период и условный
  запрос; свёртка — на сетку, порог неполного ведра и предел размера ответа;
  тренировки и записи разъехались на два независимых маршрута
- openapi-swagger разложена на спеку, гейт против расхождения и Swagger UI —
  все три вне набора, вместе с mcp-server
- набор спринта 2026-08-04 — весь HTTP-слой чтения, восемь задач
2026-08-04 14:21:57 +03:00
av 79331ac670 tasks: закрыт разбор и хранилище, начат спринт по чтению данных клиентами
- цель parsing-and-storage закрыта по своему критерию; незакрываемый остаток
  (новые формы от источника, ручные секции задним числом) переехал в тему
  parsing-completeness
- цель mcp поглощена целью read-api, переименованной в «Чтение данных
  клиентами»: адаптер — последний шаг того же направления, а не своё
- read-api-points разложена на конверт с точками, свёртку по сетке и
  тренировки с записями; спринт 2026-08-04 набран пятью задачами
2026-08-04 14:01:38 +03:00
av 637eb38bce закрыта задача unseen-sections-check 2026-08-04 13:40:09 +03:00
av bd5d17b079 первая встреча непокрытой секции стала наблюдаемым событием
- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре
  (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат.
  Признак выводится, а не хранится — реестр был бы второй копией факта
- добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение
  только на чтение, экранированные имена и названные границы носителя
- синк документации: ADR о выводе новизны из журнала, две записи в журнал
  дефектов, два правила промоутом в конвенции, терминал оператора назван
  адресатом недоверенного входа
2026-08-04 13:39:48 +03:00
av 2130763d3c tasks: решено, чем помечать покрытие экспортом
- пометка — одна строка на диапазон (метрика + слой + период), а не провенанс
  на точку: вопрос диапазонный, а поле у точки стоит того объёма, который
  устаревание нижнего слоя и приходит экономить
- провенанс на точку отвергнут по цене, а не по ненадобности — различие
  записано, чтобы решение пересмотрели при появлении потребителя
- пометку выставляет импорт экспорта, по проверенному прогону; stateOfMind не
  получает её никогда — его в экспорте Apple нет
2026-08-04 11:28:33 +03:00
av 95377f54cd review.md: записан промах — гейт после интеграции проверял пустой дифф
- на master после ff-слияния база диффа равна HEAD, все go-шаги пропускаются,
  и вакуумный прогон выглядит зелёным
- тот же прогон с явной базой нашёл красный lint
2026-08-04 11:19:14 +03:00
av 9f77e56d37 golangci: ./tmp исключён из проверок
- CLAUDE.md велит класть черновое и временное в ./tmp, но линтер про это не знал:
  диагностическая программа и worktree батча красили гейт
- краснота по причине, не связанной с изменением, приучает не читать красноту
2026-08-04 11:18:32 +03:00
av 7e6a1fc6b2 закрыта задача tie-break-equal-completeness 2026-08-04 11:16:25 +03:00
av b278501a6e store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
2026-08-04 11:16:24 +03:00
av ae607f1ceb tasks: заведена задача о замене правила полноты на last wins
- первый шаг — замер: в 1 022 координатах, где полноту решило превосходство
  полей, была ли более полная точка более поздней; от исхода ветвится всё
- «экспорт — источник правды» ограничено двумя рамками: по дате снапшота и по
  типам, которых в экспорте нет вовсе (stateOfMind)
2026-08-04 07:59:40 +03:00
av de2001dea6 tie-break: записан диагноз красного verify:archive и снято прежнее решение
- байтовый тай-брейк решает 98,8% спорных координат из 84 978: step_count
  потерял род, потому что сохранённая точка выиграла у более поздней
- вариант (б) «брать бо́льшее» снят, взято «пришедшая побеждает сохранённую»;
  оно не полурешётка, и задача обязана доказать равенство пересборки приёму
2026-08-04 07:55:11 +03:00
av 8582d3540d закрыта задача categorical-value-dictionary 2026-08-04 07:32:19 +03:00
av 1b649ba3d5 добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
2026-08-04 07:32:19 +03:00
avandClaude Opus 5 eb3fca77ee канон: поднят до версии 2 — шапка ADR мета-блоком
Миграция по записи «Версия 2» журнала канона: поля Дата и Источник в
docs/adr/template.md жирным, объявлено место под статус (- **Статус:**
заменено на ADR-… либо устарело), та же строка добавлена в «Соглашения»
docs/adr/README.md, docs/.pm.json переведён на canon 2.

Переносить статусы было не из чего: записей ADR в проекте пока нет, каталог
несёт только README и шаблон.

docs.py check зелёный, версия проекта сошлась с версией скрипта.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:04:29 +03:00
av 3df42afeca tasks: разобраны вопросы и набран спринт 2026-08-03
- открытых вопросов не осталось: три решения владельца доведены до берущегося
  вида, по entity-without-parsed-label принято хранить с NULL-меткой после Read API
- unseen-sections-check сжата до остатка — активная проверка появления секции;
  разбор невиденных секций из неё вынут, вслепую он не пишется
- спринт под целью parsing-and-storage: categorical-value-dictionary и
  unseen-sections-check, обеим написаны критерии приёмки с оракулами
2026-08-03 17:47:41 +03:00
av b2bdb6383f review.md: записан промах — ответ владельца не превращал задачу в берущуюся
- три задачи с решением от 2026-08-02 сохраняли тег question и непустой раздел
  «Вопросы», то есть sprint take отказал бы их взять
- там же названы два числа сессии: ориентир 5–8 задач ничем не замерян, а отбор
  по --stale слеп, пока у каталога нет собственной истории правок
2026-08-03 17:47:29 +03:00
195 changed files with 18840 additions and 1013 deletions
+5
View File
@@ -1,4 +1,9 @@
{
"extraKnownMarketplaces": {
"av-dev-skills": {
"source": { "source": "git", "url": "https://git.vakhrushev.me/av/dev-skills.git" }
}
},
"enabledPlugins": {
"av-dev-git@av-dev-skills": true,
"av-dev-pm@av-dev-skills": true,
+6
View File
@@ -61,6 +61,11 @@ linters:
- third_party$
- builtin$
- examples$
# Черновое и временное живёт в ./tmp (CLAUDE.md, «Запреты»): туда же
# попадают worktree батча и диагностические программы. Конвенции на них
# не распространяются — иначе черновик красит гейт по причине, не
# связанной с изменением, и настоящую красноту перестают читать.
- ^tmp/
rules:
# CLI — другая поверхность: печатает результат в stdout, это не логи.
- path: ^cmd/
@@ -86,3 +91,4 @@ formatters:
- third_party$
- builtin$
- examples$
- ^tmp/
+20 -11
View File
@@ -4,7 +4,7 @@
[docs/passport.md](docs/passport.md) (цель, сценарии, референсы),
[README.md](README.md), [docs/architecture.md](docs/architecture.md),
[docs/conventions/README.md](docs/conventions/README.md),
[docs/security.md](docs/security.md) и [docs/tasks/PLAN.md](docs/tasks/PLAN.md).
[docs/security.md](docs/security.md) и [docs/tasks/ROADMAP.md](docs/tasks/ROADMAP.md).
Документация ведётся по канону `av-dev-pm` (версия в `docs/.pm.json`);
раскладку проверяет `av-dev-pm:canon`, содержимое ведёт `av-dev-pm:docs`.
@@ -13,7 +13,7 @@
Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export и
родного экспорта Apple, хранит их и отдаёт другим моим проектам — через HTTP
API и через MCP. Это **хранилище, а не аналитика**: принять, дедуплицировать,
API и, в планах, через MCP. Это **хранилище, а не аналитика**: принять, дедуплицировать,
сохранить, отдать. Не переименовывать поля Apple, не интерпретировать
значения. Агрегат считается только в ответе на запрос и только там, где род
метрики измерен.
@@ -22,7 +22,7 @@ API и через MCP. Это **хранилище, а не аналитика**
Go, один статический бинарь (`CGO_ENABLED=0`). SQLite (`modernc.org/sqlite`,
чистый Go), `chi`, `sqlx`, `goose` (миграции), `pelletier/go-toml/v2`,
`log/slog`, ULID через `internal/ident`.
`log/slog`, ULID (`github.com/oklog/ulid/v2`) через `internal/ident`.
Module path — `git.vakhrushev.me/av/healthlog`.
@@ -51,7 +51,10 @@ Module path — `git.vakhrushev.me/av/healthlog`.
под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек —
отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он
нестабилен. Хеш канонизированного содержимого остался детектором изменений. При
столкновении выигрывает **более полная** точка, а не последняя. Изменение
столкновении выигрывает **более полная** точка, а при равной полноте —
**стоящая позже в журнале** (внутри одной доставки — порядок канонических
форм). Второе означает, что содержимое витрины есть функция **порядка**
свёртки, и порядок этот обязан равняться журнальному. Изменение
запечатанного часа — `WARN`, но данные всё равно пишутся.
- **Дыры закрываются сами.** `major`, обратимо. Три прохода разной глубины
(5 минут / сутки / неделя). Настройки данных у проходов теперь **разные** — намеренно, они
@@ -62,8 +65,10 @@ Module path — `git.vakhrushev.me/av/healthlog`.
образ жизни» на языке телефона, а родной экспорт — коды HealthKit, и без
словаря эти два источника не сойтись.
- **Своей агрегации в хранении нет — есть слои.** `critical`, обратимо
пересборкой. Метрика лежит в той подробности, в какой пришла (`sample`/`raw`/`minute`/`hour`); слой выводится
из выравнивания меток, а не из заголовка HAE — тот врёт.
пересборкой. Метрика лежит в той подробности, в какой пришла
(`sample`/`raw`/`minute`/`hour`/`day`); слой выводится
из выравнивания меток, а не из заголовка HAE — тот врёт. Перечень слоёв один и
лежит в [docs/database.md](docs/database.md), таблица `bucket`.
- **Агрегация в ответе — только измеренная.** `critical`, обратимо: ответ не
хранится, но потребитель уже принял по нему решение. Род свёртки выводится
сверкой слоёв между собой (часовое = сумма минутных → накопительная, = среднее →
@@ -90,7 +95,8 @@ Module path — `git.vakhrushev.me/av/healthlog`.
разбор, повтор обязан дать то же состояние. В гейт не входит намеренно —
минута прогона и данные, которых нет ни на какой другой машине
- `task verify:busy` — свёртка под удерживаемой блокировкой базы: занятость
обязана оставить доставку в очереди. В гейт не входит: 25 секунд на прогон
обязана оставить доставку в очереди, а отложенная доставка не должна развести
живую витрину с пересборкой. В гейт не входит: около 50 секунд на прогон
- `task tidy``go mod tidy`
- `task setup` — установка golangci-lint
@@ -106,12 +112,15 @@ Module path — `git.vakhrushev.me/av/healthlog`.
- **Исходы:** 0 — зелёный; ненулевой — красный, и до его починки опиниативные
проходы ревью **не запускаются**.
- **Что красит безусловно:** любой файл из `./data` в индексе, любой токен в
индексе, непокрытая изменённая строка, миграция без правки `docs/database.md`.
Причина одна на все: это ровно те отказы, которые не видны глазами и стоят
необратимо.
индексе, миграция без правки `docs/database.md`. Причина одна на все: это
ровно те отказы, которые не видны глазами и стоят необратимо. Покрытие
изменённых строк задумано тем же классом, но сегодня гейт от него **не
краснеет**: `scripts/diff-coverage.py` всегда возвращает `0`, и шаг печатает
`OK` при любом покрытии — разбор непокрытых строк остаётся человеку или
проходу ревью. Запись 2026-08-04 в [docs/review.md](docs/review.md).
- **Чего в гейте намеренно нет и кто обязан это гонять:**
`task verify:archive` (минута прогона, данные есть только на этой машине) и
`task verify:busy` (25 секунд). Гоняет их **человек или оркестратор задачи**
`task verify:busy` (около 50 секунд). Гоняет их **человек или оркестратор задачи**
перед любым изменением правила разбора, идентичности или слияния — а не «когда
вспомнит». Прецедент, когда молчащая краснота прожила две задачи, записан в
[docs/review.md](docs/review.md).
+16 -8
View File
@@ -60,19 +60,24 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по
полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже
разбираются; секции, которых разбор не покрывает, принимаются, хранятся и
честно помечаются как неразобранные.
честно помечаются как неразобранные — а имя, которого поток раньше не приносил,
даёт `WARN` в логе свёртки один раз и попадает в перечень `healthlog uncovered`.
Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
пересборка воспроизводима и повторный прогон ничего не меняет.
Первый маршрут чтения открыт: **каталог разрезов** (`GET /api/v1/metrics`) под
Маршрутов чтения открыто два. **Каталог разрезов** (`GET /api/v1/metrics`) под
токеном чтения отдаёт слои с диапазонами и измеренный род агрегации, а повтор
неизменившегося отвечает `304` по `ETag` — снимок витрины при этом не
открывается. Журнал WAL разбирается фоновым чекпойнтом по таймеру.
открывается. **Точки метрики за период** (`GET /api/v1/metrics/{name}`) едут
одним запросом: слой выбирается по охвату точек внутри периода, а род агрегации
приезжает вместе с данными и с явным указанием, применим ли он к ряду. Журнал
WAL разбирается фоновым чекпойнтом по таймеру.
Чего ещё нет: **read API точек**, тренировок и записей — сами данные наружу
пока не отдаются. План в [docs/tasks/PLAN.md](docs/tasks/PLAN.md).
Чего ещё нет: свёртки по сетке, условного запроса по точкам, тренировок и
записей наружу. Что умеет и чего не умеет —
[docs/tasks/ROADMAP.md](docs/tasks/ROADMAP.md).
Разведка формата закончена: 50 находок на живом потоке, половина расходится с
документацией Health Auto Export — [docs/research/apple-health.md](docs/research/apple-health.md).
@@ -80,9 +85,10 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
## Команды
```
healthlog serve приём + read API + MCP
healthlog serve приём + read API (MCP — в планах)
healthlog import родной экспорт Apple Health (в планах)
healthlog reindex пересборка витрины из журнала
healthlog uncovered перечень секций, которых разбор не покрыл
healthlog healthcheck проверка живости для docker HEALTHCHECK
```
@@ -152,7 +158,8 @@ curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
Если `auth.write_tokens` пуст, проверка токена выключена — для доверенной
локальной сети этого достаточно, сервис пишет об этом `write auth disabled`
на старте. Для доступа снаружи понадобится и токен, и TLS — это шаг «Деплой».
на старте. Для доступа снаружи понадобится и токен, и TLS — это цель
«Сервис доступен телефону из любой сети».
## Документация
@@ -168,7 +175,8 @@ curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
- [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/ROADMAP.md](docs/tasks/ROADMAP.md) — что приложение уже умеет и
чего ещё не умеет
- [docs/tasks/BACKLOG.md](docs/tasks/BACKLOG.md) — что брать следующим, включая
отложенные идеи
- [docs/research/apple-health.md](docs/research/apple-health.md) — что показал реальный поток
+7 -1
View File
@@ -45,13 +45,19 @@ tasks:
- go test ./internal/replay -run TestReplay -healthlog.archive={{.ARCHIVE | default (printf "%s/data/raw" .ROOT_DIR)}} -v -count=1
verify:busy:
desc: 'Свёртка под удерживаемой блокировкой базы: занятость обязана оставить доставку в очереди (около 25 секунд)'
desc: 'Свёртка под удерживаемой блокировкой базы: доставка остаётся в очереди, а витрина не расходится с пересборкой (около 50 секунд)'
cmds:
# Не входит в `task test` и `task gate` намеренно: busy_timeout — пять
# секунд, повторов транзакции пять, и гейт гоняет тесты трижды. Проверяет
# при этом центральное решение задачи «разнести ответ и свёртку»:
# занятость базы — обстоятельство, а не свойство доставки.
- go test ./internal/fold -run TestBusy -healthlog.busy -v -count=1
# Второй прогон — композиция, ради которой заведён барьер журнального
# порядка: занятость откладывает доставку, проход прекращается на ней, и
# живая витрина всё равно совпадает с пересборкой. Порознь барьер и
# сходимость проверены в гейте; вместе — только здесь, потому что
# настоящая занятость стоит те же двадцать пять секунд.
- go test ./internal/replay -run TestBusy -healthlog.busy -v -count=1
lint:
desc: Запуск golangci-lint
+3
View File
@@ -4,6 +4,7 @@
//
// healthlog [serve] --config <path> принимать пакеты (по умолчанию)
// healthlog reindex --config <path> пересобрать витрину из журнала
// healthlog uncovered --config <path> перечень секций, которых разбор не покрыл
// healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
package main
@@ -30,6 +31,8 @@ func main() {
err = runServe(args)
case "reindex":
err = runReindex(args)
case "uncovered":
err = runUncovered(args)
case "healthcheck":
err = runHealthcheck(args)
default:
+8
View File
@@ -179,6 +179,11 @@ type report struct {
// единица, которой нет в счётчиках, делает расхождение безадресным.
sourceWorkouts int64
sourceRecords int64
// sourceCategories — то же «было» для реестра категориальных значений.
// Перечень единиц хранения закрытый, и он пополняется ТЕМ ЖЕ изменением,
// которое заводит единицу: не внесённая сюда, она молчит ровно там, где
// расхождение впервые становится заметным.
sourceCategories int64
sourceBefore int64
sourceAfter int64
sourceMissing bool
@@ -237,6 +242,9 @@ func rebuild(ctx context.Context, cfg *config.Config, t target, log *slog.Logger
if rep.sourceRecords, err = src.CountRecords(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
if rep.sourceCategories, err = src.CountCategoryValues(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
}
removeDB(t.partial)
+30
View File
@@ -36,6 +36,13 @@ func writeReport(w io.Writer, r report) {
// сущностей стало слишком строгим.
p(" слияние: частично разобрано %d, несравнимых наборов %d, удержано версий сущностей %d, версий одного ключа в одном теле %d",
r.replay.Partial, r.replay.Incomparable, r.replay.EntitiesHeld, r.replay.EntitiesDiverging)
// То же и по той же причине — про точки. Удержания говорят, спорит ли ещё
// правило полноты с журналом; потери — единственное направление, в котором
// тай-брейк «побеждает пришедшая» способен унести содержание, и человек,
// принимающий по этому отчёту необратимое решение о подмене базы, обязан
// видеть оба числа, а не выводить их из совпавшего отпечатка.
p(" точки: удержано полнотой %d, содержание унесено пришедшей %d",
r.replay.PointsHeld, r.replay.PointsErased)
if r.replay.Canceled {
// Ни отпечаток пересобранной витрины, ни число доставок после прогона при
@@ -66,6 +73,7 @@ func writeReport(w io.Writer, r report) {
p(" объектов: %d", r.replay.Buckets)
p(" тренировок: %d", r.replay.Workouts)
p(" записей: %d", r.replay.Records)
p(" строк реестра категориальных значений: %d", r.replay.Categories)
p("")
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
p("не восстанавливаются: в архиве их нет.")
@@ -76,6 +84,8 @@ func writeReport(w io.Writer, r report) {
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
p(" тренировок: было %d, стало %d", r.sourceWorkouts, r.replay.Workouts)
p(" записей: было %d, стало %d", r.sourceRecords, r.replay.Records)
p(" строк реестра категориальных значений: было %d, стало %d",
r.sourceCategories, r.replay.Categories)
p("")
p(" отпечаток рабочей: %s", r.sourcePrint)
p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
@@ -89,6 +99,26 @@ func writeReport(w io.Writer, r report) {
p(" ожидаемые причины: исправленный разбор; покрытая разбором новая")
p(" секция (её единиц хранения в рабочей базе нет по построению);")
p(" признак sealed не переносится (правила его выставления ещё нет)")
if r.sourceCategories < r.replay.Categories {
// Класс назван отдельно от факта расхождения: реестр появился
// вместе с бинарём, и у витрины, свёрнутой прежним, его нет по
// построению. Не назвав это, отчёт приучает человека
// игнорировать расхождение — то есть обесценивает оракул ровно
// там, где по нему принимается необратимое решение.
//
// Условие — НЕПОЛНОТА, а не пустота. Между выкаткой и прогоном
// проходят дни: воркер успевает набрать частые значения (фазы
// сна, контекст пульса) и не успевает редкие — имя тренировки,
// которая с тех пор не повторялась. Проверка «в рабочей базе
// реестра нет вовсе» такое состояние не ловила бы, и человек
// получил бы безадресное «разошлись» при совпавших числах
// объектов, тренировок и записей.
p(" РЕЕСТР НЕПОЛОН: строк категориальных значений в рабочей базе %d,",
r.sourceCategories)
p(" в пересобранной %d — реестр наполняется по мере свёртки, а целиком",
r.replay.Categories)
p(" его даёт только пересборка. Расхождение объясняется этим и лечится ею же")
}
if partialJournal {
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
p(" объясняться этим, а не разбором")
+106
View File
@@ -337,3 +337,109 @@ func TestОтчётВсегдаНазываетУдержанныеВерсии(
}
}
}
// Реестр категориальных значений — четвёртая единица хранения витрины, и у
// витрины, свёрнутой прежним бинарём, его нет по построению. Расхождение
// отпечатков по нему одному законно, и отчёт обязан назвать это классом, а не
// оставить человека с безадресным «не совпало»: числа объектов, тренировок и
// записей при этом не меняются вовсе, а решение о подмене базы необратимо.
func TestОтчётНазываетПоявившийсяРеестр(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 116,
Outcome: replay.Outcome{Folded: 116},
Buckets: 2049, Workouts: 2, Records: 2, Categories: 11,
Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBuckets: 2049,
sourceWorkouts: 2,
sourceRecords: 2,
sourceCategories: 0,
sourceBefore: 116,
sourceAfter: 116,
})
out := buf.String()
for _, want := range []string{
"строк реестра категориальных значений: было 0, стало 11",
"РЕЕСТР НЕПОЛОН",
"лечится ею же",
} {
if !strings.Contains(out, want) {
t.Errorf("отчёт не содержит %q:\n%s", want, out)
}
}
// Сами строки реестра — данные о здоровье наравне со значением точки:
// отчёт отвечает счётом, а не перечислением.
for _, forbidden := range []string{"Во сне", "Сидячий образ жизни", "HKCategoryValue"} {
if strings.Contains(out, forbidden) {
t.Errorf("отчёт содержит наблюдённую строку %q", forbidden)
}
}
}
// Реестр рабочей витрины непуст, но неполон — штатное состояние через сутки
// после выкатки: частые значения воркер набрал, редкое имя тренировки с тех пор
// не повторялось. Класс обязан называться и здесь, иначе человек получит
// безадресное «разошлись» при совпавших числах объектов, тренировок и записей —
// и научится игнорировать строку, по которой принимает необратимое решение.
func TestОтчётНазываетНеполныйРеестр(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 116,
Outcome: replay.Outcome{Folded: 116},
Buckets: 2049, Workouts: 2, Records: 2, Categories: 11,
Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBuckets: 2049,
sourceWorkouts: 2,
sourceRecords: 2,
sourceCategories: 5,
sourceBefore: 116,
sourceAfter: 116,
})
out := buf.String()
for _, want := range []string{"РЕЕСТР НЕПОЛОН", "в рабочей базе 5", "в пересобранной 11"} {
if !strings.Contains(out, want) {
t.Errorf("отчёт не содержит %q:\n%s", want, out)
}
}
}
// Совпавший реестр отдельным классом не объявляется: иначе строка звучала бы
// при каждом прогоне и перестала бы что-либо значить.
func TestОтчётНеОбъявляетРеестрПоявившимсяБезПричины(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 10,
Outcome: replay.Outcome{Folded: 10},
Buckets: 5, Categories: 11, Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBuckets: 4,
sourceCategories: 11,
sourceBefore: 10,
sourceAfter: 10,
})
if out := buf.String(); strings.Contains(out, "РЕЕСТР НЕПОЛОН") {
t.Errorf("класс объявлен при совпавшем реестре рабочей витрины:\n%s", out)
}
}
+2
View File
@@ -20,6 +20,7 @@ import (
"git.vakhrushev.me/av/healthlog/internal/httpapi"
"git.vakhrushev.me/av/healthlog/internal/ingest"
"git.vakhrushev.me/av/healthlog/internal/logging"
"git.vakhrushev.me/av/healthlog/internal/points"
"git.vakhrushev.me/av/healthlog/internal/replay"
"git.vakhrushev.me/av/healthlog/internal/store"
)
@@ -122,6 +123,7 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
Handler: httpapi.New(httpapi.Options{
Ingest: ingest.New(arch, st, worker.Notify, log),
Catalog: catalog.New(st, log),
Points: points.New(st, log),
Log: log,
WriteTokens: cfg.Auth.WriteTokens,
ReadTokens: cfg.Auth.ReadTokens,
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"text/tabwriter"
"git.vakhrushev.me/av/healthlog/internal/config"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// uncoveredLimit — сколько строк перечня печатается по умолчанию.
//
// Предел объявлен, а не подразумевается: граница разбора в 32 имени действует на
// ОДНУ доставку, а различных имён журнал накопит сколько угодно — достаточно
// версии HAE, кладущей в ключ переменную часть. Двести взято с запасом: секций у
// HAE восемь, и перечень длиннее сотни означает не рост потока, а смену формы
// ключей — про неё скажет строка остатка.
const uncoveredLimit = 200
func runUncovered(args []string) error {
fs := flag.NewFlagSet("uncovered", flag.ContinueOnError)
cfgPath := fs.String("config", config.DefaultPath, "путь к config.toml")
limit := fs.Int("limit", uncoveredLimit, "сколько строк перечня печатать")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
// Справка — не отказ: иначе `uncovered -h` печатает usage и выходит
// со словом «fatal» и кодом 1.
return nil
}
return fmt.Errorf("parse flags: %w", err)
}
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
// Только на чтение и без наката миграций: команда диагностическая, и запуск
// её при живом сервисе не имеет права ни мигрировать схему, ни писать.
// Расхождение версий — отказ с указанием обеих, и он доезжает до кода
// возврата: молчаливый пустой перечень неотличим от «ничего не приезжало».
st, err := store.OpenForRead(cfg.Storage.DBPath)
if err != nil {
return err
}
defer func() { _ = st.Close() }()
sections, total, err := st.UncoveredSections(context.Background(), *limit)
if err != nil {
return err
}
writeUncovered(os.Stdout, sections, total)
return nil
}
// writeUncovered печатает перечень человеку.
//
// Имя секции идёт ЭКРАНИРОВАННЫМ (`%q`): оно приходит верхнеуровневым ключом
// чужого тела, обрезано по длине на разборе, но по содержимому не ограничено
// ничем — сырая печать впустила бы в терминал управляющие последовательности.
//
// Данных о здоровье здесь нет: имя секции — структурный ключ, а не измерение.
// Идентификатор доставки печатается затем, чтобы по нему достать тело из архива
// и посмотреть форму секции глазами.
func writeUncovered(w io.Writer, sections []store.UncoveredSection, total int64) {
if len(sections) == 0 {
// НЕ «журнал такого не приносил»: перечень отвечает по колонкам
// доживших учётных записей, а не по истории потока. Обещание, которое
// носитель не даёт, закрыло бы владельцу вопрос ложным ответом.
fmt.Fprintln(w, "В учётных записях журнала непокрытых секций сейчас нет.")
writeUncoveredLimits(w)
return
}
fmt.Fprintf(w, "Непокрытых секций: %d\n\n", total)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "СЕКЦИЯ\tДОСТАВОК\tПЕРВАЯ\tПОСЛЕДНЯЯ")
for _, s := range sections {
fmt.Fprintf(tw, "%q\t%d\t%s %s\t%s %s\n",
s.Name, s.Deliveries,
store.FormatTime(s.FirstSeen), s.FirstDeliveryID,
store.FormatTime(s.LastSeen), s.LastDeliveryID)
}
_ = tw.Flush()
// Остаток называется числом, а не обрывается молча: перечень — инструмент
// диагностики, и «здесь всё» против «здесь двести из тысячи» это разные
// ответы.
if rest := total - int64(len(sections)); rest > 0 {
fmt.Fprintf(w, "\nЕщё %d имён не показано.\n", rest)
}
writeUncoveredLimits(w)
}
// writeUncoveredLimits называет границы носителя — в любом исходе, включая
// пустой.
//
// Перечень производен от колонки учёта, а не от истории потока, и умолчать об
// этом значило бы отдать владельцу ответ, которого носитель не даёт: пустой
// перечень он прочитал бы как «ничего не приезжало» и закрыл бы вопрос.
func writeUncoveredLimits(w io.Writer) {
fmt.Fprint(w, `
Перечень собран по колонке учёта `+"`delivery.uncovered_sections`"+`, и границ у неё три:
- имена сверх 32 на одну доставку разбор в неё не кладёт;
- пересборка заполняет колонку заново и только по сохранившимся телам;
- секция, которую разбор научился покрывать, уходит из перечня при пересвёртке.
`)
}
+298
View File
@@ -0,0 +1,298 @@
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func at(t *testing.T, s string) time.Time {
t.Helper()
v, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("метка %q: %v", s, err)
}
return v.UTC()
}
// Перечень — инструмент диагностики, и границы встреч в нём нужны затем, чтобы
// достать тело из архива по идентификатору доставки.
func TestПереченьНазываетГраницыВстреч(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeUncovered(&buf, []store.UncoveredSection{{
Name: "ecg",
Deliveries: 3,
FirstSeen: at(t, "2026-08-01T10:00:00Z"),
FirstDeliveryID: "01AAA",
LastSeen: at(t, "2026-08-02T11:00:00Z"),
LastDeliveryID: "01BBB",
}}, 1)
out := buf.String()
for _, want := range []string{"ecg", "3", "2026-08-01T10:00:00Z", "01AAA", "2026-08-02T11:00:00Z", "01BBB"} {
if !strings.Contains(out, want) {
t.Errorf("в выводе нет %q:\n%s", want, out)
}
}
}
// Пустой перечень говорит о себе словами: молчаливый пустой вывод неотличим от
// «команда ничего не сделала».
func TestПустойПереченьНазванСловами(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeUncovered(&buf, nil, 0)
if strings.TrimSpace(buf.String()) == "" {
t.Error("пустой перечень напечатал пустоту")
}
// И не обещает того, чего носитель не даёт: колонка отвечает про дожившие
// учётные записи, а не про историю потока.
if strings.Contains(buf.String(), "не приносил") {
t.Errorf("пустой перечень говорит за весь поток:\n%s", buf.String())
}
}
// Границы носителя называются в любом исходе: пустой перечень без них владелец
// прочитает как «ничего не приезжало» и закроет вопрос.
func TestГраницыНосителяНазваныВОбоихИсходах(t *testing.T) {
t.Parallel()
rows := []store.UncoveredSection{{
Name: "ecg", Deliveries: 1,
FirstSeen: at(t, "2026-08-01T10:00:00Z"), FirstDeliveryID: "01AAA",
LastSeen: at(t, "2026-08-01T10:00:00Z"), LastDeliveryID: "01AAA",
}}
for name, sections := range map[string][]store.UncoveredSection{
"пустой": nil,
"непустой": rows,
} {
var buf bytes.Buffer
writeUncovered(&buf, sections, int64(len(sections)))
if !strings.Contains(buf.String(), "uncovered_sections") {
t.Errorf("%s перечень не назвал носителя:\n%s", name, buf.String())
}
if !strings.Contains(buf.String(), "32") {
t.Errorf("%s перечень не назвал границу списка:\n%s", name, buf.String())
}
}
}
// Имя приходит верхнеуровневым ключом чужого тела: длина ограничена разбором,
// содержимое — ничем. Сырая печать впустила бы в терминал оператора управляющие
// последовательности.
func TestИмяСекцииЭкранируется(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeUncovered(&buf, []store.UncoveredSection{{
Name: "ecg\x1b[31m\nfake",
Deliveries: 1,
FirstSeen: at(t, "2026-08-01T10:00:00Z"),
FirstDeliveryID: "01AAA",
LastSeen: at(t, "2026-08-01T10:00:00Z"),
LastDeliveryID: "01AAA",
}}, 1)
out := buf.String()
if strings.Contains(out, "\x1b") {
t.Errorf("управляющий байт доехал до терминала:\n%q", out)
}
// Строка перечня обязана остаться одной: перевод строки из имени разорвал
// бы её надвое, и вторая половина читалась бы как отдельная секция.
var rows int
for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
if strings.HasPrefix(line, `"`) {
rows++
}
}
if rows != 1 {
t.Errorf("строк перечня %d, ожидалась одна:\n%q", rows, out)
}
if !strings.Contains(out, `\n`) {
t.Errorf("перевод строки в имени не экранирован:\n%q", out)
}
}
// Остаток называется числом: «здесь всё» и «здесь двести из тысячи» — разные
// ответы, и молчаливый обрыв делает их неотличимыми.
func TestОстатокПеречняНазванЧислом(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeUncovered(&buf, []store.UncoveredSection{{
Name: "ecg",
Deliveries: 1,
FirstSeen: at(t, "2026-08-01T10:00:00Z"),
FirstDeliveryID: "01AAA",
LastSeen: at(t, "2026-08-01T10:00:00Z"),
LastDeliveryID: "01AAA",
}}, 5)
if !strings.Contains(buf.String(), "4") {
t.Errorf("остаток не назван числом:\n%s", buf.String())
}
}
// Базы по указанному пути нет — отказ с причиной и ненулевым кодом. Пустой
// перечень здесь был бы ложью: «ничего не приезжало» и «смотреть не во что» —
// разные ответы.
func TestОтсутствиеБазыДаётОтказ(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.toml")
cfg := "[server]\naddr = \":8080\"\ningest_token = \"t\"\nread_token = \"r\"\n" +
"[storage]\ndb_path = \"" + filepath.Join(dir, "нет.db") + "\"\n" +
"raw_dir = \"" + filepath.Join(dir, "raw") + "\"\n"
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatalf("конфиг: %v", err)
}
if err := runUncovered([]string{"--config", cfgPath}); err == nil {
t.Error("команда на несуществующей базе завершилась успехом")
}
}
// Сквозной прогон: перечень, собранный командой, сходится с тем, что посчитано
// по ТЕЛАМ архива независимо от её кода.
//
// Оракул строится от тел намеренно: сверка вывода с `SELECT DISTINCT` по той же
// колонке тем же `json_each` доказывала бы только согласие кода с самим собой —
// и молчала бы обо всём, что команда добавляет сверх множества имён.
//
// Не параллельный: подменяет `os.Stdout`.
func TestПереченьСходитсяСТеламиАрхива(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "healthlog.db")
rawDir := filepath.Join(dir, "raw")
arch, err := archive.New(rawDir)
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("база: %v", err)
}
svc := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))
body, err := os.ReadFile(filepath.Join("..", "..", "internal", "hae", "testdata", "uncovered_sections.json"))
if err != nil {
t.Fatalf("тело: %v", err)
}
want := uncoveredInBody(t, body)
if len(want) == 0 {
t.Fatal("в теле нет непокрытых секций — проверять нечего")
}
ctx := context.Background()
for _, id := range []string{"d1", "d2"} {
at := store.Now()
rawPath, err := arch.Write(id, at, body)
if err != nil {
t.Fatalf("запись в архив: %v", err)
}
err = st.CreateDelivery(ctx, store.Delivery{
ID: id, ReceivedAt: at, AutomationID: "a1", Aggregation: "Minutes",
Bytes: int64(len(body)), SHA256: "-", RawPath: rawPath,
ParseStatus: store.ParsePending,
})
if err != nil {
t.Fatalf("учёт доставки: %v", err)
}
if _, err := svc.Fold(ctx, id); err != nil {
t.Fatalf("свёртка %s: %v", id, err)
}
}
// База закрывается до команды: та открывает её сама, только на чтение.
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
cfgPath := filepath.Join(dir, "config.toml")
cfg := "[storage]\ndb_path = \"" + dbPath + "\"\narchive_dir = \"" + rawDir + "\"\n"
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatalf("конфиг: %v", err)
}
out := captureStdout(t, func() {
if err := runUncovered([]string{"--config", cfgPath}); err != nil {
t.Fatalf("команда: %v", err)
}
})
for _, name := range want {
if !strings.Contains(out, name) {
t.Errorf("в выводе нет секции %q, которая есть в теле:\n%s", name, out)
}
}
// Обе доставки принесли одно и то же тело, значит у каждой секции ровно две
// доставки, а границы — первая и последняя.
if !strings.Contains(out, " 2 ") && !strings.Contains(out, "\t2\t") {
t.Errorf("число доставок в выводе не 2:\n%s", out)
}
if !strings.Contains(out, "d1") || !strings.Contains(out, "d2") {
t.Errorf("границы встреч не названы обеими доставками:\n%s", out)
}
}
// uncoveredInBody считает непокрытые секции ПО ТЕЛУ, не трогая разбор: ключи
// `data` минус три покрытых имени.
func uncoveredInBody(t *testing.T, body []byte) []string {
t.Helper()
var envelope struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("тело не разбирается: %v", err)
}
covered := map[string]bool{"metrics": true, "workouts": true, "stateOfMind": true}
var out []string
for name := range envelope.Data {
if !covered[name] {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// captureStdout ловит пользовательский вывод команды.
func captureStdout(t *testing.T, run func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("канал: %v", err)
}
saved := os.Stdout
os.Stdout = w
defer func() { os.Stdout = saved }()
run()
_ = w.Close()
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("чтение вывода: %v", err)
}
return buf.String()
}
+1 -1
View File
@@ -26,7 +26,7 @@ write_timeout = "30s" # на отправку ответа прочих ма
# входе, открытое чтение — выгрузку всей истории здоровья любому, кто нашёл
# порт. Перед выкладкой наружу `read_tokens` обязан быть непуст.
write_tokens = [] # токены на приём данных
read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics`
read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics` и точки `GET /api/v1/metrics/{name}`
[storage]
# ВНИМАНИЕ: умолчания в коде (./healthlog.db и ./raw) остались от прежней
+1 -1
View File
@@ -1,4 +1,4 @@
{
"canon": 1,
"canon": 4,
"migrations": "internal/store/migrations"
}
@@ -0,0 +1,64 @@
# Код HealthKit кладётся реестром рядом, а не полем внутри точки
- **Дата:** 2026-08-03
- **Источник:** openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/design.md
## Решение
Стабильный код HealthKit для локализованной строки хранится **отдельной строкой
таблицы `category_value`** с ключом `(метрика, поле, значение)`, а не полем
`value_code` внутри точки, как рисовал `architecture.md`. Словарь и таблица
синонимов живут в бинаре (`internal/healthkit`), а не в базе. Наблюдение входит
в отпечаток витрины, выведенный код — **нет**.
## Почему
Рассматривались три формы, и отвергнутые названы вместе с ценой.
**Поле внутри точки** — отвергнуто. Цитата источника: «Точка хранится
**исходными байтами**; дописать в неё ключ можно только пересериализацией, а она
теряет литерал (`1.0``1`, целые больше 2^53 сдвигаются, невалидный UTF-8 →
U+FFFD) — ровно то, от чего `Point.Raw` защищает. Побайтовая врезка в чужой
JSON — фокус, а не решение. Параллельный массив кодов в `bucket` завёл бы
производную величину в путь слияния и хеширования: правило полноты, тай-брейк и
`content_hash` пришлось бы учить носить код, не давая ему влиять на исход.
Правка на поверхности `critical`-инвариантов ради нуля новых сведений — код есть
**функция** от того, что уже лежит».
**Код нигде не хранится, выводится на чтении** — отвергнуто по одной причине:
«тогда код недостижим ничем, кроме бинаря. Владелец сегодня читает витрину
`sqlite` на хосте (`Read API` ещё нет), а вся задача затевается против того, что
„клиент угадывает словарь“. Реестр без кода сообщает только „такая строка
была“ — это половина ответа».
**Словарь в базе, а не в бинаре** — отвергнуто: «словарь стал бы входом,
которого нет в журнале, и `import + replay` перестал бы задавать состояние
однозначно. `stateOfMind` уже единственная дыра в журнале; вторую заводить
незачем».
**Код вне отпечатка** — обратная сторона того же решения: «Ключ и провенанс —
функция журнала; `code` — функция журнала **и версии словаря в бинаре**. Включи
его в отпечаток, и он перестал бы отвечать на свой единственный вопрос („дал ли
повтор журнала то же состояние“) ровно тогда, когда его задают: всякое
пополнение словаря — а оно объявлено рабочим циклом — давало бы расхождение при
побайтно совпавшем журнале, и человек, принимающий необратимое решение о
подмене базы, читал бы это как дефект».
Prior art: FHIR `ConceptMap` (отображение «чужая система значений → своя») и
`CodeSystem` с `replaced-by` для устаревших имён — те же два отношения,
разведённые по разным сущностям. Форма взята, реализация FHIR отвергнута ценой.
## Последствия
- `+` Инвариант «точки хранятся дословно» не тронут вовсе: точка не меняется ни
байтом, обратное преобразование возможно всегда.
- `+` Пути слияния, тай-брейка и `content_hash` не знают о кодах — правки на
поверхности `critical`-инвариантов не потребовалось.
- `+` Пополнение словаря меняет десяток строк реестра, а не каждый объект с
фазами сна; отпечаток при этом не двигается, потому что код в него не входит.
- `` Потребитель обязан делать соединение по `(метрика, поле, значение)` вместо
чтения одного поля. Форма ответа Read API это скроет, когда он появится.
- `` Код в базе отстаёт от словаря в бинаре для строк, переставших приезжать.
Лечится пересборкой; на сходимость не влияет.
- `` Ключ реестра зафиксирован миграцией `00010`: смена формы ключа стоит
второй миграции и пересборки.
@@ -0,0 +1,126 @@
# Форма провода принадлежит транспорту, а не домену
- **Дата:** 2026-08-04
- **Источник:** openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md
## Решение
Публичный контракт читающих маршрутов объявляет транспорт: `internal/httpapi`
держит собственные типы с `json`-тегами и переводит в них доменное значение
присваиванием поле в поле. Доменные типы (`internal/catalog` и далее) тегов не
несут и до сериализации не доезжают. То же правило покрывает тело отказа; MCP
собственной формы не объявляет.
Противоположное решение — **доменные типы объявлены формой провода намеренно**
рассмотрено первым как живая и уважаемая практика и отвергнуто по названной
причине.
## Почему
Каталог до этого изменения жил вторым способом: `internal/catalog` сам нёс
`json`-теги и `Style.MarshalJSON`, а транспорт владел только оболочкой
`{"metrics": …}`. Отсюда три пути смены **публичного** контракта, ни один из
которых не касается транспорта и все три выглядят как внутренняя правка:
переименование поля; разъединение встроенного `Basis` (плоскость объекта
`aggregation` была следствием встраивания); появление внутреннего поля.
Удерживал контракт один литерал в тесте, и о том, что этот литерал и есть
контракт, не было сказано нигде.
Решение принималось до того, как образец скопируют четыре маршрута и MCP —
потом это была бы не развилка, а археология.
Литература расколота, и обе стороны названы в источнике поимённо: домен = провод
у Ben Johnson (`benbjohnson/wtf` — доменные типы корневого пакета несут теги
напрямую) и у Prometheus (`web/api/v1` — конверт свой, полезная нагрузка
доменная); раздельно у Gitea (`modules/structs` против `models`), Docker
(`api/types`), go-kit (service → endpoint → transport) и Kubernetes (internal
против версионированных `k8s.io/api` плюс кодогенерируемая конверсия).
Ортогональный совет Mat Ryer — объявлять типы ответа рядом с их обработчиком —
взят вместе с названной им ценой.
Развилку решил **факт проекта, а не вкус**. Цитата из источника:
> Правило «доменный тип и есть форма провода» ломается на втором же маршруте
> цели. Провод точек обещан как `{ts, tz_offset, units, values}`
> (`docs/architecture.md`, раздел «Форма ответа»), а `store.Point` несёт
> `{Start, End, OffsetSeconds, Raw}` — эти два набора не совпадают **ни одним
> именем**. Доменный тип формой провода там быть не может даже при желании.
Второй факт — внутренний прецедент, и он в ту же сторону:
> Хранилище уже применяет ровно предлагаемое решение. `store.Point` не несёт
> `json`-тегов вовсе; формат сжатого `payload` объявлен **отдельным
> неэкспортированным** типом `storedPoint`, а `encodePayload` переводит одно в
> другое **полем в поле**.
Плюс `internal/httpapi/ingest.go`, который своим типом ответа владел с самого
начала. То есть решение **устраняет** второй способ, а не заводит его: каталог
был отклонением от уже принятого в проекте образца.
Отдельная развилка того же изменения — **чем контракт сторожится**, и там тоже
есть поимённый отказ:
> `golang.org/x/exp/apidiff` и `go-apidiff` отвергнуты, и причина измерима: они
> сравнивают **Go-API** на предмет компилируемости клиентского кода. Смена
> строки тега (`json:"metric"` → `json:"name"`) при неизменном Go-имени поля для
> них — не изменение вовсе. То есть ровно тот класс, ради которого заводится
> сторож, они не видят.
Генерация OpenAPI из кода (`swaggo`) отвергнута как сторож по другой причине —
она фотографирует уже случившееся, — но не как способ **опубликовать** контракт:
владелец решил в этом же спринте, что источником истины будет рукописная
OpenAPI-спека. Байтовое утверждение поэтому названо **детектором изменения**, а
не контрактом.
## Последствия
- `+` Публичный контракт чтения перестал быть побочным эффектом имён полей
домена. Переименование поля домена ломает компиляцию перевода — разработчику
говорят в момент правки; байты ответа при этом те же (проверено: сборка
базовой ревизии и сборка ветки против одного файла базы дали побайтово
идентичные 2268 байт).
- `+` Появился машинный сторож: обход графа типов ответа утверждает, что ни один
тип домена не достигает сериализации, а требование `json`-тега на каждом
экспортированном поле транспортной структуры закрывает калитку
`type pointWire store.Point`. Рядом — заведомо красный случай на 13 позиций,
потому что проверка, доказывающая отсутствие, зелена и будучи сломанной.
- `+` Плоскость объекта `aggregation` перестала быть следствием встраивания
`Basis` в домене и стала записанным решением транспорта.
- `` **Цена обратная, и она взята сознательно:** новое поле домена в ответ само
не попадёт — его обязан перечислить перевод. Поле, не доехавшее до клиента, —
такой же дефект, как поле, уехавшее случайно, просто другой.
- `` Форма объявлена дважды: типы плюс перевод на каждый маршрут.
- `` Словарь рода остался в домене (`Style.String()`), и провод зовёт его же.
Правка `String()` ради читаемости лога изменит тело ответа клиенту. Из двух
цен взята эта: свой `switch` на проводе сторожил бы лучше, но завёл бы второй
словарь, который разошёлся бы с первым молча.
- `` Сторож остаётся **opt-in**: маршрут, забывший строку в таблице образцов,
останется без него молча. Развилка вынесена владельцу (см. ниже).
- `` Обход слеп к типам, достижимым только через `any`/интерфейс, и к типам
внешних зависимостей. Слепота названа в источнике и воспроизведена замером,
а не предположена.
## Открыто, решает владелец
Записано здесь, а не в файле задачи: файл закрытой задачи удаляется.
**Проверять ли полноту таблицы образцов машиной.** Сторож покрывает три типа,
идущие через `writeJSON` сегодня; впереди четыре маршрута и MCP — четыре шанса
забыть строку, и забытая строка не отличима от отсутствия проблемы.
- **(а)** обход роутера (`chi.Walk`) с утверждением, что число читающих
маршрутов равно числу строк таблицы. Около 15 строк, забывание краснеет; цена
— сцепка теста с роутером. **Рекомендация:** это ровно тот класс «проверка
отсутствия зелена и будучи сломанной», против которого это же изменение завело
конвенцию заведомо красного случая, — а на полноту таблицы конвенция не
распространена.
- **(б)** тестовый hook в `writeJSON`, собирающий типы реально закодированных
ответов. Ноль мест на новый маршрут, но шов в продакшн-коде.
- **(в)** оставить на спеке `read-api` и комментарии-образце. Ноль строк сейчас,
одна молчащая дыра на каждый забытый маршрут.
**Что в проекте считается спекой — контракт системы или ещё и дисциплина его
смены.** Здесь развилка разрешена в сторону «спека нормирует наблюдаемое,
дисциплина живёт в конвенциях»: этот выбор дешевле откатить, и у второго
варианта нет предмета для сверки «спека → код». Прецедент задан на четыре
следующие задачи цели — если владелец решит иначе, переносить придётся их все.
@@ -0,0 +1,52 @@
# Новизна имени секции выводится из журнала, а не хранится реестром
- **Дата:** 2026-08-04
- **Источник:** openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/design.md
## Решение
Признак «имя непокрытой секции встречено впервые» **не хранится**: он считается
запросом к журналу — «встречалось ли имя в доставках, стоящих строго раньше этой
по паре `(received_at, id)`». Реестр-таблица по образцу `category_value`
очевидный ответ на тот же вопрос, уже применённый в этом проекте, — отвергнут.
## Почему
Цитата из источника:
> Форма ответа взята у `category_value` — «когда имя встретилось впервые по
> журналу», — а носитель другой: факт уже лежит в `delivery.uncovered_sections`.
> Реестр здесь не добавляет ни одного сведения, он кэш запроса, а запрос идёт
> считанные разы за жизнь имени.
И там же, о цене реестра:
> Компромисс: вторая копия факта, обязанная сходиться с колонкой при каждой
> пересборке, плюс миграция и новая единица хранения витрины (а значит и
> отпечатка). Ноль новых сведений: имя выводимо из журнала.
Третья рассмотренная форма — множество виденных имён в памяти процесса —
отвергнута по инварианту «хранилище есть свёртка по журналу»: состояние стало бы
функцией жизни процесса, и живой приём разошёлся бы с пересборкой в том, что
считает первой встречей.
## Чем платим
Ценой названы три вещи, и все они следствия выбранного носителя:
- **проход по журналу** на каждой доставке с непокрытыми секциями. Измерено на
синтетическом журнале годового объёма: у секции, приезжающей давно, ранний
выход даёт десятки микросекунд, у появившейся только что — около 52 мс на
доставку, пока её не покроет отдельная задача;
- **границы носителя наследуются целиком**: имя, вытесненное границей списка в
32 имени, события не даёт вовсе; пересборка заполняет колонку заново и только
по сохранившимся телам; покрытая разбором секция уходит из перечня;
- **история не переживает удаления тел.** Ретеншен, срезающий архив, унесёт с
собой и записи о непокрытых секциях за те же периоды.
## Когда пересматривать
Последнее и есть условие пересмотра, названное заранее: **задаче ретеншена
архива реестр понадобится** — именно затем, чтобы история пережила удаление тел,
и тогда это уже другая цена, а не вторая копия факта. Запрет реестра в спеке
`uncovered-sections` — решение этого изменения, а не запрет навсегда.
@@ -0,0 +1,85 @@
# Ответ точек несёт измеренный род и его применимость к отданному ряду
- **Дата:** 2026-08-04
- **Источник:** openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md
## Решение
Конверт ответа маршрута точек несёт `aggregation` **объектом**
`{style, applicable, last_hour}`, а не строкой с применённой свёрткой:
- `style` — измеренный род метрики, тот же словарь и то же имя, что у каталога;
- `applicable` — применим ли объявленный род к **отданному ряду**;
- `last_hour` — ярлык самого свежего часа окна измерения.
`docs/architecture.md` до этого изменения обещал `"aggregation": "sum"`
строку. Решение её **пересматривает**: строка называет применённое и молчит об
основании.
Отвергнуто и названо поимённо: поле `applied` с именем применённой свёртки
(выводится из `style` и `bucket` тем же инвариантом; как строка неверно
описывает свёртку мгновенной метрики, у которой по архитектуре «среднее с
`min`/`max` рядом»); полное основание каталога (`hours`, `compared`, `agreeing`,
`conflicting`, `first_hour`) в конверте точек — второй экземпляр факта, обязанный
сходиться с первым.
## Почему
**Род есть свойство метрики, а слой — свойство ряда, и их сочетание бывает
опасным.** Конверт `{"layer": "raw", "style": "cumulative"}` законен и штатен:
правило выбора слоя при равном охвате предпочитает самый мелкий. Инвариант
«нижний слой HAE не суммируется никогда» система соблюдает, ничего не складывая,
— но потребитель об инварианте не знает, а сумма по нижнему слою завышает втрое
(находка 34 разведки). Разрыв построен проходом `review-rubric` на предложении,
до кода:
> Конверт `{"layer": "raw", "aggregation": {"style": "cumulative"}}` законен,
> штатен — и он прямо приглашает главного потребителя (агента с ограниченным
> контекстом) сложить ряд самому. Система при этом свёртки не делает, инвариант
> формально цел; результат у потребителя завышен, а решение по нему уже принято.
`applicable: false` — та самая оговорка, которая едет вместе с данными.
**`last_hour` — единственный след замершего окна.** Род считается по 48 самым
свежим **общим** часам, а не по последним 48 часам календаря: выключенная
минутная автоматизация HAE останавливает пополнение общих часов, окно замирает и
продолжает объявлять род.
**Литература расколота, и обе стороны названы.** Род **вместе с данными**:
Google Cloud Monitoring объявляет `metricKind` и `valueType` в каждом объекте
`TimeSeries` ответа, а не только в дескрипторе метрики; CloudWatch
`GetMetricData` кладёт `StatusCode` (`Complete` / `PartialData`) рядом с рядом —
оговорка едет с данными, а не оставляется клиенту на вывод; Home Assistant
`statistics_during_period` держит `start` и `end` в ответе **всегда**,
независимо от запрошенных `types`. Род **отдельно от данных**: Prometheus отдаёт
`{resultType, result}` без единого слова о типе, а тип живёт в
`/api/v1/metadata`; Graphite render не объявляет ничего. Второе отвергнуто по
измеримой причине: клиент обязан сделать второй запрос, а до тех пор не
отличает «род известен» от «род не измерен», — и согласованности между двумя
ответами всё равно нет, потому что род есть функция **окна**, а окно едет с
часами. Принцип HealthKit `HKStatistics` («род не тот — свёртки нет») взят,
механизм неприменим: у нас стиль источником не объявлен.
## Последствия
- `+` Потребитель видит не только число, но и на каком основании его можно
сворачивать, без второго запроса и без знания инвариантов проекта.
- `+` Форма объявлена **до** того, как её скопируют свёртка по сетке, порог
неполного ведра, тренировки, записи и MCP. После копирования это была бы не
развилка, а археология.
- `` Поле `applicable` избыточно по построению: клиент, знающий правило «нижний
слой HAE не суммируется», вывел бы его из `style` и `layer`. Взято сознательно
— правило принадлежит нам, и молчаливо перекладывать его на потребителя
дороже, чем поле.
- `` Чтобы разобрать, **почему** род `unknown`, придётся спросить каталог:
полное основание живёт там в одном экземпляре.
- `` Род в конверте точек и род в каталоге считаются в разные моменты и у
клиента, сравнивающего два ответа, могут разойтись. Это свойство измерения, а
не дефект; ровно поэтому `last_hour` едет вместе с родом.
## Открыто, решает владелец
**Машинно-различимый код причины отказа.** Тело отказа несёт только
человекочитаемую строку, и агент не отличит «зона не указана» от «слой
незнаком» иначе, чем разбором русского текста. Правило общее для всех маршрутов
и меняет `errorWire`, то есть и контракт приёма, — сюда не взято.
@@ -0,0 +1,80 @@
# Слой ответа выбирается по охвату точек внутри периода
- **Дата:** 2026-08-04
- **Источник:** openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md
## Решение
Слой, из которого собирается ряд, выбирается так:
> Охват слоя — длина пересечения отрезка `[первая метка слоя, последняя метка
> слоя]` с запрошенным периодом. Слой с пустым пересечением выбывает. Среди
> оставшихся берётся слой с наибольшим охватом, при равенстве — самый мелкий
> (`sample` → `raw` → `minute` → `hour` → `day`).
Это **пересмотр** прежнего правила, записанного в `docs/architecture.md`: «самый
мелкий слой, покрывающий весь запрошенный диапазон».
## Почему
**Прежняя формулировка неопределена на входе, который тот же документ объявляет
законным.** Границы слоя — границы **данных**, а не обещание покрытия: внутри
диапазона законно есть дыры, и слоя, покрывающего диапазон целиком, может не
существовать вовсе. Правило, не определённое на законном входе, реализатор
доопределяет молча.
**Мера — охват, а не число точек.** `body_mass` в нижнем слое за три плотных дня
даёт больше объектов, чем часовой слой за год с еженедельным взвешиванием: по
числу точек «вес за год» вернул бы три дня, не сказав об этом ни словом.
**Охват меряется метками точек, а не часами объектов**, и это не придирка.
Объекты адресуются часом, поэтому выборка обязана быть шире запроса (точка
`10:59` живёт в объекте `10:00`), а ряд отбирается точной меткой. Путь построен
проходом ревью на предложении:
> `from = 10:30`, `to = 10:45`. Слой `hour` имеет объект `10:00` с единственной
> точкой в `10:00`, слой `minute` — объект `10:00` с точками `10:31…10:44`. По
> часам объектов охваты равны, побеждает `hour` — и после точного отбора ответ
> уходит пустым при непустых минутных данных.
Класс общий: **предикат выбора источника и предикат отбора данных обязаны
использовать одну границу**.
**Цена меры измерена, и она не нулевая.** Индекс `bucket_catalog` идёт
`(metric, layer, hour_utc, …)`, и без предиката по слою SQLite не сужает поиск по
`hour_utc` — он просматривает все строки метрики за всю историю, а план при этом
выглядит успешным (`SEARCH … USING COVERING INDEX`). Замер эксплуатационного
прохода на копии схемы: 2.06 мс при 52 560 строках метрики против 13.9 мс при
350 400, то есть цена росла бы вместе с возрастом сервиса при любой ширине
запроса. С явным перечислением слоёв — 0.026 мс. Отсюда же следствие: **словарь
слоёв один** (`hae.Layers`), из него выводятся и порядок, и перечень выборки, и
проверка параметра запроса, и текст отказа клиенту.
## Последствия
- `+` Правило определено на любом входе, включая тот, где ни один слой периода
не покрывает.
- `+` Смены слоя внутри одного ответа не бывает: ряд, склеенный из двух слоёв,
поехал бы незаметно для клиента, а вместе с ним поехала бы и будущая свёртка.
- `` Правило **максимизирует** размер ответа: при равном охвате берётся самый
мелкий слой, то есть «пульс за неделю» без параметров это сотни тысяч точек.
Предел ответа — соседняя задача; цена измерена и названа (см. ниже).
- `` Краевой объект, у которого есть точки и до, и после периода, но ни одной
внутри, свой слой из выбора не выведет. Остаток узкий и честный: слой в ответе
назван, а `points` пуст.
## Открыто, решает владелец
**Инвертировать ли умолчание при равном охвате.** Сегодня берётся самый мелкий —
это правило `architecture.md` до пересмотра, и оно максимизирует размер ответа.
Измерено на этом маршруте: неделя нижнего слоя — 604 800 точек, 1.75 с и
1375 МиБ суммарных выделений на доменном слое; под HTTP вместе с сериализацией —
2.89 с, 279.7 МиБ тела, 1335 МиБ живой кучи; четыре одновременных запроса дают
4322 МиБ.
- **(а)** оставить как есть, предел вводит `read-api-response-limit`;
- **(б)** при равном охвате брать самый **крупный** слой, мелкий — только по
явному `layer`.
**Рекомендация:** (а). Решение сцеплено с формой предела, и принимать его
мимоходом на первой ручке — то же, от чего отказались на каталоге.
@@ -0,0 +1,119 @@
# Тай-брейк точек — порядок журнала, а не хранимая метка
- **Дата:** 2026-08-04
- **Источник:** openspec/changes/archive/2026-08-04-tie-break-equal-completeness/design.md
## Решение
При равной полноте побеждает точка, пришедшая разбираемой доставкой. Правило
слияния точек тем самым перестаёт быть функцией множества и становится **явной
функцией порядка журнала**; за это платится приведением порядка живой свёртки к
журнальному. Хранимая метка провенанса у точки — очевидный ответ на тот же
вопрос — отвергнута по цене.
## Почему
Байтовый порядок канонических форм, стоявший тай-брейком прежде, оказался не
крайним разрядом правила, а главным: перемер на живом корпусе дал 80 129 спорных
координат, из которых полнота отбрасывает кого-то лишь в 981 (1,2%), а 79 148
(98,8%) решает тай-брейк. И решает измеримо неверно — берёт меньшее значение в
1 847 случаях из 1 912, то есть системно хранит версию, которую источник уже
пересчитал. Ценой этого час `2026-08-03T07:00Z` метрики `step_count` остался
недосчитанным, сверка слоёв объявила метрику мгновенной против 23 согласных
часов, и род ушёл в `unknown`.
Готовое решение известно и рассмотрено первым. Цитата из источника:
> Регистр «последняя запись побеждает» (LWW-Register, Shapiro et al.,
> «A comprehensive study of Convergent and Commutative Replicated Data Types»,
> INRIA RR-7506) сходится **только** потому, что метка времени хранится
> **вместе со значением**: слияние сравнивает две метки, а не «кто пришёл
> вторым». Без хранимой метки то же правило вырождается в last-writer-wins по
> порядку применения — а он у реплик разный, и сходимости нет. Ровно это и
> означает «не полурешётка».
>
> Взять готовое целиком нельзя: хранимая метка — это колонка провенанса на
> точку, то есть смена формата `payload` и миграция, которые постановка
> запрещает. Отвергнуто **с названной причиной**, и причина не «нам не
> подходит», а «цена выше разрешённой рамки».
Что взято вместо метки — вывод той же литературы о плате за отказ от неё:
> Если состояние не решётка, сходимость обеспечивается **единственным
> детерминированным порядком применения операций** — это уже не CRDT, а
> конвейер репликации с журналом (state machine replication: Schneider,
> «Implementing fault-tolerant services using the state machine approach», и то
> же в Raft/Kafka log-compaction). Требование там одно и оно жёсткое: все
> потребители применяют журнал в одном порядке.
Внутренний прецедент сильнее внешнего и решён иначе: слияние сущностей ту же
развилку прошло и выбрало хранимую позицию журнала `(received_at, id)`, прямо
отвергнув «побеждает приехавшая». Разница не в намерении, а в том, что у
сущности колонка провенанса есть, а у точки нет. Критерий выбора между двумя
механизмами записан в `docs/architecture.md`, раздел «Разрешение столкновений».
Значение точки и род метрики в правило не входят намеренно: «брать бо́льшее»
неверно для мгновенных метрик, которые источник досчитывает вниз, а род есть
функция витрины — правило, читающее собственную выдачу, перестаёт быть функцией
префикса журнала (тот же дефект уже ловили на наследовании слоя «из будущего»).
## Последствия
- `+` `step_count` вернул род (`cumulative`, ноль противоречащих часов), заодно
вернулся `headphone_audio_exposure` (`instant`); общий станок
`task verify:archive` из красного стал зелёным.
- `+` Систематический недосчёт на 75 494 координатах прекращён (95% из них —
`basal_energy_burned` слоя `raw`).
- `` Правило больше не коммутативно: содержимое витрины стало функцией порядка
свёртки. Живой порядок приведён к журнальному барьером — проход воркера
прекращается на первой отложенной занятостью доставке, — но голова очереди
теперь блокирует хвост.
- `` Остаточное окно конкурентного приёма (строка учёта видна позже метки)
закрыть без изменения приёма нельзя; оно сделано наблюдаемым (`WARN`) и
оставлено вопросом владельца в `docs/tasks/items/journal-order-on-ingest.md`.
- `` Появилось направление, в котором правило теряет содержание: разряд полноты
гаснет при разошедшихся значениях общих ключей, и пришедшая точка может унести
ключ сохранённой. Замерено — 2 координаты из 80 129 спорных; вместо запрета
заведён счётчик и `WARN`, тем же решением и по той же причине, по какой
отложено объединение полей.
- `` Восстановление коммутативности «для чистоты» молча откатит починку.
Поэтому запрет записан нормативно в спеке хранения, а формулировки во всех
документах приведены к «функция множества **и позиции в журнале**».
- `` Живая витрина в `./data` расходится с новым правилом до пересборки:
подмена файла базы — необратимое действие человека и этим изменением не
выполняется.
## Открыто, решает владелец
Записано здесь, а не в файле задачи: файл закрытой задачи удаляется, а эти два
решения переживают её.
**1. Пересобирать ли живую витрину сейчас.** Правило действует только вперёд:
уже сохранённые часы держат значение прежнего, измеримо смещённого правила, пока
витрину не пересоберут, — это 75 494 координаты (95% — `basal_energy_burned`
слоя `raw`). До пересборки сверка отпечатков с `healthlog reindex` не сойдётся и
будет выглядеть отказом.
- **(а)** `reindex` с остановкой сервиса и подменой базы сразу после выкладки.
Цена: минута простоя приёма на нынешнем архиве плюс необратимое действие
руками. **Рекомендация.**
- **(б)** отложить до планового окна, приняв расхождение витрины на этот срок.
- **(в)** не пересобирать: витрина сойдётся только по координатам, которые
переприедут доставками, — смещение останется в истории навсегда.
**2. Не сузить ли тай-брейк там, где он теряет содержание.** Разряд полноты
гаснет при разошедшихся значениях общих ключей, и тогда пришедшая точка
побеждает, даже если унесёт ключ, которого сама не несёт. Замер: 2 координаты из
80 129 спорных, обе — те же, что дают несравнимые наборы.
- **(а, сделано)** оставить правило, завести счётчик `PointsErased` с `WARN`.
Событие наблюдается, но не предотвращается; обратимо пересборкой, пока жив
архив.
- **(б)** сузить «побеждает пришедшая» до случая совпавших множеств
содержательных ключей, а при строгом включении имён оставлять более полную
независимо от происхождения. Цена: правило перестаёт быть чисто структурным на
этом разряде, дельта хранения переписывается, прогон живого архива снимается
заново. Проверить обязательно: сохраняется ли починка `step_count` — по замеру
его столкновения идут с одинаковыми наборами `{date, qty}`, то есть должна.
Переход к (б) остаётся дешёвым: счётчик скажет, если событие станет массовым.
+31 -6
View File
@@ -23,17 +23,42 @@
реально принято.
- Записи неизменяемы: передумали — новая запись, старой ставится статус.
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
`устарело`.
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
источником, а не абзацем в теле.
## Записи
Новые сверху.
Новые сверху. Все шесть активны — статуса поэтому ни у одной нет.
| Дата | Запись | Статус |
| --- | --- | --- |
- [ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost](ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost.md)
— конверт точек несёт измеренный род, его **применимость к отданному ряду** и
границу окна измерения; строка `"aggregation": "sum"` пересмотрена, поле
`applied` отвергнуто как выводимое; род вместе с данными взят у Google Cloud
Monitoring и CloudWatch, отдельный `/metadata` Prometheus отвергнут.
- [ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek](ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.md)
— «самый мелкий слой, покрывающий весь диапазон» пересмотрено: правило было
неопределено на законном входе. Охват меряется метками **точек**, а не часами
объектов, иначе период короче часа отдаёт пустой ряд при непустых данных;
цена меры измерена (13.9 мс против 0.026 мс) и потребовала одного словаря
слоёв.
- [ADR-2026-08-04-forma-provoda-prinadlezhit-transportu](ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md)
— публичный контракт чтения объявляет транспорт, а не домен; «доменные типы и
есть форма провода» (`wtf`, Prometheus) отвергнуто фактом — поля `store.Point`
не совпадают с обещанным проводом точек ни одним именем; `apidiff` как сторож
отвергнут: смены `json`-тега он не видит вовсе.
- [ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala](ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala.md)
— признак «секция встречена впервые» выводится запросом к журналу; реестр по
образцу `category_value` отвергнут как вторая копия факта, с названным
условием пересмотра — ретеншен архива.
- [ADR-2026-08-04-tie-break-po-poryadku-zhurnala](ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md)
— тай-брейк точек при равной полноте: побеждает пришедшая, то есть правило
становится явной функцией порядка журнала; хранимая метка провенанса
(LWW-Register) отвергнута по цене формата и миграции.
- [ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom](ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom.md)
— код HealthKit кладётся реестром рядом со строкой, а не полем внутри точки;
словарь живёт в бинаре, выведенный код в отпечаток витрины не входит.
Записей пока нет: каталог заведён переездом на канон 2026-08-03. Сырьё для
промоута накоплено — девять архивных изменений в
Сырьё для промоута накоплено — архивные изменения в
`openspec/changes/archive/`, из них решения с дорогим откатом и намеренные
отказы есть как минимум в `2026-08-01-polnota-tochki-mnozhestvom-klyuchey`
(идентичность точки и тай-брейк), `2026-08-02-reindex-iz-arhiva` (подмену базы
+6 -2
View File
@@ -1,7 +1,11 @@
# Краткий заголовок решения
- Дата: ГГГГ-ММ-ДД
- Источник: openspec/changes/archive/<id>/design.md
- **Дата:** ГГГГ-ММ-ДД
- **Источник:** openspec/changes/archive/<id>/design.md
Статус ставится тем же полем и только при пересмотре:
`- **Статус:** заменено на ADR-…` либо `- **Статус:** устарело`.
У активной записи поля нет.
## Решение
+281 -78
View File
@@ -33,10 +33,11 @@ healthlog принимает выгрузки Apple Health из приложен
(`метрика + слой + начало + конец`; у точки-измерения конец равен началу);
`source` в ключ не входит, он
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
чтобы не писать зря. При столкновении выигрывает более полная точка, а не
последняя пришедшая: бедная доставка не должна стирать поля у богатой.
Полнота — **множество** ключей с непустым значением, а не их число (см.
«Разрешение столкновений»).
чтобы не писать зря. При столкновении выигрывает более полная точка, а при
равной полноте — стоящая **позже в журнале**: бедная доставка не должна
стирать поля у богатой, но и устаревшее значение не должно пережить свой
досчёт. Полнота — **множество** ключей с непустым значением, а не их число
(см. «Разрешение столкновений»).
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
см. «Модель синхронизации».
@@ -45,12 +46,14 @@ healthlog принимает выгрузки Apple Health из приложен
с переведённой строкой (см. «Категориальные значения»): он приписывается, а
не подменяет.
- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в тех
разрезах подробности, в которых пришла (`sample`/`raw`/`minute`/`hour`);
переагрегирования при записи не происходит никогда.
- **Агрегация в ответе — только измеренная.** Read API умеет свести метрику к
запрошенной сетке, но род свёртки (сумма или среднее) выведен сверкой слоёв
между собой, а не проставлен вручную. Где род неизвестен, агрегация не
предлагается: отдаются значения как есть.
разрезах подробности, в которых пришла (перечень слоёв —
[database.md](database.md), таблица `bucket`); переагрегирования при записи
не происходит никогда.
- **Агрегация в ответе — только измеренная.** Род свёртки (сумма или среднее)
выведен сверкой слоёв между собой, а не проставлен вручную. Где род
неизвестен, агрегация не предлагается: отдаются значения как есть. Свёртка к
запрошенной сетке объявлена контрактом и **ещё не реализована** — параметр
`bucket` отвергается `400` (задача `read-api-points-bucket`).
- **Минимум компонентов** — один процесс, SQLite, файлы. Без очередей и
внешних зависимостей.
@@ -169,7 +172,7 @@ HRV); у накопительных — только `date`. Поэтому то
```
дыра моложе суток → закроется в течение часа
дыра моложе недели → закроется в течение суток
дыра старше недели → не закроется; лечится `healthlog import`
дыра старше недели → не закроется; лечится только `healthlog import` (ещё не написан)
```
Широкие проходы почти бесплатны именно из-за часовых объектов: глубокий проход
@@ -203,10 +206,11 @@ HRV); у накопительных — только `date`. Поэтому то
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
единицы записей, и месячное окно там почти ничего не стоит.
Правило слияния одинаково для всех проходов, и порядок прихода значения не
имеет. Но «последние данные всегда актуализируют картину» — неверно и никогда
не было верным: при столкновении выигрывает более полная точка, а не последняя
пришедшая (см. «Разрешение столкновений»).
Правило слияния одинаково для всех проходов. Порядок прихода при этом значение
**имеет**: полнота решает первой, а при равной полноте побеждает пришедшая
позже по журналу. «Последние данные всегда актуализируют картину» остаётся
неверным ровно в одном разряде — более полная точка бедную не пропускает
(см. «Разрешение столкновений»).
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
иначе `automation-name` приходит пустым (находка 12).
@@ -223,15 +227,18 @@ capability**, и здесь стоит ссылка, а не пересказ т
| `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) |
| `ingest` | use-case приёма, общий для HTTP и будущего CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md), [`uncovered-sections`](../openspec/specs/uncovered-sections/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) |
| `points` | ряд точек метрики за период: выбор слоя, применимость рода | [`points`](../openspec/specs/points/spec.md) |
| `httpapi` | приём, read API и **форма провода** ответов чтения | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md), [`read-api`](../openspec/specs/read-api/spec.md), [`points`](../openspec/specs/points/spec.md) |
## Приём
<!-- канон: поведение → openspec/specs/ingest -->
```
запрос → токен → лимит тела, gzip → проверка формы JSON
→ запись тела в архив → строка в delivery → 200
@@ -261,7 +268,8 @@ capability**, и здесь стоит ссылка, а не пересказ т
- **200** — тело сохранено в архив. Дальше даже полный провал разбора
(незнакомая метрика, новая форма точки) не меняет ответ: данные уже в
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
`/stats`, а доразобрать их можно командой `reindex`.
`/stats` (маршрут — задача `stats-endpoint`), а доразобрать их можно командой
`reindex`.
#### Очередь свёртки — таблица, а не структура в памяти
@@ -353,6 +361,40 @@ capability**, и здесь стоит ссылка, а не пересказ т
`partial` — не отклонение, а установившееся состояние, поэтому уровень лога от
него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень.
**Первая встреча имени — другое дело**
([`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md)). Момент, когда поток принёс секцию,
которой раньше не было, фиксировался колонкой, но не наблюдался ничем: увидеть
его мог только тот, кто догадается заглянуть в базу. Теперь свёртка спрашивает
журнал, встречалось ли имя в доставках **строго раньше** этой (пара
`(received_at, id)`, запросом вне транзакции записи), и первая встреча даёт
`WARN` с именами отдельным атрибутом `uncovered_new`. Повторные молчат. Признак
выводится, а не хранится: реестр был бы второй копией факта, обязанной сходиться
с колонкой при каждой пересборке. Отсюда же идемпотентность — проигрывание
полного журнала повторяет ровно те же события.
Событие переживает **отказ** свёртки: список непокрытых секций переживает его
(доставка с невыводимым слоем всё равно пишет имена), и смолчать значило бы
потерять событие навсегда — следующая доставка сочла бы имя виденным. А
отложенный по обстоятельствам исход событий не даёт: учётной записи он не
меняет, доставка вернётся следующим проходом.
Перечень накопленного отдаёт `healthlog uncovered` — имя, число доставок,
первая и последняя встреча, чтением только на чтение и с экранированием имён
(ключ приходит из чужого тела). Границы у перечня три, и они названы, а не
замолчаны: имя, вытесненное границей списка в 32 имени, в колонку не попадает
вовсе; пересборка обнуляет колонку и заполняет её заново только по сохранившимся
телам; а имя, секцию которого разбор научился покрывать, уходит из колонки при
пересвёртке — то есть перечень отвечает о текущем состоянии покрытия, а не об
истории.
Цена сверки измерена на синтетическом журнале годового объёма; числа и метод
живут в одном месте — `design.md` изменения `aktivnaya-proverka-novyh-sekcij`,
решение 3, — и здесь не дублируются. Правило из замера: ранний выход есть только
у секции, приезжающей давно (строки просматриваются от старых к новым); у только
что появившейся секции проход идёт почти по всему журналу на каждой доставке,
пока её не покроет отдельная задача. Имён больше одного спрашиваются одним
запросом — тридцать два запроса подряд стоили секунду с лишним на доставку.
**Правило для будущих задач: покрыли секцию — пересверните.** Список это снимок
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
@@ -417,10 +459,14 @@ capability**, и здесь стоит ссылка, а не пересказ т
пересобрать что угодно.
**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же
состояние, что и приём в реальном времени. Слияние «выигрывает более полная
точка» коммутативно и порядка не требует; но когда две одинаково полные точки
несут разные значения, исход решает порядок — поэтому воспроизведение идёт
строго по `received_at`, а не по порядку файлов в каталоге.
состояние, что и приём в реальном времени. Разряд полноты коммутативен и
порядка не требует, а разряд равной полноты — **нет**: побеждает пришедшая, то
есть исход есть функция порядка свёртки. Отсюда два следствия. Воспроизведение
идёт строго по `(received_at, id)`, а не по порядку файлов в каталоге. И живая
свёртка обязана идти тем же порядком: проход воркера прекращается на первой
отложенной доставке, а свёртка, всё-таки пошедшая вне порядка (конкурентный
приём делает строку учёта видимой позже метки), пишет `WARN` — закрыть это окно
можно только на приёме.
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
@@ -795,7 +841,7 @@ hour метки выровнены на час heart_rate 00:00:00
доставки той же автоматизации; если её не было, берём **надёжный** заголовок
(`Minutes``minute`, `Hours``hour`). Иначе точки не сохраняются вовсе:
молчаливый `raw` создал бы призрачный разрез, который поедет в каталог и в
правило Read API «самый мелкий слой, покрывающий диапазон».
правило Read API выбора слоя (см. «Read API»).
Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от
**префикса журнала**. Наследование от последней доставки вообще делает свёртку
@@ -901,8 +947,11 @@ hour метки выровнены на час heart_rate 00:00:00
#### Разрешение столкновений
По одним координатам приезжают разные содержимые: 2 897 случаев из 444 256
координат, 0.65% (находка 49). Выигрывает **более полная** точка, и полнота
По одним координатам приезжают разные содержимые: спорных координат 80 129 из
460 995 (17,4%), и полнота отбрасывает кого-то лишь в 981 из них (1,2%)
остальное решает тай-брейк ([research/apple-health.md](research/apple-health.md),
находка 54; прежняя оценка «0,65%» из находки 49 считала ключ без слоя).
Выигрывает **более полная** точка, и полнота —
это сравнение **множеств** ключей с непустым значением, а не их числа.
Число сравнимо всегда и потому отвечает там, где ответа нет: точка
@@ -928,24 +977,50 @@ hour метки выровнены на час heart_rate 00:00:00
проигрывает `{date, qty:10}` по жребию. Несравнимость на втором разряде исходом
не является: лишние ключи там заведомо пусты, объединять в них нечего.
**Победитель — функция множества точек, а не порядка их поступления.** Попарная
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и
вместе они образуют нетранзитивное отношение победы, то есть цикл. При цикле
повторная свёртка одной и той же доставки меняет содержимое объекта, и витрина
перестаёт быть свёрткой журнала. Поэтому кандидаты координаты собираются
**Победитель — функция множества кандидатов вместе с их происхождением, а не
порядка элементов на проводе.** Попарная свёртка этого не даёт: полнота —
частичный порядок, тай-брейк — тотальный, и вместе они образуют нетранзитивное
отношение победы, то есть цикл. При цикле повторная свёртка одной и той же
доставки меняет содержимое объекта. Поэтому кандидаты координаты собираются
вместе: отбрасываются превзойдённые по полноте, среди оставшихся берётся
минимум по каноническому порядку. Обе операции зависят только от состава
множества.
минимум тотального порядка — сперва происхождение (пришедшая раньше
сохранённой), затем каноническая форма. Антицикловое свойство от этого не
страдает; зависимость от **порядка журнала** появляется намеренно и оплачена
отдельно (см. ниже).
**Несравнимые множества не сливаются, а считаются.** Объединение полей — самая
дорогая часть правила — на живом потоке не потребовалось ни разу (0 из 2 897),
поэтому вместо реализации стоит счётчик и `WARN` с координатами объекта. Если
событие наступит, оно будет видно, а не додумано заранее.
дорогая часть правила — на живом корпусе наступило дважды на 155 доставок
(находка 54), поэтому вместо реализации стоит счётчик и `WARN` с координатами
объекта. Событие видно, а не додумано заранее.
**Тай-брейк при равной полноте не выбран.** Сегодня это порядок канонических
форм, и он измеримо смещён: в 96% случаев берёт меньшее значение. Правильный
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой —
значит он и станет известен точно, вместо того чтобы быть угаданным.
**Тай-брейк при равной полноте — пришедшая доставка.** Порядок канонических
форм отвергнут замером: он берёт меньшее значение в 96% случаев (находка 49) и
стоил `step_count` его рода. Значение точки в правило не входит («брать
бо́льшее» неверно для мгновенных метрик), род метрики — тоже: род есть функция
витрины, а правило, читающее собственную выдачу, перестаёт быть функцией
префикса журнала. Байтовый порядок остался тай-брейком **внутри одной
доставки**, где провенанс общий.
Цена названа вслух: правило перестало быть функцией множества и стало явной
функцией порядка журнала. Витрина остаётся свёрткой журнала ровно потому, что
порядок свёртки приведён к порядку журнала (см. «Свёртка обязана быть
детерминированной»).
**Два правила равной полноты и когда какое.** У точки и у сущности развилка
одна, а механизмы разные — вот критерий, чтобы третья единица хранения не
открывала спор заново:
| | точка | сущность (`workout`, `record`) |
| --- | --- | --- |
| разряд полноты | множества ключей с непустым значением | покрытие содержания |
| тай-брейк равной полноты | происхождение кандидата: пришедшая побеждает | хранимая позиция журнала `(received_at, id)` |
| внутри одной доставки | порядок канонических форм | он же |
| гарантия | верна, пока порядок свёртки равен порядку журнала | верна всегда |
| в остаточном окне конкурентного приёма | расходится, пишет `WARN`, лечится `reindex` | не расходится |
| почему так | провенанса у точки нет, и заводить его дорого: колонка на точку меняет формат содержимого объекта | колонка провенанса уже есть |
Правило выбора для будущего: есть где хранить позицию журнала — храним её;
негде и завести дорого — берём происхождение и обеспечиваем порядок свёртки.
### Измерение рода агрегации
@@ -1103,21 +1178,45 @@ HAE отдаёт перечислимые значения строками из
Он объявлен источником истины, и на нём держится ретеншен нижнего слоя —
но сверить покрытие по этим полям было бы нечем.
Поэтому строка **хранится дословно, а рядом кладётся выведенный код**:
Поэтому строка **хранится дословно, а рядом кладётся выведенный код**
отдельной строкой реестра `category_value`, а не полем внутри точки:
```
value "БДГ" ← как прислал HAE
value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по словарю
category_value sleep_analysis / value / "БДГ" → HKCategoryValueSleepAnalysisAsleepREM
точка {"date": …, "value": "БДГ", …} ← не тронута
```
Словарь ключуется парой `(локаль, строка)`; локаль берётся из
`Accept-Language`, который мы уже сохраняем (находка 32). Для незнакомой
строки код пустой — пустота честнее догадки, и она же видна в `/stats` как
список того, что пора добавить в словарь.
Рядом, а не внутри, по трём причинам: точка хранится исходными байтами и
дописать в неё ключ можно только пересериализацией; параллельный массив кодов в
`bucket` завёл бы производную величину в путь слияния и хеширования; пополнение
словаря переписывало бы каждый объект с фазами сна. Обоснование целиком — в
[журнале решений](adr/README.md).
Ключ реестра — `(метрика, поле, значение)`. Словарь при этом ключуется парой
`(локаль, строка)`, локаль берётся из `Accept-Language` (находка 32) и **в ключ
реестра не входит**: заголовков в сыром архиве нет, и ключ с локалью сделал бы
состояние функцией от того, уцелела ли учётная строка. Локаль сужает поиск; её
отсутствие вывода не отменяет, если строка однозначна по всем локалям.
Словарь и таблица синонимов кодов живут в бинаре (`internal/healthkit`), а не в
базе: словарь, наполняемый руками, стал бы входом, которого нет в журнале, и
`import + replay` перестал бы задавать состояние однозначно. Синонимы нужны
потому, что коды тоже не вечны: Apple переименовала `…Asleep` в
`…AsleepUnspecified` и переписывает историю при выгрузке (находка 43).
Для незнакомой строки код пустой — пустота честнее догадки, и перечень таких
строк в реестре есть заявка на пополнение словаря. Счётчик неизвестных строк
уходит в лог свёртки числом; сами строки — данные о здоровье и в лог не
попадают.
Дословность инварианта не нарушена: код **приписывается**, а не подменяет
строку. Обратное преобразование всегда возможно.
Реестр — единица хранения витрины и входит в отпечаток **наблюдением**, но не
выведенным кодом: код производен от словаря в бинаре, а не от журнала, и в
отпечатке он превратил бы всякое пополнение словаря в расхождение при совпавшем
журнале.
### Тренировки и прочие секции
<!-- канон: поведение → openspec/specs/parsing -->
@@ -1354,18 +1453,26 @@ MongoDB, и так просилось из слова «перезаписыва
## Read API
<!-- канон: поведение → openspec/specs/read-api -->
```
GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами
GET /api/v1/metrics/{name}?from&to&bucket&layer точки метрики, при желании свёрнутые
GET /api/v1/workouts?from&to заголовки тренировок
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
GET /api/v1/records/{kind}?from&to прочие секции
GET /api/v1/schema схемы всего, что есть в хранилище
GET /api/v1/metrics/{name}/schema схема и статистика одной метрики
GET /stats последняя доставка, счётчики, тишина по потоку
GET /api/v1/metrics/{name}?from&to&layer точки метрики за период (bucket — соседняя задача, пока 400)
GET /healthz
```
**Целевая поверхность шире реализованной.** Маршрутов ниже в роутере ещё нет,
и запрос к ним получает `404`:
```
GET /api/v1/workouts?from&to заголовки тренировок → read-api-workouts
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом → read-api-workouts
GET /api/v1/records/{kind}?from&to прочие секции → read-api-records
GET /api/v1/schema схемы всего, что есть в хранилище → цель self-description
GET /api/v1/metrics/{name}/schema схема и статистика одной метрики → цель self-description
GET /stats последняя доставка, счётчики, тишина → stats-endpoint
```
Хранение пачками на контракт не влияет: `GET /metrics/{name}` собирает ответ
из часовых объектов, попавших в диапазон, и отдаёт точки. Клиент про объекты
не знает — это деталь хранения, а не API.
@@ -1407,9 +1514,21 @@ GET /healthz
законно есть дыры. Поэтому правило выбора слоя опирается на фактические объекты
запрошенного диапазона, а не на каталожную пару границ.
Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
границе периода нельзя: ряд поедет незаметно для клиента.
Параметр `layer` выбирает разрез. Если он не указан — берём слой с **наибольшим
охватом внутри запрошенного периода**, а при равном охвате самый мелкий (порядок
`sample``raw``minute``hour``day`). Молча переключать слой на границе
периода нельзя: ряд поедет незаметно для клиента, и ряд из одного ответа всегда
собран из одного слоя.
**Охват — длина пересечения** отрезка «первая метка слоя … последняя метка слоя»
с периодом; слой с пустым пересечением выбывает. Меряется он метками **точек**,
а не часами объектов. Почему прежняя формулировка («самый мелкий, покрывающий
весь диапазон») пересмотрена, почему мера именно такая и во что она обошлась —
[ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek](adr/ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.md).
Словарь слоёв при этом **один** (`hae.Layers`): из него выводятся и порядок, и
перечень слоёв в выборке охватов, и проверка параметра запроса, и текст отказа
клиенту.
### Условный запрос
@@ -1488,26 +1607,105 @@ GET /healthz
Нормализованная оболочка, сырое содержимое:
```json
{"layer": "minute", "bucket": "hour", "aggregation": "sum",
{"metric": "heart_rate",
"from": "2026-07-31T00:00:00Z", "to": "2026-08-01T00:00:00Z",
"layer": "minute", "bucket": null,
"aggregation": {"style": "instant", "applicable": true,
"last_hour": "2026-08-02T14:00:00Z"},
"points": [
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count",
"values": {"qty": 812}}
{"ts": "2026-07-31T09:00:00Z", "ts_end": "2026-07-31T09:00:00Z",
"tz_offset": 10800, "units": "count", "values": {"qty": 812}}
]}
```
`layer`, `bucket` и `aggregation` присутствуют всегда, даже когда свёртки не
было (`"bucket": null`): клиент не должен выводить их наличием или
отсутствием поля.
Все поля присутствуют ВСЕГДА, даже когда сообщить нечего: клиент не должен
выводить исход наличием или отсутствием поля. `bucket` равен `null`, когда
свёртки не было; `layer``null`, когда слой выбирала система и выбирать было
не из чего (явно запрошенный слой уезжает всегда, в том числе при пустом ряде).
`aggregation`**объект, а не строка**. Строка называла бы только применённую
свёртку, а инвариант требует, чтобы клиент видел ещё и основание (решение и
разбор чужих API —
[ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost](adr/ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost.md)):
- `style` — измеренный род метрики, тот же словарь, что у каталога;
- `applicable` — применим ли род к **отданному ряду**. Род есть свойство
метрики, слой — свойство ряда, и сочетание `{"layer": "raw", "style":
"cumulative"}` законно и штатно: оно приглашает потребителя сложить
интерполяцию самому и завысить втрое. Система при этом не складывает ничего —
а потребитель об инварианте не знает;
- `last_hour` — ярлык самого свежего часа окна измерения. Окно считается в
**общих** часах, а не в часах календаря: выключенная минутная автоматизация
HAE останавливает их пополнение, окно замирает и продолжает объявлять род.
Это единственный след.
`ts_end` — конец координаты точки; у точки-измерения равен `ts`. Он есть потому,
что идентичность точки — интервал, а не метка: под одной меткой лежит до трёх
записей сна, и конверт с одним `ts` предлагал бы клиенту различать их, разбирая
дословное содержимое.
Принадлежность точки периоду определяется её **началом** — тем же правилом,
каким час объекта берётся по началу. Цена названа: «сон за ночь с полуночи» не
увидит эпизод, начавшийся в 23:40.
Время приведено к единому виду, значения отданы как пришли: ни
переименований, ни пересчёта единиц. Метрик у Apple много и они разные —
семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
что каждая новая метрика требует правки коллектора, а незнакомая теряется.
переименований, ни пересчёта единиц, ни экранирования (сериализатор ответа
HTML-символы не экранирует — иначе `&` в имени источника уезжал бы как
`\u0026`, и обещание дословности переставало быть правдой). Метрик у Apple
много и они разные — семантику разбирает клиент по имени метрики. Полная
нормализация означала бы, что каждая новая метрика требует правки коллектора,
а незнакомая теряется.
### Форма провода
**Форму ответа объявляет транспорт, а не домен.** Каждый читающий маршрут
`internal/httpapi` держит собственные типы с `json`-тегами и переводит в них
доменное значение присваиванием поле в поле; доменные типы (`internal/catalog`
и далее) `json`-тегов не несут и до сериализации не доезжают. То же правило
покрывает тело отказа. MCP собственной формы не объявляет — адаптер переводит
вызовы в те же обработчики.
Цена названа с обеих сторон, потому что она обратная, а не односторонняя.
- **Домен = провод** (как было у каталога): формы объявлены один раз, перевода
нет, ноль строк на маршрут. Платим тем, что публичный контракт меняется
правкой домена **молча** — переименованием поля, разъединением встроенной
структуры (плоскость `aggregation` была следствием встраивания `Basis`),
появлением внутреннего поля. Ни одна из трёх правок транспорт не трогает.
- **Раздельно** (взято): контракт меняется только правкой транспорта, то есть
действием. Платим двумя вещами. Форма объявлена дважды — типы и перевод на
каждый маршрут. И цена **обратная**: новое поле домена в ответ само не
попадёт, его обязан перечислить перевод; поле, не доехавшее до клиента, —
такой же дефект, как поле, уехавшее случайно, просто другой.
Развилку решил факт, а не вкус: провод точек обещан как
`{ts, tz_offset, units, values}`, а `store.Point` несёт
`{Start, End, OffsetSeconds, Raw}` — эти наборы не совпадают ни одним именем,
и доменный тип формой провода там быть не может. Хранилище, кстати, уже живёт
по этому правилу: формат `payload` объявлен отдельным неэкспортированным
`storedPoint`, а `encodePayload` переводит в него полем в поле.
Сторожей два, и роли у них разные. **Обход графа типов ответа** (внутренний
тест `httpapi`) утверждает, что домен до энкодера не доезжает — отсюда и
следует, что переименование поля домена байт не меняет; рядом стоит заведомо
красный случай, потому что проверка, доказывающая отсутствие, зелена и будучи
сломанной. **Байтовый литерал** на каждую различимую форму ответа — детектор
изменения формы: он краснеет в момент правки. Источником истины контракта он
не является — им станет рукописная OpenAPI-спека, и сверку с маршрутами внесёт
в гейт отдельная задача.
Разбор чужих решений (домен = провод у `wtf` и Prometheus; раздельно у Gitea,
Docker и go-kit; версионирование с конверсией у Kubernetes; отвергнутый
`apidiff`, который смены `json`-тега не видит вовсе) —
[design.md изменения](../openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md).
Ссылка markdown-ссылкой намеренно: инлайн-код `docs.py check` не проверяет, а
путь угадывался до архивации.
### MCP
Поверх Read API адаптер MCP, чтобы агент подключался без промежуточного
кода. Инструментов ровно два, по числу форм запроса выше, плюс каталог.
Поверх Read API **встанет** адаптер MCP, чтобы агент подключался без
промежуточного кода — кода адаптера сегодня нет, это задача `mcp-server` цели
`read-api`. Инструментов ровно два, по числу форм запроса выше, плюс каталог.
Собственной логики в адаптере нет: он переводит вызовы в те же обработчики.
**Транспорт — HTTP** (Streamable HTTP), не stdio: сервис живёт на VPS, и агент
@@ -1560,18 +1758,18 @@ GET /healthz
## Аутентификация
Статический токен в заголовке `Authorization: Bearer …`; список допустимых
токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно.
Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий
данные, не может писать. MCP пользуется токеном чтения — отдельного контура
у него нет, см. «MCP».
Наружу открыты два контура: приём (телефон) и чтение вместе с MCP (агенты и
приложения). Оба через Caddy с TLS, оба с разными токенами.
Периметр, модель угроз и разграничение контуров — [security.md](security.md),
разделы «Периметр» и «Что разграничивает доступ»; сегодняшний контур отличается
от целевого, и сказано это там. Здесь важно одно следствие для компоновки: MCP —
эндпоинт того же процесса и того же контура чтения, отдельного контура доступа у
него нет (см. «MCP»).
## Деплой
**Целевая** раскладка; сегодняшний контур — [security.md](security.md),
«Периметр», статус работ — [tasks/ROADMAP.md](tasks/ROADMAP.md),
«Сопровождение».
VPS **rivendell** (Timeweb), доступен всегда. Перед сервисом — **Caddy**, он
терминирует TLS; сам сервис слушает plain HTTP. Приём открыт наружу на
отдельном поддомене — телефон должен доставать до него из любой сети, иначе
@@ -1583,7 +1781,12 @@ VPS **rivendell** (Timeweb), доступен всегда. Перед серв
Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг
(с токенами) — отдельно, `0600`.
**Откат бинаря поверх новой схемы отказывает на старте.** Версия схемы базы выше
<!-- канон: поведение → openspec/specs/storage -->
**Откат бинаря поверх новой схемы отказывает на старте** — правило нормировано в
[`storage`](../openspec/specs/storage/spec.md), требование «Открытие базы
отказывает при схеме из будущего»; здесь только следствия для деплоя. Версия
схемы базы выше
версии, вшитой в бинарь, — отказ, а не повод мигрировать; в контейнере это
выглядит циклом перезапуска, и лечится возвратом бинаря вперёд. Версию читает
сам goose (`Provider.GetVersions`), а не собственный запрос: имя таблицы учёта и
+64 -3
View File
@@ -16,11 +16,35 @@
единица, которой нет в счётчиках, делает расхождение безадресным: человек
видит «не совпало» при неизменившемся числе объектов и принимает по этому
необратимое решение о подмене базы.
- **Провенанс, входящий в отпечаток, обязан быть явной функцией журнала.**
«Кто первым записал строку» — функция порядка свёртки, а он порядку журнала не
равен: живой приём и пересборка разойдутся при одинаковом журнале. Там, где
провенанс в отпечаток не идёт, слабое правило допустимо и должно быть названо
слабым на месте — иначе его скопируют туда, где оно неверно (`bucket` против
`category_value`).
- **Колонка, производная от бинаря, а не от журнала, в отпечаток не входит.**
Кэш чистой функции (код по словарю, справочное имя) в отпечатке превращает
всякую правку бинаря в расхождение при побайтно совпавшем журнале — и человек,
принимающий по отпечатку необратимое решение о подмене базы, читает это как
дефект. Правильность самой производной проверяют её тесты: это другой вопрос,
и смешение обесценивает оракул сходимости.
- **Граница на число элементов, набираемых из чужого тела, применяется при
накоплении, а не при выдаче.** Накопитель без границы растёт вместе с телом,
а тело контролирует отправитель; отказ по памяти в фоновой горутине не
перехватывается, и перезапуск берёт ту же доставку. Усечение при этом обязано
остаться функцией множества (например, N наименьших ключей), иначе порядок
элементов на проводе решает состав витрины.
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
множества версий**, либо явно **функцией порядка журнала** — третьего
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
порядок свёртки порядку журнала не равен, и живая витрина расходится с
пересборкой молча.
состояния нет. «Побеждает последняя свёрнутая» третьим состоянием и является:
порядок свёртки сам по себе порядку журнала не равен, и живая витрина
расходится с пересборкой молча. Объявив правило функцией порядка журнала,
изменение обязано **внести плату целиком**: привести порядок свёртки к
журнальному (барьер на отложенной доставке), назвать остаточное окно и сделать
его наблюдаемым, а равенство «пересборка = приём» доказать оракулом с
отрицательным контролем. Так сделано для точек; у сущностей на тот же вопрос
отвечает хранимая позиция журнала, и её гарантия строго сильнее — критерий
выбора в `architecture.md`, «Разрешение столкновений».
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
названный предел длины (имена непокрытых секций, `id` сущности).
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не
@@ -47,3 +71,40 @@
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
структуры обновляем схему в [database.md](../database.md) тем же изменением —
это проверяет `task gate`.
- **Значение, читаемое табличной функцией SQLite (`json_each` и родня), проходит
проверку ВНУТРИ её аргумента, а не условием в `WHERE`.** Функция получает
значение строки раньше, чем применится фильтр, и порядок этот SQLite не
обещает: неразбираемое значение роняет **весь** запрос, а не пропускает
строку. Условие в `WHERE` работает, пока планировщик проталкивает его вниз, и
перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка
`delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не
состоялась» на каждой доставке), и перечень целиком.
## Предикат выбора источника и предикат отбора данных — одна граница
Объекты витрины адресуются часом, а точки отбираются точной меткой. Выборка
объектов поэтому обязана быть **шире** запроса (точка `10:59` живёт в объекте
`10:00`) — и ровно здесь появляется разрыв: множество «слои, у которых есть
объекты в периоде» не совпадает с множеством «слои, у которых есть точки в
периоде».
Правило: **решение о том, откуда брать данные, принимается по той же границе, по
которой данные потом отбираются.** Иначе узел выбирает источник, в котором после
точного отбора не остаётся ничего, и отдаёт пустоту при непустых данных
соседнего источника — молча, потому что и выбор, и отбор по отдельности верны.
Прецедент: правило выбора слоя в Read API мерило охват часами объектов, а ряд
отбирало метками точек; на периоде короче часа ответ уходил пустым при непустых
минутных данных (ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek).
## Значение из чужого тела имеет предел длины у КАЖДОГО адресата
Правило `docs/security.md` про предел длины читается как «в ключ, в лог, в
отчёт» — и адресаты кончаются не там. Имя метрики уезжает ещё и в заголовок
ответа: без предела `ETag` растёт вместе с именем, а кавычка внутри имени по
RFC 9110 кончает метку, и условный запрос по такой метрике не сработает никогда.
Когда предел неудобен (значение нужно целиком), его заменяет **форма**: в метку
уезжает хеш канонизированной строки, а не строка. Хеш здесь не секрет — он
ограничитель длины и экранирование разом.
+64
View File
@@ -23,6 +23,70 @@
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
без числа не отличается от предположения, а цена ошибки здесь — необратимое
решение о судьбе тел.
- **В проверке на живом корпусе утверждается инвариант, а число печатается.**
Корпус растёт с каждой доставкой, а прогон живого архива в гейт не входит —
значит константа, производная от его размера, протухает по расписанию
телефона и краснеет у того, кто мимо проходил. Правило шире, чем «не
сравнивай с числом»: протухает и **оценка области действия**, снятая на
прежнем корпусе. «Тай-брейк — крайний разряд после полноты» было верно на
2 897 столкновениях и неверно на 80 129, где полнота решает 1,2%; на этой
оценке стоял нормативный текст спеки. Число, попавшее в спеку или в довод
решения, обязано нести рядом **метод замера** — иначе следующий замер
посчитает другое и разойдётся молча (так и вышло: ключ без слоя дал 29-кратное
расхождение). Три случая одного класса за три дня: записи 2026-08-02,
2026-08-03 и 2026-08-04 в [review.md](../review.md).
Метода мало — **синтетический корпус обязан содержать измеряемый случай в той
форме, в какой он бывает в жизни**. Сверка новизны секции мерялась на журнале,
где новое имя стояло во всех доставках, то есть его первая встреча лежала в
начале — ранний выход давал 31 мкс. В жизни секцию включают сегодня, первая
встреча оказывается в хвосте, и та же операция стоит 52 мс: три порядка
разницы, а на числе стояло решение «индекс не нужен» (запись 2026-08-04).
И **число живёт в одном месте.** Один и тот же замер, записанный в
комментарий кода и в `architecture.md`, разошёлся внутри одного изменения.
Дом числа — `design.md` изменения; остальные формулируют правило и ссылаются.
- **Оракул сходимости называет свою посылку рядом с собой, и прогон её
печатает.** «Пересборка = приём» — не тождество, а утверждение с условиями:
живая свёртка шла в порядке журнала, в журнале нет доставок, чью свёртку живой
путь провалил, а пересборка проведёт, и за время прогона новых доставок не
приезжало. Оракул, чья посылка не названа, краснеет по причине, к правилу
отношения не имеющей, и краснота становится неотличимой от дефекта — то есть
с ней начинают жить.
- **Проверка правила, зависящего от порядка, несёт отрицательный контроль.**
Тест «два пути дали один отпечаток» зеленеет и на правиле, которое к порядку
безразлично, — то есть не проверяет ничего. Рядом обязан стоять прогон в
заведомо другом порядке с утверждением, что отпечаток **отличается**.
- **Значение, попадающее в ключ витрины или в словарь, приёмочный тест берёт из
`testdata`, а не из литерала в тесте.** Литерал, набранный руками, не
воспроизводит невидимые символы источника — Apple шлёт неразрывные пробелы
внутри своих строк (находка 24), — и совпадение теста с реализацией доказывает
только согласие автора с самим собой.
- **Утверждение о таблице-константе обходит саму таблицу, а не её видимые
следствия.** Проверка «таблица синонимов плоская», написанная через
экспортированные функции, обходит лишь записи, достижимые из словаря: с
неплоской таблицей она остаётся зелёной (воспроизведено). Такие утверждения
живут во внутреннем тесте пакета и перебирают саму структуру.
- **Публичная форма ответа закрепляется байтами целого тела, и каждая различимая
форма — своим литералом.** Разбор проглатывает молча ровно то, что клиент
видит первым: `nil`-срез уезжает как `null`, отсутствующий ключ неотличим от
ключа с нулём, а разыменованный `*time.Time` даёт правдоподобную дату
`0001-01-01` вместо `null`. Тест, сличающий разобранные структуры или
подстроки, зелен в каждом из этих случаев — проверка «в ответе есть
`"first_hour"`» проходит и на нулевой дате. Различимых форм у ответа обычно
больше одной (пустая коллекция, измеренное значение, неизмеренное), и литерал
нужен каждой: одна закреплённая форма оставляет остальные без сторожа именно
там, где ручной перевод и ошибается. Литерал при этом **детектор изменения**,
а не источник истины контракта — правишь литерал, значит правишь контракт, и
рядом обязана лежать правка спеки.
- **Проверка, доказывающая ОТСУТСТВИЕ, несёт рядом заведомо красный случай.**
«Доменного типа в графе ответа нет», «значения точки в логе нет», «записи в
таблице нет» — все они зелены и будучи сломанными: протухшая константа,
пропущенная позиция обхода, перепутанное сравнение выглядят снаружи как
«искомого нет». Это обобщение двух правил ниже (отрицательный контроль для
правил порядка; утверждение о таблице-константе обходит саму таблицу): у
проверки на отсутствие обязан быть предъявленный вход, на котором она
краснеет.
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
+57 -1
View File
@@ -46,6 +46,17 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
│ created_at TEXT │ └──────────────────────────┘
│ updated_at TEXT │
└──────────────────────────┘
┌────────────────────────────┐
│ category_value │
│ ───────────────────────── │
│ metric TEXT ┐ │
│ field TEXT ├PK │
│ value TEXT ┘ │
│ code TEXT │
│ first_seen_utc TEXT │
│ first_delivery_id TEXT │
└────────────────────────────┘
```
Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
@@ -116,7 +127,10 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
**Идентичность точки внутри объекта** — координаты
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
столкновении выигрывает более полная точка, а не последняя пришедшая.
столкновении выигрывает более полная точка, а при равной полноте — стоящая
позже в журнале (внутри одной доставки — минимум канонической формы). Провенанса
у точки нет: «позже в журнале» выражено происхождением кандидата, и потому
порядок свёртки обязан равняться журнальному.
## `workout` и `record` — сущности с собственным `id`
@@ -156,6 +170,47 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
журнала, а не свёрнутая последней. Подробности и обоснование — в
`architecture.md`, раздел «Тренировки и прочие секции».
## `category_value` — реестр категориальных значений
Какие перечислимые строки поток приносил и какой у них стабильный код
HealthKit. HAE отдаёт фазу сна как «БДГ», контекст пульса как «Сидячий образ
жизни», тип тренировки как «В помещении Ходьба» — строками локали телефона, а
родной экспорт Apple говорит кодами; без словаря источники не сходятся
(находка 37). Словарь фаз сна выведен сопоставлением потока с экспортом за тот
же период (находка 43).
| Колонка | Смысл |
|---|---|
| `metric` | имя метрики или секции, **то же**, которым адресуется единица хранения (`sleep_analysis_summary` после разделения схем, `workouts` у тренировок). Второе имя для того же понятия развело бы наблюдение и объект по разным ключам |
| `field` | имя поля внутри точки или сущности дословно как у HAE: `value`, `context`, `name` |
| `value` | строка **дословно**, как прислал HAE. Код приписывается рядом, а не подменяет её: инвариант «точки хранятся дословно» это и означает |
| `code` | канонический код HealthKit. Пустая строка — законное состояние: «словарь этой строки не знает», и перечень таких строк есть заявка на пополнение словаря. **Это кэш**: код производен от словаря в бинаре, а не от журнала, и потому в отпечаток витрины не входит. Строка, переставшая приезжать, держит код прежнего словаря до пересборки |
| `first_seen_utc`, `first_delivery_id` | провенанс **первой** встречи, минимум по журналу `(received_at, id)`. Минимум идемпотентен при повторной свёртке той же доставки; счётчик встреч не идемпотентен и потому не заводится вовсе. Отвечает на вопрос «когда сменился язык телефона», а язык доставки восстанавливается по `delivery.headers` |
Ключ — тройка без локали, и это решение, а не упущение. Локаль приезжает
заголовком `Accept-Language`, а заголовков в сыром архиве нет: они были
заголовками запроса, а не телом. Доставка, восстановленная из осиротевшего
тела, приходит без локали — ключ с локалью положил бы вторую строку на то же
значение, то есть состояние стало бы функцией от того, уцелела ли учётная
строка. Локаль при выводе кода сужает поиск по словарю; её отсутствие вывода не
отменяет, если строка однозначна.
Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, а строк
единицы — на живом потоке различных значений по всем трём полям около
одиннадцати. Индексов нет: чтение идёт целиком, в порядке ключа.
Границы разбора не дают доставке положить больше 64 различных значений и
значение длиннее 128 байт (измерено: ~11 значений, самое длинное 36 байт).
Слишком длинное **отбрасывается со счётчиком, а не обрезается** — обрезанная
строка неотличима от настоящей и стала бы самостоятельным ключом; сама точка
при этом хранится целиком.
Data-миграции у таблицы нет и быть не может: коды выводятся из тел, а тела
лежат в архиве. Реестр рабочей витрины наполняется по мере свёртки новых
доставок и целиком — пересборкой. Отсюда первое расхождение отпечатков после
выкатки: оно законно, и отчёт `reindex` называет его ожидаемым классом
«появилась единица хранения».
## Представление данных
- **Точки часового объекта лежат сжатым BLOB** (`gzip`) в колонке `payload`.
@@ -185,5 +240,6 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
| таймаут чтения запроса | 5 мин (`server.read_timeout`) | конфиг; щедро: экспорт истории по мобильной сети |
| таймаут отправки ответа | 30 с (`server.write_timeout`) | конфиг; маршрут приёма держит собственный бюджет |
| бюджет остановки | 30 с | `cmd/healthlog/serve.go`, `shutdownTimeout` |
| дедлайн свёртки одной доставки | 2 мин | `internal/replay/worker.go`, `foldTimeout`; обстоятельством не считается — не уложившаяся доставка уходит в `failed` |
| ретеншен сырого архива | до следующего проверенного экспорта (~2 ГБ за квартал) | правило, а не число; не реализован — задача `raw-archive-retention` |
| предела на одну сущность | **нет** | задача `entity-size-limits` |
+17 -13
View File
@@ -1,8 +1,8 @@
# Паспорт проекта
Куда мы идём, как выглядит работа проекта в жизни и у кого подсматривать,
когда упёрлись. Самый верхний документ: [tasks/PLAN.md](tasks/PLAN.md) отвечает «в каком
порядке», [architecture.md](architecture.md) — «как устроено», паспорт —
когда упёрлись. Самый верхний документ: [tasks/ROADMAP.md](tasks/ROADMAP.md) отвечает «что
приложение умеет», [architecture.md](architecture.md) — «как устроено», паспорт —
**«зачем и для кого»**.
## Цель
@@ -51,23 +51,25 @@
## Типовые сценарии
Ситуации, ради которых всё написано. В скобках — шаги [tasks/PLAN.md](tasks/PLAN.md),
которыми сценарий закрывается; названы, а не пронумерованы, потому что план
живой и нумерация в нём поедет.
Ситуации, ради которых всё написано. В скобках — цели [tasks/ROADMAP.md](tasks/ROADMAP.md),
которыми сценарий закрывается: достигнутые названы слагом из «Готово», открытые —
заголовком цели. Названы, а не пронумерованы, потому что роадмап живой и
нумерация в нём сдвинется на первой же вставке.
**1. Молчаливый приём** (приём, разбор и хранилище). Телефон каждые 5 минут шлёт доставку;
**1. Молчаливый приём** (`ingest`, `parsing-and-storage` — сделаны). Телефон каждые 5 минут шлёт доставку;
сервис кладёт тело в архив, отвечает `200`, разбирает метрики в часовые
объекты. Никто ничего не спрашивает и не смотрит.
*Успех:* сутки работы не порождают ни одной строки лога уровня `WARN` и ни
одного действия человека.
**2. Дыра закрывается сама** (разбор и хранилище). Телефон был заблокирован ночью,
**2. Дыра закрывается сама** (`parsing-and-storage` — сделано). Телефон был заблокирован ночью,
автоматизация не отработала, часть дня отсутствует. Средний проход (сутки) и
глубокий (неделя) переприсылают окно целиком, точки доезжают.
*Успех:* дыра моложе недели закрывается без вмешательства; никто о ней даже не
узнаёт.
**3. Квартальный экспорт** (`healthlog import`, устаревание нижнего слоя).
**3. Квартальный экспорт** (История из родного экспорта Apple лежит в
хранилище; Нижний слой чистится после проверенного экспорта).
Изредка владелец выгружает
родной экспорт Apple Health и скармливает его `healthlog import`. Нижний слой
за прошлое становится честным (настоящие сэмплы вместо посекундной развёртки
@@ -75,32 +77,34 @@ HAE), а сырой архив получает право быть подчищ
*Успех:* экспорт разобран, покрытие периода проверено, объём архива вернулся к
норме, ничего не потеряно.
**4. Агент спрашивает про здоровье** (каталог и род агрегации, Read API, MCP). Агент-медик по MCP
**4. Агент спрашивает про здоровье** (`catalog` — сделан; Клиенты читают данные
через HTTP и MCP). Агент-медик по MCP
спрашивает каталог («что у тебя вообще есть»), затем «шаги по дням за месяц»
или «пульс за вчера». Получает свёрнутый ряд с честным указанием слоя, сетки и
рода агрегации.
*Успех:* ответ влезает в контекст агента, число не завышено вдвое, и агенту не
пришлось знать про слои, чтобы спросить правильно.
**5. Приложение берёт тренировки** (Read API). Разборщик тренировок запрашивает
**5. Приложение берёт тренировки** (Клиенты читают данные через HTTP и MCP). Разборщик тренировок запрашивает
заголовки за период, потом одну тренировку целиком — с маршрутом и рядом
пульса.
*Успех:* тренировка отдана одним пакетом в том виде, в каком её прислал Apple,
без нашей интерпретации того, что в ней главное.
**6. Разбор поменялся** (`healthlog reindex`). Мы начали разбирать секцию, которую
**6. Разбор поменялся** (`reindex` — сделан). Мы начали разбирать секцию, которую
раньше пропускали, или нашли ошибку в старом разборе. Запускается пересборка
по сырому архиву: `import(экспорт) + replay(доставки по received_at)`.
*Успех:* состояние пересобрано детерминированно, повтор даёт то же самое,
доставки со снятым статусом `partial` подобраны.
**7. Владелец проверяет, жив ли поток** (наблюдаемость). Раз в сколько-то дней —
**7. Владелец проверяет, жив ли поток** (Приложение сообщает о своём состоянии). Раз в сколько-то дней —
взгляд в `/stats`: когда была последняя доставка, сколько точек, есть ли
тишина, какие строки не легли в словарь кодов.
*Успех:* один экран отвечает «всё идёт» или «встало тогда-то», без залезания
в SQLite.
**8. Приехало незнакомое** (разбор и хранилище). HAE обновился и прислал новую метрику,
**8. Приехало незнакомое** (`parsing-and-storage` — сделано; Новая форма от
источника не теряется молча). HAE обновился и прислал новую метрику,
новую форму точки или новую секцию. Тело сохраняется, ответ — `200`, разбор
честно помечает доставку `partial` и перечисляет непокрытое.
*Успех:* данные в архиве и восстановимы, факт виден в логе и `/stats`, а
+2 -2
View File
@@ -51,7 +51,7 @@ python3 tmp/research/hl.py workouts тренировки, ряд
## Записи
- [apple-health.md](apple-health.md) — 53 находки на живом потоке Health Auto
- [apple-health.md](apple-health.md) — 54 находки на живом потоке Health Auto
Export и на родном экспорте Apple.
Записи нумерованы сквозным номером внутри файла, и **на номер ссылаются
@@ -64,7 +64,7 @@ python3 tmp/research/hl.py workouts тренировки, ряд
| --- | --- |
| Форма точки, схемы, типы значений | 4, 21, 38, 39, 44 |
| Слой и гранулярность, режимы автоматизации | 5, 6, 13, 19, 20, 23, 33, 41 |
| Идентичность, столкновения, слияние, полнота | 11, 14, 36, 47, 49 |
| Идентичность, столкновения, слияние, полнота | 11, 14, 36, 47, 49, 54 |
| Досчёт задним числом и стабильность значений | 3, 10, 30, 48, 51 |
| Локализация и категориальные значения | 8, 24, 37, 43 |
| Секции потока и их состав | 9, 15, 16, 17, 22, 34, 50, 52 |
+81 -1
View File
@@ -1369,6 +1369,14 @@ RFC3339 Z 20 data.stateOfMind[].end = 2026-07-31T18:03:51
То есть первую и главную часть словаря не надо составлять вручную — она
выводится сопоставлением потока с экспортом за тот же период.
**Замер покрытия, 2026-08-03.** Прогон всего живого архива (145 доставок) через
разбор с этим словарём даёт **12 различных категориальных строк** по трём полям:
6 фаз сна — все с кодом, 6 без кода (`heart_rate.context` и имена тренировок,
для которых словарь не выводился). То есть шесть выведенных строк покрывают
поток целиком, а не частично: неопознанных фаз сна на корпусе ноль. Заголовков
в архиве нет, поэтому прогон идёт с пустой локалью — и коды всё равно выводятся,
что подтверждает: сопоставление по строке однозначно, пока словарь одноязычен.
## 44. `Correlation` — структурный элемент, и он появился только что
Давление приезжает не записью, а обёрткой из двух записей:
@@ -1766,6 +1774,73 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
заполненности (`xFilesFactor`) измерению не нужно: две конкурирующие гипотезы
отсеивают неполный час сами.
## 54. Перемер тай-брейка: 98,8% спорных координат решает не полнота, а порядок форм
Замер 2026-08-04, повод — `task verify:archive` покраснел на `master` без
единого коммита, с ростом корпуса. Метод назван целиком, потому что прежняя
оценка (находка 49) и эта расходятся в 29 раз, и расхождение объясняется
методом, а не данными.
**Метод.** 155 тел архива, разбор настоящий (`hae.Parse` с наследованием слоя по
цепочке), ключ координаты **настоящий**`метрика + слой + начало + конец`.
Кандидаты схлопываются по канонической форме (`canon.SortKey`, округление до 12
значащих цифр, находка 30); полнота — `canon.Fields.Relate`, то есть с условием
«значения общих содержательных ключей совпали». Программа лежала в `tmp/`
(вне репозитория: она ходит в рабочий архив).
Прежний замер того же дня давал «84 978 спорных из 453 171» — он считал ключ
**без слоя**, а без слоя часовая точка сталкивается с минутной, и это не
столкновение, а два разных ряда (та же ошибка названа в находке 49 первой
строкой её таблицы).
| что мерялось | сколько |
| --- | --- |
| координат всего | 460 995 |
| спорных (больше одной канонической формы) | 80 129 (17,4%) |
| из них полнота кого-то отбрасывает | 981 (1,2%) |
| из них все кандидаты непревзойдённые — решает тай-брейк | **79 148 (98,8%)** |
| несравнимых пар среди непревзойдённых | 2 |
| координат, где смена тай-брейка меняет исход | 75 494 |
**Соотношение 1,2% / 98,8% устойчиво** — оно совпало у обоих методов, и именно
оно, а не абсолютное число, было основанием решения: инвариант «выигрывает
более полная точка» на живом потоке отвечает в одном случае из восьмидесяти.
**Изменение сосредоточено в одной метрике одного слоя.** Из 75 494 изменившихся
координат 71 773 (95%) — `basal_energy_burned` слоя `raw`, то есть посекундная
развёртка HAE, которую Read API суммировать и так не имеет права. Следом
`basal_energy_burned/minute` (1 833), `walking_running_distance/raw` (777),
`step_count/raw` (746). Ошибка «системно храним меньшее» была массовой по
координатам и узкой по метрикам.
**Несравнимых наборов больше не ноль.** Находка 49 фиксировала 0 из 2 897; на
155 доставках их 2. Порог «объединять поля не будем, пока счётчик молчит»
поэтому подтверждается, но уже не абсолютен: событие наступило, просто редко.
**Направление, в котором новое правило теряет содержание, замерено отдельно.**
Разряд полноты гаснет, когда значения общих содержательных ключей разошлись, —
и тогда пришедшая точка побеждает, даже если у проигравшей был содержательный
ключ, которого у неё нет. Таких координат на корпусе **2**, обе
`sleep_analysis_summary/day`, и обе — ровно те же, что дают несравнимые наборы.
То есть случай «сохранённая беднее по именам, но значения разошлись» на живом
потоке не наблюдался вовсе. Прежний байтовый порядок давал ту же потерю по
жребию и так же молча; теперь она детерминирована и считается
(`MergeStats.PointsErased`, `WARN`).
**Исход починки, тем же прогоном.** Смена тай-брейка на «побеждает пришедшая»
вернула род двум метрикам: `step_count` (`unknown``cumulative`, ноль
противоречащих часов вместо одного) и `headphone_audio_exposure`
(`unknown``instant`). Итог каталога: накопительных 6 → 7, мгновенных 9 → 10,
неизвестных 16 → 14. Отпечаток витрины сменился, как и требовалось: 3 194
объекта, `bf36b477…``03aace91…`. Удержаний правилом полноты на весь
корпус — 1 247, потерь содержания — 2.
**Проверено ещё раз на выросшем корпусе.** Пока шла работа, телефон прислал ещё
три доставки; прогон на 158 телах остался зелёным (3 255 объектов, отпечаток
`c4fbb1c7…`, ноль противоречащих часов, `step_count` по-прежнему
`cumulative`). Это и есть ответ на то, чем дефект был найден: прежнее правило
покраснело именно от роста корпуса, новое рост пережило.
## Открытые вопросы
- **Переживает ли «Since Last Sync» неудачную отправку.** Ключевой вопрос для
@@ -1777,7 +1852,12 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
Меняется ли что-то на глубине часов и суток — покажет более длинный ряд
доставок.
- **Секции, которых мы не видели живьём:** `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`.
`heartRateNotifications`, `cycleTracking`, `medications`. Разбор покрывает
ровно остальные три (`metrics`, `workouts`, `stateOfMind``decodeCovered` в
`internal/hae`), сверено поимённо 2026-08-04. Момент их появления больше не
требует догадки: первая встреча имени даёт `WARN` в логе свёртки, а перечень
накопленного отдаёт `healthlog uncovered`. Разбор самой секции пишется, когда
её будет на чём проверить, — вслепую он не пишется.
- **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается
отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в
экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен
+302 -15
View File
@@ -34,8 +34,10 @@
сам же меняет, без границы по `received_at` разбираемой доставки.
- Правило выбора между версиями — функция множества версий либо явно функция
порядка журнала; третьего состояния нет.
- Столкновение разрешается полнотой, а не свежестью; изменение запечатанного
часа пишется `WARN`, но данные пишутся.
- Столкновение разрешается полнотой, а при равной полноте — положением в
журнале: побеждает стоящая позже
([ADR](adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md)). Изменение
запечатанного часа пишется `WARN`, но данные пишутся.
- Транзакция не держит блокировку дольше `busy_timeout`: канонизация и
сжатие — вне её.
@@ -52,7 +54,8 @@
- Расход памяти не растёт вместе с длиной журнала.
- Подмена базы — решение человека при остановленном сервисе, не команды.
**Обработчик чтения и адаптер MCP** (Read API, MCP — ещё не написаны)
**Обработчик чтения и адаптер MCP** (`internal/httpapi`: каталог и точки
написаны; свёртка по сетке, тренировки, записи и MCP — ещё нет)
- Агрегат считается только там, где род свёртки измерен; нижний слой HAE не
суммируется никогда.
@@ -105,24 +108,41 @@
- `triage`: перечислены ли запущенные проходы поимённо и с исходом; непущенный
проход идёт в границы покрытия строкой «не запускался» (запись 2026-08-02,
чекпоинт кода прошёл без трёх проходов).
- `specs`: считается ли внешним поведением **состояние, которое даёт
пересборка** — витрина наблюдаема через пересборку, поэтому расхождение с
журналом не внутренняя деталь, а поведение, которого спека не заказывала.
Внешнее здесь — ещё и код ответа приёма, форма ответа чтения и содержимое
архива (переселено из триггеров профиля, канон 3).
### Триггеры профиля
Уточняет умолчания конвейера, не отменяет их.
Уточняет умолчания конвейера, не отменяет их. Рабочее умолчание — `standard`:
миграция схемы, публичный контракт и инвариант ступень **не** поднимают, их
проверяют проходы, которые в `standard` и так есть.
- **`deep`** — изменения в правиле разбора, идентичности, слияния или вывода
слоя; миграции схемы; всё, что трогает `internal/store`, `internal/fold`,
`internal/replay`.
- **«Поведение, видимое снаружи»** здесь — код ответа приёма, форма ответа
чтения, содержимое архива и **состояние, которое даёт пересборка**: витрина
наблюдаема через пересборку, поэтому расхождение с журналом — внешнее
поведение, а не внутренняя деталь.
- **`reimpl`** запускается по триггеру «новое правило слияния, идентичности или
разбора». Единственный раз, когда триаж назвал его отсутствие дырой
покрытия, — это была задача с новым правилом слияния сущностей.
- **Новое понятие или структурная единица** (`wide`) — новый пакет в
`internal/`, новый род узла из перечня выше, новый тип провода в
`internal/httpapi`, новая единица хранения, входящая в отпечаток, новый
транспорт рядом с HTTP.
- **Правила идентичности, слияния и разбора** (`deep`) живут в трёх местах:
`internal/hae` — разбор пакета и вывод слоя; `internal/fold` — выбор между
версиями точки; `internal/store` — координатный ключ и запись часового
объекта. Правку правила в любом из них ступень поднимает; перенос кода без
правки правила — нет.
- **`quick`** — правка документов, конфигурации, сообщений; ничего, что меняет
хранимое.
`reimpl` живёт за барьером `deep` и по тому же триггеру — новое правило слияния,
идентичности или разбора. Замеры окупаемости: единственный раз, когда триаж
назвал его отсутствие дырой покрытия, — задача с новым правилом слияния
сущностей. Второй замер (2026-08-03, словарь категориальных значений): триггер
сработал на новом правиле разбора и ключе реестра, проход **окупился** — он
независимо подтвердил замером две находки, до того имевшие только одно измерение
(пик памяти накопителя: 1002 МиБ против 780 на базе; единицы счётчика
отброшенных), и отдельно назвал семь мест, где существующее решение оказалось
**лучше** его собственного. Второе ценно не меньше первого: оно показывает, где
проход соглашается, а не только где спорит.
### Недоступно проверке
**Не проверит ни один проход.** Реальный профиль нагрузки: телефон шлёт молча и
@@ -135,8 +155,15 @@
**Перестали проверять сознательно.**
- **Шаг покрытия диффа гейт не красит.** `CLAUDE.md` объявляет, что непокрытая
изменённая строка красит гейт безусловно; `scripts/diff-coverage.py` всегда
возвращает `0`, и шаг печатает `OK` при любом покрытии. То есть «гейт зелёный»
не означает «покрытие диффа полное», и разбор непокрытых строк остаётся
человеку или проходу. Найдено проходом `gate` 2026-08-04, подтверждено
триажем; чинить нельзя мимоходом — починка немедленно красит гейт задачи, в
которой её сделали.
- Прогон живого архива (`task verify:archive`) и свёртка под удерживаемой
блокировкой (`task verify:busy`) в гейт не входят: минута и 25 секунд
блокировкой (`task verify:busy`) в гейт не входят: минута и около 50 секунд
соответственно, плюс данные, которых нет ни на какой другой машине. Гоняет их
человек перед задачей, трогающей разбор или слияние (запись 2026-08-02,
прогон живого архива был красным и об этом никто не знал).
@@ -151,6 +178,42 @@
Запись на каждый воспроизведённый дефект, сразу, а не ретроспективно: со
временем теряется не факт, а причина непоймания.
## 2026-08-04 — правило выбора слоя мерило одно, а отбор шёл по другому [пойман]
**Что было.** Правило выбора слоя ответа Read API мерило охват **часами
объектов**, а ряд отбирался **точной меткой точки**. На периоде короче часа
множества расходятся: часовой объект попадает в границы часов запроса, а его
единственная точка в период не попадает. Ответ уходил бы пустым при непустых
данных соседнего слоя — с непустым `layer`, то есть неотличимо от честной
пустоты только по числу точек.
**Почему поймано.** Профиль `design` на предложении, до кода: и `review-specs`,
и `review-rubric` построили один и тот же вход независимо друг от друга
(`from = 10:30`, `to = 10:45`). На готовом коде находка стоила бы переписывания
выборки; на предложении — абзаца.
**Что сделано.** Охват меряется метками точек (`first_ts`/`last_ts` уже лежат в
покрывающем индексе). Класс промоутнут в
`docs/conventions/storage.md` — «предикат выбора источника и предикат отбора
данных используют одну границу»: он повторится всюду, где огрубление ради
полноты выборки соседствует с точным фильтром.
## 2026-08-04 — чекпоинт, заведённый ревью, не существовал бы в проде [пойман]
**Что было.** Враждебный проход построил путь «ответ оборвался по `WriteTimeout`
на середине, а `accessLog` написал `200`»: тело в 13 МиБ доехало на 2.7 МиБ,
клиент получил нечитаемый JSON, лог сообщил успех. Чекпоинт об обрыве завели —
и поставили ему уровень `DEBUG`.
**Почему поймано.** Эксплуатационный проход прочитал **боевой** конфиг
(`config.docker.toml`, `level = "info"`) и показал, что запись уровня `DEBUG`
не проходит фильтр `slog` никогда. То есть находка была закрыта наблюдаемостью,
которой в проде не существует.
**Что сделано.** Уровень поднят до `WARN`. Правило, которое из этого следует:
**уровень нового чекпоинта сверяется с боевым конфигом, а не с тем, что видно в
тестах** — в тестах уровень всегда `DEBUG`.
Реализованные задачи, находки ревью и решения сюда не пишутся: у них есть
коммит, спека и задача. Здесь только промахи конвейера и решения о его составе.
@@ -359,3 +422,227 @@
покрытия, а не считать проверенным.
- **Итог по конвейеру:** `quick` 4, `standard` 6, `deep` 78, `design` 3.
Было 11 на коде и 4 на дизайне.
## 2026-08-03 — метка от часов в отпечатке сделала тест функцией секунды прогона [пойман]
- **Где:** `internal/fold/categorical_test.go`, `TestFoldЛокальНеМеняетСостояния`
- **Симптом:** гейт покраснел на одном подтесте из четырёх: «заголовок
`{"Accept-Language":["de"]}` сдвинул отпечаток витрины». Три подтеста прошли.
- **Причина:** тест сравнивал отпечатки четырёх независимых витрин, а метку
приёма доставки брал из `store.Now()`. Провенанс первой встречи входит в
отпечаток реестра — значит отпечаток зависел от того, уложились ли подтесты в
одну секунду. Тест был флаки по построению и краснел бы у того, кто мимо
проходил.
- **Чем воспроизведён:** сам гейт; после замены `store.Now()` на фиксированную
метку — `go test ./internal/fold -count=2` зелёный.
- **Что меняем:** ничего в конвейере — гейт сработал ровно так, как задуман, и
поймал класс, который прошлый раз (2026-08-02, подстрока «5.1» в метке
времени) прожил незамеченным. Правило то же и уже записано в
[conventions/testing.md](conventions/testing.md): величина, зависящая от хода
часов, не участвует в утверждении. Запись здесь — потому что это второй случай
одного класса за два дня, и третий стоит считать сигналом, а не совпадением.
## 2026-08-03 — прогон живого архива красный на master, и это не заметили две задачи подряд [проскочил]
- **Где:** `internal/replay/archive_test.go`, `measureStyles`
- **Симптом:** `task verify:archive` в задаче про словарь категориальных
значений упал на `step_count: противоречащих часов 1 при 22 согласных`.
Проверено прогоном **базовой ревизии** `3df42af` из копии дерева на том же
архиве: те же 2875 объектов, те же 285 координат сна, тот же отказ. Краснота
унаследована, изменением не внесена.
- **Причина:** утверждение «противоречий ноль» — посылка «род измерим», верная
на корпусе, где её снимали. Корпус вырос до 145 доставок, и у `step_count`
появился час, где минутный и часовой слои разошлись. Сама система при этом
ведёт себя правильно: род объявляется только при единогласном свидетельстве,
и `step_count` числится `unknown`.
- **Почему не поймали:** ровно та же причина, что и в записи 2026-08-02, — у
проверки, которую гейт не гоняет, краснота никому не видна. Разница в том, что
тогда протухла константа, а теперь под вопросом сама посылка: противоречие —
это либо дефект правила, либо законное свойство корпуса, и решать это не
прогону.
- **Что меняем:** конвейер — ничего. Решение о том, чем стал `step_count`
(дефект измерения рода или законное противоречие, которое надо печатать, а не
утверждать), принадлежит владельцу и заведено задачей отдельно от этого
изменения. Названо здесь, чтобы третья задача подряд не открывала его заново.
## 2026-08-03 — ответ владельца не превращал задачу в берущуюся [проскочил]
- **Где:** конвейер, а не код — учёт задач, шаг «ответ на вопрос»
- **Симптом:** первая сессия по `av-dev-pm:session` показала четыре задачи с
тегом `question`. Три из них были решены владельцем **2026-08-02**, и решение
лежало первым абзацем тела: тай-брейк — вариант (б), порядок журнала —
вариант (в) после `/stats`, откат релиза — вариант (2). Но раздел «Вопросы»
остался непустым, тег остался на месте, и `sprint take` отказал бы взять эти
задачи в набор.
- **Причина:** ответ на вопрос — это **три правки** (опустошить раздел, снять
тег, переписать «зачем»), и делаются они в момент ответа. Была сделана только
запись решения. Судит при этом раздел, а не тег, поэтому решённая задача
выглядела нерешённой ровно так же, как настоящая нерешённая.
- **Чем воспроизведён:** `tasks.py list --questions` — 4 записи, из них 3 с
датированным решением в теле. После правок — 0.
- **Что изменено:** ничего в коде; три задачи приведены в берущийся вид,
четвёртая (`entity-without-parsed-label`) решена на этой сессии.
Два числа этой же сессии, названные, чтобы их было с чем сравнивать:
- **Ориентир «5–8 задач в спринте» ничем не замерян** — он взят из умолчания
скилла. Первый собственный замер даст этот спринт, и пересматривать ориентир
надо на следующей сессии, а не «когда-нибудь».
- **Отбор порции по залежалости (`list --stale`) в этом цикле слеп:** все 49
файлов каталога получили одну дату при переезде на канон (коммит `d79189b`),
и храповик на давно неподвижных задачах включится только с накоплением
собственной истории правок. Порция этой сессии отобрана по цели.
## 2026-08-04 — оракул `verify:archive` покраснел от роста корпуса второй раз за два дня [проскочил]
- **Где:** `internal/store/bucket.go`, `pointLess` — тай-брейк равной полноты
- **Симптом:** `task verify:archive` красный на `master` без единого коммита:
`step_count: противоречащих часов 1 при 22 согласных`. Разбор довёл до
причины: на час `2026-08-03T07:00Z` приехало четыре точки с двумя значениями,
победило меньшее — оно же приехавшее первым, — потому что его каноническая
форма сортируется раньше. Сверка слоёв объявила метрику мгновенной против 22
согласных часов, и `step_count` ушёл в `unknown`.
- **Причина:** байтовый тай-брейк выбран как «детерминированный и ни на что не
опирающийся», и это было верно. Неверной оказалась оценка его области:
считалось, что он крайний разряд после полноты. Перемер (находка 54) на
настоящем ключе: полнота решает 1,2% спорных координат, тай-брейк — 98,8%.
То есть «выигрывает более полная точка» — не главное правило слияния, а
редкий частный случай, и главным всё это время был лексикографический
порядок JSON.
- **Чем воспроизведён:** `task verify:archive` до и после. До — FAIL,
`step_count unknown`, отпечаток `bf36b477…`; после — PASS, `step_count
cumulative`, отпечаток `03aace91…`, ноль противоречащих часов, и заодно
`headphone_audio_exposure` вернулся из `unknown` в `instant`.
- **Почему не поймали:** та же причина, что 2026-08-02 и 2026-08-03, третий раз
подряд. Прогон живого архива в гейт не входит, значит его краснота видна
только следующей задаче, которая до него дотянется. Но добавилось новое:
здесь протухла не константа, а **оценка области действия правила**, снятая на
корпусе, где спорных координат было 2 897. Ни один проход ревью не
перепроверяет числа, на которых стоит нормативный текст спеки, — они читаются
как факт. Поймал это проход `specs` на профиле `design`: он сверил число в
дельте с находкой 49, увидел расхождение в 29 раз и потребовал назвать метод.
Метод оказался неверным (ключ без слоя), число — завышенным, а соотношение —
верным.
- **Что меняем:** ничего в составе конвейера — он сработал. Два правила
промоутятся в конвенции (см. `conventions/testing.md`): «в проверке на живом
корпусе утверждается инвариант, число печатается» — оно было записано здесь
2026-08-02 со словами «годится в конвенции» и не доехало, после чего класс
повторился дважды; и «оракул сходимости называет свою посылку рядом с собой».
Третий случай одного класса за три дня — это уже не совпадение, и в
`docs/conventions/testing.md` он теперь правило, а не запись в журнале.
## 2026-08-04 — гейт после интеграции пропустил все go-шаги и объявил себя зелёным [пойман]
- **Где:** конвейер, а не код — `Taskfile.yml`, шаг `gate`, и правило батча
«после каждой интеграции — гейт на основной ветке»
- **Симптом:** после `git merge --ff-only` ветки задачи `task gate` без
аргументов напечатал «код не менялся — go-шаги пропускаются» и вышел с нулём.
Сборка, тесты, гонки, покрытие диффа и миграции **не гонялись вовсе**, а исход
выглядел как зелёный прогон.
- **Причина:** база диффа по умолчанию — `git merge-base HEAD master`. На самой
ветке `master` после ff-слияния это сам `HEAD`, дифф пуст, и все шаги,
привязанные к изменённым файлам, честно пропускаются. Пропуск по пустому
диффу — правильное поведение шага; неправильно то, что **правило интеграции
на него опирается**: батч вливает ветку и проверяет результат прогоном,
который в этот момент проверить ничего не может.
- **Чем воспроизведён:** `task gate` — 0, все go-шаги SKIP. `task gate
BASE=<коммит до слияния>` на том же дереве — 45 изменённых файлов, 13 шагов,
и **красный** `lint`.
- **Что изменено:** `.golangci.yml``./tmp` исключён из проверок
(`9f77e56`): `CLAUDE.md` велит держать черновое в `./tmp`, а линтер про это не
знал, и туда попадали и worktree батча, и диагностические программы. Краснота
по причине, не связанной с изменением, приучает не читать красноту.
- **Что осталось незакрытым:** гейт после интеграции обязан звать `BASE`
вершиной **до** слияния. Сейчас это знание живёт только в этой записи —
ни `Taskfile.yml`, ни скилл батча его не несут.
## 2026-08-04 — событие о новой секции терялось на отказе слияния [пойман]
- **Где:** `internal/fold/fold.go`, ветвь отказа `store.Merge` в change
`2026-08-04-aktivnaya-proverka-novyh-sekcij`
- **Симптом:** доставка, принёсшая имя секции впервые, при нетранзиентном отказе
слияния писала имя в `delivery.uncovered_sections`, но запись об отказе его не
называла. Следующая доставка считала имя виденным — событие, однократное за
всю жизнь имени, пропадало **навсегда**, то есть ровно то, ради чего задача и
делалась.
- **Причина:** ветвей записи исхода в свёртке четыре, а дизайн рассмотрел одну.
Признак новизны считался до ветвления и корректно доезжал до `residueOf`
(отказ разбора), но ветвь отказа слияния собирала остаток **вручную** и поле
новизны в него не клала. Дельта-спека говорила «до ветвления на успех и
отказ», подразумевая один отказ.
- **Чем воспроизведён:** свёртка доставки с новой секцией при снесённой таблице
`bucket` — запись `ERROR` без `uncovered_new`, а `SectionsSeenBefore` на
следующей доставке уже отвечает «виденное». Тест закреплён:
`TestFoldОтказСлиянияНазываетНовуюСекцию`.
- **Чем пойман:** тремя проходами независимо (`specs`, `code`, `adversary`),
причём двое написали падающий тест. Дешёвый `code`-проход нашёл его наравне с
дорогими — признак того, что дефект был в форме «ветвь собрана руками рядом с
ветвью, собранной функцией», а такое видно чтением.
- **Что изменено:** новизна передаётся и в эту ветвь; дельта-спека переписана в
терминах «каждый исход, который пишет список в учётную запись», и отдельно
названы исходы, которые список очищают (нечитаемое тело, паника) и потому
события не теряют.
## 2026-08-04 — замер стоимости снят на корпусе, где измеряемого случая не бывает [пойман]
- **Где:** `design.md` того же change, решение 3; утверждение «в режиме
постоянного приезда секции сверка стоит 18 мкс на доставку»
- **Симптом:** на числе стояло решение «частичный индекс не нужен». Число
описывало **не тот** режим.
- **Причина:** синтетический журнал наполнялся так, что новая секция была во
**всех** доставках, то есть её первая встреча лежала в самом начале журнала —
и `LIMIT 1` выходил рано. В жизни секцию включают на телефоне сегодня: первая
встреча оказывается в хвосте, и проход идёт почти по всему журналу на каждой
доставке. Разница — три порядка (31 мкс против 52 мс).
- **Чем воспроизведён:** `tmp/seenmeasure` с хвостовым именем: голова 31 мкс,
хвост 52 мс, отсутствующее имя 50 мс.
- **Чем пойман:** `adversary` — он не поверил числу и построил корпус, в котором
измеряемый случай выглядит как в жизни. Это третий случай за три дня, когда
оценка оказалась функцией того, **как устроен корпус**, а не того, что
измеряют (записи 2026-08-02, 2026-08-04 про `verify:archive`).
- **Что изменено:** замер перемерян тремя случаями (голова, хвост, отсутствие),
числа сведены в одно место (`design.md`), код и `architecture.md` формулируют
правило и ссылаются на источник. Развилка «принять цену или завести индекс»
вынесена владельцу.
- **Что осталось незакрытым:** правило «число замера обязано нести метод и
описывать тот случай, ради которого снято» действует только для тестов
(`conventions/testing.md`). На `design.md` оно теперь распространено записью
ниже, но механизировать его нечем.
## 2026-08-04 — гейт дважды покраснел от чужого мусора: кеш линтера и черновик в `./tmp` [пойман]
- **Где:** конвейер, а не код — `scripts/gate.py`, шаги `lint` и `test`
- **Симптом:** в задаче про форму провода `task gate` дал `FAIL lint` с
сообщением `../../internal/store/store.go:260: use of time.Now forbidden`
путь ведёт в **главный репозиторий**, а прогон шёл в worktree задачи. Позже, в
том же прогоне задачи, `FAIL test` на
`TestОднаМеткаИзТелаУбиваетМаршрутКаталога` — тесте, которого в задаче нет
вовсе.
- **Причина:** два разных механизма, один класс — в гейт затекает то, что к
изменению отношения не имеет.
- `golangci-lint` ходит в **общий на машину** `~/.cache/golangci-lint`, а
конвейер задач работает в нескольких worktree одного модуля (`tmp/wt-*`).
Кеш отдаёт замечания, привязанные к путям чужого дерева, и правило-исключение
`^internal/(ident|store)/` на путь вида `../…` не распространяется.
- `go test ./...` не знает про `./tmp`: `.golangci.yml` каталог исключает
(коммит `9f77e56`), а `go test` — нет. Проход `adversary` оставил там свой
падающий тест-оракул, и он стал частью набора.
- **Чем воспроизведён:** первое — независимо проходом `review-gate`: временный
worktree базовой ревизии, `golangci-lint run ./...` без очистки кеша даёт
замечание с путём **другого** дерева; после `golangci-lint cache clean` на той
же ревизии — `0 issues`. Второе — `tmp/gate/test.log`: `FAIL` в пакете
`git.vakhrushev.me/av/healthlog/tmp/adv/oracle`.
- **Чем пойман:** обоими случаями — самим гейтом, но **ценой разбора**: краснота
выглядела как дефект изменения, и каждый раз пришлось доказывать, что это не
он. Ровно та цена, что названа записью 2026-08-04 выше: «краснота по причине,
не связанной с изменением, приучает не читать красноту».
- **Что изменено:** шаг `lint` получил свой кеш —
`GOLANGCI_LINT_CACHE=tmp/gate/golangci`, — то есть прогон стал герметичным по
дереву. Цена названа и замерена проходом `ops`: N деревьев × 10–15 МиБ вместо
одного общего кеша, штатный трим go-build-формата у него есть.
- **Что осталось незакрытым:** `go test ./...` по-прежнему видит черновые
go-пакеты в `./tmp`. Убирать за собой обязан тот, кто их создал (в этот раз —
проход ревью), и механизма против забывчивости нет. Дешёвый кандидат, если
класс повторится: `go test` по явному списку `./cmd/... ./internal/...` вместо
`./...`. Не сделано намеренно — один случай не отличим от случайности, а
правило, введённое по одному случаю, потом никто не помнит зачем.
+31 -5
View File
@@ -32,8 +32,10 @@ disabled`, `read auth disabled`), но стартовать не отказыв
значения, метки времени, имена источников и устройств, имена секций, `id`
тренировок и записей, содержимое маршрута.
- **Заголовки доставки** — включая `automation-id`, `automation-aggregation`,
`User-Agent`, `Accept-Language`, `Upload-Complete`; они пишутся в `delivery` и
участвуют в выводе слоя. Заголовки полуправдивы: `automation-aggregation`
`User-Agent`, `Accept-Language`, `Upload-Complete`; они пишутся в `delivery`,
участвуют в выводе слоя, а `Accept-Language` — ещё и в выводе кода
категориального значения (тег ограничен по длине и по форме, не тег даёт
пустую локаль). Заголовки полуправдивы: `automation-aggregation`
реальной гранулярности не описывает (разведка, находка 33).
- **Размер тела** — предела на одну сущность нет; наблюдалось 63 МиБ на одной
координате и 768 МиБ пика кучи на теле 40 МиБ.
@@ -42,6 +44,13 @@ disabled`, `read auth disabled`), но стартовать не отказыв
`export.xml`, который выбирает человек, но формируется он устройством и по
объёму (3,6 млн записей) глазами не проверяется.
**Новый адресат недоверенного входа — терминал оператора.** Подкоманда
`healthlog uncovered` печатает имена секций, а имя это верхнеуровневый ключ
чужого тела: длина у него ограничена разбором (64 байта, не больше 32 имён),
содержимое — ничем. Печатается оно экранированным (`%q`), иначе управляющая
последовательность из тела подделала бы строки вывода. Тот же вход попадает
структурным атрибутом в лог свёртки, где его экранирует кодировщик `slog`.
Ответы внешних систем в недоверенный вход не входят: исходящих вызовов у
сервиса нет.
@@ -52,11 +61,28 @@ disabled`, `read auth disabled`), но стартовать не отказыв
**Ни один сегмент пути не берётся из тела или заголовков доставки** — это и
есть защита от выхода за пределы каталога, и она держится ровно на этом.
- **Координатный ключ точки**`метрика + слой + начало + конец`. Имя метрики
приходит из тела и в путь на диске не попадает, но попадает в ключ, в лог и в
ответ каталога. Любое значение из чужого JSON, попадающее в ключ, в лог или в
отчёт, имеет названный предел длины.
приходит из тела и в путь на диске не попадает, но попадает в ключ, в лог, в
ответ каталога и — с появлением маршрута точек — **в адрес запроса и в
заголовок `ETag` ответа**. Любое значение из чужого JSON, попадающее в ключ, в
лог, в отчёт или в заголовок, имеет названный предел длины. У метки ответа
предел взят формой: в неё уезжает не имя, а хеш канонизированной формы запроса
(128 бит). Причина не только в длине — имя законно содержит кавычку, которая
по RFC 9110 кончает метку, и разбор обрезал бы её ровно там.
- **Имя метрики в адресе** декодируется из пути **ровно один раз**. Второе
декодирование превращает имя `a%41b` в имя `aAb` — то есть в имя **другой**
метрики витрины, и маршрут отвечает `200` её данными. Путь построен и прогнан
враждебным проходом ревью.
- **Ключ сущности**`род секции + id` из HealthKit для `record`, `id` для
`workout`. `id` приходит из тела.
- **Ключ наблюдённого категориального значения**`метрика + поле + значение`.
Значение приходит из тела дословно и уезжает в первичный ключ: предел на него
назван числом (128 байт), число различных значений одной доставки ограничено
(64), и **граница применяется при накоплении, а не при выдаче** — иначе
накопитель растёт вместе с телом, а тело контролирует отправитель (измерено:
миллион различных значений в теле 60 МиБ поднимал пик процесса с 780 до
1002 МиБ). Значение, которое разбор JSON подменил (невалидный UTF-8, одинокий
суррогат), наблюдением не считается вовсе: в ключ обязано попасть то, что
пришло, а не то, что получилось.
- **Файл базы и каталог архива** — из конфига, не из запроса.
## Что разграничивает доступ
+43 -42
View File
@@ -1,52 +1,53 @@
# Беклог
Что **можно взять**. Одна задача = один файл `items/<slug>.md`
+ строка здесь. Целей тут нет — они в [PLAN.md](PLAN.md): беклог — то, что берут,
план — то, подо что берут. Порядка внутри секции нет: «что делать
+ строка здесь. Целей тут нет — они в [ROADMAP.md](ROADMAP.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 коммитится — так нельзя выезжать наружу
- [✨ Проверять целостность собранной витрины до подмены](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) — Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым
- [✨ Не задваивать тренировки при импорте родного экспорта](items/workout-identity-on-import.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
- [✨ Импортировать родной экспорт Apple Health](items/apple-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [🐞 Не отбирать строки в data-миграциях по обрезаемым спискам](items/data-migration-row-selection.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
- [🧹 Не держать весь журнал в памяти при пересборке](items/rebuild-memory-footprint.md) — Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
- [🐞 Держать порядок журнала при конкурентных приёмах](items/journal-order-on-ingest.md) — Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
- [✨ Ограничить размер и число заголовков доставки](items/delivery-header-limits.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
- [✨ Ограничить размер сущности и считать форму потоково](items/entity-size-limits.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
- [✨ Выводить схемы содержимого из данных](items/derived-content-schemas.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [✨ Сверять живую витрину с пересборкой](items/rebuild-comparison-check.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- [✨ Помечать нижний слой устаревшим после экспорта](items/lower-layer-expiry.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [🐞 Класть заголовки доставки в архив рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
- [✨ Поднять MCP-сервер поверх Read API](items/mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [✨ Написать OpenAPI-спеку руками](items/openapi-spec.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [🧹 Ловить гейтом расхождение спеки с маршрутами](items/openapi-gate-check.md) — Рукописная спека — источник истины, а расходится она с кодом молча: без гейта решение о рукописной спеке не держится
- [✨ Поднять Swagger UI без внешней сети](items/swagger-ui.md) — Контракт читается машиной, но человеку нечем выполнить запрос к живому сервису из браузера, а внешних CDN в локальной сети нет
- [🔬 Измерить, нужно ли правило полноты рядом с LWW](items/last-wins-over-completeness.md) — Полнота решает 1,2% спорных координат, и неизвестно, была ли более полная точка более поздней — от этого зависит, нужна ли она вообще
- [🔬 Человеческие аннотации поверх выведенных схем](items/schema-annotations.md) — Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
- [🔬 Месячный проход по ручным секциям](items/monthly-manual-sections-pass.md) — Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
- [🔬 NDJSON-поток для больших выборок Read API](items/ndjson-stream.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
- [🔬 Отказ от heartbeatSeries](items/drop-heartbeat-series.md) — 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
- [🔬 Пересекающиеся источники одной метрики](items/overlapping-sources.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
- [🔬 Порог sealed: с какого возраста час считается запечатанным](items/sealed-threshold.md) — WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
- [🔬 Разворачивание маршрутов тренировок в отдельную таблицу](items/workout-routes-table.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
- [🔬 Что считать сутками при смене часового пояса](items/day-boundary-timezone.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- [🔬 Выгрузка в parquet отдельной командой](items/parquet-export.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
## Инфра
- [✨ Слать уведомление, когда данных нет 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) — Архив не подчищается вовсе, а резать его раньше даты проверенного экспорта нельзя — в журнале останется дыра, которую нечем пересобрать
- [✨ Отдавать состояние сервиса маршрутом /stats](items/stats-endpoint.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- [🐞 Свести умолчания конфига с рабочей раскладкой данных](items/config-defaults-data-dir.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
- [✨ Развести токены контуров и убрать секреты из репозитория](items/token-and-secret-management.md) — Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
-45
View File
@@ -1,45 +0,0 @@
# План
Оглавление целей. Цель — файл `[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
+6
View File
@@ -8,3 +8,9 @@
- 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-04 `mcp` — 🎯 MCP. Причина: поглощена целью read-api («Чтение данных клиентами»): MCP — не направление, а последний шаг того же направления; адаптер переводит вызовы в те же обработчики и собственной логики не несёт. Очередь «Read API перед MCP» стала порядком задач внутри цели. Задача mcp-server жива и перевешена на read-api. Была секция: порядок.
- 2026-08-04 `read-api-points` — Read API: точки, выбор слоя, свёртка по сетке. Причина: разложена на read-api-envelope-and-points (конверт, точки за период, форма провода, условный запрос), read-api-bucketing (свёртка по сетке, предел размера ответа, порог неполного ведра) и read-api-workouts-and-records (тренировки и записи наружу). Одним заходом не мерджилась: десяток критериев приёмки и три развилки в одном файле. Была секция: ядро.
- 2026-08-04 `read-api-envelope-and-points` — Конверт ответа и точки за период. Причина: разложена на read-api-wire-format (форма провода, мерджится первой и трогает только живой каталог), read-api-points-period (точки за период с конвертом) и read-api-points-conditional (условный запрос со scope-etag). Была секция: ядро.
- 2026-08-04 `read-api-bucketing` — Свёртка по сетке и предел размера ответа. Причина: разложена на read-api-points-bucket (свёртка по сетке), read-api-partial-bucket (порог неполного ведра и его полярность) и read-api-response-limit (предел размера ответа, общий для всех маршрутов чтения). Была секция: ядро.
- 2026-08-04 `read-api-workouts-and-records` — Тренировки и записи наружу. Причина: разложена на read-api-workouts и read-api-records: разные сущности и разные маршруты, независимые друг от друга. Была секция: ядро.
- 2026-08-04 `openapi-swagger` — OpenAPI-спека и Swagger UI. Причина: разложена на openapi-spec (рукописная спека), openapi-gate-check (гейт красит расхождение спеки с маршрутами) и swagger-ui (UI без внешней сети). Была секция: ядро.
+54
View File
@@ -0,0 +1,54 @@
# Роадмап
Что приложение уже умеет и чего ещё не умеет. Цель — возможность приложения,
файл типа `goal` в `items/`; её задачи здесь **не перечисляются** — перечень даёт
`tasks.py list --goal <слаг>`. Очередь значима только в «Запланировано» и
обосновывается прозой рядом. В «Сопровождении» лежит то, чем держат проект —
выкладка, инструмент, эксплуатация; граница проходит по тому, кто наблюдает:
сообщает ли о состоянии приложение своему пользователю или дежурный смотрит на
сервис снаружи.
## Запланировано
Очередь держится на двух зависимостях. **`healthlog import` идёт перед чисткой
нижнего слоя:** пока импорт экспорта не написан, помечать что-либо устаревшим не
на основании чего. **MCP входит в чтение, а не идёт отдельной целью:** адаптер
собственной логики не несёт, он переводит вызовы в те же обработчики, и очередь
осталась порядком задач внутри цели.
- [🎯 Клиенты читают данные через HTTP и MCP](items/read-api.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей, включая агента-медика, ничего прочитать не может
- [🎯 Клиент узнаёт форму данных из ответа](items/self-description.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [🎯 История из родного экспорта Apple лежит в хранилище](items/native-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [🎯 Нижний слой чистится после проверенного экспорта](items/lower-layer-cleanup.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [🎯 Приложение сообщает о своём состоянии](items/observability.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
## Направления
- [🎯 Исход слияния не зависит от порядка элементов на проводе](items/merge-robustness.md) — Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
- [🎯 Расхождение витрины с журналом не молчит](items/journal-and-rebuild.md) — Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
- [🎯 У каждого входа есть названный предел](items/limits-and-load.md) — Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
- [🎯 Новая форма от источника не теряется молча](items/parsing-completeness.md) — Пять секций HAE поток ещё не приносил, а ручные секции заводятся задним числом — разбор полон ровно до следующей новой формы
## Сопровождение
- [🎯 Сервис доступен телефону из любой сети](items/deploy.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
## Готово
- 2026-08-01 `ingest` — Сервис принимает доставки HAE и кладёт тела в архив.
Приём отвечает `200` до разбора, свёртку ведёт фоновый воркер: код ответа
отражает доставку, а не её понимание.
- 2026-08-02 `reindex``healthlog reindex` проигрывает журнал в свежую витрину
и печатает оба отпечатка. Повторный прогон ничего не меняет.
- 2026-08-02 `catalog` — Клиент видит перечень разрезов с измеренным родом
агрегации. Сверка минутного слоя с часовым разложила метрики живого корпуса на
накопительные и мгновенные, не сойдясь ни на одной.
- 2026-08-04 `parsing-and-storage` — Метрики, тренировки и записи со своими `id`
разобраны и лежат в часовых объектах. Ни одна секция живого потока не числится
неразобранной, категориальные значения несут стабильный код рядом с
переведённой строкой, первая встреча незнакомой секции наблюдаема.
Разведка формата закончена там же и записана в
[research/apple-health.md](../research/apple-health.md): правило вывода слоя,
модель идентичности и формы точки проверены на живом потоке. Возможностью
приложения она не была, поэтому строки среди достигнутых целей не занимает.
+12 -2
View File
@@ -1,6 +1,16 @@
# Спринт
Спринта нет. Цель называет человек, набор собирает агент:
`tasks.py sprint start --goal <слаг>`.
- **Цель:** [🎯 Клиенты читают данные через HTTP и MCP](items/read-api.md)
- **Начат:** 2026-08-04
- **Спринт:** `2026-08-04`
Урожай спринта перечисляет `tasks.py list --tag sprint:2026-08-04`; в наборе — первая порция задач, переоценённых в этой сессии.
## Набор
- [✨ Отвечать 304 на повторный запрос точек](items/read-api-points-conditional.md) — Агент опрашивает по расписанию, а каждый повтор стоит полного чтения: на каталоге это 693 мс и +153 МиБ
- [✨ Сворачивать точки по заданной сетке](items/read-api-points-bucket.md) — «Шаги за неделю по дням» — базовый запрос трекера и игры, и сегодня его нечем задать
- [✨ Отличать неполное ведро от полного](items/read-api-partial-bucket.md) — Текущий час неполон всегда, и без порога свёртка отдаёт его наравне с полными — клиент видит провал вместо неизвестности
- [✨ Ограничить размер ответа маршрутов чтения](items/read-api-response-limit.md) — У маршрутов чтения нет ни одного потолка: множители «метрики × окно × точки × одновременные запросы» ничем не ограничены
- [✨ Отдавать тренировки вместе с маршрутом](items/read-api-workouts.md) — Тренировки с маршрутами разобраны и лежат в витрине, а маршрутов чтения нет — сценарий трекера не закрыт
- [✨ Отдавать записи со своим id за период](items/read-api-records.md) — stateOfMind разобран и хранится, но наружу не отдаётся — а восстановить его нечем: в экспорте Apple его нет
+32 -4
View File
@@ -1,6 +1,7 @@
# Импорт родного экспорта Apple Health
# Импортировать родной экспорт Apple Health
- **Секция:** ядро
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- **Теги:** goal:native-export-import
@@ -37,9 +38,36 @@
должен ничего менять;
- `export_cda.xml` игнорируем — это клинический формат тех же данных.
Двигает строку «Завершения» цели: «Слой `sample` наполнен историей с 2019 года, повторный импорт того же экспорта ничего не меняет».
## Импорт выставляет пометку покрытия
Импорт — единственный, кто знает, какой период каким слоем обеспечен, поэтому
пометку ставит он, а не отдельный проход задним числом.
Форма пометки решена в [lower-layer-expiry](lower-layer-expiry.md): **одна
строка на диапазон** — `метрика + слой + период + «покрыто проверенным
экспортом»`. Провенанс на каждую точку не заводим: вопрос диапазонный, а поле у
точки стоило бы того же объёма, который устаревание нижнего слоя и приходит
экономить.
Два условия, оба из ограничителей той задачи:
- пометка ставится **по проверенному** импорту, а не по факту запуска команды.
Проверка та же, что уже названа в приёмке: непрерывность по дням и сходимость
сумм с часовым слоем HAE на пересечении периодов. Не сошлось — пометки нет,
и это не отказ импорта, а честный отказ от обещания;
- пометка **ничего не удаляет**. Она только даёт устареванию нижнего слоя
основание; само удаление включается отдельно и позже.
**`stateOfMind` пометку не получает никогда** — его в экспорте Apple нет ни
одним типом (находка 42), источник у него единственный, и устаревание к нему
неприменимо. Это надо записать явно, а не оставить следовать из отсутствия
данных.
Готово, когда история за несколько лет лежит в слое `sample`, повторный импорт
не меняет ничего, а суммы по слою сходятся с часовым слоем HAE на пересечении
периодов.
не меняет ничего, суммы по слою сходятся с часовым слоем HAE на пересечении
периодов, а покрытые периоды помечены и видны без пересборки.
Архивы: `/home/av/MediaEverything/HealthData/apple_health/` — девять штук,
2021-12 … 2026-08. Старые версии формата годятся как регрессионный набор.
@@ -1,41 +0,0 @@
# Словарь категориальных значений → коды HealthKit
- **Секция:** ядро
- **Зачем:** Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
- **Теги:** goal:parsing-and-storage
HAE отдаёт перечислимые значения строками локали телефона: «БДГ», «Сидячий
образ жизни», «В помещении Ходьба». Родной экспорт Apple при этом говорит
кодами (`HKCategoryValueSleepAnalysisAsleepREM`) — источники несопоставимы
(находка 37).
Три следствия, и третье решающее: клиент угадывает словарь; смена языка
телефона молча расколет историю; сверить покрытие экспортом нечем — а на этой
сверке стоит устаревание нижнего слоя.
Решение (вариант «б»): строка хранится **дословно**, рядом кладётся выведенный
код. Словарь ключуется парой `(локаль, строка)`, локаль берётся из
`Accept-Language`. Незнакомая строка → пустой код, а не догадка.
**Словарь фаз сна уже выведен** сопоставлением потока с экспортом за тот же
период (находка 43) — составлять руками не нужно:
```
Основная → AsleepCore Бодрствование → Awake БДГ → AsleepREM
Глубокий → AsleepDeep В кровати → InBed Во сне → AsleepUnspecified
```
Тем же способом добираются `heart_rate.context` и типы тренировок.
Осложнение, всплывшее на истории экспортов: **коды тоже не вечны.** Одни и те
же записи сна приезжают как `…Asleep` в экспорте 2021 года и как
`…AsleepUnspecified` в экспорте 2026-го: Apple переименовала значение и
переписывает историю при выгрузке (находка 43). Значит словарь должен
переживать переименование самих кодов, иначе после обновления iOS история
расколется вторично — уже на «стабильной» стороне. Простейшее решение: хранить код как есть, а
эквивалентность старых и новых имён держать отдельной таблицей синонимов.
Готово, когда фазы сна из потока и из экспорта Apple сравниваются напрямую, а
`/stats` показывает строки, для которых кода ещё нет.
`stateOfMind` в словаре не нуждается — он и так шлёт коды HealthKit.
+5 -2
View File
@@ -1,6 +1,7 @@
# Умолчания конфига указывают на прежнюю раскладку
# 🐞 Свести умолчания конфига с рабочей раскладкой данных
- **Секция:** инфра
- **Тип:** fix
- **Категория:** Инфра
- **Зачем:** Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
- **Теги:** goal:deploy
@@ -16,3 +17,5 @@
Готово, когда запуск без конфига использует `./data` и не создаёт ничего в
корне репозитория. Тогда же снимается предупреждение из `config.example.toml`.
Двигает строку «Завершения» цели: «Запуск без конфига не заводит базу мимо `./data`».
@@ -1,12 +1,15 @@
# Data-миграции не отбирают строки по обрезаемым спискам
# 🐞 Не отбирать строки в data-миграциях по обрезаемым спискам
- **Секция:** ядро
- **Тип:** fix
- **Категория:** Ядро
- **Зачем:** Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
- **Теги:** goal:journal-and-rebuild
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`).
Двигает строку «Завершения» цели: «Data-миграции не наследуют слепые зоны обрезаемых списков».
## Оракул: механизм доказан, дефект пока пустой
Миграция `00007` переводит в `pending` доставки, у которых имя ставшей покрытой
+3 -2
View File
@@ -1,6 +1,7 @@
# [idea] Что считать сутками при смене часового пояса
# 🔬 Что считать сутками при смене часового пояса
- **Секция:** ядро
- **Тип:** research
- **Категория:** Ядро
- **Зачем:** Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- **Теги:** goal:read-api
+5 -2
View File
@@ -1,6 +1,7 @@
# Предел на размер и число заголовков доставки
# ✨ Ограничить размер и число заголовков доставки
- **Секция:** ядро
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
- **Теги:** goal:limits-and-load
@@ -27,3 +28,5 @@
не оставляя следа в базе, а обычная доставка проходит как раньше.
Связано: `internal/httpapi`, `internal/ingest`, `docs/architecture.md` → «Приём».
Двигает строку «Завершения» цели: «У заголовков доставки есть названный предел».
@@ -1,6 +1,7 @@
# Заголовки доставки в архиве рядом с телом
# 🐞 Класть заголовки доставки в архив рядом с телом
- **Секция:** ядро
- **Тип:** fix
- **Категория:** Ядро
- **Зачем:** Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
- **Теги:** goal:journal-and-rebuild
@@ -32,7 +33,7 @@ Prior art прямой: **WARC** (формат веб-архивов) храни
операциям, но появляется третья сущность.
Цена ошибки высокая: правится **путь приёма**, а доставка, не попавшая в архив,
теряется навсегда. Значит профиль ревью — `deep`, и менять надо так, чтобы
теряется навсегда. Значит менять надо так, чтобы
старые тела без заголовков продолжали читаться.
Готово, когда пересборка на архиве, у которого рабочей базы нет вовсе, даёт то
@@ -40,3 +41,5 @@ Prior art прямой: **WARC** (формат веб-архивов) храни
Связано: `docs/architecture.md` → «Сырой архив и восстановление состояния»,
`internal/replay`.
Двигает строку «Завершения» цели: «Пересборка восстановима без базы: заголовки доставки лежат в архиве рядом с телом».
+5 -2
View File
@@ -1,6 +1,7 @@
# Деплой на rivendell
# ✨ Выложить сервис на rivendell
- **Секция:** инфра
- **Тип:** feature
- **Категория:** Инфра
- **Зачем:** Сервис живёт на рабочей машине — телефон достаёт до него только дома
- **Теги:** goal:deploy
@@ -30,3 +31,5 @@
Том стоит смонтировать так, чтобы серверный бекап забирал его без отдельной
настройки, и снимать копию SQLite через `VACUUM INTO`, а не `cp`: телефон шлёт
непрерывно, и файл под записью копировать нельзя.
Двигает строку «Завершения» цели: «Телефон шлёт на публичный адрес из любой сети, агент читает по тому же домену».
+10 -9
View File
@@ -1,17 +1,18 @@
# [goal] Деплой
# 🎯 Сервис доступен телефону из любой сети
- **Секция:** порядок
- **Тип:** goal
- **Секция:** Сопровождение
- **Зачем:** Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
- **Теги:** decomposed
Сервис переезжает на rivendell и становится доступен телефону из любой сети.
Выведена из шага 11 плана.
Завершена, когда оба контура закрыты разными токенами, откат релиза имеет
названный механизм, а запуск без конфига не заводит базу мимо данных.
## Завершение
Оба контура закрыты разными токенами, откат релиза имеет названный механизм,
а запуск без конфига не заводит базу мимо данных.
- Телефон шлёт на публичный адрес из любой сети, агент читает по тому же домену
- Оба контура закрыты разными токенами, и без токенов сервис стартует только на
localhost
- Откат релиза после наката миграции имеет названный механизм
- Запуск без конфига не заводит базу мимо `./data`
- Остановка сервиса называет виновный этап честно, а накат миграций виден в логе
старта
+5 -2
View File
@@ -1,6 +1,7 @@
# Выведенные из данных схемы содержимого
# Выводить схемы содержимого из данных
- **Секция:** ядро
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- **Теги:** goal:self-description
@@ -25,3 +26,5 @@
Связано: `docs/architecture.md` → «Самоописание». Форму конверта API описывает
не эта задача, а OpenAPI.
Двигает строку «Завершения» цели: «Формы содержимого метрик выведены из данных, а не описаны руками».
+3 -2
View File
@@ -1,6 +1,7 @@
# [idea] Отказ от heartbeatSeries
# 🔬 Отказ от heartbeatSeries
- **Секция:** ядро
- **Тип:** research
- **Категория:** Ядро
- **Зачем:** 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
- **Теги:** goal:lower-layer-cleanup
+5 -2
View File
@@ -1,6 +1,7 @@
# Пределы на размер сущности и потоковый расчёт формы
# ✨ Ограничить размер сущности и считать форму потоково
- **Секция:** ядро
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
- **Теги:** goal:limits-and-load
@@ -10,6 +11,8 @@
структурное: **предела на размер одной сущности нет вовсе**, а форма и хеш
считаются материализацией значения целиком.
Двигает строку «Завершения» цели: «У тела, сущности и секции доставки есть названный предел».
## Оракул: измерено
Оракулы жили в `tmp/adv/mem_test.go` и `tmp/adv/lock_test.go`; числа снимались
+26 -18
View File
@@ -1,8 +1,21 @@
# Сущность с id, но неразобранной меткой
# 🐞 Не терять сущность с id и неразобранной меткой
- **Секция:** ядро
- **Зачем:** Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
- **Теги:** goal:parsing-and-storage, question
- **Тип:** fix
- **Категория:** Ядро
- **Зачем:** Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым
- **Теги:** goal:parsing-completeness
**Решение принято владельцем 2026-08-03: вариант (1) — хранить с NULL-меткой.**
`start_utc`/`ts_utc` становятся NULLABLE, содержимое (включая маршрут) хранится,
метку восстановит пересборка, когда разбор научится читать формат. Вариант (3)
отвергнут при постановке: подстановка метки доставки — выдуманное измерение в
колонке, по которой идёт выборка.
**Берётся после [тренировок](read-api-workouts.md) и [записей](read-api-records.md) наружу.** Правило
чтения — что выборка «за период» делает со строками без метки — обязано
проектироваться вместе с читателем, иначе такие строки молча исчезнут из любого
ответа. Порядок тот же, что у [journal-order-on-ingest](journal-order-on-ingest.md)
после `/stats`: решение принято, момент взятия назван.
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`). Та задача сделала мягким чтение заголовка:
@@ -10,6 +23,8 @@
разбор кладёт сущность в `ts_utc`/`start_utc`, колонки `NOT NULL`, и сущность
с неразбираемой меткой по-прежнему пропускается целиком.
Двигает строку «Завершения» цели: «Сущность с `id` и неразобранной меткой не пропадает целиком».
## Что известно
- Оракул: `internal/hae/entity_test.go`, случаи «метка в ином формате», «метка
@@ -22,18 +37,11 @@
пропусков всех трёх классов. Дрейф формата дат у HAE при этом
задокументирован (`docs/research/apple-health.md`), то есть вход не выдуман.
## Вопросы
Хранить ли сущность с разобранным `id` и неразобранной меткой. Цена:
## Чем платим за отсрочку
1. **Хранить с NULL-меткой** — правка схемы (`start_utc`/`ts_utc` становятся
NULLABLE) плюс правила чтения витрины: выборка «за период» обязана сказать,
что делает с такими строками, иначе они молча исчезнут из любого ответа.
Зато содержимое (маршрут!) сохраняется, а метку восстановит пересборка,
когда разбор научится читать формат.
2. **Не хранить** — как сейчас. Тело живёт в архиве до ретеншена, доставку
вернёт `reindex`. После включения ретеншена окно становится необратимым.
3. **Хранить, подставив метку доставки** — отвергается сразу: это выдуманное
измерение в колонке, по которой идёт выборка.
Рекомендация — (1), но не раньше, чем появится Read API по сущностям: правило
чтения без читателя проектируется вслепую.
Вариант «не хранить» — то, чем живём сегодня: тело лежит в архиве, доставку
вернёт `reindex`. Отсрочка безопасна ровно до включения
[ретеншена](raw-archive-retention.md): после него окно становится необратимым.
Значит эти две задачи связаны порядком — ретеншен не включается раньше, чем
сущность без метки начнёт храниться, либо включается с явной записью о том,
что этот класс теряется.
+5 -2
View File
@@ -1,6 +1,7 @@
# Проверка целостности собранной витрины перед подменой
# Проверять целостность собранной витрины до подмены
- **Секция:** ядро
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
- **Теги:** goal:journal-and-rebuild
@@ -27,3 +28,5 @@
называть результат годным, а на здоровом — не замедляется заметно.
Связано: `cmd/healthlog/reindex.go`, `docs/architecture.md` → «Пересборка».
Двигает строку «Завершения» цели: «Годность собранной витрины подтверждена до подмены файла».
+17 -10
View File
@@ -1,20 +1,27 @@
# [goal] Журнал и пересборка
# 🎯 Расхождение витрины с журналом не молчит
- **Секция:** темы
- **Тип:** goal
- **Секция:** Направления
- **Зачем:** Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
- **Теги:** decomposed
Тема: инвариант «`import` + `replay` даёт то же состояние» и всё, что его
Направление: инвариант «`import` + `replay` даёт то же состояние» и всё, что его
держит — архив, ретеншен, отпечаток витрины, расход памяти пересборки.
В порядок не встаёт: работа приходит находками и растёт вместе с
В «Запланировано» не встаёт: работа приходит находками и растёт вместе с
журналом.
Завершена не бывает: закрывается по мере того, как расхождение витрины с
журналом перестаёт быть молчащим.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как расхождение витрины
с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с
журналом.
Завершена не бывает — это направление. Закрывается по мере того, как расхождение
витрины с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с
журналом. Открыто сегодня:
- Расхождение живой витрины с пересборкой замечает сервис, а не человек
- Годность собранной витрины подтверждена до подмены файла
- Порядок журнала держится при конкурентных приёмах
- Пересборка восстановима без базы: заголовки доставки лежат в архиве рядом с
телом
- Сырой архив подчищается до последнего проверенного экспорта
- Расход пересборки не растёт вместе с журналом
- Data-миграции не наследуют слепые зоны обрезаемых списков
+71 -3
View File
@@ -1,7 +1,8 @@
# Порядок журнала при конкурентных приёмах
# 🐞 Держать порядок журнала при конкурентных приёмах
- **Секция:** ядро
- **Зачем:** Решено: повторы, но после /stats. Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда
- **Тип:** fix
- **Категория:** Ядро
- **Зачем:** Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
- **Теги:** goal:journal-and-rebuild, question
**Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.**
@@ -13,7 +14,74 @@
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
`deep`, враждебный проход, находка с построенным путём и прогоном).
Двигает строку «Завершения» цели: «Порядок журнала держится при конкурентных приёмах».
## Вопросы
**Решение (в) порядок журнала не восстанавливает, а цена окна выросла.**
Записано 2026-08-04 задачей `tie-break-equal-completeness`.
Что случилось. Тай-брейк точек при равной полноте сменён на «побеждает
пришедшая»: байтовый порядок системно хранил меньшее значение и стоил
`step_count` его рода. Плата за это названа и внесена — порядок свёртки
приведён к порядку журнала: проход воркера прекращается на первой отложенной
занятостью доставке, а не перешагивает её. Это закрыло ту половину окна,
которая была во власти воркера.
Вторая половина осталась и закрывается только на приёме: `received_at`
фиксируется при выпуске ULID, строка учёта становится видимой после записи тела
(184 мс на 62 МиБ), поэтому при конкурентном приёме доставка с более ранней
меткой сворачивается позже своей преемницы.
**Что изменилось по сравнению с постановкой ниже.** Прежде цена окна была узкой:
доставка без плотных метрик не выводила слой и уходила в `failed` — класс редкий
(только автоматизации без плотных метрик). Теперь та же перестановка оставляет в
витрине значение не той доставки, что стоит в журнале последней, — **у любой
метрики**. Расхождение живой витрины с пересборкой перестало быть свойством
редкого класса и стало свойством любого столкновения равной полноты, то есть
98,8% спорных координат (находка 54).
**Почему это вопрос, а не работа.** Выбранный вариант **(в)** — повторы при
`ErrLayerUnknown` — лечит невыводимый слой, но порядок журнала не
восстанавливает: доставка всё равно сворачивается после своей преемницы, просто
не уходит в `failed`. Порядок восстанавливают только **(а)** (резервировать
строку учёта в начале `Accept`) и **(б)** (выдержка перед свёрткой). То есть
после реализации (в) заявленное равенство «пересборка = приём» останется
недостижимым, а спека хранения будет обещать его условно.
**Что сделано вместо, чтобы не молчать.** Воркер перед свёрткой спрашивает
журнал, есть ли доставка позже этой в статусе `parsed` или `partial`; есть —
пишется `WARN` с идентификатором. Расхождение стало наблюдаемым и лечится
`healthlog reindex`. Это страж окна, и его сносят вместе с окном.
**Варианты и цена — те же, что ниже, плюс четвёртый.**
- **(в), как решено** — окно живёт, наблюдается `WARN`, лечится пересборкой.
Дёшево; цена — «витрина есть свёртка журнала» держится на прогоне, который в
гейт не входит.
- **(а)** — резервировать строку учёта до записи тела. Закрывает окно совсем.
Цена: ломается инвариант «тело на диск раньше строки учёта», появляется
состояние «строка есть, тела нет», которое обязаны понимать пересборка и
ретеншен.
- **(а′)** — не резервировать, а **сериализовать** выпуск ULID вместе с записью
тела и вставкой строки: тогда видимость строк монотонна вместе с метками, а
инвариант «тело раньше строки» сохраняется. Цена: приём становится
последовательным, и батч-доставки HAE выстраиваются в очередь (184 мс на
62 МиБ на доставку).
- **Провенанс на объект** (не на точку) — колонка с позицией журнала у часового
объекта, тай-брейк по ней, как у сущностей. Правило снова становится
коммутативным, окно перестаёт быть дефектом, барьер и `WARN` не нужны. Цена:
миграция и смена формата, которую решение владельца 2026-08-04 запретило по
бюджету, — но запрет там назван бюджетным, а не принципиальным.
**Рекомендация.** Пересмотреть (в) в пользу **(а′)**: он единственный закрывает
окно, не трогая ни схему, ни инвариант «тело раньше строки». Если
последовательный приём неприемлем по задержке — тогда провенанс на объект, а не
жизнь с условным равенством: сегодня его проверяет один прогон, который гоняют
руками.
## Что происходит
Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи
тела в архив и до вставки строки учёта. Порядок, в котором строки становятся
видимыми воркеру, порядку меток не подчиняется: между выпуском идентификатора и
@@ -0,0 +1,89 @@
# 🔬 Измерить, нужно ли правило полноты рядом с LWW
- **Тип:** research
- **Категория:** Ядро
- **Зачем:** Полнота решает 1,2% спорных координат, и неизвестно, была ли более полная точка более поздней — от этого зависит, нужна ли она вообще
- **Теги:** goal:merge-robustness, sprint:2026-08-03
Правило слияния перестаёт зависеть от того, чей набор полей богаче, — либо
зависит ровно там, где замер показал, что без этого теряются данные. Сегодня
неизвестно, какой из двух случаев верен.
Двигает строку «Завершения» цели: «Правило выбора между версиями измерено: полнота либо нужна, либо снята».
## Откуда задача
Владелец предложил 2026-08-04 держаться стратегии **LWW** («выигрывает
последняя»): экспорт Apple
Health — база снапшота, новые доставки HAE затирают предыдущие. Это отменяет
`critical`-инвариант `CLAUDE.md` «при столкновении выигрывает более полная
точка, а не последняя», и потому меняется не молча, а этой задачей.
Соседняя задача «Тай-брейк при равной полноте» двигала то же правило в ту же
сторону, но осторожнее: она поменяла только тай-брейк при **равной** полноте,
оставив саму полноту первичной. Она сделана 2026-08-04 — решение записано в
[ADR о тай-брейке по порядку журнала](../../adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md).
Эта задача решает, надо ли снимать и саму полноту.
## Замер — первый шаг, и от него ветвится всё остальное
Из 84 978 спорных координат живого архива (замер 2026-08-04, `tmp/diag`):
| | координат |
| --- | --- |
| решено полнотой | 1 022 (1,2%) |
| упало на тай-брейк | 83 956 (98,8%) |
Вопрос ровно один: **в этих 1 022 случаях более полная точка была более поздней
или более ранней?**
- **Всегда более поздней** — полнота ничего не решает сверх порядка, LWW
строго проще и ничего не теряет. Ветка полноты удаляется, инвариант в
`CLAUDE.md` переписывается.
- **Иногда более ранней** — значит HAE присылает обеднённые версии задним
числом, и LWW будет молча стирать поля. Тогда полнота остаётся, а
граница её применения записывается числом: сколько таких случаев, у каких
метрик, какие поля пропадали.
Замер обязан различать **точки метрик** и **сущности** (`workouts`,
`stateOfMind`): у сущностей отношение другое — покрытие, код другой
(`internal/store/winner.go`), и он не мерялся вовсе.
Оба исхода — законный результат задачи. Исход «полнота нужна» не считается
провалом и не отменяет предложение владельца: он его уточняет границей.
## Две рамки, без которых «экспорт — источник правды» ломает работающее
Обе выведены при постановке и в замере не нуждаются:
1. **По времени.** Экспорт — снапшот на дату выгрузки; доставки HAE после этой
даты обязаны его перекрывать, иначе новые данные не доедут. Совместимо с
инвариантом «хранилище — свёртка по журналу»: `import(экспорт) +
replay(доставки по received_at)`.
2. **По типам.** `stateOfMind` в экспорте Apple отсутствует ни одним типом
(измерено, `docs/research/apple-health.md`). Для него единственный источник —
доставки HAE, и объявить экспорт источником правды для него нельзя.
## Критерии приёмки
- для 1 022 координат, где полнота решила исход, названо число: в скольких из
них более полная точка была более поздней — оракул: прогон замера на живом
архиве, число воспроизводится вторым прогоном
- тот же вопрос отвечён отдельно для сущностей (`workouts`, `stateOfMind`) —
оракул: тот же прогон, отдельная колонка
- правило слияния приведено к исходу замера, и `CLAUDE.md` говорит то же, что
делает код — оракул: глазами, сверка формулировки инварианта с реализацией
- повторный прогон живого архива даёт тот же отпечаток, живая свёртка равна
пересборке — оракул: `task verify:archive` дважды подряд
- ни одна метрика не потеряла род из-за изменения правила — оракул:
`task verify:archive`, ноль противоречащих часов
## Рамки
Схему не трогаем. Отпечаток витрины изменится — пересборка обязательна и
делается человеком при остановленном сервисе; подмена файла базы необратима и в
задаче не выполняется. Тай-брейк при равной полноте уже влит, поэтому замер
отвечает про действующее правило, а не про снятое.
Связано: находки 10, 47, 49, 53; `docs/architecture.md` → «Разрешение
столкновений»; `docs/review.md`, запись 2026-08-04.
+11 -9
View File
@@ -1,18 +1,20 @@
# [goal] Пределы и поведение под объёмом
# 🎯 У каждого входа есть названный предел
- **Секция:** темы
- **Тип:** goal
- **Секция:** Направления
- **Зачем:** Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
- **Теги:** decomposed
Тема: названные пределы на размер тела, сущности, заголовков и ответа плюс
Направление: названные пределы на размер тела, сущности, заголовков и ответа плюс
поведение под удерживаемой блокировкой.
В порядок не встаёт: пределы всплывают замерами, а не планом.
Завершена не бывает: закрывается по мере того, как каждый вход получает
названный предел вместо подразумеваемого.
В «Запланировано» не встаёт: предел находит замер, а не очередь.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как каждый вход получает
названный предел вместо подразумеваемого.
Завершена не бывает — это направление. Закрывается по мере того, как каждый вход
получает названный предел вместо подразумеваемого. Открыто сегодня:
- У тела, сущности и секции доставки есть названный предел
- У заголовков доставки есть названный предел
- Занятость базы не выводит доставку из очереди
+8 -10
View File
@@ -1,18 +1,16 @@
# [goal] Устаревание нижнего слоя
# 🎯 Нижний слой чистится после проверенного экспорта
- **Секция:** порядок
- **Тип:** goal
- **Секция:** Запланировано
- **Зачем:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- **Теги:** decomposed
После проверенного экспорта нижний слой HAE избыточен и подлежит чистке.
После проверенного экспорта нижний слой HAE избыточен, и его можно чистить.
Выведена из шага 9 плана. Нижний слой растёт на ~100 тысяч координат в сутки.
Завершена, когда чистка идёт по правилу, а не по календарю, и решение о
удалении опирается на колонку, отличающую ноль от «не измерялось».
Нижний слой растёт на ~100 тысяч координат в сутки.
## Завершение
Чистка идёт по правилу «до следующего проверенного экспорта», а не по
календарю, и решение об удалении опирается на колонку, отличающую ноль от
«не измерялось».
- Нижний слой помечен покрытым после проверенного экспорта
- Чистка идёт по правилу «до следующего проверенного экспорта», а не по календарю
- Решение об удалении опирается на колонку, отличающую ноль от «не измерялось»
+39 -3
View File
@@ -1,6 +1,7 @@
# Устаревание нижнего слоя после экспорта
# ✨ Помечать нижний слой устаревшим после экспорта
- **Секция:** ядро
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- **Теги:** goal:lower-layer-cleanup
@@ -17,7 +18,42 @@
- пометка ≠ удаление. Удаление включается только после того, как восстановление
из экспорта отработает на живых данных хотя бы раз.
Двигает строку «Завершения» цели: «Нижний слой помечен покрытым после проверенного экспорта».
## Чем помечать: разряд на диапазон, а не провенанс на точку
Решено при постановке 2026-08-04. Пометка — **одна строка на диапазон**:
`метрика + слой + период + «покрыто проверенным экспортом»`. Не поле у точки.
Основание — соотношение цены и потребности:
- **вопрос, на который надо ответить, диапазонный**: «за этот период нижний
слой обеспечен настоящими сэмплами Apple, посекундную развёртку HAE можно
выбросить». Он не требует знать, из какой доставки приехало конкретное число;
- **цена совпадает с самой проблемой**: нижний слой растёт на ~100 тысяч
координат в сутки, и поле у точки платит тем же объёмом, который задача и
пришла экономить. Пометка на диапазон — десятки строк.
**Провенанс на точку рассмотрен и отвергнут по цене, а не по ненадобности.**
Различать эти два основания важно: отказ по ненадобности закрывает вопрос
навсегда, отказ по цене — только до появления потребителя. Появится тот, кому
нужно «покажи, из какой конкретно доставки это число», — решение
пересматривается. Сегодня такого потребителя нет: ни агент-медик, ни трекер, ни
игра его не просят ([passport.md](../../passport.md)).
Отдельно стоит помнить, что **отделить старое от нового можно и без пометок**:
состояние по определению есть `import(экспорт) + replay(доставок по
received_at)`, порядок известен, происхождение значения выводится пересборкой.
Пометка нужна ровно затем, чтобы отвечать на этот вопрос **при чтении**, не
пересчитывая.
Смежное: у сущностей (`workouts`, `stateOfMind`) провенанс уже есть — колонки
`delivery_id` и `delivery_received_at` (миграция `00007`). У часового объекта
метрики есть `first_delivery_id` (миграция `00003`), но это **первая** доставка,
а не источник каждой точки, и для этой задачи он не годится.
Приоритет низкий: пока история измеряется днями, экономить нечего. Задача
станет актуальной, когда нижний слой перевалит за несколько гигабайт.
Зависит от импорта экспорта Apple — до него помечать нечем.
Зависит от импорта экспорта Apple — до него помечать нечем; выставляет пометку
[apple-export-import](apple-export-import.md).
+29 -5
View File
@@ -1,14 +1,15 @@
# MCP-сервер поверх Read API
# ✨ Поднять MCP-сервер поверх Read API
- **Секция:** ядро
- **Тип:** feature
- **Категория:** Ядро — набор ограничен HTTP-слоем чтения после дробления; адаптер берётся следующим спринтом по той же цели
- **Зачем:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- **Теги:** goal:mcp
- **Теги:** goal:read-api
Агент-медик — первый заказчик проекта. Ему нужна актуальная сводка, а не срез
на дату последнего ручного экспорта.
Транспорт — **Streamable HTTP**, не stdio: сервис живёт на VPS, агент ходит по
сети. Отсюда: MCP — эндпоинт того же процесса и того же порта, аутентификация —
сети. Отсюда: MCP — маршрут того же процесса и того же порта, аутентификация —
тот же токен чтения, что у Read API. Отдельного контура доступа не заводим:
MCP не даёт ничего, чего не даёт HTTP, и права обязаны совпадать.
@@ -21,4 +22,27 @@ MCP не даёт ничего, чего не даёт HTTP, и права об
Готово, когда агент подключается по URL и отвечает на «как я спал на прошлой
неделе» без промежуточного кода.
Связано: `docs/architecture.md` → «MCP», план → шаг «MCP».
Двигает строку «Завершения» цели: «Агент-медик читает то же самое через MCP тем же токеном чтения».
## Критерии приёмки
- живой агент подключается по URL и отвечает на «как я спал на прошлой неделе»
без промежуточного кода — оракул: подключение реального MCP-клиента к
поднятому сервису
- вызов инструмента и соответствующий HTTP-запрос дают одни и те же данные —
оракул: тест, сравнивающий выход инструмента с ответом маршрута на тех же
параметрах
- запрос без токена чтения отклоняется обоими транспортами одинаково — оракул:
тест на паре «MCP без токена / HTTP без токена»
- правило размера ответа действует и в MCP: слишком широкий запрос получает
названную сетку или ошибку со списком, а не обрезанный ответ — оракул: тест на
запросе за пределом
## Рамки
Схема не трогается, данные только читаются, сервис перезапускается. Собственной
логики адаптер не несёт — новое поведение здесь признак того, что оно должно
было появиться в маршруте чтения. Берётся последней в цели: переводить нечего,
пока обработчиков нет.
Связано: `docs/architecture.md` → «MCP».
-18
View File
@@ -1,18 +0,0 @@
# [goal] MCP
- **Секция:** порядок
- **Зачем:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- **Теги:** decomposed
Агент-медик — первый заказчик проекта — подключается к хранилищу.
Выведена из шага 7 плана. Идёт после Read API намеренно: адаптер собственной
логики не несёт, он переводит вызовы в те же обработчики, и переводить пока
нечего.
Завершена, когда агент читает данные через MCP тем же токеном чтения.
## Завершение
Агент читает данные через MCP тем же токеном чтения, и собственной логики
адаптер не несёт.
+5 -2
View File
@@ -1,12 +1,15 @@
# Цена слияния на широкой доставке
# 🐞 Снизить цену слияния на широкой доставке
- **Секция:** ядро
- **Тип:** fix
- **Категория:** Ядро
- **Зачем:** 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
- **Теги:** goal:limits-and-load
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный
проход и независимая реализация — независимо друг от друга).
Двигает строку «Завершения» цели: «Занятость базы не выводит доставку из очереди».
## Оракул: измерено
Тело 63 МБ (в запросе ~200 КБ gzip — предел приёма 64 МиБ), 119 точек на ОДНОЙ
+6 -3
View File
@@ -1,12 +1,15 @@
# Счётчики слияния переживают ротацию логов
# ✨ Хранить счётчики слияния вне логов
- **Секция:** инфра
- **Зачем:** единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- **Тип:** feature
- **Категория:** Инфра
- **Зачем:** Единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- **Теги:** goal:observability
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, проход
негативного пространства, подтверждено эксплуатационным).
Двигает строку «Завершения» цели: «Счётчики слияния переживают ротацию логов».
## Что не так
Решение не реализовывать объединение полей при несравнимых наборах стоит на
+11 -9
View File
@@ -1,17 +1,19 @@
# [goal] Прочность слияния и идентичности
# 🎯 Исход слияния не зависит от порядка элементов на проводе
- **Секция:** темы
- **Тип:** goal
- **Секция:** Направления
- **Зачем:** Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
- **Теги:** decomposed
Тема: правила, по которым две версии одних данных превращаются в одну.
В порядок не встаёт — работа приходит находками ревью и замерами на
Направление: правила, по которым две версии одних данных превращаются в одну.
В «Запланировано» не встаёт — очереди у направления нет: работа приходит находками ревью и замерами на
живом корпусе.
Завершена не бывает: закрывается по мере того, как правила перестают зависеть
от порядка на проводе.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как правила выбора между
версиями перестают зависеть от порядка элементов на проводе.
Завершена не бывает — это направление. Закрывается по мере того, как правила
выбора между версиями перестают зависеть от порядка элементов на проводе.
Открыто сегодня:
- Правило выбора между версиями измерено: полнота либо нужна, либо снята
- Порог `sealed` выбран по накопленной статистике досчёта
@@ -1,8 +1,9 @@
# [idea] Месячный проход по ручным секциям
# 🔬 Месячный проход по ручным секциям
- **Секция:** ядро
- **Тип:** research
- **Категория:** Ядро
- **Зачем:** Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
- **Теги:** goal:parsing-and-storage
- **Теги:** goal:parsing-completeness
Окно досчёта не единое, и это измеренное различие, а не предположение.
Количественные метрики (пульс, шаги, энергия) человек руками не правит — они
+7 -8
View File
@@ -1,19 +1,18 @@
# [goal] Импорт родного экспорта Apple
# 🎯 История из родного экспорта Apple лежит в хранилище
- **Секция:** порядок
- **Тип:** goal
- **Секция:** Запланировано
- **Зачем:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- **Теги:** decomposed
`healthlog import`: снапшот всей истории из родного экспорта Apple Health
ложится в хранилище перед проигрыванием хвоста доставок.
Выведена из шага 8 плана. Идёт перед устареванием нижнего слоя намеренно: пока
Идёт перед чисткой нижнего слоя намеренно: пока
импорт экспорта не написан, помечать что-либо устаревшим не на основании чего.
Завершена, когда слой `sample` наполнен историей с 2019 года, а повторный
импорт того же экспорта ничего не меняет.
## Завершение
Слой `sample` наполнен историей с 2019 года, повторный импорт того же экспорта
ничего не меняет, а тренировки из экспорта не задваивают приехавшие от HAE.
- Слой `sample` наполнен историей с 2019 года
- Повторный импорт того же экспорта ничего не меняет
- Тренировки из экспорта не задваивают приехавшие от HAE
+4 -3
View File
@@ -1,6 +1,7 @@
# [idea] NDJSON-поток для больших выборок Read API
# 🔬 NDJSON-поток для больших выборок Read API
- **Секция:** ядро
- **Тип:** research
- **Категория:** Ядро
- **Зачем:** Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
- **Теги:** goal:read-api
@@ -19,4 +20,4 @@ Read API отдаёт ответ одним JSON. Для выборок нижн
последовательно или с возвратами.
Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача
`read-api-points`.
`read-api-response-limit` (правило размера ответа проектируется там).
+7 -9
View File
@@ -1,18 +1,16 @@
# [goal] Наблюдаемость
# 🎯 Приложение сообщает о своём состоянии
- **Секция:** порядок
- **Тип:** goal
- **Секция:** Запланировано
- **Зачем:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- **Теги:** decomposed
Тихо сломавшаяся автоматизация — главный эксплуатационный риск: телефон шлёт
молча, и молчание неотличимо от нормы.
Выведена из шага 10 плана.
Завершена, когда пропажа потока и расхождение витрины с журналом видны
владельцу без чтения логов.
## Завершение
Пропажа потока и расхождение витрины с журналом видны владельцу без чтения
логов и переживают ротацию логов.
- Пропажа потока видна владельцу без чтения логов
- Состояние сервиса — последняя доставка, счётчики, тишина — читается одним
запросом
- Счётчики слияния переживают ротацию логов
+34
View File
@@ -0,0 +1,34 @@
# 🧹 Ловить гейтом расхождение спеки с маршрутами
- **Тип:** chore
- **Категория:** Ядро
- **Зачем:** Рукописная спека — источник истины, а расходится она с кодом молча: без гейта решение о рукописной спеке не держится
- **Теги:** goal:read-api, sprint:2026-08-04
Маршрут, которого нет в спеке, и поле ответа, которого спека не обещала, красят
гейт — рукописный контракт перестаёт расходиться с кодом молча.
Это не украшение к спеке, а то, чем держится решение писать её руками. Без
проверки рукописная спека расходится с первого же маршрута, и потребитель,
сгенерировавший по ней клиент, узнаёт об этом последним.
Класс отказа тот же, что у остальных безусловных шагов гейта проекта: не виден
глазами и стоит дорого. Место ему там же.
Двигает строку «Завершения» цели: «Контракт чтения читается машиной».
## Критерии приёмки
- добавленный маршрут без правки спеки красит гейт — оракул: намеренно
рассогласованный маршрут в прогоне гейта
- переименованное поле ответа красит гейт — оракул: намеренное переименование в
прогоне гейта
- проверка укладывается в бюджет гейта — оракул: замер шага по логу
`tmp/gate/`
- проверка работает без внешней сети — оракул: прогон гейта в контейнере без
доступа наружу
## Рамки
Трогает `Taskfile` и шаги гейта, кода маршрутов не касается. Берётся после
спеки: проверять нечего, пока нет источника истины.
+36
View File
@@ -0,0 +1,36 @@
# ✨ Написать OpenAPI-спеку руками
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- **Теги:** goal:read-api, sprint:2026-08-04
Контракт читается машиной: по спеке генерируется клиент, и сгенерированный
клиент выполняет запрос к живому сервису.
**Решено владельцем 2026-08-04: спека пишется руками и она источник истины.**
Для API из горстки ручек это честнее вывода из кода — контракт проектируется, а
не фотографируется с того, что вышло: опечатка в имени поля иначе становится
частью спеки. Совпадает с тем, как в проекте уже устроен OpenSpec: спека
первична к коду. Плата названа — рукописная спека расходится с кодом молча, — и
именно поэтому проверка расхождения вынесена в
[отдельную задачу](openapi-gate-check.md), а не оставлена регламентом.
Заодно снимает вопрос, чем быть «схеме контракта API» из раздела самоописания:
ею и будет OpenAPI-документ, а не собственный формат.
Двигает строку «Завершения» цели: «Контракт чтения читается машиной».
## Критерии приёмки
- по спеке генерируется клиент, и он выполняет запрос к живому сервису — оракул:
прогон генератора плюс запрос сгенерированным клиентом
- спека покрывает все маршруты, которые сервис действительно регистрирует —
оракул: сверка перечня путей спеки с обходом роутера поднятого сервиса
(`chi.Walk`)
- спека проходит валидатор OpenAPI 3.1 — оракул: прогон валидатора
## Рамки
Кода маршрутов не трогает: описывает то, что уже есть. `/stats` не описывается —
его ещё нет, и его добавит [своя задача](stats-endpoint.md).
-25
View File
@@ -1,25 +0,0 @@
# OpenAPI-спека и Swagger UI
- **Секция:** ядро
- **Зачем:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- **Теги:** goal:read-api
Потребителей три, и один из них — агент, который читает контракт машиной.
Пересказывать форму ответа в чате не годится, а самоописание из плана покрывает
только **содержимое** метрик; форма конверта, коды ответов и параметры запроса —
это OpenAPI.
Заодно снимает вопрос, чем быть «схеме контракта API» из раздела самоописания:
ею и будет OpenAPI-документ, а не собственный формат.
Шаги:
- спека OpenAPI 3.1 на приём, каталог, точки, тренировки, записи, `/stats`;
- Swagger UI на отдельном пути, отдаётся самим сервисом (без внешних CDN —
он должен работать в локальной сети без интернета);
- проверка актуальности спеки в гейте: контракт разъезжается молча.
Готово, когда по спеке можно сгенерировать клиент, а Swagger UI открывается
локально и выполняет запрос к живому сервису.
Развилка на решение: спека пишется руками как источник истины или выводится из
кода. Для маленького API рукописная спека честнее — но это стоит обсудить.
+3 -2
View File
@@ -1,6 +1,7 @@
# [idea] Пересекающиеся источники одной метрики
# 🔬 Пересекающиеся источники одной метрики
- **Секция:** ядро
- **Тип:** research
- **Категория:** Ядро
- **Зачем:** Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
- **Теги:** goal:read-api
+3 -2
View File
@@ -1,6 +1,7 @@
# [idea] Выгрузка в parquet отдельной командой
# 🔬 Выгрузка в parquet отдельной командой
- **Секция:** ядро
- **Тип:** research
- **Категория:** Ядро
- **Зачем:** Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
- **Теги:** goal:read-api
-20
View File
@@ -1,20 +0,0 @@
# [goal] Разбор и хранилище
- **Секция:** порядок
- **Зачем:** Секции живого потока разобраны все, кроме словаря категориальных значений и пяти невиденных
- **Теги:** decomposed
Метрики, тренировки и записи со своими `id` разбираются и ложатся в часовые
объекты; тела перестали быть недифференцированной кучей.
Выведена из шага 3 плана. Сделано: разбор метрик в объекты, тренировки и
записи, `reindex`. Осталось: словарь категориальных значений и секции, которых
поток ещё не приносил.
Завершена, когда ни одна секция живого потока не числится неразобранной, а
категориальные значения имеют стабильный код рядом с переведённой строкой.
## Завершение
Ни одна секция живого потока не числится неразобранной, а категориальные
значения несут стабильный код рядом с переведённой строкой.
+28
View File
@@ -0,0 +1,28 @@
# 🎯 Новая форма от источника не теряется молча
- **Тип:** goal
- **Секция:** Направления
- **Зачем:** Пять секций HAE поток ещё не приносил, а ручные секции заводятся задним числом — разбор полон ровно до следующей новой формы
- **Теги:** decomposed
Направление: всё, что приезжает от источника, разобрано и доехало до витрины — не
только сегодня, но и после того, как источник изменится.
Выделена из цели «Разбор и хранилище», когда та достигла своего критерия
завершения: секции живого потока разобраны, категориальные значения несут
стабильный код. Осталось то, что заканчиваться не умеет по природе — источник
вправе прислать форму, которой раньше не было, а часть секций заводится
человеком задним числом.
В «Запланировано» не встаёт: работа приходит от потока, а не от очереди. Первая встреча
новой секции наблюдаема (`healthlog uncovered` и `WARN` на свёртке) — работа
направления приходит от этих событий.
## Завершение
Завершена не бывает — это направление. Закрывается по мере того, как каждая
приезжающая форма доезжает до витрины, а не теряется между «принято» и
«разобрано». Открыто сегодня:
- Сущность с `id` и неразобранной меткой не пропадает целиком
- Ручные секции, заведённые задним числом, доезжают до витрины
+6 -3
View File
@@ -1,7 +1,8 @@
# Ретеншен сырого архива
# ✨ Подчищать сырой архив до последнего проверенного экспорта
- **Секция:** инфра
- **Зачем:** Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает
- **Тип:** feature
- **Категория:** Инфра
- **Зачем:** Архив не подчищается вовсе, а резать его раньше даты проверенного экспорта нельзя — в журнале останется дыра, которую нечем пересобрать
- **Теги:** goal:journal-and-rebuild
Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но
@@ -28,6 +29,8 @@
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
глубину архива и дату снапшота, до которой он подрезан.
Двигает строку «Завершения» цели: «Сырой архив подчищается до последнего проверенного экспорта».
## Предусловие снова открыто
Признак «доставка с непокрытой секцией» появился в change
@@ -0,0 +1,42 @@
# ✨ Отличать неполное ведро от полного
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** Текущий час неполон всегда, и без порога свёртка отдаёт его наравне с полными — клиент видит провал вместо неизвестности
- **Теги:** goal:read-api, sprint:2026-08-04
Ведро, в котором известна не вся сетка, отличимо от полного — а полярность
порога названа вслух, а не выводится читателем из умолчания.
Измерению рода агрегации порог не понадобился: у него две конкурирующие
гипотезы, и неполный час не сходится ни с одной сам собой. Свёртке в ответе он
нужен — текущий час неполон **всегда**, и без порога накопительная метрика
показывает за него провал вместо неизвестности.
**Готовые решения задают порог противоположно.** Graphite `xFilesFactor` — доля
обязательно известных точек (умолчание 0.5 при свёртке на записи и 0 при
отдаче ответа: один параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе
величины выглядят как «0.5», означая разное. Полярность придётся назвать вслух,
иначе через полгода два места кода поймут поле по-разному — и разойдутся молча.
Двигает строку «Завершения» цели: «Неполное ведро отличимо от полного, и полярность порога названа».
## Затрагивает
Форма ответа свёртки — признак неполного ведра рядом со значением. Конфиг и его
образцы — порог с названной полярностью. Раздел о свёртке в
`docs/architecture.md`. Схемы и формата на диске не трогает.
## Критерии приёмки
- полярность и умолчание порога названы в `docs/architecture.md` одной
формулировкой, и там же сказано, у какого из двух прототипов взято — оракул:
глазами по разделу
- ведро ниже порога помечено неизвестным, а не отдано значением — оракул: тест
на границе: ведро ровно на пороге и на единицу ниже
- текущий незакрытый час не выглядит провалом накопительной метрики — оракул:
запрос за сегодня на живом архиве
## Рамки
Схема не трогается, данные только читаются. Берётся после свёртки по сетке.
@@ -0,0 +1,43 @@
# ✨ Сворачивать точки по заданной сетке
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** «Шаги за неделю по дням» — базовый запрос трекера и игры, и сегодня его нечем задать
- **Теги:** goal:read-api, sprint:2026-08-04
«Шаги за неделю по дням» отвечаются одним запросом `?from&to&bucket`, и род
свёртки берётся измеренным, а не угаданным.
Род агрегации измерен каталогом (change `2026-08-02-katalog-i-rod-agregacii`):
сверка минутного слоя с часовым разложила метрики живого корпуса на
накопительные и мгновенные, не сойдясь ни на одной. Свёртка в ответе опирается
на это измерение и **только** на него: род неизвестен — свёртки нет.
Инвариант, который здесь легче всего нарушить: **нижний слой HAE не
суммируется** ни при какой сетке. Это интерполяция, а не сэмплы, и суммирование
завышает втрое.
Порог неполного ведра и предел размера ответа — соседние задачи; здесь они
берутся в том виде, в каком есть на момент вливания, и не проектируются.
Двигает строку «Завершения» цели: «Точки сворачиваются по заданной сетке измеренным родом агрегации».
## Затрагивает
Маршрут `GET /api/v1/metrics/{name}` — параметр запроса `bucket`, поля
`bucket` и `aggregation` в конверте ответа, код отказа на метрике с неизвестным
родом. Схемы и формата на диске не трогает.
## Критерии приёмки
- «шаги за неделю по дням» отвечаются одним запросом, и в ответе названы
фактические `bucket` и `aggregation` — оракул: запрос к поднятому сервису на
живом архиве
- нижний слой HAE не суммируется ни при какой сетке — оракул: тест на
накопительной метрике, у которой есть и нижний, и часовой слой
- метрика с неизвестным родом не сворачивается вовсе и отвечает отказом с
названной причиной, а не значением — оракул: тест
## Рамки
Схема не трогается, данные только читаются. Берётся после точек за период.
@@ -0,0 +1,46 @@
# ✨ Отвечать 304 на повторный запрос точек
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** Агент опрашивает по расписанию, а каждый повтор стоит полного чтения: на каталоге это 693 мс и +153 МиБ
- **Теги:** goal:read-api, sprint:2026-08-04
Повторный опрос точек с той же меткой стоит `304` вместо полного чтения, и метка
не может ответить на другой набор данных.
Машинерия готова и берётся, а не пишется заново: `store.VersionedRead` держит
правило «версией, снятой после чтения, не подписывать», `internal/httpapi/conditional.go`
разбирает `If-None-Match` и отдаёт `304`. Новое здесь ровно одно — **область
действия метки**. У каталога ответ есть функция версии витрины; у точек он ещё и
функция параметров запроса, поэтому `etag(scope, version)` требует их
канонизированной формы. Ошибиться тут значит ответить `304` на другой набор
данных — молча и без следов.
Цена, которую это снимает, измерена на каталоге: 693 мс и +153 МиБ живой кучи на
враждебном запросе. Агент опрашивает по расписанию, и без условного запроса
каждый его повтор стоит полного чтения.
Двигает строку «Завершения» цели: «Повторный запрос тех же точек стоит `304`, а не полного чтения».
## Затрагивает
Маршрут `GET /api/v1/metrics/{name}` — заголовки `ETag` и `If-None-Match`,
код ответа `304`. Область действия метки: набор параметров запроса (метрика,
окно, слой) и версия витрины, из которых метка считается, и их каноническая
форма. Схемы и формата на диске не трогает.
## Критерии приёмки
- повторный запрос с `If-None-Match` при неизменной витрине даёт `304` — оракул:
тест
- запрос, отличающийся любым параметром по очереди (метрика, окно, слой), при
той же версии витрины даёт `200` и другое тело — оракул: тест, перебирающий
параметры по одному
- параметры, различающиеся только формой записи (порядок, регистр, эквивалентная
запись времени), дают одну и ту же метку — оракул: тест на канонизации
## Рамки
Схема не трогается, данные только читаются. Берётся после точек за период.
Связано: `docs/architecture.md` → «Условный запрос».
-78
View File
@@ -1,78 +0,0 @@
# Read API: точки, выбор слоя, свёртка по сетке
- **Секция:** ядро
- **Зачем:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- **Теги:** goal:read-api
Сейчас данные достаются только `sqlite3` на хосте. Все три сценария —
агент-медик, трекер тренировок, фитнес-игра — упираются в отсутствие чтения.
Формы запроса ровно две, и это один запрос с необязательным параметром:
`?from&to` — все значения за период (вес, лекарства, симптомы), `?from&to&bucket`
— с разбивкой (шаги, энергия).
Решение по размеру ответа (вариант «б»): разбивка не задана и ответ не влезает —
сервер сам берёт сетку погрубее и **называет её в ответе**; разбивка задана явно
и не влезает — ошибка со списком доступных сеток, а не тихая подмена. Различие
существенно: иначе агент, попросивший минутную сетку, получит суточные суммы.
**Отдача тренировок и записей входит сюда же.** Разбор и хранение сущностей с
собственным `id` сделаны (change `2026-08-02-trenirovki-i-zapisi`), а эндпоинтов
нет: тренировка с маршрутом и записи `stateOfMind` лежат в витрине и наружу не
отдаются. Вводить их раньше конверта ответа значило бы задать контракт
мимоходом, поэтому `GET /workouts`, `GET /workouts/{id}` и
`GET /records/{kind}` закрываются этой задачей — вместе с формой конверта и
правилом размера ответа. Второй сценарий паспорта (трекер) до тех пор не закрыт.
Готово, когда «шаги за неделю по дням» и «вес за год» отвечаются одним запросом
каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда
видно `layer`, `bucket` и `aggregation`.
**Порог неполного ведра решается здесь, и вместе с ним — его полярность.**
Каталог и род агрегации сделаны (change `2026-08-02-katalog-i-rod-agregacii`), и
измерению порог заполненности не понадобился: у него две конкурирующие гипотезы,
и неполный час не сходится ни с одной сам собой. Свёртке в ответе он нужен, а
готовые решения задают его **противоположно**: Graphite `xFilesFactor` — доля
обязательно известных точек (умолчание 0.5 при роллапе и 0 при рендере, один
параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе
величины выглядят как «0.5», означая разное; полярность придётся назвать вслух в
`architecture.md`, иначе через полгода два места кода поймут поле по-разному.
**Предел размера ответа тоже здесь, и он унаследовал измеренную цену.** У
каталога предела нет намеренно: правило размера — общее для маршрутов чтения, и
задавать его мимоходом на первой ручке значило бы решить контракт до того, как
известна форма тяжёлого ответа. Каталог станет первым его потребителем.
Цена измерена на каталоге (задача «цена читающего маршрута», закрыта чекпойнтом
WAL и условным запросом): 693 мс и +153 МиБ живой кучи на враждебном запросе
(20 метрик × 8 часов × 5000 точек), при том что приём в том же процессе уже даёт
пик 768 МиБ на теле 40 МиБ. Условный запрос снял повтор, но первый запрос стоит
столько же, а множители «метрики × окно × точки × одновременные запросы»
по-прежнему без потолка. Сюда же уезжают отложенные варианты той задачи:
собственный дедлайн маршрута и потоковое измерение по метрике (второе — только
если счётчик заговорит).
**Машинерия условного запроса готова, и её надо взять, а не написать заново.**
`store.VersionedRead` держит правило «версией, снятой после чтения, не
подписывать»; `httpapi` — разбор `If-None-Match` и `304`. Метка обязана нести
**область действия**: у точек ответ есть функция параметров запроса, и
`etag(scope, version)` требует их канонизированную форму — иначе `304` ответит
на другой набор данных. Детали — `docs/architecture.md`, «Условный запрос».
**Форма провода наследуется от каталога, и это надо решить один раз.** Сегодня
типы `internal/catalog` сами несут json-теги, а транспорт владеет только
обёрткой: переименование поля в домене меняет публичный контракт без касания
`httpapi`. Держит это один байтовый тест непустого ответа. Либо объявить в
`architecture.md`, что типы чтения и есть форма провода для всех транспортов
(HTTP и MCP отдают её байт в байт), либо завести DTO в транспорте — но выбрать до
того, как образец скопирует эта задача.
**Клиент обязан смотреть на границы окна измерения.** Род метрики измерен по
48 самым свежим ОБЩИМ часам, а не по последним 48 часам календаря: если минутная
автоматизация HAE выключена, множество общих часов не пополняется и окно
замирает. Род при этом продолжает объявляться, и единственный след — `last_hour`
в ответе. Правило выбора свёртки в Read API обязано это учитывать (или явно
объявить, что не учитывает).
Связано: `docs/architecture.md` → «Read API», «Измерение рода агрегации»,
план → шаг «Read API».
+38
View File
@@ -0,0 +1,38 @@
# ✨ Отдавать записи со своим id за период
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** stateOfMind разобран и хранится, но наружу не отдаётся — а восстановить его нечем: в экспорте Apple его нет
- **Теги:** goal:read-api, sprint:2026-08-04
Записи со своим `id` — сегодня это `stateOfMind` — достаются за период через
`GET /records/{kind}`.
Разбор и хранение сделаны тем же изменением, что у тренировок; наружу не отдаётся
ничего. У этих данных есть особенность, которой нет больше ни у чего в проекте:
**`stateOfMind` нет в экспорте Apple**, он не восстанавливается пересборкой из
снапшота, и единственный его источник — доставки HAE. Отдача наружу — не
удобство, а единственный способ увидеть то, что иначе живёт только внутри базы.
Конверт наследуется от точек; собственной формы у записей нет.
Двигает строку «Завершения» цели: «Тренировки с маршрутом и записи со своим `id` отдаются за период».
## Затрагивает
Новый маршрут `GET /api/v1/records/{kind}` и код отказа на неизвестном `kind`.
Публичный тип провода в `internal/httpapi` — конверт записей. Чтение таблицы
записей; схемы и формата на диске не трогает.
## Критерии приёмки
- записи `stateOfMind` за период отдаются одним запросом — оракул: запрос к
поднятому сервису на живом архиве
- неизвестный `kind` отвечает отказом со списком известных, а не пустым списком:
пустота и опечатка обязаны различаться — оракул: тест
- конверт совпадает с конвертом точек и тренировок — оракул: тест, сравнивающий
форму ответа трёх маршрутов
## Рамки
Схема не трогается, данные только читаются. Берётся после конверта.
@@ -0,0 +1,53 @@
# ✨ Ограничить размер ответа маршрутов чтения
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** У маршрутов чтения нет ни одного потолка: множители «метрики × окно × точки × одновременные запросы» ничем не ограничены
- **Теги:** goal:read-api, sprint:2026-08-04
У маршрутов чтения появляется названный потолок: сетка не задана и ответ не
влезает — сервер огрубляет её и **называет** в ответе; сетка задана явно и не
влезает — ошибка со списком доступных, а не тихая подмена.
Различие существенно: иначе агент, попросивший минутную сетку, получит суточные
суммы и не узнает об этом.
**Цена измерена и унаследована.** На каталоге враждебный запрос
(20 метрик × 8 часов × 5000 точек) дал 693 мс и +153 МиБ живой кучи, при том что
приём в том же процессе уже даёт пик 768 МиБ на теле 40 МиБ. Множители «метрики ×
окно × точки × одновременные запросы» сегодня без потолка ни у одного маршрута —
включая уже живой каталог, у которого предела нет намеренно: правило размера
общее, и задавать его мимоходом на первом маршруте значило бы решить контракт до
того, как известна форма тяжёлого ответа.
Правило распространяется на все маршруты чтения сразу — каталог, точки,
тренировки, записи, — а не только на тот, где написано.
Двигает строку «Завершения» цели: «У ответа любого маршрута чтения есть объявленный предел размера».
## Затрагивает
Все маршруты чтения сразу — каталог, точки, а следом тренировки и записи: код и
тело отказа на запросе за пределом, поле огрублённой сетки в ответе. Конфиг и
его образцы — сам предел. Раздел о пределах в `docs/architecture.md`. Схемы и
формата на диске не трогает.
## Критерии приёмки
- запрос без сетки, не влезающий в предел, отвечает огрублённой сеткой и
называет её в ответе — оракул: враждебный запрос на живом архиве
- явно заданная сетка за пределом даёт ошибку со списком доступных сеток —
оракул: тест
- предел читается из конфига: два разных значения дают две разные границы
отказа — оракул: тест с подменой значения предела
- предел назван в образцах конфига и в `docs/architecture.md` — оракул: шаг
образцов конфига в гейте и глазами по разделу
- каталог подчиняется тому же пределу, что и точки — оракул: тест на враждебном
запросе к каталогу
## Рамки
Схема не трогается, данные только читаются. Берётся после свёртки по сетке.
Собственный дедлайн маршрута сюда **не входит**: он в задаче
[«Развести бюджеты остановки»](shutdown-and-migration-traces.md) вместе с `BaseContext`
и раздельными бюджетами остановки.
+45
View File
@@ -0,0 +1,45 @@
# ✨ Отдавать тренировки вместе с маршрутом
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** Тренировки с маршрутами разобраны и лежат в витрине, а маршрутов чтения нет — сценарий трекера не закрыт
- **Теги:** goal:read-api, sprint:2026-08-04
Трекер забирает тренировку одним пакетом вместе с маршрутом: `GET /workouts` за
период и `GET /workouts/{id}` поштучно.
Разбор и хранение тренировок сделаны (change `2026-08-02-trenirovki-i-zapisi`),
маршрутов чтения нет: тренировка с маршрутом лежит в витрине и наружу не отдаётся.
Второй сценарий паспорта — трекер тренировок — до тех пор не закрыт.
**Это первый по-настоящему тяжёлый ответ проекта.** Маршрут лежит блобом внутри
тренировки, и общий предел размера ответа обязан распространяться и на него —
иначе одна тренировка с длинным треком проходит мимо потолка, названного для
точек. Отсюда же требование к списку: перечень за период не тянет треки, иначе
неделя тренировок превращается в один неподъёмный ответ.
Конверт и форма провода наследуются, а не изобретаются.
Двигает строку «Завершения» цели: «Тренировки с маршрутом и записи со своим `id` отдаются за период».
## Затрагивает
Два новых маршрута: `GET /api/v1/workouts` за период и
`GET /api/v1/workouts/{id}` поштучно. Публичные типы провода в
`internal/httpapi` — конверт тренировки и элемент списка. Чтение таблицы
тренировок; схемы и формата на диске не трогает.
## Критерии приёмки
- тренировка отдаётся одним пакетом вместе с маршрутом — оракул: запрос к
поднятому сервису на живом архиве
- список тренировок за период не тянет треки — оракул: тест, сравнивающий размер
ответа списка с размером ответа одной тренировки
- тренировка с длинным треком подчиняется общему пределу размера ответа —
оракул: тест на синтетическом треке за пределом
## Рамки
Схема не трогается, данные только читаются. Берётся после конверта.
Разворачивание маршрута в отдельную таблицу остаётся
[идеей](workout-routes-table.md) и здесь не решается.
+25 -11
View File
@@ -1,19 +1,33 @@
# [goal] Read API
# 🎯 Клиенты читают данные через HTTP и MCP
- **Секция:** порядок
- **Зачем:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- **Тип:** goal
- **Секция:** Запланировано
- **Зачем:** Данные видны только через sqlite на хосте — ни один из трёх потребителей, включая агента-медика, ничего прочитать не может
- **Теги:** decomposed
Потребители читают точки: выбор слоя, свёртка по сетке, предел размера ответа.
Потребители читают данные: точки с выбором слоя и свёрткой по сетке, тренировки
и записи, машиночитаемый контракт — и всё то же самое через MCP.
Выведена из шага 5 плана. Идёт после каталога и рода агрегации намеренно: без
измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться здесь
дорого — просуммировать нижний слой значит завысить втрое.
Идёт после каталога и рода агрегации намеренно:
без измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться
здесь дорого — просуммировать нижний слой значит завысить втрое.
Завершена, когда любой из трёх потребителей получает точки за период без
доступа к файлу базы.
**MCP входит в эту цель, а не идёт отдельной.** Прежде их было две, и разделяла
их очередь: адаптер собственной логики не несёт, он переводит вызовы в те же
обработчики, и переводить было нечего. Очередь никуда не делась — она стала
порядком задач внутри цели, — а вот отдельная цель под адаптер описывала не
направление, а последний шаг этого же направления. Заказчик у обоих транспортов
один: три потребителя, из которых первый — агент.
## Завершение
Любой из трёх потребителей получает точки за период без доступа к файлу базы,
и предел размера ответа объявлен, а не подразумевается.
- Точки метрики за период отдаются по HTTP без доступа к файлу базы
- Повторный запрос тех же точек стоит `304`, а не полного чтения
- Точки сворачиваются по заданной сетке измеренным родом агрегации
- Неполное ведро отличимо от полного, и полярность порога названа
- У ответа любого маршрута чтения есть объявленный предел размера
- Тренировки с маршрутом и записи со своим `id` отдаются за период
- Контракт чтения читается машиной: спека, гейт против её расхождения с
маршрутами, UI без внешней сети
- Агент-медик читает то же самое через MCP тем же токеном чтения, и собственной
логики адаптер не несёт
+6 -3
View File
@@ -1,6 +1,7 @@
# Сверка живой витрины с пересборкой
# Сверять живую витрину с пересборкой
- **Секция:** ядро
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- **Теги:** goal:journal-and-rebuild
@@ -10,7 +11,7 @@
только тем, что кто-то вручную запустил пересборку и посмотрел на два числа.
Между тем расхождение — не гипотеза. Известный путь к нему записан блокером
[«Порядок журнала при конкурентных приёмах»](journal-order-on-ingest.md):
[«Держать порядок журнала при конкурентных приёмах»](journal-order-on-ingest.md):
доставка, свёрнутая раньше своей предшественницы, уходит в `failed` навсегда, и
живая витрина расходится с пересборкой молча. Пока тот предел не закрыт, сверка
— единственный способ узнать, что он сработал.
@@ -30,3 +31,5 @@
Связано: `cmd/healthlog/reindex.go`, [наблюдаемость](stats-endpoint.md),
[деплой](deploy-rivendell.md).
Двигает строку «Завершения» цели: «Расхождение живой витрины с пересборкой замечает сервис, а не человек».
+5 -2
View File
@@ -1,6 +1,7 @@
# Пересборка держит весь журнал в памяти
# 🧹 Не держать весь журнал в памяти при пересборке
- **Секция:** ядро
- **Тип:** chore
- **Категория:** Ядро
- **Зачем:** Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
- **Теги:** goal:journal-and-rebuild
@@ -26,3 +27,5 @@
памяти, не зависящим от его длины.
Связано: `internal/replay`, `cmd/healthlog/reindex.go`.
Двигает строку «Завершения» цели: «Расход пересборки не растёт вместе с журналом».
@@ -1,8 +1,9 @@
# Чем откатывать релиз после наката миграции
# ✨ Назвать механизм отката релиза после наката миграции
- **Секция:** инфра
- **Зачем:** Решено: копия файла базы перед накатом. Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем
- **Теги:** goal:deploy, question
- **Тип:** feature
- **Категория:** Инфра
- **Зачем:** Страж версии схемы делает возврат старого бинаря отказом, а понизить схему нечем — аварийный путь пришлось бы изобретать в аварии
- **Теги:** goal:deploy
**Решение принято владельцем 2026-08-02: вариант (2) — копия файла базы перед
накатом.** Entrypoint контейнера копирует файл базы рядом до старта бинаря,
@@ -15,6 +16,8 @@
Вынуто ревью кода задачи «Дозакрыть находки ревью по слиянию сущностей»
(проходы `ops` и `negative`, профиль `deep`).
Двигает строку «Завершения» цели: «Откат релиза после наката миграции имеет названный механизм».
## Что именно решить
Та задача перенесла в `store.Open` стража версии схемы: база новее бинаря —
@@ -37,7 +40,8 @@
синхронизации; для `stateOfMind` не закрывается ничем — у него доставки HAE
единственный источник.
## Вопросы
## Варианты и цена
1. **Подкоманда `healthlog migrate --down-to N`.** Цена: новая поверхность CLI
плюс тест на `Down` каждой миграции (сейчас их нет, и `DROP COLUMN` в SQLite
ведёт себя не так, как в постгресе). Зато откат становится операцией, а не
+3 -2
View File
@@ -1,6 +1,7 @@
# [idea] Человеческие аннотации поверх выведенных схем
# 🔬 Человеческие аннотации поверх выведенных схем
- **Секция:** ядро
- **Тип:** research
- **Категория:** Ядро
- **Зачем:** Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
- **Теги:** goal:self-description
+3 -2
View File
@@ -1,6 +1,7 @@
# [idea] Порог sealed: с какого возраста час считается запечатанным
# 🔬 Порог sealed: с какого возраста час считается запечатанным
- **Секция:** ядро
- **Тип:** research
- **Категория:** Ядро
- **Зачем:** WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
- **Теги:** goal:merge-robustness
+5 -9
View File
@@ -1,17 +1,13 @@
# [goal] Самоописание
# 🎯 Клиент узнаёт форму данных из ответа
- **Секция:** порядок
- **Тип:** goal
- **Секция:** Запланировано
- **Зачем:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- **Теги:** decomposed
Клиент узнаёт форму данных из ответа сервиса, а не угадывает её по выборке.
Выведена из шага 6 плана.
Завершена, когда контракт читается машиной, а формы содержимого метрик
выведены из данных, а не описаны руками.
## Завершение
Контракт читается машиной, а формы содержимого метрик выведены из данных, а не
описаны руками.
- Формы содержимого метрик выведены из данных, а не описаны руками
- Клиент узнаёт форму одной метрики и всего хранилища одним запросом
@@ -1,6 +1,7 @@
# Остановка и миграция: раздельные бюджеты и следы в логе
# 🐞 Развести бюджеты остановки и оставить следы миграции в логе
- **Секция:** инфра
- **Тип:** fix
- **Категория:** Инфра
- **Зачем:** Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
- **Теги:** goal:deploy
@@ -49,3 +50,5 @@
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, change
`2026-08-02-cena-chitayushchego-marshruta` (архив).
Двигает строку «Завершения» цели: «Остановка сервиса называет виновный этап честно, а накат миграций виден в логе старта».
+5 -2
View File
@@ -1,6 +1,7 @@
# Наблюдаемость: /stats
# ✨ Отдавать состояние сервиса маршрутом /stats
- **Секция:** инфра
- **Тип:** feature
- **Категория:** Инфра
- **Зачем:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- **Теги:** goal:observability
@@ -59,3 +60,5 @@
сколько страниц лежит и сколько перенесено) и — тем же полем — доля ответов
чтения, которые удалось подписать `ETag`: механизм условного запроса может
перестать окупаться под плотным потоком, и снаружи это неотличимо от нормы.
Двигает строку «Завершения» цели: «Состояние сервиса — последняя доставка, счётчики, тишина — читается одним запросом».
+7 -4
View File
@@ -1,6 +1,7 @@
# Активный алерт «данных нет N часов»
# ✨ Слать уведомление, когда данных нет N часов
- **Секция:** инфра
- **Тип:** feature
- **Категория:** Инфра
- **Зачем:** Пропажу потока сейчас замечает человек, а не сервис
- **Теги:** goal:observability
@@ -18,10 +19,12 @@
После деплоя на rivendell поднимется.
## Источник алерта не может жить внутри `serve`
Двигает строку «Завершения» цели: «Пропажа потока видна владельцу без чтения логов».
## Источник уведомления не может жить внутри `serve`
Отказ стража версии схемы (база новее бинаря) останавливает процесс, а
`restart: unless-stopped` даёт цикл перезапуска. Значит алерт «данных нет N
`restart: unless-stopped` даёт цикл перезапуска. Значит уведомление «данных нет N
часов», живущий внутри сервиса, на эту причину остановки не сработает **по
построению** — он не поднимется вместе с ним. Обоснование стража («откат делает
оператор, он в этот момент рядом») верно для ручного отката и не покрывает
+35
View File
@@ -0,0 +1,35 @@
# ✨ Поднять Swagger UI без внешней сети
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** Контракт читается машиной, но человеку нечем выполнить запрос к живому сервису из браузера, а внешних CDN в локальной сети нет
- **Теги:** goal:read-api, sprint:2026-08-04
Человек открывает UI на отдельном пути и выполняет запрос к живому сервису — не
имея интернета.
Сервис живёт в локальной сети и на VPS без гарантии выхода наружу, поэтому
статика отдаётся самим сервисом и лежит в бинаре: внешние CDN здесь означают
«работает, пока работает чужой сайт».
**UI — новый адресат недоверенного входа наизнанку:** он даёт человеку одним
нажатием выполнить запрос к любому описанному маршруту, включая приём. Права
на запись из браузера не должны появляться сами собой — это ровно та граница, которую
описывает `docs/security.md`.
Двигает строку «Завершения» цели: «Контракт чтения читается машиной».
## Критерии приёмки
- UI открывается и выполняет запрос при отключённой внешней сети — оракул:
запуск контейнера без доступа наружу
- бинарь работает без каталога со статикой рядом — оракул: запуск одного файла
из пустого каталога
- запрос к маршруту приёма без заголовка с токеном приёма отклоняется, откуда
бы он ни пришёл, а отдаваемая UI статика токена приёма в себе не держит —
оракул: тест на обработчике приёма плюс поиск токена в отдаваемой статике
## Рамки
Схема не трогается. Берётся после спеки. Наружу ничего не выкладывается — это
решение человека и отдельная задача деплоя.
@@ -1,93 +0,0 @@
# Тай-брейк при равной полноте точек
- **Секция:** ядро
- **Зачем:** Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт
- **Теги:** goal:merge-robustness, question
**Решение принято владельцем 2026-08-02: вариант (б) — брать бо́льшее значение
точки.** Ниже — исходная постановка блокера, она же ТЗ; рекомендация в конце
файла и есть выбранный вариант.
Что важно не потерять при реализации: правило обязано остаться **тотальным**
числа у точки нет, значит откат на порядок канонических форм, — и обязано
остаться полурешёткой: `max` коммутативен, ассоциативен и идемпотентен, поэтому
воспроизводимость свёртки не страдает. Род агрегации в правило **не входит**:
род есть функция витрины, и правило слияния, читающее собственную выдачу,
повторяет дефект наследования слоя «из будущего» (`docs/review.md`,
2026-08-01).
Приёмка та, что названа ниже: на прогоне живого архива отпечаток витрины обязан
**измениться** (иначе правило не сработало), а число столкновений с равной
полнотой — остаться прежним.
## Вопросы
Какое правило выбирает победителя, когда по одним координатам приехали две точки
с **равными** наборами содержательных полей и разными значениями. Структурная
часть правила слияния закрыта (`pravilo-sliyaniya-tochek`); открыт только этот
разряд.
Сегодня это порядок канонических форм, и он измеримо смещён: из 1912 случаев, где
сравнение чисел определено, лексикографический порядок берёт **меньшее** значение
в 1847 — 96% (находка 49). Столкновений с равной полнотой 1916 из 444 256
координат, то есть 0.43% координат.
## Что стало известно
Задача «Измеренный род агрегации и каталог разрезов» закрыла посылку, ради
которой тай-брейк откладывали: род метрик теперь **измерен**, а не угадан
(находка 53). Четыре из шести метрик, где тай-брейк системно берёт меньшее
(`step_count`, `walking_running_distance`, `active_energy`,
`basal_energy_burned`), измерены как **накопительные** — там «меньшее» это
систематический недосчёт порядка 0.4% координат, ровно тот, что HAE досчитывает
задним числом (находка 10). Самая крупная группа, `heart_rate`, измерена как
**мгновенная**, и там выбор безразличен: это пересэмплирование, а не досчёт.
И тем же измерением закрылся напрашивавшийся ответ: **сделать тай-брейк
зависящим от измеренного рода нельзя**. Род есть функция витрины, витрина —
результат слияния, и правило слияния, читающее собственную выдачу, повторяет
ровно тот дефект, на котором свёртка уже переставала быть функцией префикса
журнала (`docs/review.md`, 2026-08-01, наследование слоя «из будущего»).
## Варианты и цена
**а. Оставить порядок канонических форм.** Цена: систематический недосчёт 0.4%
координат у накопительных метрик, невидимый до сверки с родным экспортом Apple,
то есть месяцами. Плюс: ноль работы, правило остаётся структурным и не знает
ничего о значениях.
**б. Брать бо́льшее значение точки.** Правильно для накопительных (досчёт растёт,
находка 10, и набор полей у версий тренировки ни разу не уменьшался) и безвредно
для мгновенных (пересэмплирование). Цена: слияние перестаёт быть структурным —
оно начинает знать, какое поле точки несёт число (`hae.PointValue` уже есть).
Метрика, у которой «большее» неверно, в потоке не наблюдалась, но и не
исключена; правило приходится делать тотальным (нет числа — откат на порядок
канонических форм), то есть в нём появляется вторая ветка.
**в. Провенанс у точки и тай-брейк по позиции в журнале** — как у сущностей.
Цена: колонка провенанса на точку (или на объект) и рост объёма нижнего слоя;
плюс это не работает для столкновений **внутри одной доставки**, где
`received_at` общий, а таких четверть (находка 47: 33 столкновения внутри
доставки на эпизодах сна). То есть вариант не самодостаточен и всё равно требует
второго разряда.
## Что заблокировано
Ничего срочного: сегодняшнее правило детерминировано и воспроизводимо, витрина
остаётся свёрткой журнала. Блокирован только сам недосчёт — он копится молча.
Сверить его величину можно будет после `healthlog import`: родной экспорт Apple
даст независимый эталон по тем же периодам.
## Рекомендация
**Вариант б.** Он чинит измеренное смещение там, где оно есть, и не трогает
там, где его нет; цена — одна ветка в правиле слияния и признание, что слияние
знает про число точки (а оно уже знает — `hae.PointValue` живёт в разборе). От
варианта «а» отличается тем, что перестаёт систематически терять данные;
от «в» — тем, что не требует ни колонки, ни решения для внутридоставочных
столкновений.
Проверять на прогоне живого архива: отпечаток витрины обязан измениться (иначе
правило не сработало), а число столкновений с равной полнотой — остаться прежним.
Связано: `docs/architecture.md` → «Разрешение столкновений», находки 10, 47, 49,
53.
@@ -1,6 +1,7 @@
# Управление токенами и секретами
# ✨ Развести токены контуров и убрать секреты из репозитория
- **Секция:** инфра
- **Тип:** feature
- **Категория:** Инфра
- **Зачем:** Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
- **Теги:** goal:deploy
@@ -29,3 +30,5 @@
Готово, когда запуск без токенов возможен только на localhost, а на rivendell
оба контура закрыты разными токенами.
Двигает строку «Завершения» цели: «Оба контура закрыты разными токенами, и без токенов сервис стартует только на localhost».
-47
View File
@@ -1,47 +0,0 @@
# Проверка секций, которых поток ещё не приносил
- **Секция:** ядро
- **Зачем:** Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую
- **Теги:** goal:parsing-and-storage
Разбор пишется по тем данным, что видел поток, а он приносил только `metrics`,
`workouts` и `stateOfMind`. Не виденны живьём: `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`, а также вес — а вес
агенту-медику нужен наверняка.
Пользователь настраивает оставшиеся метрики на телефоне, так что данные
появятся сами. Задача — не пропустить момент: убедиться, что новые секции
разбираются, а не молча падают в `parse_status`.
Часть вопроса закрыта разбором экспортов (находка 42): в Health эти данные
**есть** и в экспорте они присутствуют — `BodyMass` (1127 записей),
`BloodPressureSystolic`/`Diastolic` (по 18), `BodyTemperature` (11), `Headache`
(36), `SexualActivity` (46), `Dietary*` (по 88). Значит вопрос не «есть ли
данные», а «доедут ли они через HAE и в какой форме».
Остаётся непроверенным `stateOfMind`: в экспорте его нет ни одним типом. Если
подтвердится, что Apple его не выгружает, то экспорт ему не источник истины —
устаревание нижнего слоя к нему неприменимо, держим всегда.
Давление приезжает обёрткой `Correlation` из двух записей (находка 44) — в
экспорте точно, а вот как его отдаёт HAE, неизвестно. Это первое, на что
смотреть, когда данные появятся.
Готово, когда каждая новая секция либо разобрана, либо явно описана в
`docs/research/apple-health.md` как не пришедшая, и ни одна не числится в ошибках
разбора.
## Что уже сделано
Разбор перечисляет непокрытые секции и пишет их в `delivery.uncovered_sections`
(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Момент, когда поток принесёт
секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться
замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что
поток приносил, и сравнение с известным набором закрывает задачу.
Модель под секции с собственным `id` заложена (change
`2026-08-02-trenirovki-i-zapisi`): таблица `record` ключуется парой
`род + id`, и новая секция добавляется **одной строкой** в множество покрытых
имён разбора, а не миграцией. Покрыты `workouts` и `stateOfMind`; остались
`ecg`, `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`
их формы никто не видел, и разбор вслепую сознательно не писался.
@@ -1,6 +1,7 @@
# Идентичность тренировок при импорте родного экспорта
# ✨ Не задваивать тренировки при импорте родного экспорта
- **Секция:** ядро
- **Тип:** feature
- **Категория:** Ядро
- **Зачем:** В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
- **Теги:** goal:native-export-import
@@ -37,3 +38,5 @@ Prior art: `dogsheep/healthkit-to-sqlite` адресует тренировку
Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача
`apple-export-import`.
Двигает строку «Завершения» цели: «Тренировки из экспорта не задваивают приехавшие от HAE».
+3 -2
View File
@@ -1,6 +1,7 @@
# [idea] Разворачивание маршрутов тренировок в отдельную таблицу
# 🔬 Разворачивание маршрутов тренировок в отдельную таблицу
- **Секция:** ядро
- **Тип:** research
- **Категория:** Ядро
- **Зачем:** Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
- **Теги:** goal:read-api
+18
View File
@@ -474,6 +474,24 @@ func keysOf(m map[string]json.RawMessage) map[string]struct{} {
return out
}
// CarriesKeyAbsentIn отвечает, есть ли у f ключ с НЕПУСТЫМ значением, которого
// нет у g. Значения при этом не сравниваются вовсе.
//
// Заведено ради наблюдения, которого у правила слияния не было: разряд полноты
// гаснет, когда значения общих содержательных ключей разошлись (см. Relate), и
// тогда исход решает тай-брейк — а он может отдать победу точке, у которой
// содержательного ключа нет. Событие редкое (на живом корпусе 2 координаты из
// 80 129 спорных, обе несравнимые), но это единственное направление, в котором
// новое правило способно потерять содержание, и молчать о нём нельзя.
//
// Отдельным методом, а не через Relate: Relate отвечает про СОДЕРЖАНИЕ (с
// условием совпадения значений), здесь же нужен вопрос про имена, и смешение
// этих двух вопросов однажды уже дало правило, которое считало пустое поле
// содержанием.
func (f Fields) CarriesKeyAbsentIn(g Fields) bool {
return hasExtra(keysOf(f.full), keysOf(g.full))
}
// relateKeys сравнивает два множества ключей по включению.
func relateKeys(a, b map[string]struct{}) Fullness {
aExtra := hasExtra(a, b)
+73 -37
View File
@@ -63,12 +63,10 @@ func (s Style) String() string {
}
}
// MarshalJSON отдаёт род строкой. Нулевое значение уезжает как `unknown`, а не
// как пустая строка: клиент не должен видеть в ответе состояние, которого в
// словаре нет.
func (s Style) MarshalJSON() ([]byte, error) {
return []byte(`"` + s.String() + `"`), nil
}
// Собственной сериализации у Style нет намеренно: строку в ответ кладёт
// транспорт (`internal/httpapi`, форма провода). Домен владеет ЗНАЧЕНИЯМИ
// словаря, а не их видом на проводе; `String()` при этом нужен и логам, и
// сообщениям тестов, и проводу — второго словаря заводить незачем.
// Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова,
// потому что от них зависят счётчики основания в ответе.
@@ -149,13 +147,28 @@ func closeEnough(a, b float64) bool {
return math.Abs(a-b)/math.Max(math.Abs(a), math.Abs(b)) <= tolerance
}
func clipMetric(metric string) string {
// ClipMetric обрезает имя метрики для записи лога.
//
// Экспортирована потому, что предел один на всех, кто пишет имя метрики в лог:
// имя приходит из тела дословно при пределе приёма в 64 МиБ, а запись
// повторяется на каждый запрос. Второй предел разошёлся бы с первым молча.
func ClipMetric(metric string) string {
if len(metric) <= maxMetricInLog {
return metric
}
return metric[:maxMetricInLog] + "…"
}
// ФОРМЫ ПРОВОДА В ЭТОМ ПАКЕТЕ НЕТ, и это решение, а не упущение.
//
// Типы ниже — форма ответа use-case, а не форма ответа HTTP: `json`-тегов они
// не несут и до сериализации не доезжают. Публичный контракт чтения объявляет
// транспорт (`internal/httpapi`), поэтому переименование поля здесь байты
// ответа клиенту не меняет — оно ломает компиляцию перевода. Обратная цена
// названа вслух: новое поле само в ответ не попадёт, его обязан перечислить
// транспорт. Решение и цена обеих сторон — `docs/architecture.md`, раздел
// «Read API», подраздел «Форма провода».
// Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их
// разности были осмысленны: `Hours Compared` — часы, отброшенные проверкой
// пригодности, `Compared Agreeing Conflicting` — часы, не сошедшиеся ни с
@@ -165,17 +178,21 @@ func clipMetric(metric string) string {
// и «часов не было вовсе» — разные события, и клиент обязан различать их без
// второго запроса.
type Basis struct {
Hours int `json:"hours"`
Compared int `json:"compared"`
Agreeing int `json:"agreeing"`
Conflicting int `json:"conflicting"`
FirstHour *time.Time `json:"first_hour"`
LastHour *time.Time `json:"last_hour"`
Hours int
Compared int
Agreeing int
Conflicting int
FirstHour *time.Time
LastHour *time.Time
}
// Aggregation — род вместе с основанием.
//
// Встраивание здесь — удобство домена, а не форма ответа: плоскость объекта
// `aggregation` на проводе объявлена транспортом поимённо и от этого
// встраивания не зависит.
type Aggregation struct {
Style Style `json:"style"`
Style Style
Basis
}
@@ -186,22 +203,22 @@ type Aggregation struct {
// часовых объектов здесь нет: объект — деталь хранения, клиент про него не
// знает.
type LayerRange struct {
Layer string `json:"layer"`
From time.Time `json:"from"`
To time.Time `json:"to"`
Points int `json:"points"`
Layer string
From time.Time
To time.Time
Points int
}
// Metric — запись каталога.
type Metric struct {
Metric string `json:"metric"`
Metric string
// Units — множество различных единиц метрики, отсортированное. Массив, а не
// строка: на живом потоке единицы не менялись ни разу, но одна форма поля
// для обоих случаев честнее строки, которая при расхождении молча выберет
// одно из двух.
Units []string `json:"units"`
Aggregation Aggregation `json:"aggregation"`
Layers []LayerRange `json:"layers"`
Units []string
Aggregation Aggregation
Layers []LayerRange
}
// Snapshot — каталог вместе с версией ответа.
@@ -240,12 +257,38 @@ func (s *Service) Version(ctx context.Context) (string, error) {
s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err)
return "", err
}
return stamp(version, store.Now().Add(horizonSlack)), nil
return Stamp(version, Horizon()), nil
}
// stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой:
// Horizon — верхняя граница окна измерения на текущий момент.
//
// Экспортирована потому, что горизонт нужен ВСЕМ, кто объявляет измеренный род:
// маршрут точек снимает род и метку с одного горизонта, иначе метка подтвердит
// неизменность ответа, в котором род уже перевернулся ходом часов.
func Horizon() time.Time { return store.Now().Add(horizonSlack) }
// MeasureWindow — окно измерения рода для заданного горизонта.
//
// Одно на всех потребителей измерения. Второй экземпляр параметров разошёлся бы
// с первым молча, а вердикт зависит от каждого из них: слои сверки, размер окна
// и оба структурных порога уходят в предварительный отбор хранилища.
func MeasureWindow(horizon time.Time) store.CatalogWindow {
return store.CatalogWindow{
Fine: string(hae.LayerMinute),
Coarse: string(hae.LayerHour),
Hours: Window,
Horizon: horizon,
CoarsePoints: coarsePoints,
MinFinePoints: minFinePoints,
}
}
// Stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой:
// подписывать нечем — значит нечем, и горизонт этого не меняет.
func stamp(version string, horizon time.Time) string {
//
// Экспортирована по той же причине, что и Horizon: правило «метка строится из
// всего, от чего зависит ответ» держится ровно до тех пор, пока склейка одна.
func Stamp(version string, horizon time.Time) string {
if version == "" {
return ""
}
@@ -275,19 +318,12 @@ func New(st *store.Store, log *slog.Logger) *Service {
// её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён:
// версия, снятая после чтения, пометила бы устаревший снимок свежей меткой.
func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
horizon := store.Now().Add(horizonSlack)
horizon := Horizon()
var snap store.CatalogSnapshot
version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
var err error
snap, err = s.store.ReadCatalog(ctx, store.CatalogWindow{
Fine: string(hae.LayerMinute),
Coarse: string(hae.LayerHour),
Hours: Window,
Horizon: horizon,
CoarsePoints: coarsePoints,
MinFinePoints: minFinePoints,
})
snap, err = s.store.ReadCatalog(ctx, MeasureWindow(horizon))
return err
})
if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
@@ -319,7 +355,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
if basis.Conflicting > 0 {
s.log.WarnContext(ctx, "aggregation style conflict",
"capability", "query",
"metric", clipMetric(group.metric),
"metric", ClipMetric(group.metric),
"hours", basis.Hours,
"compared", basis.Compared,
"agreeing", basis.Agreeing,
@@ -332,7 +368,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
if to := group.latest(); to.After(horizon) {
s.log.WarnContext(ctx, "future data",
"capability", "query",
"metric", clipMetric(group.metric),
"metric", ClipMetric(group.metric),
"last_ts", store.FormatTime(to),
"horizon", store.FormatTime(horizon))
}
@@ -350,7 +386,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
// механизм не окупается вовсе.
s.log.DebugContext(ctx, "catalog unsigned", "capability", "query")
}
return Snapshot{Version: stamp(version, horizon), Metrics: out}, nil
return Snapshot{Version: Stamp(version, horizon), Metrics: out}, nil
}
type metricGroup struct {
+26
View File
@@ -520,3 +520,29 @@ func TestКаталогОтдаётсяСВерсиейВитрины(t *testing
t.Error("каталог собран на стоящей витрине и остался без версии")
}
}
// Версия ответа каталога — это версия витрины ПЛЮС горизонт измерения.
//
// Утверждение прямое, потому что склейка теперь общая: её же зовёт маршрут
// точек. Сломай её — и оба маршрута начнут подтверждать неизменность ответа,
// чей род перевернулся ходом часов, а не коммитом.
func TestВерсияКаталогаНесётГоризонт(t *testing.T) {
st := openStore(t)
ctx := context.Background()
got, err := service(t, st).Version(ctx)
if err != nil {
t.Fatalf("Version: %v", err)
}
bare, err := st.StateVersion(ctx)
if err != nil {
t.Fatalf("StateVersion: %v", err)
}
if got == bare {
t.Error("версия ответа равна версии витрины — горизонт в неё не вошёл")
}
if want := catalog.Stamp(bare, catalog.Horizon()); got != want {
t.Errorf("версия ответа %q, ожидалась %q", got, want)
}
}
+13 -10
View File
@@ -271,22 +271,25 @@ func TestMeasureПротиворечиеСПеревесомМгновенной
}
}
// Словарь рода живёт в домене, а его вид на проводе объявляет транспорт: род
// уезжает клиенту строкой, которую кладёт `internal/httpapi`, вызывая этот же
// `String()`. Поэтому проверяется словарь, а не сериализация — второй словарь на
// проводе разошёлся бы с этим молча.
//
// Незнакомое значение даёт `unknown`, а не пустую строку: клиент не должен
// видеть состояние, которого в словаре нет.
func TestStyleСловарь(t *testing.T) {
t.Parallel()
cases := map[catalog.Style]string{
catalog.Cumulative: `"cumulative"`,
catalog.Instant: `"instant"`,
catalog.Unknown: `"unknown"`,
catalog.Style(42): `"unknown"`,
catalog.Cumulative: "cumulative",
catalog.Instant: "instant",
catalog.Unknown: "unknown",
catalog.Style(42): "unknown",
}
for style, want := range cases {
got, err := json.Marshal(style)
if err != nil {
t.Fatalf("сериализация %v: %v", style, err)
}
if string(got) != want {
t.Errorf("род %d: получили %s, ждали %s", style, got, want)
if got := style.String(); got != want {
t.Errorf("род %d: получили %q, ждали %q", style, got, want)
}
}
}

Some files were not shown because too many files have changed in this diff Show More