Compare commits

...
18 Commits
Author SHA1 Message Date
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
192 changed files with 18613 additions and 975 deletions
+6
View File
@@ -61,6 +61,11 @@ linters:
- third_party$ - third_party$
- builtin$ - builtin$
- examples$ - examples$
# Черновое и временное живёт в ./tmp (CLAUDE.md, «Запреты»): туда же
# попадают worktree батча и диагностические программы. Конвенции на них
# не распространяются — иначе черновик красит гейт по причине, не
# связанной с изменением, и настоящую красноту перестают читать.
- ^tmp/
rules: rules:
# CLI — другая поверхность: печатает результат в stdout, это не логи. # CLI — другая поверхность: печатает результат в stdout, это не логи.
- path: ^cmd/ - path: ^cmd/
@@ -86,3 +91,4 @@ formatters:
- third_party$ - third_party$
- builtin$ - builtin$
- examples$ - examples$
- ^tmp/
+8 -4
View File
@@ -4,7 +4,7 @@
[docs/passport.md](docs/passport.md) (цель, сценарии, референсы), [docs/passport.md](docs/passport.md) (цель, сценарии, референсы),
[README.md](README.md), [docs/architecture.md](docs/architecture.md), [README.md](README.md), [docs/architecture.md](docs/architecture.md),
[docs/conventions/README.md](docs/conventions/README.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` (версия в `docs/.pm.json`);
раскладку проверяет `av-dev-pm:canon`, содержимое ведёт `av-dev-pm:docs`. раскладку проверяет `av-dev-pm:canon`, содержимое ведёт `av-dev-pm:docs`.
@@ -51,7 +51,10 @@ Module path — `git.vakhrushev.me/av/healthlog`.
под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек — под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек —
отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он
нестабилен. Хеш канонизированного содержимого остался детектором изменений. При нестабилен. Хеш канонизированного содержимого остался детектором изменений. При
столкновении выигрывает **более полная** точка, а не последняя. Изменение столкновении выигрывает **более полная** точка, а при равной полноте —
**стоящая позже в журнале** (внутри одной доставки — порядок канонических
форм). Второе означает, что содержимое витрины есть функция **порядка**
свёртки, и порядок этот обязан равняться журнальному. Изменение
запечатанного часа — `WARN`, но данные всё равно пишутся. запечатанного часа — `WARN`, но данные всё равно пишутся.
- **Дыры закрываются сами.** `major`, обратимо. Три прохода разной глубины - **Дыры закрываются сами.** `major`, обратимо. Три прохода разной глубины
(5 минут / сутки / неделя). Настройки данных у проходов теперь **разные** — намеренно, они (5 минут / сутки / неделя). Настройки данных у проходов теперь **разные** — намеренно, они
@@ -90,7 +93,8 @@ Module path — `git.vakhrushev.me/av/healthlog`.
разбор, повтор обязан дать то же состояние. В гейт не входит намеренно — разбор, повтор обязан дать то же состояние. В гейт не входит намеренно —
минута прогона и данные, которых нет ни на какой другой машине минута прогона и данные, которых нет ни на какой другой машине
- `task verify:busy` — свёртка под удерживаемой блокировкой базы: занятость - `task verify:busy` — свёртка под удерживаемой блокировкой базы: занятость
обязана оставить доставку в очереди. В гейт не входит: 25 секунд на прогон обязана оставить доставку в очереди, а отложенная доставка не должна развести
живую витрину с пересборкой. В гейт не входит: около 50 секунд на прогон
- `task tidy``go mod tidy` - `task tidy``go mod tidy`
- `task setup` — установка golangci-lint - `task setup` — установка golangci-lint
@@ -111,7 +115,7 @@ Module path — `git.vakhrushev.me/av/healthlog`.
необратимо. необратимо.
- **Чего в гейте намеренно нет и кто обязан это гонять:** - **Чего в гейте намеренно нет и кто обязан это гонять:**
`task verify:archive` (минута прогона, данные есть только на этой машине) и `task verify:archive` (минута прогона, данные есть только на этой машине) и
`task verify:busy` (25 секунд). Гоняет их **человек или оркестратор задачи** `task verify:busy` (около 50 секунд). Гоняет их **человек или оркестратор задачи**
перед любым изменением правила разбора, идентичности или слияния — а не «когда перед любым изменением правила разбора, идентичности или слияния — а не «когда
вспомнит». Прецедент, когда молчащая краснота прожила две задачи, записан в вспомнит». Прецедент, когда молчащая краснота прожила две задачи, записан в
[docs/review.md](docs/review.md). [docs/review.md](docs/review.md).
+16 -8
View File
@@ -60,19 +60,24 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по — с выводом слоя из данных, канонизацией содержимого и слиянием точек по
полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже
разбираются; секции, которых разбор не покрывает, принимаются, хранятся и разбираются; секции, которых разбор не покрывает, принимаются, хранятся и
честно помечаются как неразобранные. честно помечаются как неразобранные — а имя, которого поток раньше не приносил,
даёт `WARN` в логе свёртки один раз и попадает в перечень `healthlog uncovered`.
Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
пересборка воспроизводима и повторный прогон ничего не меняет. пересборка воспроизводима и повторный прогон ничего не меняет.
Первый маршрут чтения открыт: **каталог разрезов** (`GET /api/v1/metrics`) под Маршрутов чтения открыто два. **Каталог разрезов** (`GET /api/v1/metrics`) под
токеном чтения отдаёт слои с диапазонами и измеренный род агрегации, а повтор токеном чтения отдаёт слои с диапазонами и измеренный род агрегации, а повтор
неизменившегося отвечает `304` по `ETag` — снимок витрины при этом не неизменившегося отвечает `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 находок на живом потоке, половина расходится с Разведка формата закончена: 50 находок на живом потоке, половина расходится с
документацией Health Auto Export — [docs/research/apple-health.md](docs/research/apple-health.md). документацией 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 import родной экспорт Apple Health (в планах)
healthlog reindex пересборка витрины из журнала healthlog reindex пересборка витрины из журнала
healthlog uncovered перечень секций, которых разбор не покрыл
healthlog healthcheck проверка живости для docker HEALTHCHECK healthlog healthcheck проверка живости для docker HEALTHCHECK
``` ```
@@ -152,7 +158,8 @@ curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
Если `auth.write_tokens` пуст, проверка токена выключена — для доверенной Если `auth.write_tokens` пуст, проверка токена выключена — для доверенной
локальной сети этого достаточно, сервис пишет об этом `write auth disabled` локальной сети этого достаточно, сервис пишет об этом `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/conventions/](docs/conventions/README.md) — как пишем код
- [docs/security.md](docs/security.md) — периметр и модель угроз - [docs/security.md](docs/security.md) — периметр и модель угроз
- [docs/review.md](docs/review.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/tasks/BACKLOG.md](docs/tasks/BACKLOG.md) — что брать следующим, включая
отложенные идеи отложенные идеи
- [docs/research/apple-health.md](docs/research/apple-health.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 - go test ./internal/replay -run TestReplay -healthlog.archive={{.ARCHIVE | default (printf "%s/data/raw" .ROOT_DIR)}} -v -count=1
verify:busy: verify:busy:
desc: 'Свёртка под удерживаемой блокировкой базы: занятость обязана оставить доставку в очереди (около 25 секунд)' desc: 'Свёртка под удерживаемой блокировкой базы: доставка остаётся в очереди, а витрина не расходится с пересборкой (около 50 секунд)'
cmds: cmds:
# Не входит в `task test` и `task gate` намеренно: busy_timeout — пять # Не входит в `task test` и `task gate` намеренно: busy_timeout — пять
# секунд, повторов транзакции пять, и гейт гоняет тесты трижды. Проверяет # секунд, повторов транзакции пять, и гейт гоняет тесты трижды. Проверяет
# при этом центральное решение задачи «разнести ответ и свёртку»: # при этом центральное решение задачи «разнести ответ и свёртку»:
# занятость базы — обстоятельство, а не свойство доставки. # занятость базы — обстоятельство, а не свойство доставки.
- go test ./internal/fold -run TestBusy -healthlog.busy -v -count=1 - go test ./internal/fold -run TestBusy -healthlog.busy -v -count=1
# Второй прогон — композиция, ради которой заведён барьер журнального
# порядка: занятость откладывает доставку, проход прекращается на ней, и
# живая витрина всё равно совпадает с пересборкой. Порознь барьер и
# сходимость проверены в гейте; вместе — только здесь, потому что
# настоящая занятость стоит те же двадцать пять секунд.
- go test ./internal/replay -run TestBusy -healthlog.busy -v -count=1
lint: lint:
desc: Запуск golangci-lint desc: Запуск golangci-lint
+3
View File
@@ -4,6 +4,7 @@
// //
// healthlog [serve] --config <path> принимать пакеты (по умолчанию) // healthlog [serve] --config <path> принимать пакеты (по умолчанию)
// healthlog reindex --config <path> пересобрать витрину из журнала // healthlog reindex --config <path> пересобрать витрину из журнала
// healthlog uncovered --config <path> перечень секций, которых разбор не покрыл
// healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK) // healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
package main package main
@@ -30,6 +31,8 @@ func main() {
err = runServe(args) err = runServe(args)
case "reindex": case "reindex":
err = runReindex(args) err = runReindex(args)
case "uncovered":
err = runUncovered(args)
case "healthcheck": case "healthcheck":
err = runHealthcheck(args) err = runHealthcheck(args)
default: default:
+11 -3
View File
@@ -179,9 +179,14 @@ type report struct {
// единица, которой нет в счётчиках, делает расхождение безадресным. // единица, которой нет в счётчиках, делает расхождение безадресным.
sourceWorkouts int64 sourceWorkouts int64
sourceRecords int64 sourceRecords int64
sourceBefore int64 // sourceCategories — то же «было» для реестра категориальных значений.
sourceAfter int64 // Перечень единиц хранения закрытый, и он пополняется ТЕМ ЖЕ изменением,
sourceMissing bool // которое заводит единицу: не внесённая сюда, она молчит ровно там, где
// расхождение впервые становится заметным.
sourceCategories int64
sourceBefore int64
sourceAfter int64
sourceMissing bool
} }
// rebuild собирает витрину в промежуточный файл и переименовывает его в файл // rebuild собирает витрину в промежуточный файл и переименовывает его в файл
@@ -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 { if rep.sourceRecords, err = src.CountRecords(ctx); err != nil {
return canceledOr(rep, err, stopped) return canceledOr(rep, err, stopped)
} }
if rep.sourceCategories, err = src.CountCategoryValues(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
} }
removeDB(t.partial) removeDB(t.partial)
+30
View File
@@ -36,6 +36,13 @@ func writeReport(w io.Writer, r report) {
// сущностей стало слишком строгим. // сущностей стало слишком строгим.
p(" слияние: частично разобрано %d, несравнимых наборов %d, удержано версий сущностей %d, версий одного ключа в одном теле %d", p(" слияние: частично разобрано %d, несравнимых наборов %d, удержано версий сущностей %d, версий одного ключа в одном теле %d",
r.replay.Partial, r.replay.Incomparable, r.replay.EntitiesHeld, r.replay.EntitiesDiverging) 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 { if r.replay.Canceled {
// Ни отпечаток пересобранной витрины, ни число доставок после прогона при // Ни отпечаток пересобранной витрины, ни число доставок после прогона при
@@ -66,6 +73,7 @@ func writeReport(w io.Writer, r report) {
p(" объектов: %d", r.replay.Buckets) p(" объектов: %d", r.replay.Buckets)
p(" тренировок: %d", r.replay.Workouts) p(" тренировок: %d", r.replay.Workouts)
p(" записей: %d", r.replay.Records) p(" записей: %d", r.replay.Records)
p(" строк реестра категориальных значений: %d", r.replay.Categories)
p("") p("")
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath) p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
p("не восстанавливаются: в архиве их нет.") p("не восстанавливаются: в архиве их нет.")
@@ -76,6 +84,8 @@ func writeReport(w io.Writer, r report) {
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets) p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
p(" тренировок: было %d, стало %d", r.sourceWorkouts, r.replay.Workouts) p(" тренировок: было %d, стало %d", r.sourceWorkouts, r.replay.Workouts)
p(" записей: было %d, стало %d", r.sourceRecords, r.replay.Records) p(" записей: было %d, стало %d", r.sourceRecords, r.replay.Records)
p(" строк реестра категориальных значений: было %d, стало %d",
r.sourceCategories, r.replay.Categories)
p("") p("")
p(" отпечаток рабочей: %s", r.sourcePrint) p(" отпечаток рабочей: %s", r.sourcePrint)
p(" отпечаток пересобранной: %s", r.replay.Fingerprint) p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
@@ -89,6 +99,26 @@ func writeReport(w io.Writer, r report) {
p(" ожидаемые причины: исправленный разбор; покрытая разбором новая") p(" ожидаемые причины: исправленный разбор; покрытая разбором новая")
p(" секция (её единиц хранения в рабочей базе нет по построению);") p(" секция (её единиц хранения в рабочей базе нет по построению);")
p(" признак sealed не переносится (правила его выставления ещё нет)") p(" признак sealed не переносится (правила его выставления ещё нет)")
if r.sourceCategories < r.replay.Categories {
// Класс назван отдельно от факта расхождения: реестр появился
// вместе с бинарём, и у витрины, свёрнутой прежним, его нет по
// построению. Не назвав это, отчёт приучает человека
// игнорировать расхождение — то есть обесценивает оракул ровно
// там, где по нему принимается необратимое решение.
//
// Условие — НЕПОЛНОТА, а не пустота. Между выкаткой и прогоном
// проходят дни: воркер успевает набрать частые значения (фазы
// сна, контекст пульса) и не успевает редкие — имя тренировки,
// которая с тех пор не повторялась. Проверка «в рабочей базе
// реестра нет вовсе» такое состояние не ловила бы, и человек
// получил бы безадресное «разошлись» при совпавших числах
// объектов, тренировок и записей.
p(" РЕЕСТР НЕПОЛОН: строк категориальных значений в рабочей базе %d,",
r.sourceCategories)
p(" в пересобранной %d — реестр наполняется по мере свёртки, а целиком",
r.replay.Categories)
p(" его даёт только пересборка. Расхождение объясняется этим и лечится ею же")
}
if partialJournal { if partialJournal {
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может") p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
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/httpapi"
"git.vakhrushev.me/av/healthlog/internal/ingest" "git.vakhrushev.me/av/healthlog/internal/ingest"
"git.vakhrushev.me/av/healthlog/internal/logging" "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/replay"
"git.vakhrushev.me/av/healthlog/internal/store" "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{ Handler: httpapi.New(httpapi.Options{
Ingest: ingest.New(arch, st, worker.Notify, log), Ingest: ingest.New(arch, st, worker.Notify, log),
Catalog: catalog.New(st, log), Catalog: catalog.New(st, log),
Points: points.New(st, log),
Log: log, Log: log,
WriteTokens: cfg.Auth.WriteTokens, WriteTokens: cfg.Auth.WriteTokens,
ReadTokens: cfg.Auth.ReadTokens, 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` обязан быть непуст. # порт. Перед выкладкой наружу `read_tokens` обязан быть непуст.
write_tokens = [] # токены на приём данных write_tokens = [] # токены на приём данных
read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics` read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics` и точки `GET /api/v1/metrics/{name}`
[storage] [storage]
# ВНИМАНИЕ: умолчания в коде (./healthlog.db и ./raw) остались от прежней # ВНИМАНИЕ: умолчания в коде (./healthlog.db и ./raw) остались от прежней
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"canon": 2, "canon": 3,
"migrations": "internal/store/migrations" "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}`, то есть должна.
Переход к (б) остаётся дешёвым: счётчик скажет, если событие станет массовым.
+29 -2
View File
@@ -33,8 +33,35 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| --- | --- | --- | | --- | --- | --- |
Записей пока нет: каталог заведён переездом на канон 2026-08-03. Сырьё для - [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 кладётся реестром рядом со строкой, а не полем внутри точки;
словарь живёт в бинаре, выведенный код в отпечаток витрины не входит.
Сырьё для промоута накоплено — архивные изменения в
`openspec/changes/archive/`, из них решения с дорогим откатом и намеренные `openspec/changes/archive/`, из них решения с дорогим откатом и намеренные
отказы есть как минимум в `2026-08-01-polnota-tochki-mnozhestvom-klyuchey` отказы есть как минимум в `2026-08-01-polnota-tochki-mnozhestvom-klyuchey`
(идентичность точки и тай-брейк), `2026-08-02-reindex-iz-arhiva` (подмену базы (идентичность точки и тай-брейк), `2026-08-02-reindex-iz-arhiva` (подмену базы
+227 -46
View File
@@ -33,10 +33,11 @@ healthlog принимает выгрузки Apple Health из приложен
(`метрика + слой + начало + конец`; у точки-измерения конец равен началу); (`метрика + слой + начало + конец`; у точки-измерения конец равен началу);
`source` в ключ не входит, он `source` в ключ не входит, он
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений, нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
чтобы не писать зря. При столкновении выигрывает более полная точка, а не чтобы не писать зря. При столкновении выигрывает более полная точка, а при
последняя пришедшая: бедная доставка не должна стирать поля у богатой. равной полноте — стоящая **позже в журнале**: бедная доставка не должна
Полнота — **множество** ключей с непустым значением, а не их число (см. стирать поля у богатой, но и устаревшее значение не должно пережить свой
«Разрешение столкновений»). досчёт. Полнота — **множество** ключей с непустым значением, а не их число
(см. «Разрешение столкновений»).
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной - **Дыры закрываются сами.** Данные приходят несколькими проходами разной
глубины, поэтому пропущенная доставка не оставляет постоянного пробела — глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
см. «Модель синхронизации». см. «Модель синхронизации».
@@ -203,10 +204,11 @@ HRV); у накопительных — только `date`. Поэтому то
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
единицы записей, и месячное окно там почти ничего не стоит. единицы записей, и месячное окно там почти ничего не стоит.
Правило слияния одинаково для всех проходов, и порядок прихода значения не Правило слияния одинаково для всех проходов. Порядок прихода при этом значение
имеет. Но «последние данные всегда актуализируют картину» — неверно и никогда **имеет**: полнота решает первой, а при равной полноте побеждает пришедшая
не было верным: при столкновении выигрывает более полная точка, а не последняя позже по журналу. «Последние данные всегда актуализируют картину» остаётся
пришедшая (см. «Разрешение столкновений»). неверным ровно в одном разряде — более полная точка бедную не пропускает
(см. «Разрешение столкновений»).
Автоматизации различимы по заголовку `automation-id`; имена стоит задать, Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
иначе `automation-name` приходит пустым (находка 12). иначе `automation-name` приходит пустым (находка 12).
@@ -224,11 +226,12 @@ capability**, и здесь стоит ссылка, а не пересказ т
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) | | `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) |
| `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) | | `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) |
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) | | `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md) | | `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md), [`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md) |
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) | | `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) | | `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/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) |
## Приём ## Приём
@@ -353,6 +356,40 @@ capability**, и здесь стоит ссылка, а не пересказ т
`partial` — не отклонение, а установившееся состояние, поэтому уровень лога от `partial` — не отклонение, а установившееся состояние, поэтому уровень лога от
него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень. него не растёт. Постоянный `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` со старым списком, и ретеншен будет вечно щадить покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
@@ -417,10 +454,14 @@ capability**, и здесь стоит ссылка, а не пересказ т
пересобрать что угодно. пересобрать что угодно.
**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же **Свёртка обязана быть детерминированной.** Проигрывание должно давать то же
состояние, что и приём в реальном времени. Слияние «выигрывает более полная состояние, что и приём в реальном времени. Разряд полноты коммутативен и
точка» коммутативно и порядка не требует; но когда две одинаково полные точки порядка не требует, а разряд равной полноты — **нет**: побеждает пришедшая, то
несут разные значения, исход решает порядок — поэтому воспроизведение идёт есть исход есть функция порядка свёртки. Отсюда два следствия. Воспроизведение
строго по `received_at`, а не по порядку файлов в каталоге. идёт строго по `(received_at, id)`, а не по порядку файлов в каталоге. И живая
свёртка обязана идти тем же порядком: проход воркера прекращается на первой
отложенной доставке, а свёртка, всё-таки пошедшая вне порядка (конкурентный
приём делает строку учёта видимой позже метки), пишет `WARN` — закрыть это окно
можно только на приёме.
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт **`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
@@ -795,7 +836,7 @@ hour метки выровнены на час heart_rate 00:00:00
доставки той же автоматизации; если её не было, берём **надёжный** заголовок доставки той же автоматизации; если её не было, берём **надёжный** заголовок
(`Minutes``minute`, `Hours``hour`). Иначе точки не сохраняются вовсе: (`Minutes``minute`, `Hours``hour`). Иначе точки не сохраняются вовсе:
молчаливый `raw` создал бы призрачный разрез, который поедет в каталог и в молчаливый `raw` создал бы призрачный разрез, который поедет в каталог и в
правило Read API «самый мелкий слой, покрывающий диапазон». правило Read API выбора слоя (см. «Read API»).
Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от
**префикса журнала**. Наследование от последней доставки вообще делает свёртку **префикса журнала**. Наследование от последней доставки вообще делает свёртку
@@ -928,24 +969,50 @@ hour метки выровнены на час heart_rate 00:00:00
проигрывает `{date, qty:10}` по жребию. Несравнимость на втором разряде исходом проигрывает `{date, qty:10}` по жребию. Несравнимость на втором разряде исходом
не является: лишние ключи там заведомо пусты, объединять в них нечего. не является: лишние ключи там заведомо пусты, объединять в них нечего.
**Победитель — функция множества точек, а не порядка их поступления.** Попарная **Победитель — функция множества кандидатов вместе с их происхождением, а не
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и порядка элементов на проводе.** Попарная свёртка этого не даёт: полнота —
вместе они образуют нетранзитивное отношение победы, то есть цикл. При цикле частичный порядок, тай-брейк — тотальный, и вместе они образуют нетранзитивное
повторная свёртка одной и той же доставки меняет содержимое объекта, и витрина отношение победы, то есть цикл. При цикле повторная свёртка одной и той же
перестаёт быть свёрткой журнала. Поэтому кандидаты координаты собираются доставки меняет содержимое объекта. Поэтому кандидаты координаты собираются
вместе: отбрасываются превзойдённые по полноте, среди оставшихся берётся вместе: отбрасываются превзойдённые по полноте, среди оставшихся берётся
минимум по каноническому порядку. Обе операции зависят только от состава минимум тотального порядка — сперва происхождение (пришедшая раньше
множества. сохранённой), затем каноническая форма. Антицикловое свойство от этого не
страдает; зависимость от **порядка журнала** появляется намеренно и оплачена
отдельно (см. ниже).
**Несравнимые множества не сливаются, а считаются.** Объединение полей — самая **Несравнимые множества не сливаются, а считаются.** Объединение полей — самая
дорогая часть правила — на живом потоке не потребовалось ни разу (0 из 2 897), дорогая часть правила — на живом потоке не потребовалось ни разу (0 из 2 897),
поэтому вместо реализации стоит счётчик и `WARN` с координатами объекта. Если поэтому вместо реализации стоит счётчик и `WARN` с координатами объекта. Если
событие наступит, оно будет видно, а не додумано заранее. событие наступит, оно будет видно, а не додумано заранее.
**Тай-брейк при равной полноте не выбран.** Сегодня это порядок канонических **Тай-брейк при равной полноте — пришедшая доставка.** Порядок канонических
форм, и он измеримо смещён: в 96% случаев берёт меньшее значение. Правильный форм отвергнут замером: он берёт меньшее значение в 96% случаев (находка 49) и
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой — стоил `step_count` его рода. Значение точки в правило не входит («брать
значит он и станет известен точно, вместо того чтобы быть угаданным. бо́льшее» неверно для мгновенных метрик), род метрики — тоже: род есть функция
витрины, а правило, читающее собственную выдачу, перестаёт быть функцией
префикса журнала. Байтовый порядок остался тай-брейком **внутри одной
доставки**, где провенанс общий.
Цена названа вслух: правило перестало быть функцией множества и стало явной
функцией порядка журнала. Витрина остаётся свёрткой журнала ровно потому, что
порядок свёртки приведён к порядку журнала (см. «Свёртка обязана быть
детерминированной»).
**Два правила равной полноты и когда какое.** У точки и у сущности развилка
одна, а механизмы разные — вот критерий, чтобы третья единица хранения не
открывала спор заново:
| | точка | сущность (`workout`, `record`) |
| --- | --- | --- |
| разряд полноты | множества ключей с непустым значением | покрытие содержания |
| тай-брейк равной полноты | происхождение кандидата: пришедшая побеждает | хранимая позиция журнала `(received_at, id)` |
| внутри одной доставки | порядок канонических форм | он же |
| гарантия | верна, пока порядок свёртки равен порядку журнала | верна всегда |
| в остаточном окне конкурентного приёма | расходится, пишет `WARN`, лечится `reindex` | не расходится |
| почему так | провенанса у точки нет, и заводить его дорого: колонка на точку меняет формат содержимого объекта | колонка провенанса уже есть |
Правило выбора для будущего: есть где хранить позицию журнала — храним её;
негде и завести дорого — берём происхождение и обеспечиваем порядок свёртки.
### Измерение рода агрегации ### Измерение рода агрегации
@@ -1103,21 +1170,45 @@ HAE отдаёт перечислимые значения строками из
Он объявлен источником истины, и на нём держится ретеншен нижнего слоя — Он объявлен источником истины, и на нём держится ретеншен нижнего слоя —
но сверить покрытие по этим полям было бы нечем. но сверить покрытие по этим полям было бы нечем.
Поэтому строка **хранится дословно, а рядом кладётся выведенный код**: Поэтому строка **хранится дословно, а рядом кладётся выведенный код**
отдельной строкой реестра `category_value`, а не полем внутри точки:
``` ```
value "БДГ" ← как прислал HAE category_value sleep_analysis / value / "БДГ" → HKCategoryValueSleepAnalysisAsleepREM
value_code "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 --> <!-- канон: поведение → openspec/specs/parsing -->
@@ -1356,7 +1447,7 @@ MongoDB, и так просилось из слова «перезаписыва
``` ```
GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами
GET /api/v1/metrics/{name}?from&to&bucket&layer точки метрики, при желании свёрнутые GET /api/v1/metrics/{name}?from&to&layer точки метрики за период (bucket — соседняя задача, пока 400)
GET /api/v1/workouts?from&to заголовки тренировок GET /api/v1/workouts?from&to заголовки тренировок
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
GET /api/v1/records/{kind}?from&to прочие секции GET /api/v1/records/{kind}?from&to прочие секции
@@ -1407,9 +1498,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,21 +1591,99 @@ GET /healthz
Нормализованная оболочка, сырое содержимое: Нормализованная оболочка, сырое содержимое:
```json ```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": [ "points": [
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count", {"ts": "2026-07-31T09:00:00Z", "ts_end": "2026-07-31T09:00:00Z",
"values": {"qty": 812}} "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 ### MCP
+64 -3
View File
@@ -16,11 +16,35 @@
единица, которой нет в счётчиках, делает расхождение безадресным: человек единица, которой нет в счётчиках, делает расхождение безадресным: человек
видит «не совпало» при неизменившемся числе объектов и принимает по этому видит «не совпало» при неизменившемся числе объектов и принимает по этому
необратимое решение о подмене базы. необратимое решение о подмене базы.
- **Провенанс, входящий в отпечаток, обязан быть явной функцией журнала.**
«Кто первым записал строку» — функция порядка свёртки, а он порядку журнала не
равен: живой приём и пересборка разойдутся при одинаковом журнале. Там, где
провенанс в отпечаток не идёт, слабое правило допустимо и должно быть названо
слабым на месте — иначе его скопируют туда, где оно неверно (`bucket` против
`category_value`).
- **Колонка, производная от бинаря, а не от журнала, в отпечаток не входит.**
Кэш чистой функции (код по словарю, справочное имя) в отпечатке превращает
всякую правку бинаря в расхождение при побайтно совпавшем журнале — и человек,
принимающий по отпечатку необратимое решение о подмене базы, читает это как
дефект. Правильность самой производной проверяют её тесты: это другой вопрос,
и смешение обесценивает оракул сходимости.
- **Граница на число элементов, набираемых из чужого тела, применяется при
накоплении, а не при выдаче.** Накопитель без границы растёт вместе с телом,
а тело контролирует отправитель; отказ по памяти в фоновой горутине не
перехватывается, и перезапуск берёт ту же доставку. Усечение при этом обязано
остаться функцией множества (например, N наименьших ключей), иначе порядок
элементов на проводе решает состав витрины.
- Правило выбора между двумя версиями одних данных объявляется либо **функцией - Правило выбора между двумя версиями одних данных объявляется либо **функцией
множества версий**, либо явно **функцией порядка журнала** — третьего множества версий**, либо явно **функцией порядка журнала** — третьего
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является: состояния нет. «Побеждает последняя свёрнутая» третьим состоянием и является:
порядок свёртки порядку журнала не равен, и живая витрина расходится с порядок свёртки сам по себе порядку журнала не равен, и живая витрина
пересборкой молча. расходится с пересборкой молча. Объявив правило функцией порядка журнала,
изменение обязано **внести плату целиком**: привести порядок свёртки к
журнальному (барьер на отложенной доставке), назвать остаточное окно и сделать
его наблюдаемым, а равенство «пересборка = приём» доказать оракулом с
отрицательным контролем. Так сделано для точек; у сущностей на тот же вопрос
отвечает хранимая позиция журнала, и её гарантия строго сильнее — критерий
выбора в `architecture.md`, «Разрешение столкновений».
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет - Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
названный предел длины (имена непокрытых секций, `id` сущности). названный предел длины (имена непокрытых секций, `id` сущности).
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не - **Колонка, по которой принимается необратимое решение, отличает ноль от «не
@@ -47,3 +71,40 @@
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении - Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
структуры обновляем схему в [database.md](../database.md) тем же изменением — структуры обновляем схему в [database.md](../database.md) тем же изменением —
это проверяет `task gate`. это проверяет `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» краснела примерно раз на сотню находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
+56 -1
View File
@@ -46,6 +46,17 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
│ created_at TEXT │ └──────────────────────────┘ │ created_at TEXT │ └──────────────────────────┘
│ updated_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` **внешним ключом не объявлена** Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
@@ -116,7 +127,10 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
**Идентичность точки внутри объекта** — координаты **Идентичность точки внутри объекта** — координаты
`метрика + слой + начало + конец`, у точки-измерения конец равен началу. `метрика + слой + начало + конец`, у точки-измерения конец равен началу.
`source` в ключ не входит: он нестабилен и переписывается задним числом. При `source` в ключ не входит: он нестабилен и переписывается задним числом. При
столкновении выигрывает более полная точка, а не последняя пришедшая. столкновении выигрывает более полная точка, а при равной полноте — стоящая
позже в журнале (внутри одной доставки — минимум канонической формы). Провенанса
у точки нет: «позже в журнале» выражено происхождением кандидата, и потому
порядок свёртки обязан равняться журнальному.
## `workout` и `record` — сущности с собственным `id` ## `workout` и `record` — сущности с собственным `id`
@@ -156,6 +170,47 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
журнала, а не свёрнутая последней. Подробности и обоснование — в журнала, а не свёрнутая последней. Подробности и обоснование — в
`architecture.md`, раздел «Тренировки и прочие секции». `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`. - **Точки часового объекта лежат сжатым BLOB** (`gzip`) в колонке `payload`.
+17 -13
View File
@@ -1,8 +1,8 @@
# Паспорт проекта # Паспорт проекта
Куда мы идём, как выглядит работа проекта в жизни и у кого подсматривать, Куда мы идём, как выглядит работа проекта в жизни и у кого подсматривать,
когда упёрлись. Самый верхний документ: [tasks/PLAN.md](tasks/PLAN.md) отвечает «в каком когда упёрлись. Самый верхний документ: [tasks/ROADMAP.md](tasks/ROADMAP.md) отвечает «что
порядке», [architecture.md](architecture.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`, разбирает метрики в часовые сервис кладёт тело в архив, отвечает `200`, разбирает метрики в часовые
объекты. Никто ничего не спрашивает и не смотрит. объекты. Никто ничего не спрашивает и не смотрит.
*Успех:* сутки работы не порождают ни одной строки лога уровня `WARN` и ни *Успех:* сутки работы не порождают ни одной строки лога уровня `WARN` и ни
одного действия человека. одного действия человека.
**2. Дыра закрывается сама** (разбор и хранилище). Телефон был заблокирован ночью, **2. Дыра закрывается сама** (`parsing-and-storage` — сделано). Телефон был заблокирован ночью,
автоматизация не отработала, часть дня отсутствует. Средний проход (сутки) и автоматизация не отработала, часть дня отсутствует. Средний проход (сутки) и
глубокий (неделя) переприсылают окно целиком, точки доезжают. глубокий (неделя) переприсылают окно целиком, точки доезжают.
*Успех:* дыра моложе недели закрывается без вмешательства; никто о ней даже не *Успех:* дыра моложе недели закрывается без вмешательства; никто о ней даже не
узнаёт. узнаёт.
**3. Квартальный экспорт** (`healthlog import`, устаревание нижнего слоя). **3. Квартальный экспорт** (История из родного экспорта Apple лежит в
хранилище; Нижний слой чистится после проверенного экспорта).
Изредка владелец выгружает Изредка владелец выгружает
родной экспорт Apple Health и скармливает его `healthlog import`. Нижний слой родной экспорт 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, *Успех:* тренировка отдана одним пакетом в том виде, в каком её прислал Apple,
без нашей интерпретации того, что в ней главное. без нашей интерпретации того, что в ней главное.
**6. Разбор поменялся** (`healthlog reindex`). Мы начали разбирать секцию, которую **6. Разбор поменялся** (`reindex` — сделан). Мы начали разбирать секцию, которую
раньше пропускали, или нашли ошибку в старом разборе. Запускается пересборка раньше пропускали, или нашли ошибку в старом разборе. Запускается пересборка
по сырому архиву: `import(экспорт) + replay(доставки по received_at)`. по сырому архиву: `import(экспорт) + replay(доставки по received_at)`.
*Успех:* состояние пересобрано детерминированно, повтор даёт то же самое, *Успех:* состояние пересобрано детерминированно, повтор даёт то же самое,
доставки со снятым статусом `partial` подобраны. доставки со снятым статусом `partial` подобраны.
**7. Владелец проверяет, жив ли поток** (наблюдаемость). Раз в сколько-то дней — **7. Владелец проверяет, жив ли поток** (Приложение сообщает о своём состоянии). Раз в сколько-то дней —
взгляд в `/stats`: когда была последняя доставка, сколько точек, есть ли взгляд в `/stats`: когда была последняя доставка, сколько точек, есть ли
тишина, какие строки не легли в словарь кодов. тишина, какие строки не легли в словарь кодов.
*Успех:* один экран отвечает «всё идёт» или «встало тогда-то», без залезания *Успех:* один экран отвечает «всё идёт» или «встало тогда-то», без залезания
в SQLite. в SQLite.
**8. Приехало незнакомое** (разбор и хранилище). HAE обновился и прислал новую метрику, **8. Приехало незнакомое** (`parsing-and-storage` — сделано; Новая форма от
источника не теряется молча). HAE обновился и прислал новую метрику,
новую форму точки или новую секцию. Тело сохраняется, ответ — `200`, разбор новую форму точки или новую секцию. Тело сохраняется, ответ — `200`, разбор
честно помечает доставку `partial` и перечисляет непокрытое. честно помечает доставку `partial` и перечисляет непокрытое.
*Успех:* данные в архиве и восстановимы, факт виден в логе и `/stats`, а *Успех:* данные в архиве и восстановимы, факт виден в логе и `/stats`, а
+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` — структурный элемент, и он появился только что ## 44. `Correlation` — структурный элемент, и он появился только что
Давление приезжает не записью, а обёрткой из двух записей: Давление приезжает не записью, а обёрткой из двух записей:
@@ -1766,6 +1774,73 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
заполненности (`xFilesFactor`) измерению не нужно: две конкурирующие гипотезы заполненности (`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» неудачную отправку.** Ключевой вопрос для - **Переживает ли «Since Last Sync» неудачную отправку.** Ключевой вопрос для
@@ -1777,7 +1852,12 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
Меняется ли что-то на глубине часов и суток — покажет более длинный ряд Меняется ли что-то на глубине часов и суток — покажет более длинный ряд
доставок. доставок.
- **Секции, которых мы не видели живьём:** `symptoms`, `ecg`, - **Секции, которых мы не видели живьём:** `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`. `heartRateNotifications`, `cycleTracking`, `medications`. Разбор покрывает
ровно остальные три (`metrics`, `workouts`, `stateOfMind` — `decodeCovered` в
`internal/hae`), сверено поимённо 2026-08-04. Момент их появления больше не
требует догадки: первая встреча имени даёт `WARN` в логе свёртки, а перечень
накопленного отдаёт `healthlog uncovered`. Разбор самой секции пишется, когда
её будет на чём проверить, — вслепую он не пишется.
- **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается - **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается
отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в
экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен
+274 -15
View File
@@ -34,8 +34,10 @@
сам же меняет, без границы по `received_at` разбираемой доставки. сам же меняет, без границы по `received_at` разбираемой доставки.
- Правило выбора между версиями — функция множества версий либо явно функция - Правило выбора между версиями — функция множества версий либо явно функция
порядка журнала; третьего состояния нет. порядка журнала; третьего состояния нет.
- Столкновение разрешается полнотой, а не свежестью; изменение запечатанного - Столкновение разрешается полнотой, а при равной полноте — положением в
часа пишется `WARN`, но данные пишутся. журнале: побеждает стоящая позже
([ADR](adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md)). Изменение
запечатанного часа пишется `WARN`, но данные пишутся.
- Транзакция не держит блокировку дольше `busy_timeout`: канонизация и - Транзакция не держит блокировку дольше `busy_timeout`: канонизация и
сжатие — вне её. сжатие — вне её.
@@ -52,7 +54,8 @@
- Расход памяти не растёт вместе с длиной журнала. - Расход памяти не растёт вместе с длиной журнала.
- Подмена базы — решение человека при остановленном сервисе, не команды. - Подмена базы — решение человека при остановленном сервисе, не команды.
**Обработчик чтения и адаптер MCP** (Read API, MCP — ещё не написаны) **Обработчик чтения и адаптер MCP** (`internal/httpapi`: каталог и точки
написаны; свёртка по сетке, тренировки, записи и MCP — ещё нет)
- Агрегат считается только там, где род свёртки измерен; нижний слой HAE не - Агрегат считается только там, где род свёртки измерен; нижний слой HAE не
суммируется никогда. суммируется никогда.
@@ -105,24 +108,41 @@
- `triage`: перечислены ли запущенные проходы поимённо и с исходом; непущенный - `triage`: перечислены ли запущенные проходы поимённо и с исходом; непущенный
проход идёт в границы покрытия строкой «не запускался» (запись 2026-08-02, проход идёт в границы покрытия строкой «не запускался» (запись 2026-08-02,
чекпоинт кода прошёл без трёх проходов). чекпоинт кода прошёл без трёх проходов).
- `specs`: считается ли внешним поведением **состояние, которое даёт
пересборка** — витрина наблюдаема через пересборку, поэтому расхождение с
журналом не внутренняя деталь, а поведение, которого спека не заказывала.
Внешнее здесь — ещё и код ответа приёма, форма ответа чтения и содержимое
архива (переселено из триггеров профиля, канон 3).
### Триггеры профиля ### Триггеры профиля
Уточняет умолчания конвейера, не отменяет их. Уточняет умолчания конвейера, не отменяет их. Рабочее умолчание — `standard`:
миграция схемы, публичный контракт и инвариант ступень **не** поднимают, их
проверяют проходы, которые в `standard` и так есть.
- **`deep`** — изменения в правиле разбора, идентичности, слияния или вывода - **Новое понятие или структурная единица** (`wide`) — новый пакет в
слоя; миграции схемы; всё, что трогает `internal/store`, `internal/fold`, `internal/`, новый род узла из перечня выше, новый тип провода в
`internal/replay`. `internal/httpapi`, новая единица хранения, входящая в отпечаток, новый
- **«Поведение, видимое снаружи»** здесь — код ответа приёма, форма ответа транспорт рядом с HTTP.
чтения, содержимое архива и **состояние, которое даёт пересборка**: витрина - **Правила идентичности, слияния и разбора** (`deep`) живут в трёх местах:
наблюдаема через пересборку, поэтому расхождение с журналом — внешнее `internal/hae` — разбор пакета и вывод слоя; `internal/fold` — выбор между
поведение, а не внутренняя деталь. версиями точки; `internal/store` — координатный ключ и запись часового
- **`reimpl`** запускается по триггеру «новое правило слияния, идентичности или объекта. Правку правила в любом из них ступень поднимает; перенос кода без
разбора». Единственный раз, когда триаж назвал его отсутствие дырой правки правила — нет.
покрытия, — это была задача с новым правилом слияния сущностей.
- **`quick`** — правка документов, конфигурации, сообщений; ничего, что меняет - **`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:archive`) и свёртка под удерживаемой
блокировкой (`task verify:busy`) в гейт не входят: минута и 25 секунд блокировкой (`task verify:busy`) в гейт не входят: минута и около 50 секунд
соответственно, плюс данные, которых нет ни на какой другой машине. Гоняет их соответственно, плюс данные, которых нет ни на какой другой машине. Гоняет их
человек перед задачей, трогающей разбор или слияние (запись 2026-08-02, человек перед задачей, трогающей разбор или слияние (запись 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`.
Реализованные задачи, находки ревью и решения сюда не пишутся: у них есть Реализованные задачи, находки ревью и решения сюда не пишутся: у них есть
коммит, спека и задача. Здесь только промахи конвейера и решения о его составе. коммит, спека и задача. Здесь только промахи конвейера и решения о его составе.
@@ -360,6 +423,48 @@
- **Итог по конвейеру:** `quick` 4, `standard` 6, `deep` 78, `design` 3. - **Итог по конвейеру:** `quick` 4, `standard` 6, `deep` 78, `design` 3.
Было 11 на коде и 4 на дизайне. Было 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 — ответ владельца не превращал задачу в берущуюся [проскочил] ## 2026-08-03 — ответ владельца не превращал задачу в берущуюся [проскочил]
- **Где:** конвейер, а не код — учёт задач, шаг «ответ на вопрос» - **Где:** конвейер, а не код — учёт задач, шаг «ответ на вопрос»
@@ -387,3 +492,157 @@
файлов каталога получили одну дату при переезде на канон (коммит `d79189b`), файлов каталога получили одну дату при переезде на канон (коммит `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` значения, метки времени, имена источников и устройств, имена секций, `id`
тренировок и записей, содержимое маршрута. тренировок и записей, содержимое маршрута.
- **Заголовки доставки** — включая `automation-id`, `automation-aggregation`, - **Заголовки доставки** — включая `automation-id`, `automation-aggregation`,
`User-Agent`, `Accept-Language`, `Upload-Complete`; они пишутся в `delivery` и `User-Agent`, `Accept-Language`, `Upload-Complete`; они пишутся в `delivery`,
участвуют в выводе слоя. Заголовки полуправдивы: `automation-aggregation` участвуют в выводе слоя, а `Accept-Language` — ещё и в выводе кода
категориального значения (тег ограничен по длине и по форме, не тег даёт
пустую локаль). Заголовки полуправдивы: `automation-aggregation`
реальной гранулярности не описывает (разведка, находка 33). реальной гранулярности не описывает (разведка, находка 33).
- **Размер тела** — предела на одну сущность нет; наблюдалось 63 МиБ на одной - **Размер тела** — предела на одну сущность нет; наблюдалось 63 МиБ на одной
координате и 768 МиБ пика кучи на теле 40 МиБ. координате и 768 МиБ пика кучи на теле 40 МиБ.
@@ -42,6 +44,13 @@ disabled`, `read auth disabled`), но стартовать не отказыв
`export.xml`, который выбирает человек, но формируется он устройством и по `export.xml`, который выбирает человек, но формируется он устройством и по
объёму (3,6 млн записей) глазами не проверяется. объёму (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` для - **Ключ сущности**`род секции + id` из HealthKit для `record`, `id` для
`workout`. `id` приходит из тела. `workout`. `id` приходит из тела.
- **Ключ наблюдённого категориального значения**`метрика + поле + значение`.
Значение приходит из тела дословно и уезжает в первичный ключ: предел на него
назван числом (128 байт), число различных значений одной доставки ограничено
(64), и **граница применяется при накоплении, а не при выдаче** — иначе
накопитель растёт вместе с телом, а тело контролирует отправитель (измерено:
миллион различных значений в теле 60 МиБ поднимал пик процесса с 780 до
1002 МиБ). Значение, которое разбор JSON подменил (невалидный UTF-8, одинокий
суррогат), наблюдением не считается вовсе: в ключ обязано попасть то, что
пришло, а не то, что получилось.
- **Файл базы и каталог архива** — из конфига, не из запроса. - **Файл базы и каталог архива** — из конфига, не из запроса.
## Что разграничивает доступ ## Что разграничивает доступ
+34 -31
View File
@@ -1,50 +1,53 @@
# Беклог # Беклог
Что **можно взять**. Одна задача = один файл `items/<slug>.md` Что **можно взять**. Одна задача = один файл `items/<slug>.md`
+ строка здесь. Целей тут нет — они в [PLAN.md](PLAN.md): беклог — то, что берут, + строка здесь. Целей тут нет — они в [ROADMAP.md](ROADMAP.md): беклог — то, что
план — то, подо что берут. Порядка внутри секции нет: «что делать берут, роадмап — то, подо что берут. Порядка внутри секции нет: «что делать
дальше» отвечает набор спринта. Ведётся скиллом `tasks`. дальше» отвечает набор спринта. Ведётся скиллом `tasks`.
Секции «блокеры» здесь нет и не заводится: блокер — это состояние Секции «блокеры» здесь нет и не заводится: блокер — это состояние
(спринт не может продолжаться ни одной задачей), оно живёт до ответа (спринт не может продолжаться ни одной задачей), оно живёт до ответа
человека, а его следы — вопросами в файлах задач. человека, а его следы — вопросами в файлах задач.
## ядро ## Ядро
- [[idea] Человеческие аннотации поверх выведенных схем](items/schema-annotations.md) — Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата - [[idea] Человеческие аннотации поверх выведенных схем](items/schema-annotations.md) — Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
- [Проверка целостности собранной витрины перед подменой](items/integrity-before-swap.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе - [Проверять целостность собранной витрины до подмены](items/integrity-before-swap.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
- [Цена слияния на широкой доставке](items/merge-cost-wide-delivery.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed - [Снизить цену слияния на широкой доставке](items/merge-cost-wide-delivery.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
- [Сущность с id, но неразобранной меткой](items/entity-without-parsed-label.md) — Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым - [Не терять сущность с id и неразобранной меткой](items/entity-without-parsed-label.md) — Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым
- [Идентичность тренировок при импорте родного экспорта](items/workout-identity-on-import.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE - [Не задваивать тренировки при импорте родного экспорта](items/workout-identity-on-import.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
- [Импорт родного экспорта Apple Health](items/apple-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут - [Импортировать родной экспорт Apple Health](items/apple-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [MCP-сервер поверх Read API](items/mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [[idea] Месячный проход по ручным секциям](items/monthly-manual-sections-pass.md) — Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит - [[idea] Месячный проход по ручным секциям](items/monthly-manual-sections-pass.md) — Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
- [[idea] NDJSON-поток для больших выборок Read API](items/ndjson-stream.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация - [[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 — следующая покрытая секция унаследует слепую зону
- [Data-миграции не отбирают строки по обрезаемым спискам](items/data-migration-row-selection.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
- [[idea] Отказ от heartbeatSeries](items/drop-heartbeat-series.md) — 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе - [[idea] Отказ от heartbeatSeries](items/drop-heartbeat-series.md) — 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
- [Пересборка держит весь журнал в памяти](items/rebuild-memory-footprint.md) — Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет - [Не держать весь журнал в памяти при пересборке](items/rebuild-memory-footprint.md) — Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
- [[idea] Пересекающиеся источники одной метрики](items/overlapping-sources.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь - [[idea] Пересекающиеся источники одной метрики](items/overlapping-sources.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
- [[idea] Порог sealed: с какого возраста час считается запечатанным](items/sealed-threshold.md) — WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта - [[idea] Порог sealed: с какого возраста час считается запечатанным](items/sealed-threshold.md) — WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
- [Порядок журнала при конкурентных приёмах](items/journal-order-on-ingest.md) — Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой - [Держать порядок журнала при конкурентных приёмах](items/journal-order-on-ingest.md) — Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
- [Предел на размер и число заголовков доставки](items/delivery-header-limits.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним - [Ограничить размер и число заголовков доставки](items/delivery-header-limits.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
- [Пределы на размер сущности и потоковый расчёт формы](items/entity-size-limits.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе - [Ограничить размер сущности и считать форму потоково](items/entity-size-limits.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
- [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](items/workout-routes-table.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом - [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](items/workout-routes-table.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
- [Read API: точки, выбор слоя, свёртка по сетке](items/read-api-points.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [Выводить схемы содержимого из данных](items/derived-content-schemas.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [Выведенные из данных схемы содержимого](items/derived-content-schemas.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [[idea] Что считать сутками при смене часового пояса](items/day-boundary-timezone.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено - [[idea] Что считать сутками при смене часового пояса](items/day-boundary-timezone.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- [Сверка живой витрины с пересборкой](items/rebuild-comparison-check.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит - [Сверять живую витрину с пересборкой](items/rebuild-comparison-check.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- [Тай-брейк при равной полноте точек](items/tie-break-equal-completeness.md) — При равной полноте порядок канонических форм берёт меньшее значение в 96% случаев — у накопительных это систематический недосчёт - [Помечать нижний слой устаревшим после экспорта](items/lower-layer-expiry.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [Устаревание нижнего слоя после экспорта](items/lower-layer-expiry.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [[idea] Выгрузка в parquet отдельной командой](items/parquet-export.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно - [[idea] Выгрузка в parquet отдельной командой](items/parquet-export.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
- [Заголовки доставки в архиве рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке - [Класть заголовки доставки в архив рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
- [Измерить, нужно ли правило полноты рядом с LWW](items/last-wins-over-completeness.md) — Полнота решает 1,2% спорных координат, и неизвестно, была ли более полная точка более поздней — от этого зависит, нужна ли она вообще
- [Поднять MCP-сервер поверх Read API](items/mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [Написать OpenAPI-спеку руками](items/openapi-spec.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [Ловить гейтом расхождение спеки с маршрутами](items/openapi-gate-check.md) — Рукописная спека — источник истины, а расходится она с кодом молча: без гейта решение о рукописной спеке не держится
- [Поднять Swagger UI без внешней сети](items/swagger-ui.md) — Контракт читается машиной, но человеку нечем выполнить запрос к живому сервису из браузера, а внешних CDN в локальной сети нет
## инфра ## Инфра
- [Активный алерт «данных нет N часов»](items/stream-silence-alert.md) — Пропажу потока сейчас замечает человек, а не сервис
- [Деплой на rivendell](items/deploy-rivendell.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома - [Слать уведомление, когда данных нет N часов](items/stream-silence-alert.md) — Пропажу потока сейчас замечает человек, а не сервис
- [Счётчики слияния переживают ротацию логов](items/merge-counters-in-db.md) — единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего - [Выложить сервис на rivendell](items/deploy-rivendell.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома
- [Остановка и миграция: раздельные бюджеты и следы в логе](items/shutdown-and-migration-traces.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM - [Хранить счётчики слияния вне логов](items/merge-counters-in-db.md) — Единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- [Чем откатывать релиз после наката миграции](items/release-rollback-after-migration.md) — Страж версии схемы делает возврат старого бинаря отказом, а понизить схему нечем — аварийный путь пришлось бы изобретать в аварии - [Развести бюджеты остановки и оставить следы миграции в логе](items/shutdown-and-migration-traces.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
- [Ретеншен сырого архива](items/raw-archive-retention.md) — Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает - [Назвать механизм отката релиза после наката миграции](items/release-rollback-after-migration.md) — Страж версии схемы делает возврат старого бинаря отказом, а понизить схему нечем — аварийный путь пришлось бы изобретать в аварии
- [Наблюдаемость: /stats](items/stats-endpoint.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах - [Подчищать сырой архив до последнего проверенного экспорта](items/raw-archive-retention.md) — Архив не подчищается вовсе, а резать его раньше даты проверенного экспорта нельзя — в журнале останется дыра, которую нечем пересобрать
- [Умолчания конфига указывают на прежнюю раскладку](items/config-defaults-data-dir.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка - [Отдавать состояние сервиса маршрутом /stats](items/stats-endpoint.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- [Управление токенами и секретами](items/token-and-secret-management.md) — Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу - [Свести умолчания конфига с рабочей раскладкой данных](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 `bekap-dannyh` — Резервное копирование ./data. Причина: бекап обеспечивает готовый механизм на сервере пет-проектов — своего заводить не нужно, задача снимается деплоем. Была секция: высокий.
- 2026-08-01 `identichnost-epizodnyh-metrik` — Идентичность эпизодных метрик. Причина: решён измерением и prior art: ключ эпизода — метрика+слой+start+end (находка 47), вариант А; вернулся в scope razbor-metrik-v-obekty. Была секция: блокеры. - 2026-08-01 `identichnost-epizodnyh-metrik` — Идентичность эпизодных метрик. Причина: решён измерением и prior art: ключ эпизода — метрика+слой+start+end (находка 47), вариант А; вернулся в scope razbor-metrik-v-obekty. Была секция: блокеры.
- 2026-08-01 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Была секция: блокеры. - 2026-08-01 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Была секция: блокеры.
- 2026-08-04 `mcp` — [goal] 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 без внешней сети). Была секция: ядро.
+51
View File
@@ -0,0 +1,51 @@
# Роадмап
Что приложение уже умеет и чего ещё не умеет. Цель — возможность приложения,
файл `[goal]` в `items/`; её задачи здесь **не перечисляются** — перечень даёт
`tasks.py list --goal <слаг>`. Очередь значима только в «Запланировано» и
обосновывается прозой рядом.
## Готово
- 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): правило вывода слоя,
модель идентичности и формы точки проверены на живом потоке. Возможностью
приложения она не была, поэтому строки среди достигнутых целей не занимает.
## Запланировано
Очередь держится на двух зависимостях. **`healthlog import` идёт перед чисткой
нижнего слоя:** пока импорт экспорта не написан, помечать что-либо устаревшим не
на основании чего. **MCP входит в чтение, а не идёт отдельной целью:** адаптер
собственной логики не несёт, он переводит вызовы в те же обработчики, и очередь
осталась порядком задач внутри цели.
- [[goal] Клиенты читают данные через HTTP и MCP](items/read-api.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей, включая агента-медика, ничего прочитать не может
- [[goal] Клиент узнаёт форму данных из ответа](items/self-description.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [[goal] История из родного экспорта Apple лежит в хранилище](items/native-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [[goal] Нижний слой чистится после проверенного экспорта](items/lower-layer-cleanup.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [[goal] Приложение сообщает о своём состоянии](items/observability.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
## Направления
- [[goal] Исход слияния не зависит от порядка элементов на проводе](items/merge-robustness.md) — Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
- [[goal] Расхождение витрины с журналом не молчит](items/journal-and-rebuild.md) — Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
- [[goal] У каждого входа есть названный предел](items/limits-and-load.md) — Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
- [[goal] Новая форма от источника не теряется молча](items/parsing-completeness.md) — Пять секций HAE поток ещё не приносил, а ручные секции заводятся задним числом — разбор полон ровно до следующей новой формы
## Разработка
- [[goal] Сервис доступен телефону из любой сети](items/deploy.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
+11 -4
View File
@@ -1,9 +1,16 @@
# Спринт # Спринт
**Цель:** [[goal] Разбор и хранилище](items/parsing-and-storage.md) · **Начат:** 2026-08-03 · **Спринт:** `2026-08-03` - **Цель:** [[goal] Клиенты читают данные через HTTP и MCP](items/read-api.md)
- **Начат:** 2026-08-04
- **Спринт:** `2026-08-04`
Урожай спринта поднимается `tasks.py list --tag sprint:2026-08-03` это первая порция переоценки на сессии. Урожай спринта перечисляет `tasks.py list --tag sprint:2026-08-04`; в наборе — первая порция задач, переоценённых в этой сессии.
## Набор ## Набор
- [Словарь категориальных значений → коды HealthKit](items/categorical-value-dictionary.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
- [Активная проверка: поток принёс секцию, которой раньше не было](items/unseen-sections-check.md) — Момент появления новой секции фиксируется в базе, но заметить его может только тот, кто догадается заглянуть в колонку - [Отвечать 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 его нет
+31 -4
View File
@@ -1,6 +1,6 @@
# Импорт родного экспорта Apple Health # Импортировать родной экспорт Apple Health
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут - **Зачем:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- **Теги:** goal:native-export-import - **Теги:** goal:native-export-import
@@ -37,9 +37,36 @@
должен ничего менять; должен ничего менять;
- `export_cda.xml` игнорируем — это клинический формат тех же данных. - `export_cda.xml` игнорируем — это клинический формат тех же данных.
Двигает строку «Завершения» цели: «Слой `sample` наполнен историей с 2019 года, повторный импорт того же экспорта ничего не меняет».
## Импорт выставляет пометку покрытия
Импорт — единственный, кто знает, какой период каким слоем обеспечен, поэтому
пометку ставит он, а не отдельный проход задним числом.
Форма пометки решена в [lower-layer-expiry](lower-layer-expiry.md): **одна
строка на диапазон** — `метрика + слой + период + «покрыто проверенным
экспортом»`. Провенанс на каждую точку не заводим: вопрос диапазонный, а поле у
точки стоило бы того же объёма, который устаревание нижнего слоя и приходит
экономить.
Два условия, оба из ограничителей той задачи:
- пометка ставится **по проверенному** импорту, а не по факту запуска команды.
Проверка та же, что уже названа в приёмке: непрерывность по дням и сходимость
сумм с часовым слоем HAE на пересечении периодов. Не сошлось — пометки нет,
и это не отказ импорта, а честный отказ от обещания;
- пометка **ничего не удаляет**. Она только даёт устареванию нижнего слоя
основание; само удаление включается отдельно и позже.
**`stateOfMind` пометку не получает никогда** — его в экспорте Apple нет ни
одним типом (находка 42), источник у него единственный, и устаревание к нему
неприменимо. Это надо записать явно, а не оставить следовать из отсутствия
данных.
Готово, когда история за несколько лет лежит в слое `sample`, повторный импорт Готово, когда история за несколько лет лежит в слое `sample`, повторный импорт
не меняет ничего, а суммы по слою сходятся с часовым слоем HAE на пересечении не меняет ничего, суммы по слою сходятся с часовым слоем HAE на пересечении
периодов. периодов, а покрытые периоды помечены и видны без пересборки.
Архивы: `/home/av/MediaEverything/HealthData/apple_health/` — девять штук, Архивы: `/home/av/MediaEverything/HealthData/apple_health/` — девять штук,
2021-12 … 2026-08. Старые версии формата годятся как регрессионный набор. 2021-12 … 2026-08. Старые версии формата годятся как регрессионный набор.
@@ -1,67 +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 история
расколется вторично — уже на «стабильной» стороне. Простейшее решение: хранить код как есть, а
эквивалентность старых и новых имён держать отдельной таблицей синонимов.
`stateOfMind` в словаре не нуждается — он и так шлёт коды HealthKit.
## Критерии приёмки
- фаза сна из потока («БДГ») и из экспорта Apple
(`HKCategoryValueSleepAnalysisAsleepREM`) за один период сопоставляются
напрямую — оракул: запрос на живой базе за период с известным перекрытием,
ноль несопоставимых строк
- строка сохранена **дословно**, код лежит рядом отдельным полем — оракул: тест
разбора на реальном пакете HAE из `internal/hae/testdata`
- незнакомая строка даёт пустой код, разбор не падает, а событие попадает в
счётчик — оракул: тест на выдуманной фазе сна плюс проверка счётчика
- переименование кода самой Apple (`…Asleep``…AsleepUnspecified`, находка 43)
не раскалывает историю — оракул: тест на паре синонимов, обе формы сходятся
в один код
- повторный прогон живого архива даёт то же состояние — оракул:
`task verify:archive`
Критерий «`/stats` показывает строки без кода» снят при переоценке 2026-08-03:
`/stats` ещё нет ([stats-endpoint](stats-endpoint.md)), и вешать приёмку на
несуществующий оракул значит либо блокировать задачу, либо принять её
непроверенной. Наблюдаемость закрывается счётчиком; показ в `/stats` — строка
задачи наблюдаемости, а не этой.
## Рамки
Схема трогается: у категориального значения появляется поле кода, плюс таблица
синонимов. Дословную строку не заменяем и не нормализуем — инвариант «точки
хранятся дословно». Пересборка обязана оставаться детерминированной, оракул
тот же `task verify:archive`.
+4 -2
View File
@@ -1,6 +1,6 @@
# Умолчания конфига указывают на прежнюю раскладку # Свести умолчания конфига с рабочей раскладкой данных
- **Секция:** инфра - **Секция:** Инфра
- **Зачем:** Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка - **Зачем:** Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
- **Теги:** goal:deploy - **Теги:** goal:deploy
@@ -16,3 +16,5 @@
Готово, когда запуск без конфига использует `./data` и не создаёт ничего в Готово, когда запуск без конфига использует `./data` и не создаёт ничего в
корне репозитория. Тогда же снимается предупреждение из `config.example.toml`. корне репозитория. Тогда же снимается предупреждение из `config.example.toml`.
Двигает строку «Завершения» цели: «Запуск без конфига не заводит базу мимо `./data`».
@@ -1,12 +1,14 @@
# Data-миграции не отбирают строки по обрезаемым спискам # Не отбирать строки в data-миграциях по обрезаемым спискам
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону - **Зачем:** Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
- **Теги:** goal:journal-and-rebuild - **Теги:** goal:journal-and-rebuild
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`). `dozakryt-nahodki-sushchnostej`).
Двигает строку «Завершения» цели: «Data-миграции не наследуют слепые зоны обрезаемых списков».
## Оракул: механизм доказан, дефект пока пустой ## Оракул: механизм доказан, дефект пока пустой
Миграция `00007` переводит в `pending` доставки, у которых имя ставшей покрытой Миграция `00007` переводит в `pending` доставки, у которых имя ставшей покрытой
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Что считать сутками при смене часового пояса # [idea] Что считать сутками при смене часового пояса
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено - **Зачем:** Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- **Теги:** goal:read-api - **Теги:** goal:read-api
+4 -2
View File
@@ -1,6 +1,6 @@
# Предел на размер и число заголовков доставки # Ограничить размер и число заголовков доставки
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним - **Зачем:** MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
- **Теги:** goal:limits-and-load - **Теги:** goal:limits-and-load
@@ -27,3 +27,5 @@
не оставляя следа в базе, а обычная доставка проходит как раньше. не оставляя следа в базе, а обычная доставка проходит как раньше.
Связано: `internal/httpapi`, `internal/ingest`, `docs/architecture.md` → «Приём». Связано: `internal/httpapi`, `internal/ingest`, `docs/architecture.md` → «Приём».
Двигает строку «Завершения» цели: «У заголовков доставки есть названный предел».
@@ -1,6 +1,6 @@
# Заголовки доставки в архиве рядом с телом # Класть заголовки доставки в архив рядом с телом
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке - **Зачем:** Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
- **Теги:** goal:journal-and-rebuild - **Теги:** goal:journal-and-rebuild
@@ -32,7 +32,7 @@ Prior art прямой: **WARC** (формат веб-архивов) храни
операциям, но появляется третья сущность. операциям, но появляется третья сущность.
Цена ошибки высокая: правится **путь приёма**, а доставка, не попавшая в архив, Цена ошибки высокая: правится **путь приёма**, а доставка, не попавшая в архив,
теряется навсегда. Значит профиль ревью — `deep`, и менять надо так, чтобы теряется навсегда. Значит менять надо так, чтобы
старые тела без заголовков продолжали читаться. старые тела без заголовков продолжали читаться.
Готово, когда пересборка на архиве, у которого рабочей базы нет вовсе, даёт то Готово, когда пересборка на архиве, у которого рабочей базы нет вовсе, даёт то
@@ -40,3 +40,5 @@ Prior art прямой: **WARC** (формат веб-архивов) храни
Связано: `docs/architecture.md` → «Сырой архив и восстановление состояния», Связано: `docs/architecture.md` → «Сырой архив и восстановление состояния»,
`internal/replay`. `internal/replay`.
Двигает строку «Завершения» цели: «Пересборка восстановима без базы: заголовки доставки лежат в архиве рядом с телом».
+4 -2
View File
@@ -1,6 +1,6 @@
# Деплой на rivendell # Выложить сервис на rivendell
- **Секция:** инфра - **Секция:** Инфра
- **Зачем:** Сервис живёт на рабочей машине — телефон достаёт до него только дома - **Зачем:** Сервис живёт на рабочей машине — телефон достаёт до него только дома
- **Теги:** goal:deploy - **Теги:** goal:deploy
@@ -30,3 +30,5 @@
Том стоит смонтировать так, чтобы серверный бекап забирал его без отдельной Том стоит смонтировать так, чтобы серверный бекап забирал его без отдельной
настройки, и снимать копию SQLite через `VACUUM INTO`, а не `cp`: телефон шлёт настройки, и снимать копию SQLite через `VACUUM INTO`, а не `cp`: телефон шлёт
непрерывно, и файл под записью копировать нельзя. непрерывно, и файл под записью копировать нельзя.
Двигает строку «Завершения» цели: «Телефон шлёт на публичный адрес из любой сети, агент читает по тому же домену».
+9 -9
View File
@@ -1,17 +1,17 @@
# [goal] Деплой # [goal] Сервис доступен телефону из любой сети
- **Секция:** порядок - **Секция:** Разработка
- **Зачем:** Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты - **Зачем:** Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
- **Теги:** decomposed - **Теги:** decomposed
Сервис переезжает на rivendell и становится доступен телефону из любой сети. Сервис переезжает на rivendell и становится доступен телефону из любой сети.
Выведена из шага 11 плана.
Завершена, когда оба контура закрыты разными токенами, откат релиза имеет
названный механизм, а запуск без конфига не заводит базу мимо данных.
## Завершение ## Завершение
Оба контура закрыты разными токенами, откат релиза имеет названный механизм, - Телефон шлёт на публичный адрес из любой сети, агент читает по тому же домену
а запуск без конфига не заводит базу мимо данных. - Оба контура закрыты разными токенами, и без токенов сервис стартует только на
localhost
- Откат релиза после наката миграции имеет названный механизм
- Запуск без конфига не заводит базу мимо `./data`
- Остановка сервиса называет виновный этап честно, а накат миграций виден в логе
старта
+4 -2
View File
@@ -1,6 +1,6 @@
# Выведенные из данных схемы содержимого # Выводить схемы содержимого из данных
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке - **Зачем:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- **Теги:** goal:self-description - **Теги:** goal:self-description
@@ -25,3 +25,5 @@
Связано: `docs/architecture.md` → «Самоописание». Форму конверта API описывает Связано: `docs/architecture.md` → «Самоописание». Форму конверта API описывает
не эта задача, а OpenAPI. не эта задача, а OpenAPI.
Двигает строку «Завершения» цели: «Формы содержимого метрик выведены из данных, а не описаны руками».
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Отказ от heartbeatSeries # [idea] Отказ от heartbeatSeries
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе - **Зачем:** 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
- **Теги:** goal:lower-layer-cleanup - **Теги:** goal:lower-layer-cleanup
+4 -2
View File
@@ -1,6 +1,6 @@
# Пределы на размер сущности и потоковый расчёт формы # Ограничить размер сущности и считать форму потоково
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе - **Зачем:** Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
- **Теги:** goal:limits-and-load - **Теги:** goal:limits-and-load
@@ -10,6 +10,8 @@
структурное: **предела на размер одной сущности нет вовсе**, а форма и хеш структурное: **предела на размер одной сущности нет вовсе**, а форма и хеш
считаются материализацией значения целиком. считаются материализацией значения целиком.
Двигает строку «Завершения» цели: «У тела, сущности и секции доставки есть названный предел».
## Оракул: измерено ## Оракул: измерено
Оракулы жили в `tmp/adv/mem_test.go` и `tmp/adv/lock_test.go`; числа снимались Оракулы жили в `tmp/adv/mem_test.go` и `tmp/adv/lock_test.go`; числа снимались
@@ -1,8 +1,8 @@
# Сущность с id, но неразобранной меткой # Не терять сущность с id и неразобранной меткой
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым - **Зачем:** Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым
- **Теги:** goal:parsing-and-storage - **Теги:** goal:parsing-completeness
**Решение принято владельцем 2026-08-03: вариант (1) — хранить с NULL-меткой.** **Решение принято владельцем 2026-08-03: вариант (1) — хранить с NULL-меткой.**
`start_utc`/`ts_utc` становятся NULLABLE, содержимое (включая маршрут) хранится, `start_utc`/`ts_utc` становятся NULLABLE, содержимое (включая маршрут) хранится,
@@ -10,7 +10,7 @@
отвергнут при постановке: подстановка метки доставки — выдуманное измерение в отвергнут при постановке: подстановка метки доставки — выдуманное измерение в
колонке, по которой идёт выборка. колонке, по которой идёт выборка.
**Берётся после [Read API по точкам и сущностям](read-api-points.md).** Правило **Берётся после [тренировок](read-api-workouts.md) и [записей](read-api-records.md) наружу.** Правило
чтения — что выборка «за период» делает со строками без метки — обязано чтения — что выборка «за период» делает со строками без метки — обязано
проектироваться вместе с читателем, иначе такие строки молча исчезнут из любого проектироваться вместе с читателем, иначе такие строки молча исчезнут из любого
ответа. Порядок тот же, что у [journal-order-on-ingest](journal-order-on-ingest.md) ответа. Порядок тот же, что у [journal-order-on-ingest](journal-order-on-ingest.md)
@@ -22,6 +22,8 @@
разбор кладёт сущность в `ts_utc`/`start_utc`, колонки `NOT NULL`, и сущность разбор кладёт сущность в `ts_utc`/`start_utc`, колонки `NOT NULL`, и сущность
с неразбираемой меткой по-прежнему пропускается целиком. с неразбираемой меткой по-прежнему пропускается целиком.
Двигает строку «Завершения» цели: «Сущность с `id` и неразобранной меткой не пропадает целиком».
## Что известно ## Что известно
- Оракул: `internal/hae/entity_test.go`, случаи «метка в ином формате», «метка - Оракул: `internal/hae/entity_test.go`, случаи «метка в ином формате», «метка
+4 -2
View File
@@ -1,6 +1,6 @@
# Проверка целостности собранной витрины перед подменой # Проверять целостность собранной витрины до подмены
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе - **Зачем:** Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
- **Теги:** goal:journal-and-rebuild - **Теги:** goal:journal-and-rebuild
@@ -27,3 +27,5 @@
называть результат годным, а на здоровом — не замедляется заметно. называть результат годным, а на здоровом — не замедляется заметно.
Связано: `cmd/healthlog/reindex.go`, `docs/architecture.md` → «Пересборка». Связано: `cmd/healthlog/reindex.go`, `docs/architecture.md` → «Пересборка».
Двигает строку «Завершения» цели: «Годность собранной витрины подтверждена до подмены файла».
+16 -10
View File
@@ -1,20 +1,26 @@
# [goal] Журнал и пересборка # [goal] Расхождение витрины с журналом не молчит
- **Секция:** темы - **Секция:** Направления
- **Зачем:** Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому - **Зачем:** Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
- **Теги:** decomposed - **Теги:** decomposed
Тема: инвариант «`import` + `replay` даёт то же состояние» и всё, что его Направление: инвариант «`import` + `replay` даёт то же состояние» и всё, что его
держит — архив, ретеншен, отпечаток витрины, расход памяти пересборки. держит — архив, ретеншен, отпечаток витрины, расход памяти пересборки.
В порядок не встаёт: работа приходит находками и растёт вместе с В «Запланировано» не встаёт: работа приходит находками и растёт вместе с
журналом. журналом.
Завершена не бывает: закрывается по мере того, как расхождение витрины с
журналом перестаёт быть молчащим.
## Завершение ## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как расхождение витрины Завершена не бывает — это направление. Закрывается по мере того, как расхождение
с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с витрины с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с
журналом. журналом. Открыто сегодня:
- Расхождение живой витрины с пересборкой замечает сервис, а не человек
- Годность собранной витрины подтверждена до подмены файла
- Порядок журнала держится при конкурентных приёмах
- Пересборка восстановима без базы: заголовки доставки лежат в архиве рядом с
телом
- Сырой архив подчищается до последнего проверенного экспорта
- Расход пересборки не растёт вместе с журналом
- Data-миграции не наследуют слепые зоны обрезаемых списков
+69 -3
View File
@@ -1,8 +1,8 @@
# Порядок журнала при конкурентных приёмах # Держать порядок журнала при конкурентных приёмах
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой - **Зачем:** Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
- **Теги:** goal:journal-and-rebuild - **Теги:** goal:journal-and-rebuild, question
**Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.** **Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.**
До появления наблюдаемости живём вариантом (г) с уже записанным в спеке До появления наблюдаемости живём вариантом (г) с уже записанным в спеке
@@ -13,6 +13,72 @@
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
`deep`, враждебный проход, находка с построенным путём и прогоном). `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 — **до** записи Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи
@@ -0,0 +1,88 @@
# Измерить, нужно ли правило полноты рядом с LWW
- **Секция:** Ядро
- **Зачем:** Полнота решает 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.
+10 -9
View File
@@ -1,18 +1,19 @@
# [goal] Пределы и поведение под объёмом # [goal] У каждого входа есть названный предел
- **Секция:** темы - **Секция:** Направления
- **Зачем:** Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed - **Зачем:** Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
- **Теги:** decomposed - **Теги:** decomposed
Тема: названные пределы на размер тела, сущности, заголовков и ответа плюс Направление: названные пределы на размер тела, сущности, заголовков и ответа плюс
поведение под удерживаемой блокировкой. поведение под удерживаемой блокировкой.
В порядок не встаёт: пределы всплывают замерами, а не планом. В «Запланировано» не встаёт: предел находит замер, а не очередь.
Завершена не бывает: закрывается по мере того, как каждый вход получает
названный предел вместо подразумеваемого.
## Завершение ## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как каждый вход получает Завершена не бывает — это направление. Закрывается по мере того, как каждый вход
названный предел вместо подразумеваемого. получает названный предел вместо подразумеваемого. Открыто сегодня:
- У тела, сущности и секции доставки есть названный предел
- У заголовков доставки есть названный предел
- Занятость базы не выводит доставку из очереди
+7 -10
View File
@@ -1,18 +1,15 @@
# [goal] Устаревание нижнего слоя # [goal] Нижний слой чистится после проверенного экспорта
- **Секция:** порядок - **Секция:** Запланировано
- **Зачем:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен - **Зачем:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- **Теги:** decomposed - **Теги:** decomposed
После проверенного экспорта нижний слой HAE избыточен и подлежит чистке. После проверенного экспорта нижний слой HAE избыточен, и его можно чистить.
Выведена из шага 9 плана. Нижний слой растёт на ~100 тысяч координат в сутки. Нижний слой растёт на ~100 тысяч координат в сутки.
Завершена, когда чистка идёт по правилу, а не по календарю, и решение о
удалении опирается на колонку, отличающую ноль от «не измерялось».
## Завершение ## Завершение
Чистка идёт по правилу «до следующего проверенного экспорта», а не по - Нижний слой помечен покрытым после проверенного экспорта
календарю, и решение об удалении опирается на колонку, отличающую ноль от - Чистка идёт по правилу «до следующего проверенного экспорта», а не по календарю
«не измерялось». - Решение об удалении опирается на колонку, отличающую ноль от «не измерялось»
+38 -3
View File
@@ -1,6 +1,6 @@
# Устаревание нижнего слоя после экспорта # Помечать нижний слой устаревшим после экспорта
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен - **Зачем:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- **Теги:** goal:lower-layer-cleanup - **Теги:** goal:lower-layer-cleanup
@@ -17,7 +17,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).
+28 -5
View File
@@ -1,14 +1,14 @@
# MCP-сервер поверх Read API # Поднять MCP-сервер поверх Read API
- **Секция:** ядро - **Секция:** Ядро — набор ограничен HTTP-слоем чтения после дробления; адаптер берётся следующим спринтом по той же цели
- **Зачем:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем - **Зачем:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- **Теги:** goal:mcp - **Теги:** goal:read-api
Агент-медик — первый заказчик проекта. Ему нужна актуальная сводка, а не срез Агент-медик — первый заказчик проекта. Ему нужна актуальная сводка, а не срез
на дату последнего ручного экспорта. на дату последнего ручного экспорта.
Транспорт — **Streamable HTTP**, не stdio: сервис живёт на VPS, агент ходит по Транспорт — **Streamable HTTP**, не stdio: сервис живёт на VPS, агент ходит по
сети. Отсюда: MCP — эндпоинт того же процесса и того же порта, аутентификация — сети. Отсюда: MCP — маршрут того же процесса и того же порта, аутентификация —
тот же токен чтения, что у Read API. Отдельного контура доступа не заводим: тот же токен чтения, что у Read API. Отдельного контура доступа не заводим:
MCP не даёт ничего, чего не даёт HTTP, и права обязаны совпадать. MCP не даёт ничего, чего не даёт HTTP, и права обязаны совпадать.
@@ -21,4 +21,27 @@ MCP не даёт ничего, чего не даёт HTTP, и права об
Готово, когда агент подключается по URL и отвечает на «как я спал на прошлой Готово, когда агент подключается по 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 тем же токеном чтения, и собственной логики
адаптер не несёт.
+4 -2
View File
@@ -1,12 +1,14 @@
# Цена слияния на широкой доставке # Снизить цену слияния на широкой доставке
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed - **Зачем:** 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
- **Теги:** goal:limits-and-load - **Теги:** goal:limits-and-load
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный
проход и независимая реализация — независимо друг от друга). проход и независимая реализация — независимо друг от друга).
Двигает строку «Завершения» цели: «Занятость базы не выводит доставку из очереди».
## Оракул: измерено ## Оракул: измерено
Тело 63 МБ (в запросе ~200 КБ gzip — предел приёма 64 МиБ), 119 точек на ОДНОЙ Тело 63 МБ (в запросе ~200 КБ gzip — предел приёма 64 МиБ), 119 точек на ОДНОЙ
+5 -3
View File
@@ -1,12 +1,14 @@
# Счётчики слияния переживают ротацию логов # Хранить счётчики слияния вне логов
- **Секция:** инфра - **Секция:** Инфра
- **Зачем:** единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего - **Зачем:** Единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- **Теги:** goal:observability - **Теги:** goal:observability
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, проход Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, проход
негативного пространства, подтверждено эксплуатационным). негативного пространства, подтверждено эксплуатационным).
Двигает строку «Завершения» цели: «Счётчики слияния переживают ротацию логов».
## Что не так ## Что не так
Решение не реализовывать объединение полей при несравнимых наборах стоит на Решение не реализовывать объединение полей при несравнимых наборах стоит на
+10 -9
View File
@@ -1,17 +1,18 @@
# [goal] Прочность слияния и идентичности # [goal] Исход слияния не зависит от порядка элементов на проводе
- **Секция:** темы - **Секция:** Направления
- **Зачем:** Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе - **Зачем:** Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
- **Теги:** decomposed - **Теги:** decomposed
Тема: правила, по которым две версии одних данных превращаются в одну. Направление: правила, по которым две версии одних данных превращаются в одну.
В порядок не встаёт — работа приходит находками ревью и замерами на В «Запланировано» не встаёт — очереди у направления нет: работа приходит находками ревью и замерами на
живом корпусе. живом корпусе.
Завершена не бывает: закрывается по мере того, как правила перестают зависеть
от порядка на проводе.
## Завершение ## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как правила выбора между Завершена не бывает — это направление. Закрывается по мере того, как правила
версиями перестают зависеть от порядка элементов на проводе. выбора между версиями перестают зависеть от порядка элементов на проводе.
Открыто сегодня:
- Правило выбора между версиями измерено: полнота либо нужна, либо снята
- Порог `sealed` выбран по накопленной статистике досчёта
@@ -1,8 +1,8 @@
# [idea] Месячный проход по ручным секциям # [idea] Месячный проход по ручным секциям
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит - **Зачем:** Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
- **Теги:** goal:parsing-and-storage - **Теги:** goal:parsing-completeness
Окно досчёта не единое, и это измеренное различие, а не предположение. Окно досчёта не единое, и это измеренное различие, а не предположение.
Количественные метрики (пульс, шаги, энергия) человек руками не правит — они Количественные метрики (пульс, шаги, энергия) человек руками не правит — они
+6 -8
View File
@@ -1,19 +1,17 @@
# [goal] Импорт родного экспорта Apple # [goal] История из родного экспорта Apple лежит в хранилище
- **Секция:** порядок - **Секция:** Запланировано
- **Зачем:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут - **Зачем:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- **Теги:** decomposed - **Теги:** decomposed
`healthlog import`: снапшот всей истории из родного экспорта Apple Health `healthlog import`: снапшот всей истории из родного экспорта Apple Health
ложится в хранилище перед проигрыванием хвоста доставок. ложится в хранилище перед проигрыванием хвоста доставок.
Выведена из шага 8 плана. Идёт перед устареванием нижнего слоя намеренно: пока Идёт перед чисткой нижнего слоя намеренно: пока
импорт экспорта не написан, помечать что-либо устаревшим не на основании чего. импорт экспорта не написан, помечать что-либо устаревшим не на основании чего.
Завершена, когда слой `sample` наполнен историей с 2019 года, а повторный
импорт того же экспорта ничего не меняет.
## Завершение ## Завершение
Слой `sample` наполнен историей с 2019 года, повторный импорт того же экспорта - Слой `sample` наполнен историей с 2019 года
ничего не меняет, а тренировки из экспорта не задваивают приехавшие от HAE. - Повторный импорт того же экспорта ничего не меняет
- Тренировки из экспорта не задваивают приехавшие от HAE
+2 -2
View File
@@ -1,6 +1,6 @@
# [idea] NDJSON-поток для больших выборок Read API # [idea] NDJSON-поток для больших выборок Read API
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация - **Зачем:** Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
- **Теги:** goal:read-api - **Теги:** goal:read-api
@@ -19,4 +19,4 @@ Read API отдаёт ответ одним JSON. Для выборок нижн
последовательно или с возвратами. последовательно или с возвратами.
Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача
`read-api-points`. `read-api-response-limit` (правило размера ответа проектируется там).
+6 -9
View File
@@ -1,18 +1,15 @@
# [goal] Наблюдаемость # [goal] Приложение сообщает о своём состоянии
- **Секция:** порядок - **Секция:** Запланировано
- **Зачем:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах - **Зачем:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- **Теги:** decomposed - **Теги:** decomposed
Тихо сломавшаяся автоматизация — главный эксплуатационный риск: телефон шлёт Тихо сломавшаяся автоматизация — главный эксплуатационный риск: телефон шлёт
молча, и молчание неотличимо от нормы. молча, и молчание неотличимо от нормы.
Выведена из шага 10 плана.
Завершена, когда пропажа потока и расхождение витрины с журналом видны
владельцу без чтения логов.
## Завершение ## Завершение
Пропажа потока и расхождение витрины с журналом видны владельцу без чтения - Пропажа потока видна владельцу без чтения логов
логов и переживают ротацию логов. - Состояние сервиса — последняя доставка, счётчики, тишина — читается одним
запросом
- Счётчики слияния переживают ротацию логов
+33
View File
@@ -0,0 +1,33 @@
# Ловить гейтом расхождение спеки с маршрутами
- **Секция:** Ядро
- **Зачем:** Рукописная спека — источник истины, а расходится она с кодом молча: без гейта решение о рукописной спеке не держится
- **Теги:** goal:read-api, sprint:2026-08-04
Маршрут, которого нет в спеке, и поле ответа, которого спека не обещала, красят
гейт — рукописный контракт перестаёт расходиться с кодом молча.
Это не украшение к спеке, а то, чем держится решение писать её руками. Без
проверки рукописная спека расходится с первого же маршрута, и потребитель,
сгенерировавший по ней клиент, узнаёт об этом последним.
Класс отказа тот же, что у остальных безусловных шагов гейта проекта: не виден
глазами и стоит дорого. Место ему там же.
Двигает строку «Завершения» цели: «Контракт чтения читается машиной».
## Критерии приёмки
- добавленный маршрут без правки спеки красит гейт — оракул: намеренно
рассогласованный маршрут в прогоне гейта
- переименованное поле ответа красит гейт — оракул: намеренное переименование в
прогоне гейта
- проверка укладывается в бюджет гейта — оракул: замер шага по логу
`tmp/gate/`
- проверка работает без внешней сети — оракул: прогон гейта в контейнере без
доступа наружу
## Рамки
Трогает `Taskfile` и шаги гейта, кода маршрутов не касается. Берётся после
спеки: проверять нечего, пока нет источника истины.
+35
View File
@@ -0,0 +1,35 @@
# Написать OpenAPI-спеку руками
- **Секция:** Ядро
- **Зачем:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- **Теги:** 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 рукописная спека честнее — но это стоит обсудить.
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Пересекающиеся источники одной метрики # [idea] Пересекающиеся источники одной метрики
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь - **Зачем:** Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
- **Теги:** goal:read-api - **Теги:** goal:read-api
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Выгрузка в parquet отдельной командой # [idea] Выгрузка в parquet отдельной командой
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно - **Зачем:** Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
- **Теги:** goal:read-api - **Теги:** goal:read-api
-20
View File
@@ -1,20 +0,0 @@
# [goal] Разбор и хранилище
- **Секция:** порядок
- **Зачем:** Секции живого потока разобраны все, кроме словаря категориальных значений и пяти невиденных
- **Теги:** decomposed
Метрики, тренировки и записи со своими `id` разбираются и ложатся в часовые
объекты; тела перестали быть недифференцированной кучей.
Выведена из шага 3 плана. Сделано: разбор метрик в объекты, тренировки и
записи, `reindex`. Осталось: словарь категориальных значений и секции, которых
поток ещё не приносил.
Завершена, когда ни одна секция живого потока не числится неразобранной, а
категориальные значения имеют стабильный код рядом с переведённой строкой.
## Завершение
Ни одна секция живого потока не числится неразобранной, а категориальные
значения несут стабильный код рядом с переведённой строкой.
+27
View File
@@ -0,0 +1,27 @@
# [goal] Новая форма от источника не теряется молча
- **Секция:** Направления
- **Зачем:** Пять секций HAE поток ещё не приносил, а ручные секции заводятся задним числом — разбор полон ровно до следующей новой формы
- **Теги:** decomposed
Направление: всё, что приезжает от источника, разобрано и доехало до витрины — не
только сегодня, но и после того, как источник изменится.
Выделена из цели «Разбор и хранилище», когда та достигла своего критерия
завершения: секции живого потока разобраны, категориальные значения несут
стабильный код. Осталось то, что заканчиваться не умеет по природе — источник
вправе прислать форму, которой раньше не было, а часть секций заводится
человеком задним числом.
В «Запланировано» не встаёт: работа приходит от потока, а не от очереди. Первая встреча
новой секции наблюдаема (`healthlog uncovered` и `WARN` на свёртке) — работа
направления приходит от этих событий.
## Завершение
Завершена не бывает — это направление. Закрывается по мере того, как каждая
приезжающая форма доезжает до витрины, а не теряется между «принято» и
«разобрано». Открыто сегодня:
- Сущность с `id` и неразобранной меткой не пропадает целиком
- Ручные секции, заведённые задним числом, доезжают до витрины
+5 -3
View File
@@ -1,7 +1,7 @@
# Ретеншен сырого архива # Подчищать сырой архив до последнего проверенного экспорта
- **Секция:** инфра - **Секция:** Инфра
- **Зачем:** Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает - **Зачем:** Архив не подчищается вовсе, а резать его раньше даты проверенного экспорта нельзя — в журнале останется дыра, которую нечем пересобрать
- **Теги:** goal:journal-and-rebuild - **Теги:** goal:journal-and-rebuild
Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но
@@ -28,6 +28,8 @@
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
глубину архива и дату снапшота, до которой он подрезан. глубину архива и дату снапшота, до которой он подрезан.
Двигает строку «Завершения» цели: «Сырой архив подчищается до последнего проверенного экспорта».
## Предусловие снова открыто ## Предусловие снова открыто
Признак «доставка с непокрытой секцией» появился в change Признак «доставка с непокрытой секцией» появился в change
@@ -0,0 +1,41 @@
# Отличать неполное ведро от полного
- **Секция:** Ядро
- **Зачем:** Текущий час неполон всегда, и без порога свёртка отдаёт его наравне с полными — клиент видит провал вместо неизвестности
- **Теги:** goal:read-api, sprint:2026-08-04, kind:feature
Ведро, в котором известна не вся сетка, отличимо от полного — а полярность
порога названа вслух, а не выводится читателем из умолчания.
Измерению рода агрегации порог не понадобился: у него две конкурирующие
гипотезы, и неполный час не сходится ни с одной сам собой. Свёртке в ответе он
нужен — текущий час неполон **всегда**, и без порога накопительная метрика
показывает за него провал вместо неизвестности.
**Готовые решения задают порог противоположно.** Graphite `xFilesFactor` — доля
обязательно известных точек (умолчание 0.5 при свёртке на записи и 0 при
отдаче ответа: один параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе
величины выглядят как «0.5», означая разное. Полярность придётся назвать вслух,
иначе через полгода два места кода поймут поле по-разному — и разойдутся молча.
Двигает строку «Завершения» цели: «Неполное ведро отличимо от полного, и полярность порога названа».
## Затрагивает
Форма ответа свёртки — признак неполного ведра рядом со значением. Конфиг и его
образцы — порог с названной полярностью. Раздел о свёртке в
`docs/architecture.md`. Схемы и формата на диске не трогает.
## Критерии приёмки
- полярность и умолчание порога названы в `docs/architecture.md` одной
формулировкой, и там же сказано, у какого из двух прототипов взято — оракул:
глазами по разделу
- ведро ниже порога помечено неизвестным, а не отдано значением — оракул: тест
на границе: ведро ровно на пороге и на единицу ниже
- текущий незакрытый час не выглядит провалом накопительной метрики — оракул:
запрос за сегодня на живом архиве
## Рамки
Схема не трогается, данные только читаются. Берётся после свёртки по сетке.
@@ -0,0 +1,42 @@
# Сворачивать точки по заданной сетке
- **Секция:** Ядро
- **Зачем:** «Шаги за неделю по дням» — базовый запрос трекера и игры, и сегодня его нечем задать
- **Теги:** goal:read-api, sprint:2026-08-04, kind:feature
«Шаги за неделю по дням» отвечаются одним запросом `?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,45 @@
# Отвечать 304 на повторный запрос точек
- **Секция:** Ядро
- **Зачем:** Агент опрашивает по расписанию, а каждый повтор стоит полного чтения: на каталоге это 693 мс и +153 МиБ
- **Теги:** goal:read-api, sprint:2026-08-04, kind:feature
Повторный опрос точек с той же меткой стоит `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».
+37
View File
@@ -0,0 +1,37 @@
# Отдавать записи со своим id за период
- **Секция:** Ядро
- **Зачем:** stateOfMind разобран и хранится, но наружу не отдаётся — а восстановить его нечем: в экспорте Apple его нет
- **Теги:** goal:read-api, sprint:2026-08-04, kind:feature
Записи со своим `id` — сегодня это `stateOfMind` — достаются за период через
`GET /records/{kind}`.
Разбор и хранение сделаны тем же изменением, что у тренировок; наружу не отдаётся
ничего. У этих данных есть особенность, которой нет больше ни у чего в проекте:
**`stateOfMind` нет в экспорте Apple**, он не восстанавливается пересборкой из
снапшота, и единственный его источник — доставки HAE. Отдача наружу — не
удобство, а единственный способ увидеть то, что иначе живёт только внутри базы.
Конверт наследуется от точек; собственной формы у записей нет.
Двигает строку «Завершения» цели: «Тренировки с маршрутом и записи со своим `id` отдаются за период».
## Затрагивает
Новый маршрут `GET /api/v1/records/{kind}` и код отказа на неизвестном `kind`.
Публичный тип провода в `internal/httpapi` — конверт записей. Чтение таблицы
записей; схемы и формата на диске не трогает.
## Критерии приёмки
- записи `stateOfMind` за период отдаются одним запросом — оракул: запрос к
поднятому сервису на живом архиве
- неизвестный `kind` отвечает отказом со списком известных, а не пустым списком:
пустота и опечатка обязаны различаться — оракул: тест
- конверт совпадает с конвертом точек и тренировок — оракул: тест, сравнивающий
форму ответа трёх маршрутов
## Рамки
Схема не трогается, данные только читаются. Берётся после конверта.
@@ -0,0 +1,52 @@
# Ограничить размер ответа маршрутов чтения
- **Секция:** Ядро
- **Зачем:** У маршрутов чтения нет ни одного потолка: множители «метрики × окно × точки × одновременные запросы» ничем не ограничены
- **Теги:** goal:read-api, sprint:2026-08-04, kind:feature
У маршрутов чтения появляется названный потолок: сетка не задана и ответ не
влезает — сервер огрубляет её и **называет** в ответе; сетка задана явно и не
влезает — ошибка со списком доступных, а не тихая подмена.
Различие существенно: иначе агент, попросивший минутную сетку, получит суточные
суммы и не узнает об этом.
**Цена измерена и унаследована.** На каталоге враждебный запрос
(20 метрик × 8 часов × 5000 точек) дал 693 мс и +153 МиБ живой кучи, при том что
приём в том же процессе уже даёт пик 768 МиБ на теле 40 МиБ. Множители «метрики ×
окно × точки × одновременные запросы» сегодня без потолка ни у одного маршрута —
включая уже живой каталог, у которого предела нет намеренно: правило размера
общее, и задавать его мимоходом на первом маршруте значило бы решить контракт до
того, как известна форма тяжёлого ответа.
Правило распространяется на все маршруты чтения сразу — каталог, точки,
тренировки, записи, — а не только на тот, где написано.
Двигает строку «Завершения» цели: «У ответа любого маршрута чтения есть объявленный предел размера».
## Затрагивает
Все маршруты чтения сразу — каталог, точки, а следом тренировки и записи: код и
тело отказа на запросе за пределом, поле огрублённой сетки в ответе. Конфиг и
его образцы — сам предел. Раздел о пределах в `docs/architecture.md`. Схемы и
формата на диске не трогает.
## Критерии приёмки
- запрос без сетки, не влезающий в предел, отвечает огрублённой сеткой и
называет её в ответе — оракул: враждебный запрос на живом архиве
- явно заданная сетка за пределом даёт ошибку со списком доступных сеток —
оракул: тест
- предел читается из конфига: два разных значения дают две разные границы
отказа — оракул: тест с подменой значения предела
- предел назван в образцах конфига и в `docs/architecture.md` — оракул: шаг
образцов конфига в гейте и глазами по разделу
- каталог подчиняется тому же пределу, что и точки — оракул: тест на враждебном
запросе к каталогу
## Рамки
Схема не трогается, данные только читаются. Берётся после свёртки по сетке.
Собственный дедлайн маршрута сюда **не входит**: он в задаче
[«Развести бюджеты остановки»](shutdown-and-migration-traces.md) вместе с `BaseContext`
и раздельными бюджетами остановки.
+44
View File
@@ -0,0 +1,44 @@
# Отдавать тренировки вместе с маршрутом
- **Секция:** Ядро
- **Зачем:** Тренировки с маршрутами разобраны и лежат в витрине, а маршрутов чтения нет — сценарий трекера не закрыт
- **Теги:** goal:read-api, sprint:2026-08-04, kind:feature
Трекер забирает тренировку одним пакетом вместе с маршрутом: `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) и здесь не решается.
+24 -11
View File
@@ -1,19 +1,32 @@
# [goal] Read API # [goal] Клиенты читают данные через HTTP и MCP
- **Секция:** порядок - **Секция:** Запланировано
- **Зачем:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - **Зачем:** Данные видны только через sqlite на хосте — ни один из трёх потребителей, включая агента-медика, ничего прочитать не может
- **Теги:** decomposed - **Теги:** decomposed
Потребители читают точки: выбор слоя, свёртка по сетке, предел размера ответа. Потребители читают данные: точки с выбором слоя и свёрткой по сетке, тренировки
и записи, машиночитаемый контракт — и всё то же самое через MCP.
Выведена из шага 5 плана. Идёт после каталога и рода агрегации намеренно: без Идёт после каталога и рода агрегации намеренно:
измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться здесь без измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться
дорого — просуммировать нижний слой значит завысить втрое. здесь дорого — просуммировать нижний слой значит завысить втрое.
Завершена, когда любой из трёх потребителей получает точки за период без **MCP входит в эту цель, а не идёт отдельной.** Прежде их было две, и разделяла
доступа к файлу базы. их очередь: адаптер собственной логики не несёт, он переводит вызовы в те же
обработчики, и переводить было нечего. Очередь никуда не делась — она стала
порядком задач внутри цели, — а вот отдельная цель под адаптер описывала не
направление, а последний шаг этого же направления. Заказчик у обоих транспортов
один: три потребителя, из которых первый — агент.
## Завершение ## Завершение
Любой из трёх потребителей получает точки за период без доступа к файлу базы, - Точки метрики за период отдаются по HTTP без доступа к файлу базы
и предел размера ответа объявлен, а не подразумевается. - Повторный запрос тех же точек стоит `304`, а не полного чтения
- Точки сворачиваются по заданной сетке измеренным родом агрегации
- Неполное ведро отличимо от полного, и полярность порога названа
- У ответа любого маршрута чтения есть объявленный предел размера
- Тренировки с маршрутом и записи со своим `id` отдаются за период
- Контракт чтения читается машиной: спека, гейт против её расхождения с
маршрутами, UI без внешней сети
- Агент-медик читает то же самое через MCP тем же токеном чтения, и собственной
логики адаптер не несёт
+5 -3
View File
@@ -1,6 +1,6 @@
# Сверка живой витрины с пересборкой # Сверять живую витрину с пересборкой
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит - **Зачем:** reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- **Теги:** goal:journal-and-rebuild - **Теги:** goal:journal-and-rebuild
@@ -10,7 +10,7 @@
только тем, что кто-то вручную запустил пересборку и посмотрел на два числа. только тем, что кто-то вручную запустил пересборку и посмотрел на два числа.
Между тем расхождение — не гипотеза. Известный путь к нему записан блокером Между тем расхождение — не гипотеза. Известный путь к нему записан блокером
[«Порядок журнала при конкурентных приёмах»](journal-order-on-ingest.md): [«Держать порядок журнала при конкурентных приёмах»](journal-order-on-ingest.md):
доставка, свёрнутая раньше своей предшественницы, уходит в `failed` навсегда, и доставка, свёрнутая раньше своей предшественницы, уходит в `failed` навсегда, и
живая витрина расходится с пересборкой молча. Пока тот предел не закрыт, сверка живая витрина расходится с пересборкой молча. Пока тот предел не закрыт, сверка
— единственный способ узнать, что он сработал. — единственный способ узнать, что он сработал.
@@ -30,3 +30,5 @@
Связано: `cmd/healthlog/reindex.go`, [наблюдаемость](stats-endpoint.md), Связано: `cmd/healthlog/reindex.go`, [наблюдаемость](stats-endpoint.md),
[деплой](deploy-rivendell.md). [деплой](deploy-rivendell.md).
Двигает строку «Завершения» цели: «Расхождение живой витрины с пересборкой замечает сервис, а не человек».
+4 -2
View File
@@ -1,6 +1,6 @@
# Пересборка держит весь журнал в памяти # Не держать весь журнал в памяти при пересборке
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет - **Зачем:** Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
- **Теги:** goal:journal-and-rebuild - **Теги:** goal:journal-and-rebuild
@@ -26,3 +26,5 @@
памяти, не зависящим от его длины. памяти, не зависящим от его длины.
Связано: `internal/replay`, `cmd/healthlog/reindex.go`. Связано: `internal/replay`, `cmd/healthlog/reindex.go`.
Двигает строку «Завершения» цели: «Расход пересборки не растёт вместе с журналом».
@@ -1,6 +1,6 @@
# Чем откатывать релиз после наката миграции # Назвать механизм отката релиза после наката миграции
- **Секция:** инфра - **Секция:** Инфра
- **Зачем:** Страж версии схемы делает возврат старого бинаря отказом, а понизить схему нечем — аварийный путь пришлось бы изобретать в аварии - **Зачем:** Страж версии схемы делает возврат старого бинаря отказом, а понизить схему нечем — аварийный путь пришлось бы изобретать в аварии
- **Теги:** goal:deploy - **Теги:** goal:deploy
@@ -15,6 +15,8 @@
Вынуто ревью кода задачи «Дозакрыть находки ревью по слиянию сущностей» Вынуто ревью кода задачи «Дозакрыть находки ревью по слиянию сущностей»
(проходы `ops` и `negative`, профиль `deep`). (проходы `ops` и `negative`, профиль `deep`).
Двигает строку «Завершения» цели: «Откат релиза после наката миграции имеет названный механизм».
## Что именно решить ## Что именно решить
Та задача перенесла в `store.Open` стража версии схемы: база новее бинаря — Та задача перенесла в `store.Open` стража версии схемы: база новее бинаря —
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Человеческие аннотации поверх выведенных схем # [idea] Человеческие аннотации поверх выведенных схем
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата - **Зачем:** Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
- **Теги:** goal:self-description - **Теги:** goal:self-description
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Порог sealed: с какого возраста час считается запечатанным # [idea] Порог sealed: с какого возраста час считается запечатанным
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта - **Зачем:** WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
- **Теги:** goal:merge-robustness - **Теги:** goal:merge-robustness
+4 -9
View File
@@ -1,17 +1,12 @@
# [goal] Самоописание # [goal] Клиент узнаёт форму данных из ответа
- **Секция:** порядок - **Секция:** Запланировано
- **Зачем:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке - **Зачем:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- **Теги:** decomposed - **Теги:** decomposed
Клиент узнаёт форму данных из ответа сервиса, а не угадывает её по выборке. Клиент узнаёт форму данных из ответа сервиса, а не угадывает её по выборке.
Выведена из шага 6 плана.
Завершена, когда контракт читается машиной, а формы содержимого метрик
выведены из данных, а не описаны руками.
## Завершение ## Завершение
Контракт читается машиной, а формы содержимого метрик выведены из данных, а не - Формы содержимого метрик выведены из данных, а не описаны руками
описаны руками. - Клиент узнаёт форму одной метрики и всего хранилища одним запросом
@@ -1,6 +1,6 @@
# Остановка и миграция: раздельные бюджеты и следы в логе # Развести бюджеты остановки и оставить следы миграции в логе
- **Секция:** инфра - **Секция:** Инфра
- **Зачем:** Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM - **Зачем:** Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
- **Теги:** goal:deploy - **Теги:** goal:deploy
@@ -49,3 +49,5 @@
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, change Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, change
`2026-08-02-cena-chitayushchego-marshruta` (архив). `2026-08-02-cena-chitayushchego-marshruta` (архив).
Двигает строку «Завершения» цели: «Остановка сервиса называет виновный этап честно, а накат миграций виден в логе старта».
+4 -2
View File
@@ -1,6 +1,6 @@
# Наблюдаемость: /stats # Отдавать состояние сервиса маршрутом /stats
- **Секция:** инфра - **Секция:** Инфра
- **Зачем:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах - **Зачем:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- **Теги:** goal:observability - **Теги:** goal:observability
@@ -59,3 +59,5 @@
сколько страниц лежит и сколько перенесено) и — тем же полем — доля ответов сколько страниц лежит и сколько перенесено) и — тем же полем — доля ответов
чтения, которые удалось подписать `ETag`: механизм условного запроса может чтения, которые удалось подписать `ETag`: механизм условного запроса может
перестать окупаться под плотным потоком, и снаружи это неотличимо от нормы. перестать окупаться под плотным потоком, и снаружи это неотличимо от нормы.
Двигает строку «Завершения» цели: «Состояние сервиса — последняя доставка, счётчики, тишина — читается одним запросом».
+6 -4
View File
@@ -1,6 +1,6 @@
# Активный алерт «данных нет N часов» # Слать уведомление, когда данных нет N часов
- **Секция:** инфра - **Секция:** Инфра
- **Зачем:** Пропажу потока сейчас замечает человек, а не сервис - **Зачем:** Пропажу потока сейчас замечает человек, а не сервис
- **Теги:** goal:observability - **Теги:** goal:observability
@@ -18,10 +18,12 @@
После деплоя на rivendell поднимется. После деплоя на rivendell поднимется.
## Источник алерта не может жить внутри `serve` Двигает строку «Завершения» цели: «Пропажа потока видна владельцу без чтения логов».
## Источник уведомления не может жить внутри `serve`
Отказ стража версии схемы (база новее бинаря) останавливает процесс, а Отказ стража версии схемы (база новее бинаря) останавливает процесс, а
`restart: unless-stopped` даёт цикл перезапуска. Значит алерт «данных нет N `restart: unless-stopped` даёт цикл перезапуска. Значит уведомление «данных нет N
часов», живущий внутри сервиса, на эту причину остановки не сработает **по часов», живущий внутри сервиса, на эту причину остановки не сработает **по
построению** — он не поднимется вместе с ним. Обоснование стража («откат делает построению** — он не поднимется вместе с ним. Обоснование стража («откат делает
оператор, он в этот момент рядом») верно для ручного отката и не покрывает оператор, он в этот момент рядом») верно для ручного отката и не покрывает
+34
View File
@@ -0,0 +1,34 @@
# Поднять Swagger UI без внешней сети
- **Секция:** Ядро
- **Зачем:** Контракт читается машиной, но человеку нечем выполнить запрос к живому сервису из браузера, а внешних CDN в локальной сети нет
- **Теги:** goal:read-api, sprint:2026-08-04
Человек открывает UI на отдельном пути и выполняет запрос к живому сервису — не
имея интернета.
Сервис живёт в локальной сети и на VPS без гарантии выхода наружу, поэтому
статика отдаётся самим сервисом и лежит в бинаре: внешние CDN здесь означают
«работает, пока работает чужой сайт».
**UI — новый адресат недоверенного входа наизнанку:** он даёт человеку одним
нажатием выполнить запрос к любому описанному маршруту, включая приём. Права
на запись из браузера не должны появляться сами собой — это ровно та граница, которую
описывает `docs/security.md`.
Двигает строку «Завершения» цели: «Контракт чтения читается машиной».
## Критерии приёмки
- UI открывается и выполняет запрос при отключённой внешней сети — оракул:
запуск контейнера без доступа наружу
- бинарь работает без каталога со статикой рядом — оракул: запуск одного файла
из пустого каталога
- запрос к маршруту приёма без заголовка с токеном приёма отклоняется, откуда
бы он ни пришёл, а отдаваемая UI статика токена приёма в себе не держит —
оракул: тест на обработчике приёма плюс поиск токена в отдаваемой статике
## Рамки
Схема не трогается. Берётся после спеки. Наружу ничего не выкладывается — это
решение человека и отдельная задача деплоя.
@@ -1,94 +0,0 @@
# Тай-брейк при равной полноте точек
- **Секция:** ядро
- **Зачем:** При равной полноте порядок канонических форм берёт меньшее значение в 96% случаев — у накопительных это систематический недосчёт
- **Теги:** goal:merge-robustness
**Решение принято владельцем 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,6 @@
# Управление токенами и секретами # Развести токены контуров и убрать секреты из репозитория
- **Секция:** инфра - **Секция:** Инфра
- **Зачем:** Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу - **Зачем:** Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
- **Теги:** goal:deploy - **Теги:** goal:deploy
@@ -29,3 +29,5 @@
Готово, когда запуск без токенов возможен только на localhost, а на rivendell Готово, когда запуск без токенов возможен только на localhost, а на rivendell
оба контура закрыты разными токенами. оба контура закрыты разными токенами.
Двигает строку «Завершения» цели: «Оба контура закрыты разными токенами, и без токенов сервис стартует только на localhost».
-68
View File
@@ -1,68 +0,0 @@
# Активная проверка: поток принёс секцию, которой раньше не было
- **Секция:** ядро
- **Зачем:** Момент появления новой секции фиксируется в базе, но заметить его может только тот, кто догадается заглянуть в колонку
- **Теги:** goal:parsing-and-storage
Разбор пишется по тем данным, что видел поток, а он приносил только `metrics`,
`workouts` и `stateOfMind`. Не виденны живьём: `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`, а также вес — а вес
агенту-медику нужен наверняка.
Пользователь настраивает оставшиеся метрики на телефоне, так что данные
появятся сами. **Задача — не пропустить момент.** Разбор самих секций сюда не
входит и входить не может: их формы никто не видел, и вслепую разбор
сознательно не пишется. Каждая приехавшая секция станет отдельной задачей —
тогда, когда её будет на чём проверить.
## Что уже сделано
Разбор перечисляет непокрытые секции и пишет их в `delivery.uncovered_sections`
(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Событие **фиксируется**
но ничем не наблюдается: узнать о нём можно только запросом в базу руками.
Модель под секции с собственным `id` заложена (change
`2026-08-02-trenirovki-i-zapisi`): таблица `record` ключуется парой
`род + id`, и новая секция добавляется **одной строкой** в множество покрытых
имён разбора, а не миграцией.
## Что известно про сами секции
Часть вопроса закрыта разбором экспортов (находка 42): в Health эти данные
**есть** и в экспорте присутствуют — `BodyMass` (1127 записей),
`BloodPressureSystolic`/`Diastolic` (по 18), `BodyTemperature` (11), `Headache`
(36), `SexualActivity` (46), `Dietary*` (по 88). Значит вопрос не «есть ли
данные», а «доедут ли они через HAE и в какой форме».
Остаётся непроверенным `stateOfMind`: в экспорте его нет ни одним типом. Если
подтвердится, что Apple его не выгружает, то экспорт ему не источник истины —
устаревание нижнего слоя к нему неприменимо, держим всегда.
Давление приезжает обёрткой `Correlation` из двух записей (находка 44) — в
экспорте точно, а вот как его отдаёт HAE, неизвестно. Это первое, на что
смотреть, когда данные появятся.
## Критерии приёмки
- имя секции, которого разбор раньше не встречал, порождает событие уровня выше
рутины — оракул: тест на доставке с выдуманной секцией, в логе ровно одна
строка с этим именем
- то же имя во второй доставке события больше не порождает — оракул: тот же
тест на двух доставках подряд, вторая молчит
- список всего, что поток когда-либо приносил и разбор не покрыл, достаётся
одной командой, без ручного SQL — оракул: прогон команды на живой базе,
вывод сходится с `SELECT DISTINCT` по `delivery.uncovered_sections`
- перечень не виденных живьём секций в `docs/research/apple-health.md` сходится
с множеством покрытых имён в `internal/hae` — оракул: глазами, сверка двух
списков поимённо
- повторный прогон живого архива даёт то же состояние — оракул:
`task verify:archive`
## Рамки
Схема не трогается: колонка `uncovered_sections` уже есть. Разбор новых секций
не пишется. Данные только читаются; перезапуск сервиса допустим.
Связано: `docs/architecture.md` → «Неразобранные секции доставки», находки 42,
44; идея [monthly-manual-sections-pass](monthly-manual-sections-pass.md) ждёт
тех же данных.
@@ -1,6 +1,6 @@
# Идентичность тренировок при импорте родного экспорта # Не задваивать тренировки при импорте родного экспорта
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE - **Зачем:** В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
- **Теги:** goal:native-export-import - **Теги:** goal:native-export-import
@@ -37,3 +37,5 @@ Prior art: `dogsheep/healthkit-to-sqlite` адресует тренировку
Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача
`apple-export-import`. `apple-export-import`.
Двигает строку «Завершения» цели: «Тренировки из экспорта не задваивают приехавшие от HAE».
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Разворачивание маршрутов тренировок в отдельную таблицу # [idea] Разворачивание маршрутов тренировок в отдельную таблицу
- **Секция:** ядро - **Секция:** Ядро
- **Зачем:** Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом - **Зачем:** Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
- **Теги:** goal:read-api - **Теги:** goal:read-api
+18
View File
@@ -474,6 +474,24 @@ func keysOf(m map[string]json.RawMessage) map[string]struct{} {
return out 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 сравнивает два множества ключей по включению. // relateKeys сравнивает два множества ключей по включению.
func relateKeys(a, b map[string]struct{}) Fullness { func relateKeys(a, b map[string]struct{}) Fullness {
aExtra := hasExtra(a, b) aExtra := hasExtra(a, b)
+73 -37
View File
@@ -63,12 +63,10 @@ func (s Style) String() string {
} }
} }
// MarshalJSON отдаёт род строкой. Нулевое значение уезжает как `unknown`, а не // Собственной сериализации у Style нет намеренно: строку в ответ кладёт
// как пустая строка: клиент не должен видеть в ответе состояние, которого в // транспорт (`internal/httpapi`, форма провода). Домен владеет ЗНАЧЕНИЯМИ
// словаре нет. // словаря, а не их видом на проводе; `String()` при этом нужен и логам, и
func (s Style) MarshalJSON() ([]byte, error) { // сообщениям тестов, и проводу — второго словаря заводить незачем.
return []byte(`"` + s.String() + `"`), nil
}
// Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова, // Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова,
// потому что от них зависят счётчики основания в ответе. // потому что от них зависят счётчики основания в ответе.
@@ -149,13 +147,28 @@ func closeEnough(a, b float64) bool {
return math.Abs(a-b)/math.Max(math.Abs(a), math.Abs(b)) <= tolerance 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 { if len(metric) <= maxMetricInLog {
return metric return metric
} }
return metric[:maxMetricInLog] + "…" return metric[:maxMetricInLog] + "…"
} }
// ФОРМЫ ПРОВОДА В ЭТОМ ПАКЕТЕ НЕТ, и это решение, а не упущение.
//
// Типы ниже — форма ответа use-case, а не форма ответа HTTP: `json`-тегов они
// не несут и до сериализации не доезжают. Публичный контракт чтения объявляет
// транспорт (`internal/httpapi`), поэтому переименование поля здесь байты
// ответа клиенту не меняет — оно ломает компиляцию перевода. Обратная цена
// названа вслух: новое поле само в ответ не попадёт, его обязан перечислить
// транспорт. Решение и цена обеих сторон — `docs/architecture.md`, раздел
// «Read API», подраздел «Форма провода».
// Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их // Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их
// разности были осмысленны: `Hours Compared` — часы, отброшенные проверкой // разности были осмысленны: `Hours Compared` — часы, отброшенные проверкой
// пригодности, `Compared Agreeing Conflicting` — часы, не сошедшиеся ни с // пригодности, `Compared Agreeing Conflicting` — часы, не сошедшиеся ни с
@@ -165,17 +178,21 @@ func clipMetric(metric string) string {
// и «часов не было вовсе» — разные события, и клиент обязан различать их без // и «часов не было вовсе» — разные события, и клиент обязан различать их без
// второго запроса. // второго запроса.
type Basis struct { type Basis struct {
Hours int `json:"hours"` Hours int
Compared int `json:"compared"` Compared int
Agreeing int `json:"agreeing"` Agreeing int
Conflicting int `json:"conflicting"` Conflicting int
FirstHour *time.Time `json:"first_hour"` FirstHour *time.Time
LastHour *time.Time `json:"last_hour"` LastHour *time.Time
} }
// Aggregation — род вместе с основанием. // Aggregation — род вместе с основанием.
//
// Встраивание здесь — удобство домена, а не форма ответа: плоскость объекта
// `aggregation` на проводе объявлена транспортом поимённо и от этого
// встраивания не зависит.
type Aggregation struct { type Aggregation struct {
Style Style `json:"style"` Style Style
Basis Basis
} }
@@ -186,22 +203,22 @@ type Aggregation struct {
// часовых объектов здесь нет: объект — деталь хранения, клиент про него не // часовых объектов здесь нет: объект — деталь хранения, клиент про него не
// знает. // знает.
type LayerRange struct { type LayerRange struct {
Layer string `json:"layer"` Layer string
From time.Time `json:"from"` From time.Time
To time.Time `json:"to"` To time.Time
Points int `json:"points"` Points int
} }
// Metric — запись каталога. // Metric — запись каталога.
type Metric struct { type Metric struct {
Metric string `json:"metric"` Metric string
// Units — множество различных единиц метрики, отсортированное. Массив, а не // Units — множество различных единиц метрики, отсортированное. Массив, а не
// строка: на живом потоке единицы не менялись ни разу, но одна форма поля // строка: на живом потоке единицы не менялись ни разу, но одна форма поля
// для обоих случаев честнее строки, которая при расхождении молча выберет // для обоих случаев честнее строки, которая при расхождении молча выберет
// одно из двух. // одно из двух.
Units []string `json:"units"` Units []string
Aggregation Aggregation `json:"aggregation"` Aggregation Aggregation
Layers []LayerRange `json:"layers"` Layers []LayerRange
} }
// Snapshot — каталог вместе с версией ответа. // 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) s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err)
return "", 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 == "" { if version == "" {
return "" return ""
} }
@@ -275,19 +318,12 @@ func New(st *store.Store, log *slog.Logger) *Service {
// её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён: // её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён:
// версия, снятая после чтения, пометила бы устаревший снимок свежей меткой. // версия, снятая после чтения, пометила бы устаревший снимок свежей меткой.
func (s *Service) Metrics(ctx context.Context) (Snapshot, error) { func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
horizon := store.Now().Add(horizonSlack) horizon := Horizon()
var snap store.CatalogSnapshot var snap store.CatalogSnapshot
version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error { version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
var err error var err error
snap, err = s.store.ReadCatalog(ctx, store.CatalogWindow{ snap, err = s.store.ReadCatalog(ctx, MeasureWindow(horizon))
Fine: string(hae.LayerMinute),
Coarse: string(hae.LayerHour),
Hours: Window,
Horizon: horizon,
CoarsePoints: coarsePoints,
MinFinePoints: minFinePoints,
})
return err return err
}) })
if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
@@ -319,7 +355,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
if basis.Conflicting > 0 { if basis.Conflicting > 0 {
s.log.WarnContext(ctx, "aggregation style conflict", s.log.WarnContext(ctx, "aggregation style conflict",
"capability", "query", "capability", "query",
"metric", clipMetric(group.metric), "metric", ClipMetric(group.metric),
"hours", basis.Hours, "hours", basis.Hours,
"compared", basis.Compared, "compared", basis.Compared,
"agreeing", basis.Agreeing, "agreeing", basis.Agreeing,
@@ -332,7 +368,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
if to := group.latest(); to.After(horizon) { if to := group.latest(); to.After(horizon) {
s.log.WarnContext(ctx, "future data", s.log.WarnContext(ctx, "future data",
"capability", "query", "capability", "query",
"metric", clipMetric(group.metric), "metric", ClipMetric(group.metric),
"last_ts", store.FormatTime(to), "last_ts", store.FormatTime(to),
"horizon", store.FormatTime(horizon)) "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") 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 { type metricGroup struct {
+26
View File
@@ -520,3 +520,29 @@ func TestКаталогОтдаётсяСВерсиейВитрины(t *testing
t.Error("каталог собран на стоящей витрине и остался без версии") 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) { func TestStyleСловарь(t *testing.T) {
t.Parallel() t.Parallel()
cases := map[catalog.Style]string{ cases := map[catalog.Style]string{
catalog.Cumulative: `"cumulative"`, catalog.Cumulative: "cumulative",
catalog.Instant: `"instant"`, catalog.Instant: "instant",
catalog.Unknown: `"unknown"`, catalog.Unknown: "unknown",
catalog.Style(42): `"unknown"`, catalog.Style(42): "unknown",
} }
for style, want := range cases { for style, want := range cases {
got, err := json.Marshal(style) if got := style.String(); got != want {
if err != nil { t.Errorf("род %d: получили %q, ждали %q", style, got, want)
t.Fatalf("сериализация %v: %v", style, err)
}
if string(got) != want {
t.Errorf("род %d: получили %s, ждали %s", style, got, want)
} }
} }
} }
+3 -3
View File
@@ -15,16 +15,16 @@ func TestГоризонтВходитВВерсиюОтвета(t *testing.T) {
at := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC) at := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC)
if stamp("v", at) == stamp("v", at.Add(2*time.Hour)) { if Stamp("v", at) == Stamp("v", at.Add(2*time.Hour)) {
t.Error("версия не изменилась при сдвиге горизонта на два часа") t.Error("версия не изменилась при сдвиге горизонта на два часа")
} }
// Огрубление до часа точное, а не приблизительное: `hour_utc` объектов лежит // Огрубление до часа точное, а не приблизительное: `hour_utc` объектов лежит
// ровно на часах, поэтому отбор меняется ровно при переходе через час. // ровно на часах, поэтому отбор меняется ровно при переходе через час.
// Внутри часа метка обязана стоять — иначе она дребезжала бы ежесекундно. // Внутри часа метка обязана стоять — иначе она дребезжала бы ежесекундно.
if stamp("v", at) != stamp("v", at.Add(20*time.Minute)) { if Stamp("v", at) != Stamp("v", at.Add(20*time.Minute)) {
t.Error("версия сдвинулась внутри одного часа — метка дребезжит на месте") t.Error("версия сдвинулась внутри одного часа — метка дребезжит на месте")
} }
if stamp("", at) != "" { if Stamp("", at) != "" {
t.Error("пустая версия витрины подписана горизонтом — подписывать нечем") t.Error("пустая версия витрины подписана горизонтом — подписывать нечем")
} }
} }
+277
View File
@@ -0,0 +1,277 @@
package fold_test
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Тело с обеими наблюдаемыми формами: фаза сна, которую словарь знает, и
// контекст пульса, которого он не знает.
const categoricalBody = `{"data":{"metrics":[` +
`{"name":"sleep_analysis","units":"hr","data":[` +
`{"date":"2025-06-05 10:00:00 +0300","start":"2025-06-05 10:00:00 +0300",` +
`"end":"2025-06-05 11:00:00 +0300","qty":1,"value":"Во сне"}]},` +
`{"name":"heart_rate","units":"count/min","data":[` +
`{"date":"2025-06-05 10:00:00 +0300","Avg":62,"context":"Сидячий образ жизни"}]}` +
`]}}`
// receivedAt — фиксированная метка приёма.
//
// Не `store.Now()`: провенанс первой встречи входит в отпечаток витрины, и
// метка от часов сделала бы сравнение отпечатков между подтестами функцией
// того, в одну ли секунду они успели отработать. Ровно так этот тест и покраснел
// на гейте — у трёх подтестов из четырёх метки совпали, у четвёртого нет.
var receivedAt = time.Date(2025, 6, 5, 7, 0, 0, 0, time.UTC)
func deliverWithHeaders(t *testing.T, arch *archive.Archive, st *store.Store, id, headers string, body []byte) {
t.Helper()
at := receivedAt
rawPath, err := arch.Write(id, at, body)
if err != nil {
t.Fatalf("запись в архив: %v", err)
}
err = st.CreateDelivery(context.Background(), store.Delivery{
ID: id,
ReceivedAt: at,
AutomationID: "auto-1",
Aggregation: "Hours",
Bytes: int64(len(body)),
SHA256: "-",
RawPath: rawPath,
Headers: headers,
ParseStatus: store.ParsePending,
})
if err != nil {
t.Fatalf("запись доставки: %v", err)
}
}
func foldService(t *testing.T, log *slog.Logger) (*fold.Service, *archive.Archive, *store.Store) {
t.Helper()
dir := t.TempDir()
arch, err := archive.New(filepath.Join(dir, "raw"))
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return fold.New(arch, st, 0, log), arch, st
}
func TestFoldКладётНаблюденияВРеестр(t *testing.T) {
t.Parallel()
f, arch, st := foldService(t, slog.New(slog.DiscardHandler))
deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(categoricalBody))
stats, err := f.Fold(context.Background(), "d1")
if err != nil {
t.Fatalf("свёртка: %v", err)
}
if stats.Categoricals != 2 {
t.Errorf("наблюдений %d, ожидалось 2", stats.Categoricals)
}
if stats.CategoricalUnknown != 1 {
t.Errorf("строк без кода %d, ожидалась 1 (контекст пульса)", stats.CategoricalUnknown)
}
got, err := st.CategoryValues(context.Background())
if err != nil {
t.Fatalf("чтение реестра: %v", err)
}
if len(got) != 2 {
t.Fatalf("строк реестра %d: %+v", len(got), got)
}
if got[1].Value != "Во сне" || got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" {
t.Errorf("фаза сна в реестре: %+v", got[1])
}
if got[0].Value != "Сидячий образ жизни" || got[0].Code != "" {
t.Errorf("контекст пульса в реестре: %+v", got[0])
}
if got[1].FirstDeliveryID != "d1" {
t.Errorf("провенанс %q, ожидался d1", got[1].FirstDeliveryID)
}
}
// Локаль сужает поиск по словарю, но в ключ не входит и вывода не отменяет:
// заголовков в сыром архиве нет, и доставка, восстановленная из осиротевшего
// тела, обязана дать то же состояние.
func TestFoldЛокальНеМеняетСостояния(t *testing.T) {
t.Parallel()
cases := []struct{ name, headers string }{
{"измеренный заголовок потока", `{"Accept-Language":["ru"]}`},
{"подтег, веса и регистр", `{"accept-language":["RU-ru,ru;q=0.9,en;q=0.8"]}`},
{"заголовка нет вовсе", `{}`},
{"незнакомая локаль", `{"Accept-Language":["de"]}`},
// Пустая строка и битый JSON — не вычурность: заголовков в сыром архиве
// нет вовсе, и доставка, восстановленная из осиротевшего тела, приезжает
// ровно так. Оба обязаны дать пустую локаль и то же состояние.
{"заголовков нет вовсе", ``},
{"заголовки не разбираются", `не json`},
{"заголовок пустым списком", `{"Accept-Language":[]}`},
}
var want string
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f, arch, st := foldService(t, slog.New(slog.DiscardHandler))
deliverWithHeaders(t, arch, st, "d1", c.headers, []byte(categoricalBody))
if _, err := f.Fold(context.Background(), "d1"); err != nil {
t.Fatalf("свёртка: %v", err)
}
got, err := st.CategoryValues(context.Background())
if err != nil {
t.Fatalf("чтение реестра: %v", err)
}
if len(got) != 2 {
t.Fatalf("строк реестра %d: %+v", len(got), got)
}
if got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" {
t.Errorf("код фазы сна %q — локаль отменила вывод", got[1].Code)
}
// Отпечаток не должен зависеть от заголовка вовсе: ключ реестра —
// функция одних тел.
fp, err := st.Fingerprint(context.Background())
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
if want == "" {
want = fp
} else if fp != want {
t.Errorf("заголовок %s сдвинул отпечаток витрины", c.headers)
}
})
}
}
// Строки категориальных значений — данные о здоровье наравне со значением
// точки: «Сидячий образ жизни» описывает человека. В журнал уходят только
// счётчики.
//
// Запись РАЗБИРАЕТСЯ, служебное `time` выбрасывается, и поиск идёт в остатке:
// метка времени содержит произвольные цифры, и поиск по сырому буферу делает
// такой тест флаки по построению (запись 2026-08-02 в docs/review.md).
func TestFoldНеПишетКатегориальныхСтрокВЛог(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
f, arch, st := foldService(t, log)
deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(categoricalBody))
if _, err := f.Fold(context.Background(), "d1"); err != nil {
t.Fatalf("свёртка: %v", err)
}
logged := strings.TrimSpace(buf.String())
if logged == "" {
t.Fatal("свёртка не записала ни одной строки — чекпоинт молчит")
}
var folded map[string]any
for line := range strings.SplitSeq(logged, "\n") {
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("строка лога не JSON: %v", err)
}
delete(rec, "time")
clean, err := json.Marshal(rec)
if err != nil {
t.Fatalf("запись лога не сериализуется: %v", err)
}
for _, secret := range []string{"Во сне", "Сидячий образ жизни", "HKCategoryValue"} {
if strings.Contains(string(clean), secret) {
t.Errorf("в логе оказалось %q:\n%s", secret, clean)
}
}
if msg, _ := rec["msg"].(string); strings.HasPrefix(msg, "delivery folded") {
folded = rec
}
}
if folded == nil {
t.Fatalf("записи об исходе свёртки нет:\n%s", logged)
}
for _, attr := range []string{"categoricals", "categoricals_unknown", "categoricals_dropped"} {
if _, ok := folded[attr]; !ok {
t.Errorf("в записи нет счётчика %q: %v", attr, folded)
}
}
if folded["categoricals_unknown"] != float64(1) {
t.Errorf("счётчик строк без кода %v, ожидалась 1", folded["categoricals_unknown"])
}
}
// Срабатывание границы наблюдений обязано подниматься до WARN: границы
// подобраны по измерению, поэтому попадание в них — аномалия, а не режим, и в
// INFO оно утонуло бы среди штатных доставок раз в пять минут.
func TestFoldПревышениеГраницыДаётWarn(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
f, arch, st := foldService(t, log)
// Одно непомерно длинное значение — граница длины, а не числа: тело
// остаётся маленьким, а событие то же.
long := strings.Repeat("я", 200)
body := `{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[` +
`{"date":"2025-06-05 10:00:00 +0300","start":"2025-06-05 10:00:00 +0300",` +
`"end":"2025-06-05 11:00:00 +0300","qty":1,"value":"` + long + `"}]}]}}`
deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(body))
stats, err := f.Fold(context.Background(), "d1")
if err != nil {
t.Fatalf("свёртка: %v", err)
}
if stats.CategoricalDropped == 0 {
t.Fatal("граница не сработала — тест проверяет не то")
}
var rec map[string]any
for line := range strings.SplitSeq(strings.TrimSpace(buf.String()), "\n") {
var r map[string]any
if err := json.Unmarshal([]byte(line), &r); err != nil {
t.Fatalf("строка лога не JSON: %v", err)
}
if msg, _ := r["msg"].(string); strings.HasPrefix(msg, "delivery folded") {
rec = r
}
}
if rec == nil {
t.Fatalf("записи об исходе свёртки нет:\n%s", buf.String())
}
if rec["level"] != "WARN" {
t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String())
}
if rec["msg"] != "delivery folded, categorical values dropped by limit" {
t.Errorf("сообщение %v не называет событие", rec["msg"])
}
// И ни одного байта самой строки: она из тела доставки.
delete(rec, "time")
clean, err := json.Marshal(rec)
if err != nil {
t.Fatalf("запись лога не сериализуется: %v", err)
}
if strings.Contains(string(clean), "яяя") {
t.Errorf("в логе оказалось значение из тела:\n%s", clean)
}
}
+232 -7
View File
@@ -9,6 +9,7 @@ package fold
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -18,6 +19,7 @@ import (
"git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/hae" "git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/healthkit"
"git.vakhrushev.me/av/healthlog/internal/store" "git.vakhrushev.me/av/healthlog/internal/store"
) )
@@ -90,6 +92,27 @@ type Stats struct {
Uncovered []string Uncovered []string
// UncoveredDropped — сколько имён отброшено границей списка. // UncoveredDropped — сколько имён отброшено границей списка.
UncoveredDropped int UncoveredDropped int
// UncoveredNew — имена непокрытых секций, которых не было ни в одной
// доставке, стоящей в журнале раньше этой. Событие однократное за всю жизнь
// имени: поток дописывает метрики на телефоне молча, и момент появления
// секции наблюдать больше нечем.
UncoveredNew []string
// UncoveredSeenUnknown — сверка с журналом не состоялась, и потому все
// непокрытые имена доставки объявлены новыми. Лишняя запись стоит внимания
// один раз, промолчавшее событие не восстанавливается ничем.
UncoveredSeenUnknown bool
// UncoveredSeenError — почему не состоялась.
UncoveredSeenError error
// Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось;
// CategoricalUnknown — сколько из них словарь не знает;
// CategoricalDropped — сколько отброшено границами разбора.
//
// Только числа: сами строки — данные о здоровье, контекст пульса и фаза сна
// описывают человека не меньше, чем число.
Categoricals int
CategoricalUnknown int
CategoricalDropped int
} }
// ErrPanicked — свёртка паниковала. Доставка получает `failed`: тело в архиве, и // ErrPanicked — свёртка паниковала. Доставка получает `failed`: тело в архиве, и
@@ -144,7 +167,18 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
parsed, err := hae.Parse(body, hae.Meta{ parsed, err := hae.Parse(body, hae.Meta{
Aggregation: d.Aggregation, Aggregation: d.Aggregation,
FallbackLayer: hae.Layer(fallback), FallbackLayer: hae.Layer(fallback),
Locale: localeOf(d.Headers),
}) })
// Сверка с журналом идёт ДО ветвления на успех и отказ. Список непокрытых
// секций переживает отказ разбора, то есть имя уже записано в учёт; смолчи
// здесь — и следующая доставка сочтёт его виденным, а событие не вернётся
// ничем, кроме ручного запроса в базу.
novelty := s.novelty(ctx, parsed.Uncovered, store.DeliveryRef{
ID: d.ID,
ReceivedAt: d.ReceivedAt,
})
if err != nil { if err != nil {
// Список непокрытых секций переживает отказ: доставка, у которой не // Список непокрытых секций переживает отказ: доставка, у которой не
// определился слой, обязана остаться записью о том, что в теле есть // определился слой, обязана остаться записью о том, что в теле есть
@@ -153,12 +187,15 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
// А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший // А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший
// ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и // ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и
// записать этот ноль значило бы объявить доставку проверенной. // записать этот ноль значило бы объявить доставку проверенной.
s.fail(ctx, deliveryID, err, residueOf(parsed)) s.fail(ctx, deliveryID, err, residueOf(parsed, novelty))
return stats, err return stats, err
} }
stats.Uncovered = parsed.Uncovered stats.Uncovered = parsed.Uncovered
stats.UncoveredDropped = parsed.UncoveredDropped stats.UncoveredDropped = parsed.UncoveredDropped
stats.UncoveredNew = novelty.fresh
stats.UncoveredSeenUnknown = novelty.unknown
stats.UncoveredSeenError = novelty.cause
stats.Metrics = parsed.Metrics stats.Metrics = parsed.Metrics
stats.Points = len(parsed.Points) stats.Points = len(parsed.Points)
stats.SkippedNoTime = parsed.SkippedNoTime stats.SkippedNoTime = parsed.SkippedNoTime
@@ -169,6 +206,9 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed
stats.Layer = string(parsed.Layer) stats.Layer = string(parsed.Layer)
stats.LayerMismatch = parsed.LayerMismatch stats.LayerMismatch = parsed.LayerMismatch
stats.Categoricals = len(parsed.Categoricals)
stats.CategoricalUnknown = parsed.CategoricalUnknown
stats.CategoricalDropped = parsed.CategoricalDropped
merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{ merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{
ID: d.ID, ID: d.ID,
@@ -180,6 +220,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
s.fail(ctx, deliveryID, err, parseResidue{ s.fail(ctx, deliveryID, err, parseResidue{
uncovered: parsed.Uncovered, uncovered: parsed.Uncovered,
skipped: skippedEntities(parsed), skipped: skippedEntities(parsed),
// Новизна доезжает и сюда. Эта ветвь пишет имя в учёт ровно так же,
// как ветвь отказа разбора, — значит и терять событие ей нельзя:
// следующая доставка сочтёт имя виденным, а отказ слияния бывает
// нетранзиентным (исчерпанный дедлайн свёртки под большим телом).
novelty: novelty,
}) })
return stats, err return stats, err
} }
@@ -231,6 +276,17 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
"buckets", st.Buckets, "buckets", st.Buckets,
"unchanged", st.Unchanged, "unchanged", st.Unchanged,
"overwrites", st.Overwrites, "overwrites", st.Overwrites,
// Удержания точек идут атрибутом, а не отдельной ветвью WARN: под новым
// тай-брейком это правило полноты, работающее штатно (на живом корпусе
// 981 координата из 80 129 спорных), и эскалация обесценила бы уровень.
// Наблюдаемым событие делает то, что оно посчитано отдельно от
// перезаписей и печатается прогоном пересборки.
"points_held", st.PointsHeld,
// Разрушительное направление считается отдельно и ЭСКАЛИРУЕТСЯ ниже:
// на живом корпусе это 2 координаты из 80 129 спорных, шквала не будет,
// а событие означает потерю содержания — обратимую пересборкой, пока
// жив архив.
"points_erased", st.PointsErased,
"incomparable", st.Incomparable, "incomparable", st.Incomparable,
"units_conflicts", st.UnitsConflicts, "units_conflicts", st.UnitsConflicts,
"sealed_hits", st.SealedHits, "sealed_hits", st.SealedHits,
@@ -256,6 +312,24 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
// построчный разбор логов. Содержимого секций здесь нет. // построчный разбор логов. Содержимого секций здесь нет.
"uncovered", st.Uncovered, "uncovered", st.Uncovered,
"uncovered_dropped", st.UncoveredDropped, "uncovered_dropped", st.UncoveredDropped,
// Имена, встреченные впервые по журналу, — атрибутом ВСЕГДА, а уровень
// поднимается отдельной ветвью ниже. Наблюдаемый признак события это
// он: имя непокрытой секции стоит в атрибуте `uncovered` у каждой
// доставки, которая её принесла, и по нему первую встречу не отличить.
"uncovered_new", st.UncoveredNew,
"uncovered_seen_unknown", st.UncoveredSeenUnknown,
// Причина несостоявшейся сверки — рядом с признаком. Занятость базы
// проходит сама, испорченная колонка не проходит никогда и поднимает
// признак на каждой доставке; по одному булеву это неразличимо.
// Значений точек в ошибке нет: до текста доезжает только имя секции, и
// оно обрезано.
"uncovered_seen_error", st.UncoveredSeenError,
// Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов:
// «Сидячий образ жизни» — это контекст пульса, то есть данные о
// здоровье. Какие именно строки ждут словаря, отвечает реестр в базе.
"categoricals", st.Categoricals,
"categoricals_unknown", st.CategoricalUnknown,
"categoricals_dropped", st.CategoricalDropped,
} }
if len(st.Collisions) > 0 { if len(st.Collisions) > 0 {
attrs = append(attrs, "collisions", formatCollisions(st.Collisions)) attrs = append(attrs, "collisions", formatCollisions(st.Collisions))
@@ -263,6 +337,12 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
if len(st.IncomparableAt) > 0 { if len(st.IncomparableAt) > 0 {
attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt)) attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
} }
if len(st.PointsHeldAt) > 0 {
attrs = append(attrs, "points_held_at", formatCollisions(st.PointsHeldAt))
}
if len(st.PointsErasedAt) > 0 {
attrs = append(attrs, "points_erased_at", formatCollisions(st.PointsErasedAt))
}
if len(st.HeldAt) > 0 { if len(st.HeldAt) > 0 {
attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt)) attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt))
} }
@@ -279,6 +359,15 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0 allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0
switch { switch {
case len(st.UncoveredNew) > 0:
// ПЕРВОЙ ветвью, и это существенно. Все прочие говорят о событиях,
// повторяющихся на живом потоке; это — однократное за всю жизнь имени, и
// замаскировать его перезаписью точек значило бы потерять ровно то, ради
// чего наблюдение заведено. Уровень `WARN`, а не `ERROR`: приезд новой
// секции — штатное событие внешнего мира, «посмотри», а не «разбери
// сбой». Имена секций в лог попадать могут: имя ключа — форма пакета, а
// не измерение.
s.log.WarnContext(ctx, "delivery folded, new uncovered section", attrs...)
case st.EntitiesHeld > 0: case st.EntitiesHeld > 0:
// Приехавшая версия сущности отклонена как теряющая содержание. Плата // Приехавшая версия сущности отклонена как теряющая содержание. Плата
// за отказ объединять поля: событие обязано быть видно, потому что на // за отказ объединять поля: событие обязано быть видно, потому что на
@@ -298,11 +387,34 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь, // Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
// а границу выбило больше тридцати двух. // а границу выбило больше тридцати двух.
s.log.WarnContext(ctx, "delivery folded, uncovered section list truncated", attrs...) s.log.WarnContext(ctx, "delivery folded, uncovered section list truncated", attrs...)
case st.CategoricalDropped > 0:
// Тот же класс события и та же причина эскалации, что у списка секций:
// границы подобраны по измерению (на живом потоке около одиннадцати
// различных значений, самое длинное 36 байт), поэтому попадание в них —
// уже аномалия, а не режим. В INFO оно утонуло бы: поток идёт раз в пять
// минут, а `/stats` ещё нет. Соответствие «доставка → отброшено» живёт
// только здесь: лог ротируется, и к следующей сверке отпечатков причину
// уже не восстановить.
//
// `CategoricalUnknown` намеренно НЕ эскалируется: он штатно ненулевой —
// словарь покрывает только фазы сна, — и постоянный WARN обесценил бы
// уровень.
s.log.WarnContext(ctx, "delivery folded, categorical values dropped by limit", attrs...)
case st.Incomparable > 0: case st.Incomparable > 0:
// Выше перезаписей намеренно: несравнимый набор полей — событие реже и // Выше перезаписей намеренно: несравнимый набор полей — событие реже и
// информативнее, на живом потоке не случавшееся ни разу. Признаки при // информативнее, на живом потоке не случавшееся ни разу. Признаки при
// этом идут атрибутами всегда, так что выбор ветви ничего не прячет. // этом идут атрибутами всегда, так что выбор ветви ничего не прячет.
s.log.WarnContext(ctx, "delivery folded, incomparable point fields", attrs...) s.log.WarnContext(ctx, "delivery folded, incomparable point fields", attrs...)
case st.PointsErased > 0:
// НИЖЕ несравнимости, хотя событие тяжелее: на живом корпусе эти два
// множества совпадают (2 координаты и там, и там), и несравнимость
// сообщает больше — она называет ещё и то, что объединять поля было бы
// что. Ветвь эта говорит про случай, которого несравнимость не
// покрывает: сохранённая была надмножеством по именам, но значения
// общих ключей разошлись, разряд полноты погас, и содержание унесла
// пришедшая. Счётчики обеих ветвей идут атрибутами всегда, так что
// порядок ветвей ничего не прячет.
s.log.WarnContext(ctx, "delivery folded, arriving point dropped stored field", attrs...)
case st.Overwrites > 0: case st.Overwrites > 0:
// Единственное наблюдение, по которому проверяется правило слияния. // Единственное наблюдение, по которому проверяется правило слияния.
// В INFO оно тонуло: поток идёт раз в пять минут. // В INFO оно тонуло: поток идёт раз в пять минут.
@@ -384,10 +496,61 @@ type parseResidue struct {
// считал». Ноль означал бы «проверено, терять нечего», а по этому числу // считал». Ноль означал бы «проверено, терять нечего», а по этому числу
// ретеншен принимает необратимое решение об удалении тела. // ретеншен принимает необратимое решение об удалении тела.
skipped *int64 skipped *int64
// novelty в учёте не участвует — она едет в запись лога об отказе. Полем, а
// не пятым параметром `fail`: параметры путают местами, а поле называет
// себя само.
novelty sectionNovelty
} }
func residueOf(parsed hae.Result) parseResidue { func residueOf(parsed hae.Result, novelty sectionNovelty) parseResidue {
return parseResidue{uncovered: parsed.Uncovered} return parseResidue{uncovered: parsed.Uncovered, novelty: novelty}
}
// sectionNovelty — исход сверки имён непокрытых секций с журналом.
type sectionNovelty struct {
// fresh — имена, которых не было ни в одной доставке раньше этой.
fresh []string
// unknown — сверка не состоялась, и потому новыми объявлены ВСЕ имена
// доставки.
unknown bool
// cause — почему не состоялась. Без неё занятость базы (пройдёт сама) и
// испорченное содержимое колонки (не пройдёт никогда, и признак будет
// подниматься на каждой доставке) неотличимы, а разбираться пришлось бы тем
// самым ручным запросом в базу, от которого задача избавляет.
cause error
}
// novelty спрашивает журнал, какие из непокрытых имён встречаются впервые.
//
// Отказ запроса свёртку не роняет и исходом доставки не становится: правила
// классификации исходов наблюдение не трогает, занятая база и отменённый
// контекст остаются обстоятельствами. Но и молчания здесь быть не может — имя,
// о котором смолчали, уже записано в учёт, — поэтому при отказе новыми
// объявляются все имена, а признак несостоявшейся сверки идёт в запись.
//
// Запрос берётся только при непустом списке: на живом потоке все три
// приезжающие секции покрыты, то есть в штатном режиме сверка не стоит ничего.
func (s *Service) novelty(ctx context.Context, uncovered []string, at store.DeliveryRef) sectionNovelty {
if len(uncovered) == 0 {
return sectionNovelty{}
}
seen, err := s.store.SectionsSeenBefore(ctx, uncovered, at)
if err != nil {
return sectionNovelty{fresh: uncovered, unknown: true, cause: err}
}
fresh := make([]string, 0, len(uncovered))
for _, name := range uncovered {
if _, ok := seen[name]; ok {
continue
}
fresh = append(fresh, name)
}
if len(fresh) == 0 {
return sectionNovelty{}
}
return sectionNovelty{fresh: fresh}
} }
// skippedEntities — сколько сущностей с собственным `id` разбор пропустил. // skippedEntities — сколько сущностей с собственным `id` разбор пропустил.
@@ -407,6 +570,20 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, resi
return return
} }
// Новые имена доезжают до записи об отказе: она уже выше рутинного уровня,
// а событие опознаётся атрибутом. В отложенном исходе выше их нет намеренно
// — там учётная запись не меняется, доставка вернётся следующим проходом, и
// повторение признака на каждом проходе занятой базы превратило бы
// однократное событие в дребезг.
attrs := []any{"error", cause, "delivery_id", deliveryID}
if len(residue.novelty.fresh) > 0 {
attrs = append(attrs, "uncovered_new", residue.novelty.fresh)
}
if residue.novelty.unknown {
attrs = append(attrs, "uncovered_seen_unknown", true,
"uncovered_seen_error", residue.novelty.cause)
}
level := slog.LevelError level := slog.LevelError
switch { switch {
case errors.Is(cause, hae.ErrLayerUnknown): case errors.Is(cause, hae.ErrLayerUnknown):
@@ -419,7 +596,7 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, resi
// одного класса ошибки давали бы постоянный ERROR-шум. // одного класса ошибки давали бы постоянный ERROR-шум.
level = slog.LevelWarn level = slog.LevelWarn
} }
s.log.Log(ctx, level, "delivery fold failed", "error", cause, "delivery_id", deliveryID) s.log.Log(ctx, level, "delivery fold failed", attrs...)
// Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая // Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая
// строка здесь оборвала бы цепочку наследования, то есть изменила бы // строка здесь оборвала бы цепочку наследования, то есть изменила бы
@@ -479,12 +656,60 @@ func toIncoming(parsed hae.Result) store.Incoming {
}) })
} }
return store.Incoming{ return store.Incoming{
Points: points, Points: points,
Workouts: toEntities(parsed.Workouts), Workouts: toEntities(parsed.Workouts),
Records: toEntities(parsed.Records), Records: toEntities(parsed.Records),
Categories: toCategories(parsed.Categoricals),
} }
} }
func toCategories(in []hae.Categorical) []store.CategoryValue {
if len(in) == 0 {
return nil
}
out := make([]store.CategoryValue, 0, len(in))
for _, c := range in {
out = append(out, store.CategoryValue{
Metric: c.Metric,
Field: c.Field,
Value: c.Value,
Code: c.Code,
})
}
return out
}
// localeOf достаёт язык доставки из сохранённых заголовков запроса.
//
// Отдельной колонки под язык нет намеренно: заголовки уже лежат целиком, и
// вторая копия того же факта разошлась бы с первой при первой же правке приёма.
//
// Отсутствие заголовка, неразбираемый JSON и любая другая неожиданность дают
// пустую локаль, а не отказ. Пустая локаль законна и вывода кода не отменяет:
// заголовков в сыром архиве нет вовсе, поэтому доставка, восстановленная из
// осиротевшего тела, приезжает без них — и обязана дать то же состояние.
//
// Имя заголовка ищется без учёта регистра: `http.Header` канонизирует его при
// приёме, но в базе лежит то, что было записано, и правило регистра не наше.
func localeOf(headers string) string {
if headers == "" {
return ""
}
var h map[string][]string
if err := json.Unmarshal([]byte(headers), &h); err != nil {
return ""
}
for name, values := range h {
if !strings.EqualFold(name, acceptLanguageHeader) || len(values) == 0 {
continue
}
return healthkit.Locale(values[0])
}
return ""
}
const acceptLanguageHeader = "Accept-Language"
func toEntities(in []hae.Entity) []store.IncomingEntity { func toEntities(in []hae.Entity) []store.IncomingEntity {
if len(in) == 0 { if len(in) == 0 {
return nil return nil

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