Compare commits
20
Commits
eb3fca77ee
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d24248075
|
||
|
|
e4f62785d8
|
||
|
|
d33f37249c
|
||
|
|
b1d3b25827
|
||
|
|
29ca8d415c
|
||
|
|
b819b77f62
|
||
|
|
a834d10415
|
||
|
|
bd832337df
|
||
|
|
79331ac670
|
||
|
|
637eb38bce
|
||
|
|
bd5d17b079
|
||
|
|
2130763d3c
|
||
|
|
95377f54cd
|
||
|
|
9f77e56d37
|
||
|
|
7e6a1fc6b2
|
||
|
|
b278501a6e
|
||
|
|
ae607f1ceb
|
||
|
|
de2001dea6
|
||
|
|
8582d3540d
|
||
|
|
1b649ba3d5
|
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"av-dev-skills": {
|
||||
"source": { "source": "git", "url": "https://git.vakhrushev.me/av/dev-skills.git" }
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"av-dev-git@av-dev-skills": true,
|
||||
"av-dev-pm@av-dev-skills": true,
|
||||
|
||||
@@ -61,6 +61,11 @@ linters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
# Черновое и временное живёт в ./tmp (CLAUDE.md, «Запреты»): туда же
|
||||
# попадают worktree батча и диагностические программы. Конвенции на них
|
||||
# не распространяются — иначе черновик красит гейт по причине, не
|
||||
# связанной с изменением, и настоящую красноту перестают читать.
|
||||
- ^tmp/
|
||||
rules:
|
||||
# CLI — другая поверхность: печатает результат в stdout, это не логи.
|
||||
- path: ^cmd/
|
||||
@@ -86,3 +91,4 @@ formatters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- ^tmp/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[docs/passport.md](docs/passport.md) (цель, сценарии, референсы),
|
||||
[README.md](README.md), [docs/architecture.md](docs/architecture.md),
|
||||
[docs/conventions/README.md](docs/conventions/README.md),
|
||||
[docs/security.md](docs/security.md) и [docs/tasks/PLAN.md](docs/tasks/PLAN.md).
|
||||
[docs/security.md](docs/security.md) и [docs/tasks/ROADMAP.md](docs/tasks/ROADMAP.md).
|
||||
|
||||
Документация ведётся по канону `av-dev-pm` (версия в `docs/.pm.json`);
|
||||
раскладку проверяет `av-dev-pm:canon`, содержимое ведёт `av-dev-pm:docs`.
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export и
|
||||
родного экспорта Apple, хранит их и отдаёт другим моим проектам — через HTTP
|
||||
API и через MCP. Это **хранилище, а не аналитика**: принять, дедуплицировать,
|
||||
API и, в планах, через MCP. Это **хранилище, а не аналитика**: принять, дедуплицировать,
|
||||
сохранить, отдать. Не переименовывать поля Apple, не интерпретировать
|
||||
значения. Агрегат считается только в ответе на запрос и только там, где род
|
||||
метрики измерен.
|
||||
@@ -22,7 +22,7 @@ API и через MCP. Это **хранилище, а не аналитика**
|
||||
|
||||
Go, один статический бинарь (`CGO_ENABLED=0`). SQLite (`modernc.org/sqlite`,
|
||||
чистый Go), `chi`, `sqlx`, `goose` (миграции), `pelletier/go-toml/v2`,
|
||||
`log/slog`, ULID через `internal/ident`.
|
||||
`log/slog`, ULID (`github.com/oklog/ulid/v2`) через `internal/ident`.
|
||||
|
||||
Module path — `git.vakhrushev.me/av/healthlog`.
|
||||
|
||||
@@ -51,7 +51,10 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
||||
под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек —
|
||||
отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он
|
||||
нестабилен. Хеш канонизированного содержимого остался детектором изменений. При
|
||||
столкновении выигрывает **более полная** точка, а не последняя. Изменение
|
||||
столкновении выигрывает **более полная** точка, а при равной полноте —
|
||||
**стоящая позже в журнале** (внутри одной доставки — порядок канонических
|
||||
форм). Второе означает, что содержимое витрины есть функция **порядка**
|
||||
свёртки, и порядок этот обязан равняться журнальному. Изменение
|
||||
запечатанного часа — `WARN`, но данные всё равно пишутся.
|
||||
- **Дыры закрываются сами.** `major`, обратимо. Три прохода разной глубины
|
||||
(5 минут / сутки / неделя). Настройки данных у проходов теперь **разные** — намеренно, они
|
||||
@@ -62,8 +65,10 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
||||
образ жизни» на языке телефона, а родной экспорт — коды HealthKit, и без
|
||||
словаря эти два источника не сойтись.
|
||||
- **Своей агрегации в хранении нет — есть слои.** `critical`, обратимо
|
||||
пересборкой. Метрика лежит в той подробности, в какой пришла (`sample`/`raw`/`minute`/`hour`); слой выводится
|
||||
из выравнивания меток, а не из заголовка HAE — тот врёт.
|
||||
пересборкой. Метрика лежит в той подробности, в какой пришла
|
||||
(`sample`/`raw`/`minute`/`hour`/`day`); слой выводится
|
||||
из выравнивания меток, а не из заголовка HAE — тот врёт. Перечень слоёв один и
|
||||
лежит в [docs/database.md](docs/database.md), таблица `bucket`.
|
||||
- **Агрегация в ответе — только измеренная.** `critical`, обратимо: ответ не
|
||||
хранится, но потребитель уже принял по нему решение. Род свёртки выводится
|
||||
сверкой слоёв между собой (часовое = сумма минутных → накопительная, = среднее →
|
||||
@@ -90,7 +95,8 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
||||
разбор, повтор обязан дать то же состояние. В гейт не входит намеренно —
|
||||
минута прогона и данные, которых нет ни на какой другой машине
|
||||
- `task verify:busy` — свёртка под удерживаемой блокировкой базы: занятость
|
||||
обязана оставить доставку в очереди. В гейт не входит: 25 секунд на прогон
|
||||
обязана оставить доставку в очереди, а отложенная доставка не должна развести
|
||||
живую витрину с пересборкой. В гейт не входит: около 50 секунд на прогон
|
||||
- `task tidy` — `go mod tidy`
|
||||
- `task setup` — установка golangci-lint
|
||||
|
||||
@@ -106,12 +112,15 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
||||
- **Исходы:** 0 — зелёный; ненулевой — красный, и до его починки опиниативные
|
||||
проходы ревью **не запускаются**.
|
||||
- **Что красит безусловно:** любой файл из `./data` в индексе, любой токен в
|
||||
индексе, непокрытая изменённая строка, миграция без правки `docs/database.md`.
|
||||
Причина одна на все: это ровно те отказы, которые не видны глазами и стоят
|
||||
необратимо.
|
||||
индексе, миграция без правки `docs/database.md`. Причина одна на все: это
|
||||
ровно те отказы, которые не видны глазами и стоят необратимо. Покрытие
|
||||
изменённых строк задумано тем же классом, но сегодня гейт от него **не
|
||||
краснеет**: `scripts/diff-coverage.py` всегда возвращает `0`, и шаг печатает
|
||||
`OK` при любом покрытии — разбор непокрытых строк остаётся человеку или
|
||||
проходу ревью. Запись 2026-08-04 в [docs/review.md](docs/review.md).
|
||||
- **Чего в гейте намеренно нет и кто обязан это гонять:**
|
||||
`task verify:archive` (минута прогона, данные есть только на этой машине) и
|
||||
`task verify:busy` (25 секунд). Гоняет их **человек или оркестратор задачи**
|
||||
`task verify:busy` (около 50 секунд). Гоняет их **человек или оркестратор задачи**
|
||||
перед любым изменением правила разбора, идентичности или слияния — а не «когда
|
||||
вспомнит». Прецедент, когда молчащая краснота прожила две задачи, записан в
|
||||
[docs/review.md](docs/review.md).
|
||||
|
||||
@@ -60,19 +60,24 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
|
||||
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по
|
||||
полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже
|
||||
разбираются; секции, которых разбор не покрывает, принимаются, хранятся и
|
||||
честно помечаются как неразобранные.
|
||||
честно помечаются как неразобранные — а имя, которого поток раньше не приносил,
|
||||
даёт `WARN` в логе свёртки один раз и попадает в перечень `healthlog uncovered`.
|
||||
|
||||
Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую
|
||||
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
|
||||
пересборка воспроизводима и повторный прогон ничего не меняет.
|
||||
|
||||
Первый маршрут чтения открыт: **каталог разрезов** (`GET /api/v1/metrics`) под
|
||||
Маршрутов чтения открыто два. **Каталог разрезов** (`GET /api/v1/metrics`) под
|
||||
токеном чтения отдаёт слои с диапазонами и измеренный род агрегации, а повтор
|
||||
неизменившегося отвечает `304` по `ETag` — снимок витрины при этом не
|
||||
открывается. Журнал WAL разбирается фоновым чекпойнтом по таймеру.
|
||||
открывается. **Точки метрики за период** (`GET /api/v1/metrics/{name}`) едут
|
||||
одним запросом: слой выбирается по охвату точек внутри периода, а род агрегации
|
||||
приезжает вместе с данными и с явным указанием, применим ли он к ряду. Журнал
|
||||
WAL разбирается фоновым чекпойнтом по таймеру.
|
||||
|
||||
Чего ещё нет: **read API точек**, тренировок и записей — сами данные наружу
|
||||
пока не отдаются. План в [docs/tasks/PLAN.md](docs/tasks/PLAN.md).
|
||||
Чего ещё нет: свёртки по сетке, условного запроса по точкам, тренировок и
|
||||
записей наружу. Что умеет и чего не умеет —
|
||||
[docs/tasks/ROADMAP.md](docs/tasks/ROADMAP.md).
|
||||
|
||||
Разведка формата закончена: 50 находок на живом потоке, половина расходится с
|
||||
документацией Health Auto Export — [docs/research/apple-health.md](docs/research/apple-health.md).
|
||||
@@ -80,9 +85,10 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
|
||||
## Команды
|
||||
|
||||
```
|
||||
healthlog serve приём + read API + MCP
|
||||
healthlog serve приём + read API (MCP — в планах)
|
||||
healthlog import родной экспорт Apple Health (в планах)
|
||||
healthlog reindex пересборка витрины из журнала
|
||||
healthlog uncovered перечень секций, которых разбор не покрыл
|
||||
healthlog healthcheck проверка живости для docker HEALTHCHECK
|
||||
```
|
||||
|
||||
@@ -152,7 +158,8 @@ curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
|
||||
|
||||
Если `auth.write_tokens` пуст, проверка токена выключена — для доверенной
|
||||
локальной сети этого достаточно, сервис пишет об этом `write auth disabled`
|
||||
на старте. Для доступа снаружи понадобится и токен, и TLS — это шаг «Деплой».
|
||||
на старте. Для доступа снаружи понадобится и токен, и TLS — это цель
|
||||
«Сервис доступен телефону из любой сети».
|
||||
|
||||
|
||||
## Документация
|
||||
@@ -168,7 +175,8 @@ curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
|
||||
- [docs/conventions/](docs/conventions/README.md) — как пишем код
|
||||
- [docs/security.md](docs/security.md) — периметр и модель угроз
|
||||
- [docs/review.md](docs/review.md) — настройка конвейера ревью и журнал дефектов
|
||||
- [docs/tasks/PLAN.md](docs/tasks/PLAN.md) — цели и обоснование их порядка
|
||||
- [docs/tasks/ROADMAP.md](docs/tasks/ROADMAP.md) — что приложение уже умеет и
|
||||
чего ещё не умеет
|
||||
- [docs/tasks/BACKLOG.md](docs/tasks/BACKLOG.md) — что брать следующим, включая
|
||||
отложенные идеи
|
||||
- [docs/research/apple-health.md](docs/research/apple-health.md) — что показал реальный поток
|
||||
|
||||
+7
-1
@@ -45,13 +45,19 @@ tasks:
|
||||
- go test ./internal/replay -run TestReplay -healthlog.archive={{.ARCHIVE | default (printf "%s/data/raw" .ROOT_DIR)}} -v -count=1
|
||||
|
||||
verify:busy:
|
||||
desc: 'Свёртка под удерживаемой блокировкой базы: занятость обязана оставить доставку в очереди (около 25 секунд)'
|
||||
desc: 'Свёртка под удерживаемой блокировкой базы: доставка остаётся в очереди, а витрина не расходится с пересборкой (около 50 секунд)'
|
||||
cmds:
|
||||
# Не входит в `task test` и `task gate` намеренно: busy_timeout — пять
|
||||
# секунд, повторов транзакции пять, и гейт гоняет тесты трижды. Проверяет
|
||||
# при этом центральное решение задачи «разнести ответ и свёртку»:
|
||||
# занятость базы — обстоятельство, а не свойство доставки.
|
||||
- go test ./internal/fold -run TestBusy -healthlog.busy -v -count=1
|
||||
# Второй прогон — композиция, ради которой заведён барьер журнального
|
||||
# порядка: занятость откладывает доставку, проход прекращается на ней, и
|
||||
# живая витрина всё равно совпадает с пересборкой. Порознь барьер и
|
||||
# сходимость проверены в гейте; вместе — только здесь, потому что
|
||||
# настоящая занятость стоит те же двадцать пять секунд.
|
||||
- go test ./internal/replay -run TestBusy -healthlog.busy -v -count=1
|
||||
|
||||
lint:
|
||||
desc: Запуск golangci-lint
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
// healthlog [serve] --config <path> принимать пакеты (по умолчанию)
|
||||
// healthlog reindex --config <path> пересобрать витрину из журнала
|
||||
// healthlog uncovered --config <path> перечень секций, которых разбор не покрыл
|
||||
// healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
|
||||
package main
|
||||
|
||||
@@ -30,6 +31,8 @@ func main() {
|
||||
err = runServe(args)
|
||||
case "reindex":
|
||||
err = runReindex(args)
|
||||
case "uncovered":
|
||||
err = runUncovered(args)
|
||||
case "healthcheck":
|
||||
err = runHealthcheck(args)
|
||||
default:
|
||||
|
||||
@@ -179,9 +179,14 @@ type report struct {
|
||||
// единица, которой нет в счётчиках, делает расхождение безадресным.
|
||||
sourceWorkouts int64
|
||||
sourceRecords int64
|
||||
sourceBefore int64
|
||||
sourceAfter int64
|
||||
sourceMissing bool
|
||||
// sourceCategories — то же «было» для реестра категориальных значений.
|
||||
// Перечень единиц хранения закрытый, и он пополняется ТЕМ ЖЕ изменением,
|
||||
// которое заводит единицу: не внесённая сюда, она молчит ровно там, где
|
||||
// расхождение впервые становится заметным.
|
||||
sourceCategories int64
|
||||
sourceBefore int64
|
||||
sourceAfter int64
|
||||
sourceMissing bool
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return canceledOr(rep, err, stopped)
|
||||
}
|
||||
if rep.sourceCategories, err = src.CountCategoryValues(ctx); err != nil {
|
||||
return canceledOr(rep, err, stopped)
|
||||
}
|
||||
}
|
||||
|
||||
removeDB(t.partial)
|
||||
|
||||
@@ -36,6 +36,13 @@ func writeReport(w io.Writer, r report) {
|
||||
// сущностей стало слишком строгим.
|
||||
p(" слияние: частично разобрано %d, несравнимых наборов %d, удержано версий сущностей %d, версий одного ключа в одном теле %d",
|
||||
r.replay.Partial, r.replay.Incomparable, r.replay.EntitiesHeld, r.replay.EntitiesDiverging)
|
||||
// То же и по той же причине — про точки. Удержания говорят, спорит ли ещё
|
||||
// правило полноты с журналом; потери — единственное направление, в котором
|
||||
// тай-брейк «побеждает пришедшая» способен унести содержание, и человек,
|
||||
// принимающий по этому отчёту необратимое решение о подмене базы, обязан
|
||||
// видеть оба числа, а не выводить их из совпавшего отпечатка.
|
||||
p(" точки: удержано полнотой %d, содержание унесено пришедшей %d",
|
||||
r.replay.PointsHeld, r.replay.PointsErased)
|
||||
|
||||
if r.replay.Canceled {
|
||||
// Ни отпечаток пересобранной витрины, ни число доставок после прогона при
|
||||
@@ -66,6 +73,7 @@ func writeReport(w io.Writer, r report) {
|
||||
p(" объектов: %d", r.replay.Buckets)
|
||||
p(" тренировок: %d", r.replay.Workouts)
|
||||
p(" записей: %d", r.replay.Records)
|
||||
p(" строк реестра категориальных значений: %d", r.replay.Categories)
|
||||
p("")
|
||||
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
|
||||
p("не восстанавливаются: в архиве их нет.")
|
||||
@@ -76,6 +84,8 @@ func writeReport(w io.Writer, r report) {
|
||||
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
|
||||
p(" тренировок: было %d, стало %d", r.sourceWorkouts, r.replay.Workouts)
|
||||
p(" записей: было %d, стало %d", r.sourceRecords, r.replay.Records)
|
||||
p(" строк реестра категориальных значений: было %d, стало %d",
|
||||
r.sourceCategories, r.replay.Categories)
|
||||
p("")
|
||||
p(" отпечаток рабочей: %s", r.sourcePrint)
|
||||
p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
|
||||
@@ -89,6 +99,26 @@ func writeReport(w io.Writer, r report) {
|
||||
p(" ожидаемые причины: исправленный разбор; покрытая разбором новая")
|
||||
p(" секция (её единиц хранения в рабочей базе нет по построению);")
|
||||
p(" признак sealed не переносится (правила его выставления ещё нет)")
|
||||
if r.sourceCategories < r.replay.Categories {
|
||||
// Класс назван отдельно от факта расхождения: реестр появился
|
||||
// вместе с бинарём, и у витрины, свёрнутой прежним, его нет по
|
||||
// построению. Не назвав это, отчёт приучает человека
|
||||
// игнорировать расхождение — то есть обесценивает оракул ровно
|
||||
// там, где по нему принимается необратимое решение.
|
||||
//
|
||||
// Условие — НЕПОЛНОТА, а не пустота. Между выкаткой и прогоном
|
||||
// проходят дни: воркер успевает набрать частые значения (фазы
|
||||
// сна, контекст пульса) и не успевает редкие — имя тренировки,
|
||||
// которая с тех пор не повторялась. Проверка «в рабочей базе
|
||||
// реестра нет вовсе» такое состояние не ловила бы, и человек
|
||||
// получил бы безадресное «разошлись» при совпавших числах
|
||||
// объектов, тренировок и записей.
|
||||
p(" РЕЕСТР НЕПОЛОН: строк категориальных значений в рабочей базе %d,",
|
||||
r.sourceCategories)
|
||||
p(" в пересобранной %d — реестр наполняется по мере свёртки, а целиком",
|
||||
r.replay.Categories)
|
||||
p(" его даёт только пересборка. Расхождение объясняется этим и лечится ею же")
|
||||
}
|
||||
if partialJournal {
|
||||
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
|
||||
p(" объясняться этим, а не разбором")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"git.vakhrushev.me/av/healthlog/internal/httpapi"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
"git.vakhrushev.me/av/healthlog/internal/logging"
|
||||
"git.vakhrushev.me/av/healthlog/internal/points"
|
||||
"git.vakhrushev.me/av/healthlog/internal/replay"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
@@ -122,6 +123,7 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
|
||||
Handler: httpapi.New(httpapi.Options{
|
||||
Ingest: ingest.New(arch, st, worker.Notify, log),
|
||||
Catalog: catalog.New(st, log),
|
||||
Points: points.New(st, log),
|
||||
Log: log,
|
||||
WriteTokens: cfg.Auth.WriteTokens,
|
||||
ReadTokens: cfg.Auth.ReadTokens,
|
||||
|
||||
@@ -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 на одну доставку разбор в неё не кладёт;
|
||||
- пересборка заполняет колонку заново и только по сохранившимся телам;
|
||||
- секция, которую разбор научился покрывать, уходит из перечня при пересвёртке.
|
||||
`)
|
||||
}
|
||||
@@ -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
@@ -26,7 +26,7 @@ write_timeout = "30s" # на отправку ответа прочих ма
|
||||
# входе, открытое чтение — выгрузку всей истории здоровья любому, кто нашёл
|
||||
# порт. Перед выкладкой наружу `read_tokens` обязан быть непуст.
|
||||
write_tokens = [] # токены на приём данных
|
||||
read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics`
|
||||
read_tokens = [] # токены на чтение: каталог `GET /api/v1/metrics` и точки `GET /api/v1/metrics/{name}`
|
||||
|
||||
[storage]
|
||||
# ВНИМАНИЕ: умолчания в коде (./healthlog.db и ./raw) остались от прежней
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"canon": 2,
|
||||
"canon": 4,
|
||||
"migrations": "internal/store/migrations"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Код HealthKit кладётся реестром рядом, а не полем внутри точки
|
||||
|
||||
- **Дата:** 2026-08-03
|
||||
- **Источник:** openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Стабильный код HealthKit для локализованной строки хранится **отдельной строкой
|
||||
таблицы `category_value`** с ключом `(метрика, поле, значение)`, а не полем
|
||||
`value_code` внутри точки, как рисовал `architecture.md`. Словарь и таблица
|
||||
синонимов живут в бинаре (`internal/healthkit`), а не в базе. Наблюдение входит
|
||||
в отпечаток витрины, выведенный код — **нет**.
|
||||
|
||||
## Почему
|
||||
|
||||
Рассматривались три формы, и отвергнутые названы вместе с ценой.
|
||||
|
||||
**Поле внутри точки** — отвергнуто. Цитата источника: «Точка хранится
|
||||
**исходными байтами**; дописать в неё ключ можно только пересериализацией, а она
|
||||
теряет литерал (`1.0` → `1`, целые больше 2^53 сдвигаются, невалидный UTF-8 →
|
||||
U+FFFD) — ровно то, от чего `Point.Raw` защищает. Побайтовая врезка в чужой
|
||||
JSON — фокус, а не решение. Параллельный массив кодов в `bucket` завёл бы
|
||||
производную величину в путь слияния и хеширования: правило полноты, тай-брейк и
|
||||
`content_hash` пришлось бы учить носить код, не давая ему влиять на исход.
|
||||
Правка на поверхности `critical`-инвариантов ради нуля новых сведений — код есть
|
||||
**функция** от того, что уже лежит».
|
||||
|
||||
**Код нигде не хранится, выводится на чтении** — отвергнуто по одной причине:
|
||||
«тогда код недостижим ничем, кроме бинаря. Владелец сегодня читает витрину
|
||||
`sqlite` на хосте (`Read API` ещё нет), а вся задача затевается против того, что
|
||||
„клиент угадывает словарь“. Реестр без кода сообщает только „такая строка
|
||||
была“ — это половина ответа».
|
||||
|
||||
**Словарь в базе, а не в бинаре** — отвергнуто: «словарь стал бы входом,
|
||||
которого нет в журнале, и `import + replay` перестал бы задавать состояние
|
||||
однозначно. `stateOfMind` уже единственная дыра в журнале; вторую заводить
|
||||
незачем».
|
||||
|
||||
**Код вне отпечатка** — обратная сторона того же решения: «Ключ и провенанс —
|
||||
функция журнала; `code` — функция журнала **и версии словаря в бинаре**. Включи
|
||||
его в отпечаток, и он перестал бы отвечать на свой единственный вопрос („дал ли
|
||||
повтор журнала то же состояние“) ровно тогда, когда его задают: всякое
|
||||
пополнение словаря — а оно объявлено рабочим циклом — давало бы расхождение при
|
||||
побайтно совпавшем журнале, и человек, принимающий необратимое решение о
|
||||
подмене базы, читал бы это как дефект».
|
||||
|
||||
Prior art: FHIR `ConceptMap` (отображение «чужая система значений → своя») и
|
||||
`CodeSystem` с `replaced-by` для устаревших имён — те же два отношения,
|
||||
разведённые по разным сущностям. Форма взята, реализация FHIR отвергнута ценой.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Инвариант «точки хранятся дословно» не тронут вовсе: точка не меняется ни
|
||||
байтом, обратное преобразование возможно всегда.
|
||||
- `+` Пути слияния, тай-брейка и `content_hash` не знают о кодах — правки на
|
||||
поверхности `critical`-инвариантов не потребовалось.
|
||||
- `+` Пополнение словаря меняет десяток строк реестра, а не каждый объект с
|
||||
фазами сна; отпечаток при этом не двигается, потому что код в него не входит.
|
||||
- `−` Потребитель обязан делать соединение по `(метрика, поле, значение)` вместо
|
||||
чтения одного поля. Форма ответа Read API это скроет, когда он появится.
|
||||
- `−` Код в базе отстаёт от словаря в бинаре для строк, переставших приезжать.
|
||||
Лечится пересборкой; на сходимость не влияет.
|
||||
- `−` Ключ реестра зафиксирован миграцией `00010`: смена формы ключа стоит
|
||||
второй миграции и пересборки.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Форма провода принадлежит транспорту, а не домену
|
||||
|
||||
- **Дата:** 2026-08-04
|
||||
- **Источник:** openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Публичный контракт читающих маршрутов объявляет транспорт: `internal/httpapi`
|
||||
держит собственные типы с `json`-тегами и переводит в них доменное значение
|
||||
присваиванием поле в поле. Доменные типы (`internal/catalog` и далее) тегов не
|
||||
несут и до сериализации не доезжают. То же правило покрывает тело отказа; MCP
|
||||
собственной формы не объявляет.
|
||||
|
||||
Противоположное решение — **доменные типы объявлены формой провода намеренно** —
|
||||
рассмотрено первым как живая и уважаемая практика и отвергнуто по названной
|
||||
причине.
|
||||
|
||||
## Почему
|
||||
|
||||
Каталог до этого изменения жил вторым способом: `internal/catalog` сам нёс
|
||||
`json`-теги и `Style.MarshalJSON`, а транспорт владел только оболочкой
|
||||
`{"metrics": …}`. Отсюда три пути смены **публичного** контракта, ни один из
|
||||
которых не касается транспорта и все три выглядят как внутренняя правка:
|
||||
переименование поля; разъединение встроенного `Basis` (плоскость объекта
|
||||
`aggregation` была следствием встраивания); появление внутреннего поля.
|
||||
Удерживал контракт один литерал в тесте, и о том, что этот литерал и есть
|
||||
контракт, не было сказано нигде.
|
||||
|
||||
Решение принималось до того, как образец скопируют четыре маршрута и MCP —
|
||||
потом это была бы не развилка, а археология.
|
||||
|
||||
Литература расколота, и обе стороны названы в источнике поимённо: домен = провод
|
||||
у Ben Johnson (`benbjohnson/wtf` — доменные типы корневого пакета несут теги
|
||||
напрямую) и у Prometheus (`web/api/v1` — конверт свой, полезная нагрузка
|
||||
доменная); раздельно у Gitea (`modules/structs` против `models`), Docker
|
||||
(`api/types`), go-kit (service → endpoint → transport) и Kubernetes (internal
|
||||
против версионированных `k8s.io/api` плюс кодогенерируемая конверсия).
|
||||
Ортогональный совет Mat Ryer — объявлять типы ответа рядом с их обработчиком —
|
||||
взят вместе с названной им ценой.
|
||||
|
||||
Развилку решил **факт проекта, а не вкус**. Цитата из источника:
|
||||
|
||||
> Правило «доменный тип и есть форма провода» ломается на втором же маршруте
|
||||
> цели. Провод точек обещан как `{ts, tz_offset, units, values}`
|
||||
> (`docs/architecture.md`, раздел «Форма ответа»), а `store.Point` несёт
|
||||
> `{Start, End, OffsetSeconds, Raw}` — эти два набора не совпадают **ни одним
|
||||
> именем**. Доменный тип формой провода там быть не может даже при желании.
|
||||
|
||||
Второй факт — внутренний прецедент, и он в ту же сторону:
|
||||
|
||||
> Хранилище уже применяет ровно предлагаемое решение. `store.Point` не несёт
|
||||
> `json`-тегов вовсе; формат сжатого `payload` объявлен **отдельным
|
||||
> неэкспортированным** типом `storedPoint`, а `encodePayload` переводит одно в
|
||||
> другое **полем в поле**.
|
||||
|
||||
Плюс `internal/httpapi/ingest.go`, который своим типом ответа владел с самого
|
||||
начала. То есть решение **устраняет** второй способ, а не заводит его: каталог
|
||||
был отклонением от уже принятого в проекте образца.
|
||||
|
||||
Отдельная развилка того же изменения — **чем контракт сторожится**, и там тоже
|
||||
есть поимённый отказ:
|
||||
|
||||
> `golang.org/x/exp/apidiff` и `go-apidiff` отвергнуты, и причина измерима: они
|
||||
> сравнивают **Go-API** на предмет компилируемости клиентского кода. Смена
|
||||
> строки тега (`json:"metric"` → `json:"name"`) при неизменном Go-имени поля для
|
||||
> них — не изменение вовсе. То есть ровно тот класс, ради которого заводится
|
||||
> сторож, они не видят.
|
||||
|
||||
Генерация OpenAPI из кода (`swaggo`) отвергнута как сторож по другой причине —
|
||||
она фотографирует уже случившееся, — но не как способ **опубликовать** контракт:
|
||||
владелец решил в этом же спринте, что источником истины будет рукописная
|
||||
OpenAPI-спека. Байтовое утверждение поэтому названо **детектором изменения**, а
|
||||
не контрактом.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Публичный контракт чтения перестал быть побочным эффектом имён полей
|
||||
домена. Переименование поля домена ломает компиляцию перевода — разработчику
|
||||
говорят в момент правки; байты ответа при этом те же (проверено: сборка
|
||||
базовой ревизии и сборка ветки против одного файла базы дали побайтово
|
||||
идентичные 2268 байт).
|
||||
- `+` Появился машинный сторож: обход графа типов ответа утверждает, что ни один
|
||||
тип домена не достигает сериализации, а требование `json`-тега на каждом
|
||||
экспортированном поле транспортной структуры закрывает калитку
|
||||
`type pointWire store.Point`. Рядом — заведомо красный случай на 13 позиций,
|
||||
потому что проверка, доказывающая отсутствие, зелена и будучи сломанной.
|
||||
- `+` Плоскость объекта `aggregation` перестала быть следствием встраивания
|
||||
`Basis` в домене и стала записанным решением транспорта.
|
||||
- `−` **Цена обратная, и она взята сознательно:** новое поле домена в ответ само
|
||||
не попадёт — его обязан перечислить перевод. Поле, не доехавшее до клиента, —
|
||||
такой же дефект, как поле, уехавшее случайно, просто другой.
|
||||
- `−` Форма объявлена дважды: типы плюс перевод на каждый маршрут.
|
||||
- `−` Словарь рода остался в домене (`Style.String()`), и провод зовёт его же.
|
||||
Правка `String()` ради читаемости лога изменит тело ответа клиенту. Из двух
|
||||
цен взята эта: свой `switch` на проводе сторожил бы лучше, но завёл бы второй
|
||||
словарь, который разошёлся бы с первым молча.
|
||||
- `−` Сторож остаётся **opt-in**: маршрут, забывший строку в таблице образцов,
|
||||
останется без него молча. Развилка вынесена владельцу (см. ниже).
|
||||
- `−` Обход слеп к типам, достижимым только через `any`/интерфейс, и к типам
|
||||
внешних зависимостей. Слепота названа в источнике и воспроизведена замером,
|
||||
а не предположена.
|
||||
|
||||
## Открыто, решает владелец
|
||||
|
||||
Записано здесь, а не в файле задачи: файл закрытой задачи удаляется.
|
||||
|
||||
**Проверять ли полноту таблицы образцов машиной.** Сторож покрывает три типа,
|
||||
идущие через `writeJSON` сегодня; впереди четыре маршрута и MCP — четыре шанса
|
||||
забыть строку, и забытая строка не отличима от отсутствия проблемы.
|
||||
|
||||
- **(а)** обход роутера (`chi.Walk`) с утверждением, что число читающих
|
||||
маршрутов равно числу строк таблицы. Около 15 строк, забывание краснеет; цена
|
||||
— сцепка теста с роутером. **Рекомендация:** это ровно тот класс «проверка
|
||||
отсутствия зелена и будучи сломанной», против которого это же изменение завело
|
||||
конвенцию заведомо красного случая, — а на полноту таблицы конвенция не
|
||||
распространена.
|
||||
- **(б)** тестовый hook в `writeJSON`, собирающий типы реально закодированных
|
||||
ответов. Ноль мест на новый маршрут, но шов в продакшн-коде.
|
||||
- **(в)** оставить на спеке `read-api` и комментарии-образце. Ноль строк сейчас,
|
||||
одна молчащая дыра на каждый забытый маршрут.
|
||||
|
||||
**Что в проекте считается спекой — контракт системы или ещё и дисциплина его
|
||||
смены.** Здесь развилка разрешена в сторону «спека нормирует наблюдаемое,
|
||||
дисциплина живёт в конвенциях»: этот выбор дешевле откатить, и у второго
|
||||
варианта нет предмета для сверки «спека → код». Прецедент задан на четыре
|
||||
следующие задачи цели — если владелец решит иначе, переносить придётся их все.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Новизна имени секции выводится из журнала, а не хранится реестром
|
||||
|
||||
- **Дата:** 2026-08-04
|
||||
- **Источник:** openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Признак «имя непокрытой секции встречено впервые» **не хранится**: он считается
|
||||
запросом к журналу — «встречалось ли имя в доставках, стоящих строго раньше этой
|
||||
по паре `(received_at, id)`». Реестр-таблица по образцу `category_value` —
|
||||
очевидный ответ на тот же вопрос, уже применённый в этом проекте, — отвергнут.
|
||||
|
||||
## Почему
|
||||
|
||||
Цитата из источника:
|
||||
|
||||
> Форма ответа взята у `category_value` — «когда имя встретилось впервые по
|
||||
> журналу», — а носитель другой: факт уже лежит в `delivery.uncovered_sections`.
|
||||
> Реестр здесь не добавляет ни одного сведения, он кэш запроса, а запрос идёт
|
||||
> считанные разы за жизнь имени.
|
||||
|
||||
И там же, о цене реестра:
|
||||
|
||||
> Компромисс: вторая копия факта, обязанная сходиться с колонкой при каждой
|
||||
> пересборке, плюс миграция и новая единица хранения витрины (а значит и
|
||||
> отпечатка). Ноль новых сведений: имя выводимо из журнала.
|
||||
|
||||
Третья рассмотренная форма — множество виденных имён в памяти процесса —
|
||||
отвергнута по инварианту «хранилище есть свёртка по журналу»: состояние стало бы
|
||||
функцией жизни процесса, и живой приём разошёлся бы с пересборкой в том, что
|
||||
считает первой встречей.
|
||||
|
||||
## Чем платим
|
||||
|
||||
Ценой названы три вещи, и все они следствия выбранного носителя:
|
||||
|
||||
- **проход по журналу** на каждой доставке с непокрытыми секциями. Измерено на
|
||||
синтетическом журнале годового объёма: у секции, приезжающей давно, ранний
|
||||
выход даёт десятки микросекунд, у появившейся только что — около 52 мс на
|
||||
доставку, пока её не покроет отдельная задача;
|
||||
- **границы носителя наследуются целиком**: имя, вытесненное границей списка в
|
||||
32 имени, события не даёт вовсе; пересборка заполняет колонку заново и только
|
||||
по сохранившимся телам; покрытая разбором секция уходит из перечня;
|
||||
- **история не переживает удаления тел.** Ретеншен, срезающий архив, унесёт с
|
||||
собой и записи о непокрытых секциях за те же периоды.
|
||||
|
||||
## Когда пересматривать
|
||||
|
||||
Последнее и есть условие пересмотра, названное заранее: **задаче ретеншена
|
||||
архива реестр понадобится** — именно затем, чтобы история пережила удаление тел,
|
||||
и тогда это уже другая цена, а не вторая копия факта. Запрет реестра в спеке
|
||||
`uncovered-sections` — решение этого изменения, а не запрет навсегда.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Ответ точек несёт измеренный род и его применимость к отданному ряду
|
||||
|
||||
- **Дата:** 2026-08-04
|
||||
- **Источник:** openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Конверт ответа маршрута точек несёт `aggregation` **объектом**
|
||||
`{style, applicable, last_hour}`, а не строкой с применённой свёрткой:
|
||||
|
||||
- `style` — измеренный род метрики, тот же словарь и то же имя, что у каталога;
|
||||
- `applicable` — применим ли объявленный род к **отданному ряду**;
|
||||
- `last_hour` — ярлык самого свежего часа окна измерения.
|
||||
|
||||
`docs/architecture.md` до этого изменения обещал `"aggregation": "sum"` —
|
||||
строку. Решение её **пересматривает**: строка называет применённое и молчит об
|
||||
основании.
|
||||
|
||||
Отвергнуто и названо поимённо: поле `applied` с именем применённой свёртки
|
||||
(выводится из `style` и `bucket` тем же инвариантом; как строка неверно
|
||||
описывает свёртку мгновенной метрики, у которой по архитектуре «среднее с
|
||||
`min`/`max` рядом»); полное основание каталога (`hours`, `compared`, `agreeing`,
|
||||
`conflicting`, `first_hour`) в конверте точек — второй экземпляр факта, обязанный
|
||||
сходиться с первым.
|
||||
|
||||
## Почему
|
||||
|
||||
**Род есть свойство метрики, а слой — свойство ряда, и их сочетание бывает
|
||||
опасным.** Конверт `{"layer": "raw", "style": "cumulative"}` законен и штатен:
|
||||
правило выбора слоя при равном охвате предпочитает самый мелкий. Инвариант
|
||||
«нижний слой HAE не суммируется никогда» система соблюдает, ничего не складывая,
|
||||
— но потребитель об инварианте не знает, а сумма по нижнему слою завышает втрое
|
||||
(находка 34 разведки). Разрыв построен проходом `review-rubric` на предложении,
|
||||
до кода:
|
||||
|
||||
> Конверт `{"layer": "raw", "aggregation": {"style": "cumulative"}}` законен,
|
||||
> штатен — и он прямо приглашает главного потребителя (агента с ограниченным
|
||||
> контекстом) сложить ряд самому. Система при этом свёртки не делает, инвариант
|
||||
> формально цел; результат у потребителя завышен, а решение по нему уже принято.
|
||||
|
||||
`applicable: false` — та самая оговорка, которая едет вместе с данными.
|
||||
|
||||
**`last_hour` — единственный след замершего окна.** Род считается по 48 самым
|
||||
свежим **общим** часам, а не по последним 48 часам календаря: выключенная
|
||||
минутная автоматизация HAE останавливает пополнение общих часов, окно замирает и
|
||||
продолжает объявлять род.
|
||||
|
||||
**Литература расколота, и обе стороны названы.** Род **вместе с данными**:
|
||||
Google Cloud Monitoring объявляет `metricKind` и `valueType` в каждом объекте
|
||||
`TimeSeries` ответа, а не только в дескрипторе метрики; CloudWatch
|
||||
`GetMetricData` кладёт `StatusCode` (`Complete` / `PartialData`) рядом с рядом —
|
||||
оговорка едет с данными, а не оставляется клиенту на вывод; Home Assistant
|
||||
`statistics_during_period` держит `start` и `end` в ответе **всегда**,
|
||||
независимо от запрошенных `types`. Род **отдельно от данных**: Prometheus отдаёт
|
||||
`{resultType, result}` без единого слова о типе, а тип живёт в
|
||||
`/api/v1/metadata`; Graphite render не объявляет ничего. Второе отвергнуто по
|
||||
измеримой причине: клиент обязан сделать второй запрос, а до тех пор не
|
||||
отличает «род известен» от «род не измерен», — и согласованности между двумя
|
||||
ответами всё равно нет, потому что род есть функция **окна**, а окно едет с
|
||||
часами. Принцип HealthKit `HKStatistics` («род не тот — свёртки нет») взят,
|
||||
механизм неприменим: у нас стиль источником не объявлен.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Потребитель видит не только число, но и на каком основании его можно
|
||||
сворачивать, без второго запроса и без знания инвариантов проекта.
|
||||
- `+` Форма объявлена **до** того, как её скопируют свёртка по сетке, порог
|
||||
неполного ведра, тренировки, записи и MCP. После копирования это была бы не
|
||||
развилка, а археология.
|
||||
- `−` Поле `applicable` избыточно по построению: клиент, знающий правило «нижний
|
||||
слой HAE не суммируется», вывел бы его из `style` и `layer`. Взято сознательно
|
||||
— правило принадлежит нам, и молчаливо перекладывать его на потребителя
|
||||
дороже, чем поле.
|
||||
- `−` Чтобы разобрать, **почему** род `unknown`, придётся спросить каталог:
|
||||
полное основание живёт там в одном экземпляре.
|
||||
- `−` Род в конверте точек и род в каталоге считаются в разные моменты и у
|
||||
клиента, сравнивающего два ответа, могут разойтись. Это свойство измерения, а
|
||||
не дефект; ровно поэтому `last_hour` едет вместе с родом.
|
||||
|
||||
## Открыто, решает владелец
|
||||
|
||||
**Машинно-различимый код причины отказа.** Тело отказа несёт только
|
||||
человекочитаемую строку, и агент не отличит «зона не указана» от «слой
|
||||
незнаком» иначе, чем разбором русского текста. Правило общее для всех маршрутов
|
||||
и меняет `errorWire`, то есть и контракт приёма, — сюда не взято.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Слой ответа выбирается по охвату точек внутри периода
|
||||
|
||||
- **Дата:** 2026-08-04
|
||||
- **Источник:** openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Слой, из которого собирается ряд, выбирается так:
|
||||
|
||||
> Охват слоя — длина пересечения отрезка `[первая метка слоя, последняя метка
|
||||
> слоя]` с запрошенным периодом. Слой с пустым пересечением выбывает. Среди
|
||||
> оставшихся берётся слой с наибольшим охватом, при равенстве — самый мелкий
|
||||
> (`sample` → `raw` → `minute` → `hour` → `day`).
|
||||
|
||||
Это **пересмотр** прежнего правила, записанного в `docs/architecture.md`: «самый
|
||||
мелкий слой, покрывающий весь запрошенный диапазон».
|
||||
|
||||
## Почему
|
||||
|
||||
**Прежняя формулировка неопределена на входе, который тот же документ объявляет
|
||||
законным.** Границы слоя — границы **данных**, а не обещание покрытия: внутри
|
||||
диапазона законно есть дыры, и слоя, покрывающего диапазон целиком, может не
|
||||
существовать вовсе. Правило, не определённое на законном входе, реализатор
|
||||
доопределяет молча.
|
||||
|
||||
**Мера — охват, а не число точек.** `body_mass` в нижнем слое за три плотных дня
|
||||
даёт больше объектов, чем часовой слой за год с еженедельным взвешиванием: по
|
||||
числу точек «вес за год» вернул бы три дня, не сказав об этом ни словом.
|
||||
|
||||
**Охват меряется метками точек, а не часами объектов**, и это не придирка.
|
||||
Объекты адресуются часом, поэтому выборка обязана быть шире запроса (точка
|
||||
`10:59` живёт в объекте `10:00`), а ряд отбирается точной меткой. Путь построен
|
||||
проходом ревью на предложении:
|
||||
|
||||
> `from = 10:30`, `to = 10:45`. Слой `hour` имеет объект `10:00` с единственной
|
||||
> точкой в `10:00`, слой `minute` — объект `10:00` с точками `10:31…10:44`. По
|
||||
> часам объектов охваты равны, побеждает `hour` — и после точного отбора ответ
|
||||
> уходит пустым при непустых минутных данных.
|
||||
|
||||
Класс общий: **предикат выбора источника и предикат отбора данных обязаны
|
||||
использовать одну границу**.
|
||||
|
||||
**Цена меры измерена, и она не нулевая.** Индекс `bucket_catalog` идёт
|
||||
`(metric, layer, hour_utc, …)`, и без предиката по слою SQLite не сужает поиск по
|
||||
`hour_utc` — он просматривает все строки метрики за всю историю, а план при этом
|
||||
выглядит успешным (`SEARCH … USING COVERING INDEX`). Замер эксплуатационного
|
||||
прохода на копии схемы: 2.06 мс при 52 560 строках метрики против 13.9 мс при
|
||||
350 400, то есть цена росла бы вместе с возрастом сервиса при любой ширине
|
||||
запроса. С явным перечислением слоёв — 0.026 мс. Отсюда же следствие: **словарь
|
||||
слоёв один** (`hae.Layers`), из него выводятся и порядок, и перечень выборки, и
|
||||
проверка параметра запроса, и текст отказа клиенту.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Правило определено на любом входе, включая тот, где ни один слой периода
|
||||
не покрывает.
|
||||
- `+` Смены слоя внутри одного ответа не бывает: ряд, склеенный из двух слоёв,
|
||||
поехал бы незаметно для клиента, а вместе с ним поехала бы и будущая свёртка.
|
||||
- `−` Правило **максимизирует** размер ответа: при равном охвате берётся самый
|
||||
мелкий слой, то есть «пульс за неделю» без параметров это сотни тысяч точек.
|
||||
Предел ответа — соседняя задача; цена измерена и названа (см. ниже).
|
||||
- `−` Краевой объект, у которого есть точки и до, и после периода, но ни одной
|
||||
внутри, свой слой из выбора не выведет. Остаток узкий и честный: слой в ответе
|
||||
назван, а `points` пуст.
|
||||
|
||||
## Открыто, решает владелец
|
||||
|
||||
**Инвертировать ли умолчание при равном охвате.** Сегодня берётся самый мелкий —
|
||||
это правило `architecture.md` до пересмотра, и оно максимизирует размер ответа.
|
||||
Измерено на этом маршруте: неделя нижнего слоя — 604 800 точек, 1.75 с и
|
||||
1375 МиБ суммарных выделений на доменном слое; под HTTP вместе с сериализацией —
|
||||
2.89 с, 279.7 МиБ тела, 1335 МиБ живой кучи; четыре одновременных запроса дают
|
||||
4322 МиБ.
|
||||
|
||||
- **(а)** оставить как есть, предел вводит `read-api-response-limit`;
|
||||
- **(б)** при равном охвате брать самый **крупный** слой, мелкий — только по
|
||||
явному `layer`.
|
||||
|
||||
**Рекомендация:** (а). Решение сцеплено с формой предела, и принимать его
|
||||
мимоходом на первой ручке — то же, от чего отказались на каталоге.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Тай-брейк точек — порядок журнала, а не хранимая метка
|
||||
|
||||
- **Дата:** 2026-08-04
|
||||
- **Источник:** openspec/changes/archive/2026-08-04-tie-break-equal-completeness/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
При равной полноте побеждает точка, пришедшая разбираемой доставкой. Правило
|
||||
слияния точек тем самым перестаёт быть функцией множества и становится **явной
|
||||
функцией порядка журнала**; за это платится приведением порядка живой свёртки к
|
||||
журнальному. Хранимая метка провенанса у точки — очевидный ответ на тот же
|
||||
вопрос — отвергнута по цене.
|
||||
|
||||
## Почему
|
||||
|
||||
Байтовый порядок канонических форм, стоявший тай-брейком прежде, оказался не
|
||||
крайним разрядом правила, а главным: перемер на живом корпусе дал 80 129 спорных
|
||||
координат, из которых полнота отбрасывает кого-то лишь в 981 (1,2%), а 79 148
|
||||
(98,8%) решает тай-брейк. И решает измеримо неверно — берёт меньшее значение в
|
||||
1 847 случаях из 1 912, то есть системно хранит версию, которую источник уже
|
||||
пересчитал. Ценой этого час `2026-08-03T07:00Z` метрики `step_count` остался
|
||||
недосчитанным, сверка слоёв объявила метрику мгновенной против 23 согласных
|
||||
часов, и род ушёл в `unknown`.
|
||||
|
||||
Готовое решение известно и рассмотрено первым. Цитата из источника:
|
||||
|
||||
> Регистр «последняя запись побеждает» (LWW-Register, Shapiro et al.,
|
||||
> «A comprehensive study of Convergent and Commutative Replicated Data Types»,
|
||||
> INRIA RR-7506) сходится **только** потому, что метка времени хранится
|
||||
> **вместе со значением**: слияние сравнивает две метки, а не «кто пришёл
|
||||
> вторым». Без хранимой метки то же правило вырождается в last-writer-wins по
|
||||
> порядку применения — а он у реплик разный, и сходимости нет. Ровно это и
|
||||
> означает «не полурешётка».
|
||||
>
|
||||
> Взять готовое целиком нельзя: хранимая метка — это колонка провенанса на
|
||||
> точку, то есть смена формата `payload` и миграция, которые постановка
|
||||
> запрещает. Отвергнуто **с названной причиной**, и причина не «нам не
|
||||
> подходит», а «цена выше разрешённой рамки».
|
||||
|
||||
Что взято вместо метки — вывод той же литературы о плате за отказ от неё:
|
||||
|
||||
> Если состояние не решётка, сходимость обеспечивается **единственным
|
||||
> детерминированным порядком применения операций** — это уже не CRDT, а
|
||||
> конвейер репликации с журналом (state machine replication: Schneider,
|
||||
> «Implementing fault-tolerant services using the state machine approach», и то
|
||||
> же в Raft/Kafka log-compaction). Требование там одно и оно жёсткое: все
|
||||
> потребители применяют журнал в одном порядке.
|
||||
|
||||
Внутренний прецедент сильнее внешнего и решён иначе: слияние сущностей ту же
|
||||
развилку прошло и выбрало хранимую позицию журнала `(received_at, id)`, прямо
|
||||
отвергнув «побеждает приехавшая». Разница не в намерении, а в том, что у
|
||||
сущности колонка провенанса есть, а у точки нет. Критерий выбора между двумя
|
||||
механизмами записан в `docs/architecture.md`, раздел «Разрешение столкновений».
|
||||
|
||||
Значение точки и род метрики в правило не входят намеренно: «брать бо́льшее»
|
||||
неверно для мгновенных метрик, которые источник досчитывает вниз, а род есть
|
||||
функция витрины — правило, читающее собственную выдачу, перестаёт быть функцией
|
||||
префикса журнала (тот же дефект уже ловили на наследовании слоя «из будущего»).
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` `step_count` вернул род (`cumulative`, ноль противоречащих часов), заодно
|
||||
вернулся `headphone_audio_exposure` (`instant`); общий станок
|
||||
`task verify:archive` из красного стал зелёным.
|
||||
- `+` Систематический недосчёт на 75 494 координатах прекращён (95% из них —
|
||||
`basal_energy_burned` слоя `raw`).
|
||||
- `−` Правило больше не коммутативно: содержимое витрины стало функцией порядка
|
||||
свёртки. Живой порядок приведён к журнальному барьером — проход воркера
|
||||
прекращается на первой отложенной занятостью доставке, — но голова очереди
|
||||
теперь блокирует хвост.
|
||||
- `−` Остаточное окно конкурентного приёма (строка учёта видна позже метки)
|
||||
закрыть без изменения приёма нельзя; оно сделано наблюдаемым (`WARN`) и
|
||||
оставлено вопросом владельца в `docs/tasks/items/journal-order-on-ingest.md`.
|
||||
- `−` Появилось направление, в котором правило теряет содержание: разряд полноты
|
||||
гаснет при разошедшихся значениях общих ключей, и пришедшая точка может унести
|
||||
ключ сохранённой. Замерено — 2 координаты из 80 129 спорных; вместо запрета
|
||||
заведён счётчик и `WARN`, тем же решением и по той же причине, по какой
|
||||
отложено объединение полей.
|
||||
- `−` Восстановление коммутативности «для чистоты» молча откатит починку.
|
||||
Поэтому запрет записан нормативно в спеке хранения, а формулировки во всех
|
||||
документах приведены к «функция множества **и позиции в журнале**».
|
||||
- `−` Живая витрина в `./data` расходится с новым правилом до пересборки:
|
||||
подмена файла базы — необратимое действие человека и этим изменением не
|
||||
выполняется.
|
||||
|
||||
## Открыто, решает владелец
|
||||
|
||||
Записано здесь, а не в файле задачи: файл закрытой задачи удаляется, а эти два
|
||||
решения переживают её.
|
||||
|
||||
**1. Пересобирать ли живую витрину сейчас.** Правило действует только вперёд:
|
||||
уже сохранённые часы держат значение прежнего, измеримо смещённого правила, пока
|
||||
витрину не пересоберут, — это 75 494 координаты (95% — `basal_energy_burned`
|
||||
слоя `raw`). До пересборки сверка отпечатков с `healthlog reindex` не сойдётся и
|
||||
будет выглядеть отказом.
|
||||
|
||||
- **(а)** `reindex` с остановкой сервиса и подменой базы сразу после выкладки.
|
||||
Цена: минута простоя приёма на нынешнем архиве плюс необратимое действие
|
||||
руками. **Рекомендация.**
|
||||
- **(б)** отложить до планового окна, приняв расхождение витрины на этот срок.
|
||||
- **(в)** не пересобирать: витрина сойдётся только по координатам, которые
|
||||
переприедут доставками, — смещение останется в истории навсегда.
|
||||
|
||||
**2. Не сузить ли тай-брейк там, где он теряет содержание.** Разряд полноты
|
||||
гаснет при разошедшихся значениях общих ключей, и тогда пришедшая точка
|
||||
побеждает, даже если унесёт ключ, которого сама не несёт. Замер: 2 координаты из
|
||||
80 129 спорных, обе — те же, что дают несравнимые наборы.
|
||||
|
||||
- **(а, сделано)** оставить правило, завести счётчик `PointsErased` с `WARN`.
|
||||
Событие наблюдается, но не предотвращается; обратимо пересборкой, пока жив
|
||||
архив.
|
||||
- **(б)** сузить «побеждает пришедшая» до случая совпавших множеств
|
||||
содержательных ключей, а при строгом включении имён оставлять более полную
|
||||
независимо от происхождения. Цена: правило перестаёт быть чисто структурным на
|
||||
этом разряде, дельта хранения переписывается, прогон живого архива снимается
|
||||
заново. Проверить обязательно: сохраняется ли починка `step_count` — по замеру
|
||||
его столкновения идут с одинаковыми наборами `{date, qty}`, то есть должна.
|
||||
|
||||
Переход к (б) остаётся дешёвым: счётчик скажет, если событие станет массовым.
|
||||
+29
-5
@@ -28,13 +28,37 @@
|
||||
|
||||
## Записи
|
||||
|
||||
Новые сверху.
|
||||
Новые сверху. Все шесть активны — статуса поэтому ни у одной нет.
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
- [ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost](ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost.md)
|
||||
— конверт точек несёт измеренный род, его **применимость к отданному ряду** и
|
||||
границу окна измерения; строка `"aggregation": "sum"` пересмотрена, поле
|
||||
`applied` отвергнуто как выводимое; род вместе с данными взят у Google Cloud
|
||||
Monitoring и CloudWatch, отдельный `/metadata` Prometheus отвергнут.
|
||||
- [ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek](ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.md)
|
||||
— «самый мелкий слой, покрывающий весь диапазон» пересмотрено: правило было
|
||||
неопределено на законном входе. Охват меряется метками **точек**, а не часами
|
||||
объектов, иначе период короче часа отдаёт пустой ряд при непустых данных;
|
||||
цена меры измерена (13.9 мс против 0.026 мс) и потребовала одного словаря
|
||||
слоёв.
|
||||
- [ADR-2026-08-04-forma-provoda-prinadlezhit-transportu](ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md)
|
||||
— публичный контракт чтения объявляет транспорт, а не домен; «доменные типы и
|
||||
есть форма провода» (`wtf`, Prometheus) отвергнуто фактом — поля `store.Point`
|
||||
не совпадают с обещанным проводом точек ни одним именем; `apidiff` как сторож
|
||||
отвергнут: смены `json`-тега он не видит вовсе.
|
||||
- [ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala](ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala.md)
|
||||
— признак «секция встречена впервые» выводится запросом к журналу; реестр по
|
||||
образцу `category_value` отвергнут как вторая копия факта, с названным
|
||||
условием пересмотра — ретеншен архива.
|
||||
- [ADR-2026-08-04-tie-break-po-poryadku-zhurnala](ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md)
|
||||
— тай-брейк точек при равной полноте: побеждает пришедшая, то есть правило
|
||||
становится явной функцией порядка журнала; хранимая метка провенанса
|
||||
(LWW-Register) отвергнута по цене формата и миграции.
|
||||
- [ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom](ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom.md)
|
||||
— код HealthKit кладётся реестром рядом со строкой, а не полем внутри точки;
|
||||
словарь живёт в бинаре, выведенный код в отпечаток витрины не входит.
|
||||
|
||||
Записей пока нет: каталог заведён переездом на канон 2026-08-03. Сырьё для
|
||||
промоута накоплено — девять архивных изменений в
|
||||
Сырьё для промоута накоплено — архивные изменения в
|
||||
`openspec/changes/archive/`, из них решения с дорогим откатом и намеренные
|
||||
отказы есть как минимум в `2026-08-01-polnota-tochki-mnozhestvom-klyuchey`
|
||||
(идентичность точки и тай-брейк), `2026-08-02-reindex-iz-arhiva` (подмену базы
|
||||
|
||||
+281
-78
@@ -33,10 +33,11 @@ healthlog принимает выгрузки Apple Health из приложен
|
||||
(`метрика + слой + начало + конец`; у точки-измерения конец равен началу);
|
||||
`source` в ключ не входит, он
|
||||
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
|
||||
чтобы не писать зря. При столкновении выигрывает более полная точка, а не
|
||||
последняя пришедшая: бедная доставка не должна стирать поля у богатой.
|
||||
Полнота — **множество** ключей с непустым значением, а не их число (см.
|
||||
«Разрешение столкновений»).
|
||||
чтобы не писать зря. При столкновении выигрывает более полная точка, а при
|
||||
равной полноте — стоящая **позже в журнале**: бедная доставка не должна
|
||||
стирать поля у богатой, но и устаревшее значение не должно пережить свой
|
||||
досчёт. Полнота — **множество** ключей с непустым значением, а не их число
|
||||
(см. «Разрешение столкновений»).
|
||||
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
|
||||
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
|
||||
см. «Модель синхронизации».
|
||||
@@ -45,12 +46,14 @@ healthlog принимает выгрузки Apple Health из приложен
|
||||
с переведённой строкой (см. «Категориальные значения»): он приписывается, а
|
||||
не подменяет.
|
||||
- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в тех
|
||||
разрезах подробности, в которых пришла (`sample`/`raw`/`minute`/`hour`);
|
||||
переагрегирования при записи не происходит никогда.
|
||||
- **Агрегация в ответе — только измеренная.** Read API умеет свести метрику к
|
||||
запрошенной сетке, но род свёртки (сумма или среднее) выведен сверкой слоёв
|
||||
между собой, а не проставлен вручную. Где род неизвестен, агрегация не
|
||||
предлагается: отдаются значения как есть.
|
||||
разрезах подробности, в которых пришла (перечень слоёв —
|
||||
[database.md](database.md), таблица `bucket`); переагрегирования при записи
|
||||
не происходит никогда.
|
||||
- **Агрегация в ответе — только измеренная.** Род свёртки (сумма или среднее)
|
||||
выведен сверкой слоёв между собой, а не проставлен вручную. Где род
|
||||
неизвестен, агрегация не предлагается: отдаются значения как есть. Свёртка к
|
||||
запрошенной сетке объявлена контрактом и **ещё не реализована** — параметр
|
||||
`bucket` отвергается `400` (задача `read-api-points-bucket`).
|
||||
- **Минимум компонентов** — один процесс, SQLite, файлы. Без очередей и
|
||||
внешних зависимостей.
|
||||
|
||||
@@ -169,7 +172,7 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
```
|
||||
дыра моложе суток → закроется в течение часа
|
||||
дыра моложе недели → закроется в течение суток
|
||||
дыра старше недели → не закроется; лечится `healthlog import`
|
||||
дыра старше недели → не закроется; лечится только `healthlog import` (ещё не написан)
|
||||
```
|
||||
|
||||
Широкие проходы почти бесплатны именно из-за часовых объектов: глубокий проход
|
||||
@@ -203,10 +206,11 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
|
||||
единицы записей, и месячное окно там почти ничего не стоит.
|
||||
|
||||
Правило слияния одинаково для всех проходов, и порядок прихода значения не
|
||||
имеет. Но «последние данные всегда актуализируют картину» — неверно и никогда
|
||||
не было верным: при столкновении выигрывает более полная точка, а не последняя
|
||||
пришедшая (см. «Разрешение столкновений»).
|
||||
Правило слияния одинаково для всех проходов. Порядок прихода при этом значение
|
||||
**имеет**: полнота решает первой, а при равной полноте побеждает пришедшая
|
||||
позже по журналу. «Последние данные всегда актуализируют картину» остаётся
|
||||
неверным ровно в одном разряде — более полная точка бедную не пропускает
|
||||
(см. «Разрешение столкновений»).
|
||||
|
||||
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
|
||||
иначе `automation-name` приходит пустым (находка 12).
|
||||
@@ -223,15 +227,18 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
||||
| `ident` | генерация и разбор ULID | — |
|
||||
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) |
|
||||
| `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) |
|
||||
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
|
||||
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md) |
|
||||
| `ingest` | use-case приёма, общий для HTTP и будущего CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
|
||||
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md), [`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md) |
|
||||
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
|
||||
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
|
||||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) |
|
||||
| `httpapi` | приём и read API | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md) |
|
||||
| `points` | ряд точек метрики за период: выбор слоя, применимость рода | [`points`](../openspec/specs/points/spec.md) |
|
||||
| `httpapi` | приём, read API и **форма провода** ответов чтения | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md), [`read-api`](../openspec/specs/read-api/spec.md), [`points`](../openspec/specs/points/spec.md) |
|
||||
|
||||
## Приём
|
||||
|
||||
<!-- канон: поведение → openspec/specs/ingest -->
|
||||
|
||||
```
|
||||
запрос → токен → лимит тела, gzip → проверка формы JSON
|
||||
→ запись тела в архив → строка в delivery → 200
|
||||
@@ -261,7 +268,8 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
||||
- **200** — тело сохранено в архив. Дальше даже полный провал разбора
|
||||
(незнакомая метрика, новая форма точки) не меняет ответ: данные уже в
|
||||
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
|
||||
`/stats`, а доразобрать их можно командой `reindex`.
|
||||
`/stats` (маршрут — задача `stats-endpoint`), а доразобрать их можно командой
|
||||
`reindex`.
|
||||
|
||||
#### Очередь свёртки — таблица, а не структура в памяти
|
||||
|
||||
@@ -353,6 +361,40 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
||||
`partial` — не отклонение, а установившееся состояние, поэтому уровень лога от
|
||||
него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень.
|
||||
|
||||
**Первая встреча имени — другое дело**
|
||||
([`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md)). Момент, когда поток принёс секцию,
|
||||
которой раньше не было, фиксировался колонкой, но не наблюдался ничем: увидеть
|
||||
его мог только тот, кто догадается заглянуть в базу. Теперь свёртка спрашивает
|
||||
журнал, встречалось ли имя в доставках **строго раньше** этой (пара
|
||||
`(received_at, id)`, запросом вне транзакции записи), и первая встреча даёт
|
||||
`WARN` с именами отдельным атрибутом `uncovered_new`. Повторные молчат. Признак
|
||||
выводится, а не хранится: реестр был бы второй копией факта, обязанной сходиться
|
||||
с колонкой при каждой пересборке. Отсюда же идемпотентность — проигрывание
|
||||
полного журнала повторяет ровно те же события.
|
||||
|
||||
Событие переживает **отказ** свёртки: список непокрытых секций переживает его
|
||||
(доставка с невыводимым слоем всё равно пишет имена), и смолчать значило бы
|
||||
потерять событие навсегда — следующая доставка сочла бы имя виденным. А
|
||||
отложенный по обстоятельствам исход событий не даёт: учётной записи он не
|
||||
меняет, доставка вернётся следующим проходом.
|
||||
|
||||
Перечень накопленного отдаёт `healthlog uncovered` — имя, число доставок,
|
||||
первая и последняя встреча, чтением только на чтение и с экранированием имён
|
||||
(ключ приходит из чужого тела). Границы у перечня три, и они названы, а не
|
||||
замолчаны: имя, вытесненное границей списка в 32 имени, в колонку не попадает
|
||||
вовсе; пересборка обнуляет колонку и заполняет её заново только по сохранившимся
|
||||
телам; а имя, секцию которого разбор научился покрывать, уходит из колонки при
|
||||
пересвёртке — то есть перечень отвечает о текущем состоянии покрытия, а не об
|
||||
истории.
|
||||
|
||||
Цена сверки измерена на синтетическом журнале годового объёма; числа и метод
|
||||
живут в одном месте — `design.md` изменения `aktivnaya-proverka-novyh-sekcij`,
|
||||
решение 3, — и здесь не дублируются. Правило из замера: ранний выход есть только
|
||||
у секции, приезжающей давно (строки просматриваются от старых к новым); у только
|
||||
что появившейся секции проход идёт почти по всему журналу на каждой доставке,
|
||||
пока её не покроет отдельная задача. Имён больше одного спрашиваются одним
|
||||
запросом — тридцать два запроса подряд стоили секунду с лишним на доставку.
|
||||
|
||||
**Правило для будущих задач: покрыли секцию — пересверните.** Список это снимок
|
||||
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
|
||||
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
|
||||
@@ -417,10 +459,14 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
||||
пересобрать что угодно.
|
||||
|
||||
**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же
|
||||
состояние, что и приём в реальном времени. Слияние «выигрывает более полная
|
||||
точка» коммутативно и порядка не требует; но когда две одинаково полные точки
|
||||
несут разные значения, исход решает порядок — поэтому воспроизведение идёт
|
||||
строго по `received_at`, а не по порядку файлов в каталоге.
|
||||
состояние, что и приём в реальном времени. Разряд полноты коммутативен и
|
||||
порядка не требует, а разряд равной полноты — **нет**: побеждает пришедшая, то
|
||||
есть исход есть функция порядка свёртки. Отсюда два следствия. Воспроизведение
|
||||
идёт строго по `(received_at, id)`, а не по порядку файлов в каталоге. И живая
|
||||
свёртка обязана идти тем же порядком: проход воркера прекращается на первой
|
||||
отложенной доставке, а свёртка, всё-таки пошедшая вне порядка (конкурентный
|
||||
приём делает строку учёта видимой позже метки), пишет `WARN` — закрыть это окно
|
||||
можно только на приёме.
|
||||
|
||||
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
|
||||
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
|
||||
@@ -795,7 +841,7 @@ hour метки выровнены на час heart_rate 00:00:00
|
||||
доставки той же автоматизации; если её не было, берём **надёжный** заголовок
|
||||
(`Minutes` → `minute`, `Hours` → `hour`). Иначе точки не сохраняются вовсе:
|
||||
молчаливый `raw` создал бы призрачный разрез, который поедет в каталог и в
|
||||
правило Read API «самый мелкий слой, покрывающий диапазон».
|
||||
правило Read API выбора слоя (см. «Read API»).
|
||||
|
||||
Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от
|
||||
**префикса журнала**. Наследование от последней доставки вообще делает свёртку
|
||||
@@ -901,8 +947,11 @@ hour метки выровнены на час heart_rate 00:00:00
|
||||
|
||||
#### Разрешение столкновений
|
||||
|
||||
По одним координатам приезжают разные содержимые: 2 897 случаев из 444 256
|
||||
координат, 0.65% (находка 49). Выигрывает **более полная** точка, и полнота —
|
||||
По одним координатам приезжают разные содержимые: спорных координат 80 129 из
|
||||
460 995 (17,4%), и полнота отбрасывает кого-то лишь в 981 из них (1,2%) —
|
||||
остальное решает тай-брейк ([research/apple-health.md](research/apple-health.md),
|
||||
находка 54; прежняя оценка «0,65%» из находки 49 считала ключ без слоя).
|
||||
Выигрывает **более полная** точка, и полнота —
|
||||
это сравнение **множеств** ключей с непустым значением, а не их числа.
|
||||
|
||||
Число сравнимо всегда и потому отвечает там, где ответа нет: точка
|
||||
@@ -928,24 +977,50 @@ hour метки выровнены на час heart_rate 00:00:00
|
||||
проигрывает `{date, qty:10}` по жребию. Несравнимость на втором разряде исходом
|
||||
не является: лишние ключи там заведомо пусты, объединять в них нечего.
|
||||
|
||||
**Победитель — функция множества точек, а не порядка их поступления.** Попарная
|
||||
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и
|
||||
вместе они образуют нетранзитивное отношение победы, то есть цикл. При цикле
|
||||
повторная свёртка одной и той же доставки меняет содержимое объекта, и витрина
|
||||
перестаёт быть свёрткой журнала. Поэтому кандидаты координаты собираются
|
||||
**Победитель — функция множества кандидатов вместе с их происхождением, а не
|
||||
порядка элементов на проводе.** Попарная свёртка этого не даёт: полнота —
|
||||
частичный порядок, тай-брейк — тотальный, и вместе они образуют нетранзитивное
|
||||
отношение победы, то есть цикл. При цикле повторная свёртка одной и той же
|
||||
доставки меняет содержимое объекта. Поэтому кандидаты координаты собираются
|
||||
вместе: отбрасываются превзойдённые по полноте, среди оставшихся берётся
|
||||
минимум по каноническому порядку. Обе операции зависят только от состава
|
||||
множества.
|
||||
минимум тотального порядка — сперва происхождение (пришедшая раньше
|
||||
сохранённой), затем каноническая форма. Антицикловое свойство от этого не
|
||||
страдает; зависимость от **порядка журнала** появляется намеренно и оплачена
|
||||
отдельно (см. ниже).
|
||||
|
||||
**Несравнимые множества не сливаются, а считаются.** Объединение полей — самая
|
||||
дорогая часть правила — на живом потоке не потребовалось ни разу (0 из 2 897),
|
||||
поэтому вместо реализации стоит счётчик и `WARN` с координатами объекта. Если
|
||||
событие наступит, оно будет видно, а не додумано заранее.
|
||||
дорогая часть правила — на живом корпусе наступило дважды на 155 доставок
|
||||
(находка 54), поэтому вместо реализации стоит счётчик и `WARN` с координатами
|
||||
объекта. Событие видно, а не додумано заранее.
|
||||
|
||||
**Тай-брейк при равной полноте не выбран.** Сегодня это порядок канонических
|
||||
форм, и он измеримо смещён: в 96% случаев берёт меньшее значение. Правильный
|
||||
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой —
|
||||
значит он и станет известен точно, вместо того чтобы быть угаданным.
|
||||
**Тай-брейк при равной полноте — пришедшая доставка.** Порядок канонических
|
||||
форм отвергнут замером: он берёт меньшее значение в 96% случаев (находка 49) и
|
||||
стоил `step_count` его рода. Значение точки в правило не входит («брать
|
||||
бо́льшее» неверно для мгновенных метрик), род метрики — тоже: род есть функция
|
||||
витрины, а правило, читающее собственную выдачу, перестаёт быть функцией
|
||||
префикса журнала. Байтовый порядок остался тай-брейком **внутри одной
|
||||
доставки**, где провенанс общий.
|
||||
|
||||
Цена названа вслух: правило перестало быть функцией множества и стало явной
|
||||
функцией порядка журнала. Витрина остаётся свёрткой журнала ровно потому, что
|
||||
порядок свёртки приведён к порядку журнала (см. «Свёртка обязана быть
|
||||
детерминированной»).
|
||||
|
||||
**Два правила равной полноты и когда какое.** У точки и у сущности развилка
|
||||
одна, а механизмы разные — вот критерий, чтобы третья единица хранения не
|
||||
открывала спор заново:
|
||||
|
||||
| | точка | сущность (`workout`, `record`) |
|
||||
| --- | --- | --- |
|
||||
| разряд полноты | множества ключей с непустым значением | покрытие содержания |
|
||||
| тай-брейк равной полноты | происхождение кандидата: пришедшая побеждает | хранимая позиция журнала `(received_at, id)` |
|
||||
| внутри одной доставки | порядок канонических форм | он же |
|
||||
| гарантия | верна, пока порядок свёртки равен порядку журнала | верна всегда |
|
||||
| в остаточном окне конкурентного приёма | расходится, пишет `WARN`, лечится `reindex` | не расходится |
|
||||
| почему так | провенанса у точки нет, и заводить его дорого: колонка на точку меняет формат содержимого объекта | колонка провенанса уже есть |
|
||||
|
||||
Правило выбора для будущего: есть где хранить позицию журнала — храним её;
|
||||
негде и завести дорого — берём происхождение и обеспечиваем порядок свёртки.
|
||||
|
||||
### Измерение рода агрегации
|
||||
|
||||
@@ -1103,21 +1178,45 @@ HAE отдаёт перечислимые значения строками из
|
||||
Он объявлен источником истины, и на нём держится ретеншен нижнего слоя —
|
||||
но сверить покрытие по этим полям было бы нечем.
|
||||
|
||||
Поэтому строка **хранится дословно, а рядом кладётся выведенный код**:
|
||||
Поэтому строка **хранится дословно, а рядом кладётся выведенный код** —
|
||||
отдельной строкой реестра `category_value`, а не полем внутри точки:
|
||||
|
||||
```
|
||||
value "БДГ" ← как прислал HAE
|
||||
value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по словарю
|
||||
category_value sleep_analysis / value / "БДГ" → HKCategoryValueSleepAnalysisAsleepREM
|
||||
точка {"date": …, "value": "БДГ", …} ← не тронута
|
||||
```
|
||||
|
||||
Словарь ключуется парой `(локаль, строка)`; локаль берётся из
|
||||
`Accept-Language`, который мы уже сохраняем (находка 32). Для незнакомой
|
||||
строки код пустой — пустота честнее догадки, и она же видна в `/stats` как
|
||||
список того, что пора добавить в словарь.
|
||||
Рядом, а не внутри, по трём причинам: точка хранится исходными байтами и
|
||||
дописать в неё ключ можно только пересериализацией; параллельный массив кодов в
|
||||
`bucket` завёл бы производную величину в путь слияния и хеширования; пополнение
|
||||
словаря переписывало бы каждый объект с фазами сна. Обоснование целиком — в
|
||||
[журнале решений](adr/README.md).
|
||||
|
||||
Ключ реестра — `(метрика, поле, значение)`. Словарь при этом ключуется парой
|
||||
`(локаль, строка)`, локаль берётся из `Accept-Language` (находка 32) и **в ключ
|
||||
реестра не входит**: заголовков в сыром архиве нет, и ключ с локалью сделал бы
|
||||
состояние функцией от того, уцелела ли учётная строка. Локаль сужает поиск; её
|
||||
отсутствие вывода не отменяет, если строка однозначна по всем локалям.
|
||||
|
||||
Словарь и таблица синонимов кодов живут в бинаре (`internal/healthkit`), а не в
|
||||
базе: словарь, наполняемый руками, стал бы входом, которого нет в журнале, и
|
||||
`import + replay` перестал бы задавать состояние однозначно. Синонимы нужны
|
||||
потому, что коды тоже не вечны: Apple переименовала `…Asleep` в
|
||||
`…AsleepUnspecified` и переписывает историю при выгрузке (находка 43).
|
||||
|
||||
Для незнакомой строки код пустой — пустота честнее догадки, и перечень таких
|
||||
строк в реестре есть заявка на пополнение словаря. Счётчик неизвестных строк
|
||||
уходит в лог свёртки числом; сами строки — данные о здоровье и в лог не
|
||||
попадают.
|
||||
|
||||
Дословность инварианта не нарушена: код **приписывается**, а не подменяет
|
||||
строку. Обратное преобразование всегда возможно.
|
||||
|
||||
Реестр — единица хранения витрины и входит в отпечаток **наблюдением**, но не
|
||||
выведенным кодом: код производен от словаря в бинаре, а не от журнала, и в
|
||||
отпечатке он превратил бы всякое пополнение словаря в расхождение при совпавшем
|
||||
журнале.
|
||||
|
||||
### Тренировки и прочие секции
|
||||
|
||||
<!-- канон: поведение → openspec/specs/parsing -->
|
||||
@@ -1354,18 +1453,26 @@ MongoDB, и так просилось из слова «перезаписыва
|
||||
|
||||
## Read API
|
||||
|
||||
<!-- канон: поведение → openspec/specs/read-api -->
|
||||
|
||||
```
|
||||
GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами
|
||||
GET /api/v1/metrics/{name}?from&to&bucket&layer точки метрики, при желании свёрнутые
|
||||
GET /api/v1/workouts?from&to заголовки тренировок
|
||||
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
|
||||
GET /api/v1/records/{kind}?from&to прочие секции
|
||||
GET /api/v1/schema схемы всего, что есть в хранилище
|
||||
GET /api/v1/metrics/{name}/schema схема и статистика одной метрики
|
||||
GET /stats последняя доставка, счётчики, тишина по потоку
|
||||
GET /api/v1/metrics/{name}?from&to&layer точки метрики за период (bucket — соседняя задача, пока 400)
|
||||
GET /healthz
|
||||
```
|
||||
|
||||
**Целевая поверхность шире реализованной.** Маршрутов ниже в роутере ещё нет,
|
||||
и запрос к ним получает `404`:
|
||||
|
||||
```
|
||||
GET /api/v1/workouts?from&to заголовки тренировок → read-api-workouts
|
||||
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом → read-api-workouts
|
||||
GET /api/v1/records/{kind}?from&to прочие секции → read-api-records
|
||||
GET /api/v1/schema схемы всего, что есть в хранилище → цель self-description
|
||||
GET /api/v1/metrics/{name}/schema схема и статистика одной метрики → цель self-description
|
||||
GET /stats последняя доставка, счётчики, тишина → stats-endpoint
|
||||
```
|
||||
|
||||
Хранение пачками на контракт не влияет: `GET /metrics/{name}` собирает ответ
|
||||
из часовых объектов, попавших в диапазон, и отдаёт точки. Клиент про объекты
|
||||
не знает — это деталь хранения, а не API.
|
||||
@@ -1407,9 +1514,21 @@ GET /healthz
|
||||
законно есть дыры. Поэтому правило выбора слоя опирается на фактические объекты
|
||||
запрошенного диапазона, а не на каталожную пару границ.
|
||||
|
||||
Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий
|
||||
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
|
||||
границе периода нельзя: ряд поедет незаметно для клиента.
|
||||
Параметр `layer` выбирает разрез. Если он не указан — берём слой с **наибольшим
|
||||
охватом внутри запрошенного периода**, а при равном охвате самый мелкий (порядок
|
||||
`sample` → `raw` → `minute` → `hour` → `day`). Молча переключать слой на границе
|
||||
периода нельзя: ряд поедет незаметно для клиента, и ряд из одного ответа всегда
|
||||
собран из одного слоя.
|
||||
|
||||
**Охват — длина пересечения** отрезка «первая метка слоя … последняя метка слоя»
|
||||
с периодом; слой с пустым пересечением выбывает. Меряется он метками **точек**,
|
||||
а не часами объектов. Почему прежняя формулировка («самый мелкий, покрывающий
|
||||
весь диапазон») пересмотрена, почему мера именно такая и во что она обошлась —
|
||||
[ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek](adr/ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.md).
|
||||
|
||||
Словарь слоёв при этом **один** (`hae.Layers`): из него выводятся и порядок, и
|
||||
перечень слоёв в выборке охватов, и проверка параметра запроса, и текст отказа
|
||||
клиенту.
|
||||
|
||||
### Условный запрос
|
||||
|
||||
@@ -1488,26 +1607,105 @@ GET /healthz
|
||||
Нормализованная оболочка, сырое содержимое:
|
||||
|
||||
```json
|
||||
{"layer": "minute", "bucket": "hour", "aggregation": "sum",
|
||||
{"metric": "heart_rate",
|
||||
"from": "2026-07-31T00:00:00Z", "to": "2026-08-01T00:00:00Z",
|
||||
"layer": "minute", "bucket": null,
|
||||
"aggregation": {"style": "instant", "applicable": true,
|
||||
"last_hour": "2026-08-02T14:00:00Z"},
|
||||
"points": [
|
||||
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count",
|
||||
"values": {"qty": 812}}
|
||||
{"ts": "2026-07-31T09:00:00Z", "ts_end": "2026-07-31T09:00:00Z",
|
||||
"tz_offset": 10800, "units": "count", "values": {"qty": 812}}
|
||||
]}
|
||||
```
|
||||
|
||||
`layer`, `bucket` и `aggregation` присутствуют всегда, даже когда свёртки не
|
||||
было (`"bucket": null`): клиент не должен выводить их наличием или
|
||||
отсутствием поля.
|
||||
Все поля присутствуют ВСЕГДА, даже когда сообщить нечего: клиент не должен
|
||||
выводить исход наличием или отсутствием поля. `bucket` равен `null`, когда
|
||||
свёртки не было; `layer` — `null`, когда слой выбирала система и выбирать было
|
||||
не из чего (явно запрошенный слой уезжает всегда, в том числе при пустом ряде).
|
||||
|
||||
`aggregation` — **объект, а не строка**. Строка называла бы только применённую
|
||||
свёртку, а инвариант требует, чтобы клиент видел ещё и основание (решение и
|
||||
разбор чужих API —
|
||||
[ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost](adr/ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost.md)):
|
||||
|
||||
- `style` — измеренный род метрики, тот же словарь, что у каталога;
|
||||
- `applicable` — применим ли род к **отданному ряду**. Род есть свойство
|
||||
метрики, слой — свойство ряда, и сочетание `{"layer": "raw", "style":
|
||||
"cumulative"}` законно и штатно: оно приглашает потребителя сложить
|
||||
интерполяцию самому и завысить втрое. Система при этом не складывает ничего —
|
||||
а потребитель об инварианте не знает;
|
||||
- `last_hour` — ярлык самого свежего часа окна измерения. Окно считается в
|
||||
**общих** часах, а не в часах календаря: выключенная минутная автоматизация
|
||||
HAE останавливает их пополнение, окно замирает и продолжает объявлять род.
|
||||
Это единственный след.
|
||||
|
||||
`ts_end` — конец координаты точки; у точки-измерения равен `ts`. Он есть потому,
|
||||
что идентичность точки — интервал, а не метка: под одной меткой лежит до трёх
|
||||
записей сна, и конверт с одним `ts` предлагал бы клиенту различать их, разбирая
|
||||
дословное содержимое.
|
||||
|
||||
Принадлежность точки периоду определяется её **началом** — тем же правилом,
|
||||
каким час объекта берётся по началу. Цена названа: «сон за ночь с полуночи» не
|
||||
увидит эпизод, начавшийся в 23:40.
|
||||
|
||||
Время приведено к единому виду, значения отданы как пришли: ни
|
||||
переименований, ни пересчёта единиц. Метрик у Apple много и они разные —
|
||||
семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
|
||||
что каждая новая метрика требует правки коллектора, а незнакомая теряется.
|
||||
переименований, ни пересчёта единиц, ни экранирования (сериализатор ответа
|
||||
HTML-символы не экранирует — иначе `&` в имени источника уезжал бы как
|
||||
`\u0026`, и обещание дословности переставало быть правдой). Метрик у Apple
|
||||
много и они разные — семантику разбирает клиент по имени метрики. Полная
|
||||
нормализация означала бы, что каждая новая метрика требует правки коллектора,
|
||||
а незнакомая теряется.
|
||||
|
||||
### Форма провода
|
||||
|
||||
**Форму ответа объявляет транспорт, а не домен.** Каждый читающий маршрут
|
||||
`internal/httpapi` держит собственные типы с `json`-тегами и переводит в них
|
||||
доменное значение присваиванием поле в поле; доменные типы (`internal/catalog`
|
||||
и далее) `json`-тегов не несут и до сериализации не доезжают. То же правило
|
||||
покрывает тело отказа. MCP собственной формы не объявляет — адаптер переводит
|
||||
вызовы в те же обработчики.
|
||||
|
||||
Цена названа с обеих сторон, потому что она обратная, а не односторонняя.
|
||||
|
||||
- **Домен = провод** (как было у каталога): формы объявлены один раз, перевода
|
||||
нет, ноль строк на маршрут. Платим тем, что публичный контракт меняется
|
||||
правкой домена **молча** — переименованием поля, разъединением встроенной
|
||||
структуры (плоскость `aggregation` была следствием встраивания `Basis`),
|
||||
появлением внутреннего поля. Ни одна из трёх правок транспорт не трогает.
|
||||
- **Раздельно** (взято): контракт меняется только правкой транспорта, то есть
|
||||
действием. Платим двумя вещами. Форма объявлена дважды — типы и перевод на
|
||||
каждый маршрут. И цена **обратная**: новое поле домена в ответ само не
|
||||
попадёт, его обязан перечислить перевод; поле, не доехавшее до клиента, —
|
||||
такой же дефект, как поле, уехавшее случайно, просто другой.
|
||||
|
||||
Развилку решил факт, а не вкус: провод точек обещан как
|
||||
`{ts, tz_offset, units, values}`, а `store.Point` несёт
|
||||
`{Start, End, OffsetSeconds, Raw}` — эти наборы не совпадают ни одним именем,
|
||||
и доменный тип формой провода там быть не может. Хранилище, кстати, уже живёт
|
||||
по этому правилу: формат `payload` объявлен отдельным неэкспортированным
|
||||
`storedPoint`, а `encodePayload` переводит в него полем в поле.
|
||||
|
||||
Сторожей два, и роли у них разные. **Обход графа типов ответа** (внутренний
|
||||
тест `httpapi`) утверждает, что домен до энкодера не доезжает — отсюда и
|
||||
следует, что переименование поля домена байт не меняет; рядом стоит заведомо
|
||||
красный случай, потому что проверка, доказывающая отсутствие, зелена и будучи
|
||||
сломанной. **Байтовый литерал** на каждую различимую форму ответа — детектор
|
||||
изменения формы: он краснеет в момент правки. Источником истины контракта он
|
||||
не является — им станет рукописная OpenAPI-спека, и сверку с маршрутами внесёт
|
||||
в гейт отдельная задача.
|
||||
|
||||
Разбор чужих решений (домен = провод у `wtf` и Prometheus; раздельно у Gitea,
|
||||
Docker и go-kit; версионирование с конверсией у Kubernetes; отвергнутый
|
||||
`apidiff`, который смены `json`-тега не видит вовсе) —
|
||||
[design.md изменения](../openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md).
|
||||
Ссылка markdown-ссылкой намеренно: инлайн-код `docs.py check` не проверяет, а
|
||||
путь угадывался до архивации.
|
||||
|
||||
### MCP
|
||||
|
||||
Поверх Read API — адаптер MCP, чтобы агент подключался без промежуточного
|
||||
кода. Инструментов ровно два, по числу форм запроса выше, плюс каталог.
|
||||
Поверх Read API **встанет** адаптер MCP, чтобы агент подключался без
|
||||
промежуточного кода — кода адаптера сегодня нет, это задача `mcp-server` цели
|
||||
`read-api`. Инструментов ровно два, по числу форм запроса выше, плюс каталог.
|
||||
Собственной логики в адаптере нет: он переводит вызовы в те же обработчики.
|
||||
|
||||
**Транспорт — HTTP** (Streamable HTTP), не stdio: сервис живёт на VPS, и агент
|
||||
@@ -1560,18 +1758,18 @@ GET /healthz
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Статический токен в заголовке `Authorization: Bearer …`; список допустимых
|
||||
токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно.
|
||||
|
||||
Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий
|
||||
данные, не может писать. MCP пользуется токеном чтения — отдельного контура
|
||||
у него нет, см. «MCP».
|
||||
|
||||
Наружу открыты два контура: приём (телефон) и чтение вместе с MCP (агенты и
|
||||
приложения). Оба через Caddy с TLS, оба с разными токенами.
|
||||
Периметр, модель угроз и разграничение контуров — [security.md](security.md),
|
||||
разделы «Периметр» и «Что разграничивает доступ»; сегодняшний контур отличается
|
||||
от целевого, и сказано это там. Здесь важно одно следствие для компоновки: MCP —
|
||||
эндпоинт того же процесса и того же контура чтения, отдельного контура доступа у
|
||||
него нет (см. «MCP»).
|
||||
|
||||
## Деплой
|
||||
|
||||
**Целевая** раскладка; сегодняшний контур — [security.md](security.md),
|
||||
«Периметр», статус работ — [tasks/ROADMAP.md](tasks/ROADMAP.md),
|
||||
«Сопровождение».
|
||||
|
||||
VPS **rivendell** (Timeweb), доступен всегда. Перед сервисом — **Caddy**, он
|
||||
терминирует TLS; сам сервис слушает plain HTTP. Приём открыт наружу на
|
||||
отдельном поддомене — телефон должен доставать до него из любой сети, иначе
|
||||
@@ -1583,7 +1781,12 @@ VPS **rivendell** (Timeweb), доступен всегда. Перед серв
|
||||
Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг
|
||||
(с токенами) — отдельно, `0600`.
|
||||
|
||||
**Откат бинаря поверх новой схемы отказывает на старте.** Версия схемы базы выше
|
||||
<!-- канон: поведение → openspec/specs/storage -->
|
||||
|
||||
**Откат бинаря поверх новой схемы отказывает на старте** — правило нормировано в
|
||||
[`storage`](../openspec/specs/storage/spec.md), требование «Открытие базы
|
||||
отказывает при схеме из будущего»; здесь только следствия для деплоя. Версия
|
||||
схемы базы выше
|
||||
версии, вшитой в бинарь, — отказ, а не повод мигрировать; в контейнере это
|
||||
выглядит циклом перезапуска, и лечится возвратом бинаря вперёд. Версию читает
|
||||
сам goose (`Provider.GetVersions`), а не собственный запрос: имя таблицы учёта и
|
||||
|
||||
@@ -16,11 +16,35 @@
|
||||
единица, которой нет в счётчиках, делает расхождение безадресным: человек
|
||||
видит «не совпало» при неизменившемся числе объектов и принимает по этому
|
||||
необратимое решение о подмене базы.
|
||||
- **Провенанс, входящий в отпечаток, обязан быть явной функцией журнала.**
|
||||
«Кто первым записал строку» — функция порядка свёртки, а он порядку журнала не
|
||||
равен: живой приём и пересборка разойдутся при одинаковом журнале. Там, где
|
||||
провенанс в отпечаток не идёт, слабое правило допустимо и должно быть названо
|
||||
слабым на месте — иначе его скопируют туда, где оно неверно (`bucket` против
|
||||
`category_value`).
|
||||
- **Колонка, производная от бинаря, а не от журнала, в отпечаток не входит.**
|
||||
Кэш чистой функции (код по словарю, справочное имя) в отпечатке превращает
|
||||
всякую правку бинаря в расхождение при побайтно совпавшем журнале — и человек,
|
||||
принимающий по отпечатку необратимое решение о подмене базы, читает это как
|
||||
дефект. Правильность самой производной проверяют её тесты: это другой вопрос,
|
||||
и смешение обесценивает оракул сходимости.
|
||||
- **Граница на число элементов, набираемых из чужого тела, применяется при
|
||||
накоплении, а не при выдаче.** Накопитель без границы растёт вместе с телом,
|
||||
а тело контролирует отправитель; отказ по памяти в фоновой горутине не
|
||||
перехватывается, и перезапуск берёт ту же доставку. Усечение при этом обязано
|
||||
остаться функцией множества (например, N наименьших ключей), иначе порядок
|
||||
элементов на проводе решает состав витрины.
|
||||
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
|
||||
множества версий**, либо явно **функцией порядка журнала** — третьего
|
||||
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
|
||||
порядок свёртки порядку журнала не равен, и живая витрина расходится с
|
||||
пересборкой молча.
|
||||
состояния нет. «Побеждает последняя свёрнутая» третьим состоянием и является:
|
||||
порядок свёртки сам по себе порядку журнала не равен, и живая витрина
|
||||
расходится с пересборкой молча. Объявив правило функцией порядка журнала,
|
||||
изменение обязано **внести плату целиком**: привести порядок свёртки к
|
||||
журнальному (барьер на отложенной доставке), назвать остаточное окно и сделать
|
||||
его наблюдаемым, а равенство «пересборка = приём» доказать оракулом с
|
||||
отрицательным контролем. Так сделано для точек; у сущностей на тот же вопрос
|
||||
отвечает хранимая позиция журнала, и её гарантия строго сильнее — критерий
|
||||
выбора в `architecture.md`, «Разрешение столкновений».
|
||||
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
|
||||
названный предел длины (имена непокрытых секций, `id` сущности).
|
||||
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не
|
||||
@@ -47,3 +71,40 @@
|
||||
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
|
||||
структуры обновляем схему в [database.md](../database.md) тем же изменением —
|
||||
это проверяет `task gate`.
|
||||
|
||||
- **Значение, читаемое табличной функцией SQLite (`json_each` и родня), проходит
|
||||
проверку ВНУТРИ её аргумента, а не условием в `WHERE`.** Функция получает
|
||||
значение строки раньше, чем применится фильтр, и порядок этот SQLite не
|
||||
обещает: неразбираемое значение роняет **весь** запрос, а не пропускает
|
||||
строку. Условие в `WHERE` работает, пока планировщик проталкивает его вниз, и
|
||||
перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка
|
||||
`delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не
|
||||
состоялась» на каждой доставке), и перечень целиком.
|
||||
|
||||
## Предикат выбора источника и предикат отбора данных — одна граница
|
||||
|
||||
Объекты витрины адресуются часом, а точки отбираются точной меткой. Выборка
|
||||
объектов поэтому обязана быть **шире** запроса (точка `10:59` живёт в объекте
|
||||
`10:00`) — и ровно здесь появляется разрыв: множество «слои, у которых есть
|
||||
объекты в периоде» не совпадает с множеством «слои, у которых есть точки в
|
||||
периоде».
|
||||
|
||||
Правило: **решение о том, откуда брать данные, принимается по той же границе, по
|
||||
которой данные потом отбираются.** Иначе узел выбирает источник, в котором после
|
||||
точного отбора не остаётся ничего, и отдаёт пустоту при непустых данных
|
||||
соседнего источника — молча, потому что и выбор, и отбор по отдельности верны.
|
||||
|
||||
Прецедент: правило выбора слоя в Read API мерило охват часами объектов, а ряд
|
||||
отбирало метками точек; на периоде короче часа ответ уходил пустым при непустых
|
||||
минутных данных (ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek).
|
||||
|
||||
## Значение из чужого тела имеет предел длины у КАЖДОГО адресата
|
||||
|
||||
Правило `docs/security.md` про предел длины читается как «в ключ, в лог, в
|
||||
отчёт» — и адресаты кончаются не там. Имя метрики уезжает ещё и в заголовок
|
||||
ответа: без предела `ETag` растёт вместе с именем, а кавычка внутри имени по
|
||||
RFC 9110 кончает метку, и условный запрос по такой метрике не сработает никогда.
|
||||
|
||||
Когда предел неудобен (значение нужно целиком), его заменяет **форма**: в метку
|
||||
уезжает хеш канонизированной строки, а не строка. Хеш здесь не секрет — он
|
||||
ограничитель длины и экранирование разом.
|
||||
|
||||
@@ -23,6 +23,70 @@
|
||||
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
|
||||
без числа не отличается от предположения, а цена ошибки здесь — необратимое
|
||||
решение о судьбе тел.
|
||||
- **В проверке на живом корпусе утверждается инвариант, а число печатается.**
|
||||
Корпус растёт с каждой доставкой, а прогон живого архива в гейт не входит —
|
||||
значит константа, производная от его размера, протухает по расписанию
|
||||
телефона и краснеет у того, кто мимо проходил. Правило шире, чем «не
|
||||
сравнивай с числом»: протухает и **оценка области действия**, снятая на
|
||||
прежнем корпусе. «Тай-брейк — крайний разряд после полноты» было верно на
|
||||
2 897 столкновениях и неверно на 80 129, где полнота решает 1,2%; на этой
|
||||
оценке стоял нормативный текст спеки. Число, попавшее в спеку или в довод
|
||||
решения, обязано нести рядом **метод замера** — иначе следующий замер
|
||||
посчитает другое и разойдётся молча (так и вышло: ключ без слоя дал 29-кратное
|
||||
расхождение). Три случая одного класса за три дня: записи 2026-08-02,
|
||||
2026-08-03 и 2026-08-04 в [review.md](../review.md).
|
||||
|
||||
Метода мало — **синтетический корпус обязан содержать измеряемый случай в той
|
||||
форме, в какой он бывает в жизни**. Сверка новизны секции мерялась на журнале,
|
||||
где новое имя стояло во всех доставках, то есть его первая встреча лежала в
|
||||
начале — ранний выход давал 31 мкс. В жизни секцию включают сегодня, первая
|
||||
встреча оказывается в хвосте, и та же операция стоит 52 мс: три порядка
|
||||
разницы, а на числе стояло решение «индекс не нужен» (запись 2026-08-04).
|
||||
|
||||
И **число живёт в одном месте.** Один и тот же замер, записанный в
|
||||
комментарий кода и в `architecture.md`, разошёлся внутри одного изменения.
|
||||
Дом числа — `design.md` изменения; остальные формулируют правило и ссылаются.
|
||||
- **Оракул сходимости называет свою посылку рядом с собой, и прогон её
|
||||
печатает.** «Пересборка = приём» — не тождество, а утверждение с условиями:
|
||||
живая свёртка шла в порядке журнала, в журнале нет доставок, чью свёртку живой
|
||||
путь провалил, а пересборка проведёт, и за время прогона новых доставок не
|
||||
приезжало. Оракул, чья посылка не названа, краснеет по причине, к правилу
|
||||
отношения не имеющей, и краснота становится неотличимой от дефекта — то есть
|
||||
с ней начинают жить.
|
||||
- **Проверка правила, зависящего от порядка, несёт отрицательный контроль.**
|
||||
Тест «два пути дали один отпечаток» зеленеет и на правиле, которое к порядку
|
||||
безразлично, — то есть не проверяет ничего. Рядом обязан стоять прогон в
|
||||
заведомо другом порядке с утверждением, что отпечаток **отличается**.
|
||||
- **Значение, попадающее в ключ витрины или в словарь, приёмочный тест берёт из
|
||||
`testdata`, а не из литерала в тесте.** Литерал, набранный руками, не
|
||||
воспроизводит невидимые символы источника — Apple шлёт неразрывные пробелы
|
||||
внутри своих строк (находка 24), — и совпадение теста с реализацией доказывает
|
||||
только согласие автора с самим собой.
|
||||
- **Утверждение о таблице-константе обходит саму таблицу, а не её видимые
|
||||
следствия.** Проверка «таблица синонимов плоская», написанная через
|
||||
экспортированные функции, обходит лишь записи, достижимые из словаря: с
|
||||
неплоской таблицей она остаётся зелёной (воспроизведено). Такие утверждения
|
||||
живут во внутреннем тесте пакета и перебирают саму структуру.
|
||||
- **Публичная форма ответа закрепляется байтами целого тела, и каждая различимая
|
||||
форма — своим литералом.** Разбор проглатывает молча ровно то, что клиент
|
||||
видит первым: `nil`-срез уезжает как `null`, отсутствующий ключ неотличим от
|
||||
ключа с нулём, а разыменованный `*time.Time` даёт правдоподобную дату
|
||||
`0001-01-01` вместо `null`. Тест, сличающий разобранные структуры или
|
||||
подстроки, зелен в каждом из этих случаев — проверка «в ответе есть
|
||||
`"first_hour"`» проходит и на нулевой дате. Различимых форм у ответа обычно
|
||||
больше одной (пустая коллекция, измеренное значение, неизмеренное), и литерал
|
||||
нужен каждой: одна закреплённая форма оставляет остальные без сторожа именно
|
||||
там, где ручной перевод и ошибается. Литерал при этом **детектор изменения**,
|
||||
а не источник истины контракта — правишь литерал, значит правишь контракт, и
|
||||
рядом обязана лежать правка спеки.
|
||||
- **Проверка, доказывающая ОТСУТСТВИЕ, несёт рядом заведомо красный случай.**
|
||||
«Доменного типа в графе ответа нет», «значения точки в логе нет», «записи в
|
||||
таблице нет» — все они зелены и будучи сломанными: протухшая константа,
|
||||
пропущенная позиция обхода, перепутанное сравнение выглядят снаружи как
|
||||
«искомого нет». Это обобщение двух правил ниже (отрицательный контроль для
|
||||
правил порядка; утверждение о таблице-константе обходит саму таблицу): у
|
||||
проверки на отсутствие обязан быть предъявленный вход, на котором она
|
||||
краснеет.
|
||||
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
|
||||
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
|
||||
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
|
||||
|
||||
+57
-1
@@ -46,6 +46,17 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
|
||||
│ created_at TEXT │ └──────────────────────────┘
|
||||
│ updated_at TEXT │
|
||||
└──────────────────────────┘
|
||||
|
||||
┌────────────────────────────┐
|
||||
│ category_value │
|
||||
│ ───────────────────────── │
|
||||
│ metric TEXT ┐ │
|
||||
│ field TEXT ├PK │
|
||||
│ value TEXT ┘ │
|
||||
│ code TEXT │
|
||||
│ first_seen_utc TEXT │
|
||||
│ first_delivery_id TEXT │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
|
||||
@@ -116,7 +127,10 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
|
||||
**Идентичность точки внутри объекта** — координаты
|
||||
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
|
||||
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
|
||||
столкновении выигрывает более полная точка, а не последняя пришедшая.
|
||||
столкновении выигрывает более полная точка, а при равной полноте — стоящая
|
||||
позже в журнале (внутри одной доставки — минимум канонической формы). Провенанса
|
||||
у точки нет: «позже в журнале» выражено происхождением кандидата, и потому
|
||||
порядок свёртки обязан равняться журнальному.
|
||||
|
||||
## `workout` и `record` — сущности с собственным `id`
|
||||
|
||||
@@ -156,6 +170,47 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
|
||||
журнала, а не свёрнутая последней. Подробности и обоснование — в
|
||||
`architecture.md`, раздел «Тренировки и прочие секции».
|
||||
|
||||
## `category_value` — реестр категориальных значений
|
||||
|
||||
Какие перечислимые строки поток приносил и какой у них стабильный код
|
||||
HealthKit. HAE отдаёт фазу сна как «БДГ», контекст пульса как «Сидячий образ
|
||||
жизни», тип тренировки как «В помещении Ходьба» — строками локали телефона, а
|
||||
родной экспорт Apple говорит кодами; без словаря источники не сходятся
|
||||
(находка 37). Словарь фаз сна выведен сопоставлением потока с экспортом за тот
|
||||
же период (находка 43).
|
||||
|
||||
| Колонка | Смысл |
|
||||
|---|---|
|
||||
| `metric` | имя метрики или секции, **то же**, которым адресуется единица хранения (`sleep_analysis_summary` после разделения схем, `workouts` у тренировок). Второе имя для того же понятия развело бы наблюдение и объект по разным ключам |
|
||||
| `field` | имя поля внутри точки или сущности дословно как у HAE: `value`, `context`, `name` |
|
||||
| `value` | строка **дословно**, как прислал HAE. Код приписывается рядом, а не подменяет её: инвариант «точки хранятся дословно» это и означает |
|
||||
| `code` | канонический код HealthKit. Пустая строка — законное состояние: «словарь этой строки не знает», и перечень таких строк есть заявка на пополнение словаря. **Это кэш**: код производен от словаря в бинаре, а не от журнала, и потому в отпечаток витрины не входит. Строка, переставшая приезжать, держит код прежнего словаря до пересборки |
|
||||
| `first_seen_utc`, `first_delivery_id` | провенанс **первой** встречи, минимум по журналу `(received_at, id)`. Минимум идемпотентен при повторной свёртке той же доставки; счётчик встреч не идемпотентен и потому не заводится вовсе. Отвечает на вопрос «когда сменился язык телефона», а язык доставки восстанавливается по `delivery.headers` |
|
||||
|
||||
Ключ — тройка без локали, и это решение, а не упущение. Локаль приезжает
|
||||
заголовком `Accept-Language`, а заголовков в сыром архиве нет: они были
|
||||
заголовками запроса, а не телом. Доставка, восстановленная из осиротевшего
|
||||
тела, приходит без локали — ключ с локалью положил бы вторую строку на то же
|
||||
значение, то есть состояние стало бы функцией от того, уцелела ли учётная
|
||||
строка. Локаль при выводе кода сужает поиск по словарю; её отсутствие вывода не
|
||||
отменяет, если строка однозначна.
|
||||
|
||||
Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, а строк
|
||||
единицы — на живом потоке различных значений по всем трём полям около
|
||||
одиннадцати. Индексов нет: чтение идёт целиком, в порядке ключа.
|
||||
|
||||
Границы разбора не дают доставке положить больше 64 различных значений и
|
||||
значение длиннее 128 байт (измерено: ~11 значений, самое длинное 36 байт).
|
||||
Слишком длинное **отбрасывается со счётчиком, а не обрезается** — обрезанная
|
||||
строка неотличима от настоящей и стала бы самостоятельным ключом; сама точка
|
||||
при этом хранится целиком.
|
||||
|
||||
Data-миграции у таблицы нет и быть не может: коды выводятся из тел, а тела
|
||||
лежат в архиве. Реестр рабочей витрины наполняется по мере свёртки новых
|
||||
доставок и целиком — пересборкой. Отсюда первое расхождение отпечатков после
|
||||
выкатки: оно законно, и отчёт `reindex` называет его ожидаемым классом
|
||||
«появилась единица хранения».
|
||||
|
||||
## Представление данных
|
||||
|
||||
- **Точки часового объекта лежат сжатым BLOB** (`gzip`) в колонке `payload`.
|
||||
@@ -185,5 +240,6 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
|
||||
| таймаут чтения запроса | 5 мин (`server.read_timeout`) | конфиг; щедро: экспорт истории по мобильной сети |
|
||||
| таймаут отправки ответа | 30 с (`server.write_timeout`) | конфиг; маршрут приёма держит собственный бюджет |
|
||||
| бюджет остановки | 30 с | `cmd/healthlog/serve.go`, `shutdownTimeout` |
|
||||
| дедлайн свёртки одной доставки | 2 мин | `internal/replay/worker.go`, `foldTimeout`; обстоятельством не считается — не уложившаяся доставка уходит в `failed` |
|
||||
| ретеншен сырого архива | до следующего проверенного экспорта (~2 ГБ за квартал) | правило, а не число; не реализован — задача `raw-archive-retention` |
|
||||
| предела на одну сущность | **нет** | задача `entity-size-limits` |
|
||||
|
||||
+17
-13
@@ -1,8 +1,8 @@
|
||||
# Паспорт проекта
|
||||
|
||||
Куда мы идём, как выглядит работа проекта в жизни и у кого подсматривать,
|
||||
когда упёрлись. Самый верхний документ: [tasks/PLAN.md](tasks/PLAN.md) отвечает «в каком
|
||||
порядке», [architecture.md](architecture.md) — «как устроено», паспорт —
|
||||
когда упёрлись. Самый верхний документ: [tasks/ROADMAP.md](tasks/ROADMAP.md) отвечает «что
|
||||
приложение умеет», [architecture.md](architecture.md) — «как устроено», паспорт —
|
||||
**«зачем и для кого»**.
|
||||
|
||||
## Цель
|
||||
@@ -51,23 +51,25 @@
|
||||
|
||||
## Типовые сценарии
|
||||
|
||||
Ситуации, ради которых всё написано. В скобках — шаги [tasks/PLAN.md](tasks/PLAN.md),
|
||||
которыми сценарий закрывается; названы, а не пронумерованы, потому что план
|
||||
живой и нумерация в нём поедет.
|
||||
Ситуации, ради которых всё написано. В скобках — цели [tasks/ROADMAP.md](tasks/ROADMAP.md),
|
||||
которыми сценарий закрывается: достигнутые названы слагом из «Готово», открытые —
|
||||
заголовком цели. Названы, а не пронумерованы, потому что роадмап живой и
|
||||
нумерация в нём сдвинется на первой же вставке.
|
||||
|
||||
**1. Молчаливый приём** (приём, разбор и хранилище). Телефон каждые 5 минут шлёт доставку;
|
||||
**1. Молчаливый приём** (`ingest`, `parsing-and-storage` — сделаны). Телефон каждые 5 минут шлёт доставку;
|
||||
сервис кладёт тело в архив, отвечает `200`, разбирает метрики в часовые
|
||||
объекты. Никто ничего не спрашивает и не смотрит.
|
||||
*Успех:* сутки работы не порождают ни одной строки лога уровня `WARN` и ни
|
||||
одного действия человека.
|
||||
|
||||
**2. Дыра закрывается сама** (разбор и хранилище). Телефон был заблокирован ночью,
|
||||
**2. Дыра закрывается сама** (`parsing-and-storage` — сделано). Телефон был заблокирован ночью,
|
||||
автоматизация не отработала, часть дня отсутствует. Средний проход (сутки) и
|
||||
глубокий (неделя) переприсылают окно целиком, точки доезжают.
|
||||
*Успех:* дыра моложе недели закрывается без вмешательства; никто о ней даже не
|
||||
узнаёт.
|
||||
|
||||
**3. Квартальный экспорт** (`healthlog import`, устаревание нижнего слоя).
|
||||
**3. Квартальный экспорт** (История из родного экспорта Apple лежит в
|
||||
хранилище; Нижний слой чистится после проверенного экспорта).
|
||||
Изредка владелец выгружает
|
||||
родной экспорт Apple Health и скармливает его `healthlog import`. Нижний слой
|
||||
за прошлое становится честным (настоящие сэмплы вместо посекундной развёртки
|
||||
@@ -75,32 +77,34 @@ HAE), а сырой архив получает право быть подчищ
|
||||
*Успех:* экспорт разобран, покрытие периода проверено, объём архива вернулся к
|
||||
норме, ничего не потеряно.
|
||||
|
||||
**4. Агент спрашивает про здоровье** (каталог и род агрегации, Read API, MCP). Агент-медик по MCP
|
||||
**4. Агент спрашивает про здоровье** (`catalog` — сделан; Клиенты читают данные
|
||||
через HTTP и MCP). Агент-медик по MCP
|
||||
спрашивает каталог («что у тебя вообще есть»), затем «шаги по дням за месяц»
|
||||
или «пульс за вчера». Получает свёрнутый ряд с честным указанием слоя, сетки и
|
||||
рода агрегации.
|
||||
*Успех:* ответ влезает в контекст агента, число не завышено вдвое, и агенту не
|
||||
пришлось знать про слои, чтобы спросить правильно.
|
||||
|
||||
**5. Приложение берёт тренировки** (Read API). Разборщик тренировок запрашивает
|
||||
**5. Приложение берёт тренировки** (Клиенты читают данные через HTTP и MCP). Разборщик тренировок запрашивает
|
||||
заголовки за период, потом одну тренировку целиком — с маршрутом и рядом
|
||||
пульса.
|
||||
*Успех:* тренировка отдана одним пакетом в том виде, в каком её прислал Apple,
|
||||
без нашей интерпретации того, что в ней главное.
|
||||
|
||||
**6. Разбор поменялся** (`healthlog reindex`). Мы начали разбирать секцию, которую
|
||||
**6. Разбор поменялся** (`reindex` — сделан). Мы начали разбирать секцию, которую
|
||||
раньше пропускали, или нашли ошибку в старом разборе. Запускается пересборка
|
||||
по сырому архиву: `import(экспорт) + replay(доставки по received_at)`.
|
||||
*Успех:* состояние пересобрано детерминированно, повтор даёт то же самое,
|
||||
доставки со снятым статусом `partial` подобраны.
|
||||
|
||||
**7. Владелец проверяет, жив ли поток** (наблюдаемость). Раз в сколько-то дней —
|
||||
**7. Владелец проверяет, жив ли поток** (Приложение сообщает о своём состоянии). Раз в сколько-то дней —
|
||||
взгляд в `/stats`: когда была последняя доставка, сколько точек, есть ли
|
||||
тишина, какие строки не легли в словарь кодов.
|
||||
*Успех:* один экран отвечает «всё идёт» или «встало тогда-то», без залезания
|
||||
в SQLite.
|
||||
|
||||
**8. Приехало незнакомое** (разбор и хранилище). HAE обновился и прислал новую метрику,
|
||||
**8. Приехало незнакомое** (`parsing-and-storage` — сделано; Новая форма от
|
||||
источника не теряется молча). HAE обновился и прислал новую метрику,
|
||||
новую форму точки или новую секцию. Тело сохраняется, ответ — `200`, разбор
|
||||
честно помечает доставку `partial` и перечисляет непокрытое.
|
||||
*Успех:* данные в архиве и восстановимы, факт виден в логе и `/stats`, а
|
||||
|
||||
@@ -51,7 +51,7 @@ python3 tmp/research/hl.py workouts тренировки, ряд
|
||||
|
||||
## Записи
|
||||
|
||||
- [apple-health.md](apple-health.md) — 53 находки на живом потоке Health Auto
|
||||
- [apple-health.md](apple-health.md) — 54 находки на живом потоке Health Auto
|
||||
Export и на родном экспорте Apple.
|
||||
|
||||
Записи нумерованы сквозным номером внутри файла, и **на номер ссылаются
|
||||
@@ -64,7 +64,7 @@ python3 tmp/research/hl.py workouts тренировки, ряд
|
||||
| --- | --- |
|
||||
| Форма точки, схемы, типы значений | 4, 21, 38, 39, 44 |
|
||||
| Слой и гранулярность, режимы автоматизации | 5, 6, 13, 19, 20, 23, 33, 41 |
|
||||
| Идентичность, столкновения, слияние, полнота | 11, 14, 36, 47, 49 |
|
||||
| Идентичность, столкновения, слияние, полнота | 11, 14, 36, 47, 49, 54 |
|
||||
| Досчёт задним числом и стабильность значений | 3, 10, 30, 48, 51 |
|
||||
| Локализация и категориальные значения | 8, 24, 37, 43 |
|
||||
| Секции потока и их состав | 9, 15, 16, 17, 22, 34, 50, 52 |
|
||||
|
||||
@@ -1369,6 +1369,14 @@ RFC3339 Z 20 data.stateOfMind[].end = 2026-07-31T18:03:51
|
||||
То есть первую и главную часть словаря не надо составлять вручную — она
|
||||
выводится сопоставлением потока с экспортом за тот же период.
|
||||
|
||||
**Замер покрытия, 2026-08-03.** Прогон всего живого архива (145 доставок) через
|
||||
разбор с этим словарём даёт **12 различных категориальных строк** по трём полям:
|
||||
6 фаз сна — все с кодом, 6 без кода (`heart_rate.context` и имена тренировок,
|
||||
для которых словарь не выводился). То есть шесть выведенных строк покрывают
|
||||
поток целиком, а не частично: неопознанных фаз сна на корпусе ноль. Заголовков
|
||||
в архиве нет, поэтому прогон идёт с пустой локалью — и коды всё равно выводятся,
|
||||
что подтверждает: сопоставление по строке однозначно, пока словарь одноязычен.
|
||||
|
||||
## 44. `Correlation` — структурный элемент, и он появился только что
|
||||
|
||||
Давление приезжает не записью, а обёрткой из двух записей:
|
||||
@@ -1766,6 +1774,73 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
|
||||
заполненности (`xFilesFactor`) измерению не нужно: две конкурирующие гипотезы
|
||||
отсеивают неполный час сами.
|
||||
|
||||
## 54. Перемер тай-брейка: 98,8% спорных координат решает не полнота, а порядок форм
|
||||
|
||||
Замер 2026-08-04, повод — `task verify:archive` покраснел на `master` без
|
||||
единого коммита, с ростом корпуса. Метод назван целиком, потому что прежняя
|
||||
оценка (находка 49) и эта расходятся в 29 раз, и расхождение объясняется
|
||||
методом, а не данными.
|
||||
|
||||
**Метод.** 155 тел архива, разбор настоящий (`hae.Parse` с наследованием слоя по
|
||||
цепочке), ключ координаты **настоящий** — `метрика + слой + начало + конец`.
|
||||
Кандидаты схлопываются по канонической форме (`canon.SortKey`, округление до 12
|
||||
значащих цифр, находка 30); полнота — `canon.Fields.Relate`, то есть с условием
|
||||
«значения общих содержательных ключей совпали». Программа лежала в `tmp/`
|
||||
(вне репозитория: она ходит в рабочий архив).
|
||||
|
||||
Прежний замер того же дня давал «84 978 спорных из 453 171» — он считал ключ
|
||||
**без слоя**, а без слоя часовая точка сталкивается с минутной, и это не
|
||||
столкновение, а два разных ряда (та же ошибка названа в находке 49 первой
|
||||
строкой её таблицы).
|
||||
|
||||
| что мерялось | сколько |
|
||||
| --- | --- |
|
||||
| координат всего | 460 995 |
|
||||
| спорных (больше одной канонической формы) | 80 129 (17,4%) |
|
||||
| из них полнота кого-то отбрасывает | 981 (1,2%) |
|
||||
| из них все кандидаты непревзойдённые — решает тай-брейк | **79 148 (98,8%)** |
|
||||
| несравнимых пар среди непревзойдённых | 2 |
|
||||
| координат, где смена тай-брейка меняет исход | 75 494 |
|
||||
|
||||
**Соотношение 1,2% / 98,8% устойчиво** — оно совпало у обоих методов, и именно
|
||||
оно, а не абсолютное число, было основанием решения: инвариант «выигрывает
|
||||
более полная точка» на живом потоке отвечает в одном случае из восьмидесяти.
|
||||
|
||||
**Изменение сосредоточено в одной метрике одного слоя.** Из 75 494 изменившихся
|
||||
координат 71 773 (95%) — `basal_energy_burned` слоя `raw`, то есть посекундная
|
||||
развёртка HAE, которую Read API суммировать и так не имеет права. Следом
|
||||
`basal_energy_burned/minute` (1 833), `walking_running_distance/raw` (777),
|
||||
`step_count/raw` (746). Ошибка «системно храним меньшее» была массовой по
|
||||
координатам и узкой по метрикам.
|
||||
|
||||
**Несравнимых наборов больше не ноль.** Находка 49 фиксировала 0 из 2 897; на
|
||||
155 доставках их 2. Порог «объединять поля не будем, пока счётчик молчит»
|
||||
поэтому подтверждается, но уже не абсолютен: событие наступило, просто редко.
|
||||
|
||||
**Направление, в котором новое правило теряет содержание, замерено отдельно.**
|
||||
Разряд полноты гаснет, когда значения общих содержательных ключей разошлись, —
|
||||
и тогда пришедшая точка побеждает, даже если у проигравшей был содержательный
|
||||
ключ, которого у неё нет. Таких координат на корпусе **2**, обе
|
||||
`sleep_analysis_summary/day`, и обе — ровно те же, что дают несравнимые наборы.
|
||||
То есть случай «сохранённая беднее по именам, но значения разошлись» на живом
|
||||
потоке не наблюдался вовсе. Прежний байтовый порядок давал ту же потерю по
|
||||
жребию и так же молча; теперь она детерминирована и считается
|
||||
(`MergeStats.PointsErased`, `WARN`).
|
||||
|
||||
**Исход починки, тем же прогоном.** Смена тай-брейка на «побеждает пришедшая»
|
||||
вернула род двум метрикам: `step_count` (`unknown` → `cumulative`, ноль
|
||||
противоречащих часов вместо одного) и `headphone_audio_exposure`
|
||||
(`unknown` → `instant`). Итог каталога: накопительных 6 → 7, мгновенных 9 → 10,
|
||||
неизвестных 16 → 14. Отпечаток витрины сменился, как и требовалось: 3 194
|
||||
объекта, `bf36b477…` → `03aace91…`. Удержаний правилом полноты на весь
|
||||
корпус — 1 247, потерь содержания — 2.
|
||||
|
||||
**Проверено ещё раз на выросшем корпусе.** Пока шла работа, телефон прислал ещё
|
||||
три доставки; прогон на 158 телах остался зелёным (3 255 объектов, отпечаток
|
||||
`c4fbb1c7…`, ноль противоречащих часов, `step_count` по-прежнему
|
||||
`cumulative`). Это и есть ответ на то, чем дефект был найден: прежнее правило
|
||||
покраснело именно от роста корпуса, новое рост пережило.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- **Переживает ли «Since Last Sync» неудачную отправку.** Ключевой вопрос для
|
||||
@@ -1777,7 +1852,12 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
|
||||
Меняется ли что-то на глубине часов и суток — покажет более длинный ряд
|
||||
доставок.
|
||||
- **Секции, которых мы не видели живьём:** `symptoms`, `ecg`,
|
||||
`heartRateNotifications`, `cycleTracking`, `medications`.
|
||||
`heartRateNotifications`, `cycleTracking`, `medications`. Разбор покрывает
|
||||
ровно остальные три (`metrics`, `workouts`, `stateOfMind` — `decodeCovered` в
|
||||
`internal/hae`), сверено поимённо 2026-08-04. Момент их появления больше не
|
||||
требует догадки: первая встреча имени даёт `WARN` в логе свёртки, а перечень
|
||||
накопленного отдаёт `healthlog uncovered`. Разбор самой секции пишется, когда
|
||||
её будет на чём проверить, — вслепую он не пишется.
|
||||
- **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается
|
||||
отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в
|
||||
экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен
|
||||
|
||||
+274
-15
@@ -34,8 +34,10 @@
|
||||
сам же меняет, без границы по `received_at` разбираемой доставки.
|
||||
- Правило выбора между версиями — функция множества версий либо явно функция
|
||||
порядка журнала; третьего состояния нет.
|
||||
- Столкновение разрешается полнотой, а не свежестью; изменение запечатанного
|
||||
часа пишется `WARN`, но данные пишутся.
|
||||
- Столкновение разрешается полнотой, а при равной полноте — положением в
|
||||
журнале: побеждает стоящая позже
|
||||
([ADR](adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md)). Изменение
|
||||
запечатанного часа пишется `WARN`, но данные пишутся.
|
||||
- Транзакция не держит блокировку дольше `busy_timeout`: канонизация и
|
||||
сжатие — вне её.
|
||||
|
||||
@@ -52,7 +54,8 @@
|
||||
- Расход памяти не растёт вместе с длиной журнала.
|
||||
- Подмена базы — решение человека при остановленном сервисе, не команды.
|
||||
|
||||
**Обработчик чтения и адаптер MCP** (Read API, MCP — ещё не написаны)
|
||||
**Обработчик чтения и адаптер MCP** (`internal/httpapi`: каталог и точки
|
||||
написаны; свёртка по сетке, тренировки, записи и MCP — ещё нет)
|
||||
|
||||
- Агрегат считается только там, где род свёртки измерен; нижний слой HAE не
|
||||
суммируется никогда.
|
||||
@@ -105,24 +108,41 @@
|
||||
- `triage`: перечислены ли запущенные проходы поимённо и с исходом; непущенный
|
||||
проход идёт в границы покрытия строкой «не запускался» (запись 2026-08-02,
|
||||
чекпоинт кода прошёл без трёх проходов).
|
||||
- `specs`: считается ли внешним поведением **состояние, которое даёт
|
||||
пересборка** — витрина наблюдаема через пересборку, поэтому расхождение с
|
||||
журналом не внутренняя деталь, а поведение, которого спека не заказывала.
|
||||
Внешнее здесь — ещё и код ответа приёма, форма ответа чтения и содержимое
|
||||
архива (переселено из триггеров профиля, канон 3).
|
||||
|
||||
### Триггеры профиля
|
||||
|
||||
Уточняет умолчания конвейера, не отменяет их.
|
||||
Уточняет умолчания конвейера, не отменяет их. Рабочее умолчание — `standard`:
|
||||
миграция схемы, публичный контракт и инвариант ступень **не** поднимают, их
|
||||
проверяют проходы, которые в `standard` и так есть.
|
||||
|
||||
- **`deep`** — изменения в правиле разбора, идентичности, слияния или вывода
|
||||
слоя; миграции схемы; всё, что трогает `internal/store`, `internal/fold`,
|
||||
`internal/replay`.
|
||||
- **«Поведение, видимое снаружи»** здесь — код ответа приёма, форма ответа
|
||||
чтения, содержимое архива и **состояние, которое даёт пересборка**: витрина
|
||||
наблюдаема через пересборку, поэтому расхождение с журналом — внешнее
|
||||
поведение, а не внутренняя деталь.
|
||||
- **`reimpl`** запускается по триггеру «новое правило слияния, идентичности или
|
||||
разбора». Единственный раз, когда триаж назвал его отсутствие дырой
|
||||
покрытия, — это была задача с новым правилом слияния сущностей.
|
||||
- **Новое понятие или структурная единица** (`wide`) — новый пакет в
|
||||
`internal/`, новый род узла из перечня выше, новый тип провода в
|
||||
`internal/httpapi`, новая единица хранения, входящая в отпечаток, новый
|
||||
транспорт рядом с HTTP.
|
||||
- **Правила идентичности, слияния и разбора** (`deep`) живут в трёх местах:
|
||||
`internal/hae` — разбор пакета и вывод слоя; `internal/fold` — выбор между
|
||||
версиями точки; `internal/store` — координатный ключ и запись часового
|
||||
объекта. Правку правила в любом из них ступень поднимает; перенос кода без
|
||||
правки правила — нет.
|
||||
- **`quick`** — правка документов, конфигурации, сообщений; ничего, что меняет
|
||||
хранимое.
|
||||
|
||||
`reimpl` живёт за барьером `deep` и по тому же триггеру — новое правило слияния,
|
||||
идентичности или разбора. Замеры окупаемости: единственный раз, когда триаж
|
||||
назвал его отсутствие дырой покрытия, — задача с новым правилом слияния
|
||||
сущностей. Второй замер (2026-08-03, словарь категориальных значений): триггер
|
||||
сработал на новом правиле разбора и ключе реестра, проход **окупился** — он
|
||||
независимо подтвердил замером две находки, до того имевшие только одно измерение
|
||||
(пик памяти накопителя: 1002 МиБ против 780 на базе; единицы счётчика
|
||||
отброшенных), и отдельно назвал семь мест, где существующее решение оказалось
|
||||
**лучше** его собственного. Второе ценно не меньше первого: оно показывает, где
|
||||
проход соглашается, а не только где спорит.
|
||||
|
||||
### Недоступно проверке
|
||||
|
||||
**Не проверит ни один проход.** Реальный профиль нагрузки: телефон шлёт молча и
|
||||
@@ -135,8 +155,15 @@
|
||||
|
||||
**Перестали проверять сознательно.**
|
||||
|
||||
- **Шаг покрытия диффа гейт не красит.** `CLAUDE.md` объявляет, что непокрытая
|
||||
изменённая строка красит гейт безусловно; `scripts/diff-coverage.py` всегда
|
||||
возвращает `0`, и шаг печатает `OK` при любом покрытии. То есть «гейт зелёный»
|
||||
не означает «покрытие диффа полное», и разбор непокрытых строк остаётся
|
||||
человеку или проходу. Найдено проходом `gate` 2026-08-04, подтверждено
|
||||
триажем; чинить нельзя мимоходом — починка немедленно красит гейт задачи, в
|
||||
которой её сделали.
|
||||
- Прогон живого архива (`task verify:archive`) и свёртка под удерживаемой
|
||||
блокировкой (`task verify:busy`) в гейт не входят: минута и 25 секунд
|
||||
блокировкой (`task verify:busy`) в гейт не входят: минута и около 50 секунд
|
||||
соответственно, плюс данные, которых нет ни на какой другой машине. Гоняет их
|
||||
человек перед задачей, трогающей разбор или слияние (запись 2026-08-02,
|
||||
прогон живого архива был красным и об этом никто не знал).
|
||||
@@ -151,6 +178,42 @@
|
||||
Запись на каждый воспроизведённый дефект, сразу, а не ретроспективно: со
|
||||
временем теряется не факт, а причина непоймания.
|
||||
|
||||
## 2026-08-04 — правило выбора слоя мерило одно, а отбор шёл по другому [пойман]
|
||||
|
||||
**Что было.** Правило выбора слоя ответа Read API мерило охват **часами
|
||||
объектов**, а ряд отбирался **точной меткой точки**. На периоде короче часа
|
||||
множества расходятся: часовой объект попадает в границы часов запроса, а его
|
||||
единственная точка в период не попадает. Ответ уходил бы пустым при непустых
|
||||
данных соседнего слоя — с непустым `layer`, то есть неотличимо от честной
|
||||
пустоты только по числу точек.
|
||||
|
||||
**Почему поймано.** Профиль `design` на предложении, до кода: и `review-specs`,
|
||||
и `review-rubric` построили один и тот же вход независимо друг от друга
|
||||
(`from = 10:30`, `to = 10:45`). На готовом коде находка стоила бы переписывания
|
||||
выборки; на предложении — абзаца.
|
||||
|
||||
**Что сделано.** Охват меряется метками точек (`first_ts`/`last_ts` уже лежат в
|
||||
покрывающем индексе). Класс промоутнут в
|
||||
`docs/conventions/storage.md` — «предикат выбора источника и предикат отбора
|
||||
данных используют одну границу»: он повторится всюду, где огрубление ради
|
||||
полноты выборки соседствует с точным фильтром.
|
||||
|
||||
## 2026-08-04 — чекпоинт, заведённый ревью, не существовал бы в проде [пойман]
|
||||
|
||||
**Что было.** Враждебный проход построил путь «ответ оборвался по `WriteTimeout`
|
||||
на середине, а `accessLog` написал `200`»: тело в 13 МиБ доехало на 2.7 МиБ,
|
||||
клиент получил нечитаемый JSON, лог сообщил успех. Чекпоинт об обрыве завели —
|
||||
и поставили ему уровень `DEBUG`.
|
||||
|
||||
**Почему поймано.** Эксплуатационный проход прочитал **боевой** конфиг
|
||||
(`config.docker.toml`, `level = "info"`) и показал, что запись уровня `DEBUG`
|
||||
не проходит фильтр `slog` никогда. То есть находка была закрыта наблюдаемостью,
|
||||
которой в проде не существует.
|
||||
|
||||
**Что сделано.** Уровень поднят до `WARN`. Правило, которое из этого следует:
|
||||
**уровень нового чекпоинта сверяется с боевым конфигом, а не с тем, что видно в
|
||||
тестах** — в тестах уровень всегда `DEBUG`.
|
||||
|
||||
Реализованные задачи, находки ревью и решения сюда не пишутся: у них есть
|
||||
коммит, спека и задача. Здесь только промахи конвейера и решения о его составе.
|
||||
|
||||
@@ -360,6 +423,48 @@
|
||||
- **Итог по конвейеру:** `quick` 4, `standard` 6, `deep` 7–8, `design` 3.
|
||||
Было 11 на коде и 4 на дизайне.
|
||||
|
||||
## 2026-08-03 — метка от часов в отпечатке сделала тест функцией секунды прогона [пойман]
|
||||
|
||||
- **Где:** `internal/fold/categorical_test.go`, `TestFoldЛокальНеМеняетСостояния`
|
||||
- **Симптом:** гейт покраснел на одном подтесте из четырёх: «заголовок
|
||||
`{"Accept-Language":["de"]}` сдвинул отпечаток витрины». Три подтеста прошли.
|
||||
- **Причина:** тест сравнивал отпечатки четырёх независимых витрин, а метку
|
||||
приёма доставки брал из `store.Now()`. Провенанс первой встречи входит в
|
||||
отпечаток реестра — значит отпечаток зависел от того, уложились ли подтесты в
|
||||
одну секунду. Тест был флаки по построению и краснел бы у того, кто мимо
|
||||
проходил.
|
||||
- **Чем воспроизведён:** сам гейт; после замены `store.Now()` на фиксированную
|
||||
метку — `go test ./internal/fold -count=2` зелёный.
|
||||
- **Что меняем:** ничего в конвейере — гейт сработал ровно так, как задуман, и
|
||||
поймал класс, который прошлый раз (2026-08-02, подстрока «5.1» в метке
|
||||
времени) прожил незамеченным. Правило то же и уже записано в
|
||||
[conventions/testing.md](conventions/testing.md): величина, зависящая от хода
|
||||
часов, не участвует в утверждении. Запись здесь — потому что это второй случай
|
||||
одного класса за два дня, и третий стоит считать сигналом, а не совпадением.
|
||||
|
||||
## 2026-08-03 — прогон живого архива красный на master, и это не заметили две задачи подряд [проскочил]
|
||||
|
||||
- **Где:** `internal/replay/archive_test.go`, `measureStyles`
|
||||
- **Симптом:** `task verify:archive` в задаче про словарь категориальных
|
||||
значений упал на `step_count: противоречащих часов 1 при 22 согласных`.
|
||||
Проверено прогоном **базовой ревизии** `3df42af` из копии дерева на том же
|
||||
архиве: те же 2875 объектов, те же 285 координат сна, тот же отказ. Краснота
|
||||
унаследована, изменением не внесена.
|
||||
- **Причина:** утверждение «противоречий ноль» — посылка «род измерим», верная
|
||||
на корпусе, где её снимали. Корпус вырос до 145 доставок, и у `step_count`
|
||||
появился час, где минутный и часовой слои разошлись. Сама система при этом
|
||||
ведёт себя правильно: род объявляется только при единогласном свидетельстве,
|
||||
и `step_count` числится `unknown`.
|
||||
- **Почему не поймали:** ровно та же причина, что и в записи 2026-08-02, — у
|
||||
проверки, которую гейт не гоняет, краснота никому не видна. Разница в том, что
|
||||
тогда протухла константа, а теперь под вопросом сама посылка: противоречие —
|
||||
это либо дефект правила, либо законное свойство корпуса, и решать это не
|
||||
прогону.
|
||||
- **Что меняем:** конвейер — ничего. Решение о том, чем стал `step_count`
|
||||
(дефект измерения рода или законное противоречие, которое надо печатать, а не
|
||||
утверждать), принадлежит владельцу и заведено задачей отдельно от этого
|
||||
изменения. Названо здесь, чтобы третья задача подряд не открывала его заново.
|
||||
|
||||
## 2026-08-03 — ответ владельца не превращал задачу в берущуюся [проскочил]
|
||||
|
||||
- **Где:** конвейер, а не код — учёт задач, шаг «ответ на вопрос»
|
||||
@@ -387,3 +492,157 @@
|
||||
файлов каталога получили одну дату при переезде на канон (коммит `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
@@ -32,8 +32,10 @@ disabled`, `read auth disabled`), но стартовать не отказыв
|
||||
значения, метки времени, имена источников и устройств, имена секций, `id`
|
||||
тренировок и записей, содержимое маршрута.
|
||||
- **Заголовки доставки** — включая `automation-id`, `automation-aggregation`,
|
||||
`User-Agent`, `Accept-Language`, `Upload-Complete`; они пишутся в `delivery` и
|
||||
участвуют в выводе слоя. Заголовки полуправдивы: `automation-aggregation`
|
||||
`User-Agent`, `Accept-Language`, `Upload-Complete`; они пишутся в `delivery`,
|
||||
участвуют в выводе слоя, а `Accept-Language` — ещё и в выводе кода
|
||||
категориального значения (тег ограничен по длине и по форме, не тег даёт
|
||||
пустую локаль). Заголовки полуправдивы: `automation-aggregation`
|
||||
реальной гранулярности не описывает (разведка, находка 33).
|
||||
- **Размер тела** — предела на одну сущность нет; наблюдалось 63 МиБ на одной
|
||||
координате и 768 МиБ пика кучи на теле 40 МиБ.
|
||||
@@ -42,6 +44,13 @@ disabled`, `read auth disabled`), но стартовать не отказыв
|
||||
`export.xml`, который выбирает человек, но формируется он устройством и по
|
||||
объёму (3,6 млн записей) глазами не проверяется.
|
||||
|
||||
**Новый адресат недоверенного входа — терминал оператора.** Подкоманда
|
||||
`healthlog uncovered` печатает имена секций, а имя это верхнеуровневый ключ
|
||||
чужого тела: длина у него ограничена разбором (64 байта, не больше 32 имён),
|
||||
содержимое — ничем. Печатается оно экранированным (`%q`), иначе управляющая
|
||||
последовательность из тела подделала бы строки вывода. Тот же вход попадает
|
||||
структурным атрибутом в лог свёртки, где его экранирует кодировщик `slog`.
|
||||
|
||||
Ответы внешних систем в недоверенный вход не входят: исходящих вызовов у
|
||||
сервиса нет.
|
||||
|
||||
@@ -52,11 +61,28 @@ disabled`, `read auth disabled`), но стартовать не отказыв
|
||||
**Ни один сегмент пути не берётся из тела или заголовков доставки** — это и
|
||||
есть защита от выхода за пределы каталога, и она держится ровно на этом.
|
||||
- **Координатный ключ точки** — `метрика + слой + начало + конец`. Имя метрики
|
||||
приходит из тела и в путь на диске не попадает, но попадает в ключ, в лог и в
|
||||
ответ каталога. Любое значение из чужого JSON, попадающее в ключ, в лог или в
|
||||
отчёт, имеет названный предел длины.
|
||||
приходит из тела и в путь на диске не попадает, но попадает в ключ, в лог, в
|
||||
ответ каталога и — с появлением маршрута точек — **в адрес запроса и в
|
||||
заголовок `ETag` ответа**. Любое значение из чужого JSON, попадающее в ключ, в
|
||||
лог, в отчёт или в заголовок, имеет названный предел длины. У метки ответа
|
||||
предел взят формой: в неё уезжает не имя, а хеш канонизированной формы запроса
|
||||
(128 бит). Причина не только в длине — имя законно содержит кавычку, которая
|
||||
по RFC 9110 кончает метку, и разбор обрезал бы её ровно там.
|
||||
- **Имя метрики в адресе** декодируется из пути **ровно один раз**. Второе
|
||||
декодирование превращает имя `a%41b` в имя `aAb` — то есть в имя **другой**
|
||||
метрики витрины, и маршрут отвечает `200` её данными. Путь построен и прогнан
|
||||
враждебным проходом ревью.
|
||||
- **Ключ сущности** — `род секции + id` из HealthKit для `record`, `id` для
|
||||
`workout`. `id` приходит из тела.
|
||||
- **Ключ наблюдённого категориального значения** — `метрика + поле + значение`.
|
||||
Значение приходит из тела дословно и уезжает в первичный ключ: предел на него
|
||||
назван числом (128 байт), число различных значений одной доставки ограничено
|
||||
(64), и **граница применяется при накоплении, а не при выдаче** — иначе
|
||||
накопитель растёт вместе с телом, а тело контролирует отправитель (измерено:
|
||||
миллион различных значений в теле 60 МиБ поднимал пик процесса с 780 до
|
||||
1002 МиБ). Значение, которое разбор JSON подменил (невалидный UTF-8, одинокий
|
||||
суррогат), наблюдением не считается вовсе: в ключ обязано попасть то, что
|
||||
пришло, а не то, что получилось.
|
||||
- **Файл базы и каталог архива** — из конфига, не из запроса.
|
||||
|
||||
## Что разграничивает доступ
|
||||
|
||||
+43
-40
@@ -1,50 +1,53 @@
|
||||
# Беклог
|
||||
|
||||
Что **можно взять**. Одна задача = один файл `items/<slug>.md`
|
||||
+ строка здесь. Целей тут нет — они в [PLAN.md](PLAN.md): беклог — то, что берут,
|
||||
план — то, подо что берут. Порядка внутри секции нет: «что делать
|
||||
+ строка здесь. Целей тут нет — они в [ROADMAP.md](ROADMAP.md): беклог — то, что
|
||||
берут, роадмап — то, подо что берут. Порядка внутри секции нет: «что делать
|
||||
дальше» отвечает набор спринта. Ведётся скиллом `tasks`.
|
||||
|
||||
Секции «блокеры» здесь нет и не заводится: блокер — это состояние
|
||||
(спринт не может продолжаться ни одной задачей), оно живёт до ответа
|
||||
человека, а его следы — вопросами в файлах задач.
|
||||
|
||||
## ядро
|
||||
- [[idea] Человеческие аннотации поверх выведенных схем](items/schema-annotations.md) — Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
|
||||
- [Проверка целостности собранной витрины перед подменой](items/integrity-before-swap.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
|
||||
- [Цена слияния на широкой доставке](items/merge-cost-wide-delivery.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
|
||||
- [Сущность с id, но неразобранной меткой](items/entity-without-parsed-label.md) — Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым
|
||||
- [Идентичность тренировок при импорте родного экспорта](items/workout-identity-on-import.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
|
||||
- [Импорт родного экспорта Apple Health](items/apple-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
|
||||
- [MCP-сервер поверх Read API](items/mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||
- [[idea] Месячный проход по ручным секциям](items/monthly-manual-sections-pass.md) — Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
|
||||
- [[idea] NDJSON-поток для больших выборок Read API](items/ndjson-stream.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
|
||||
- [OpenAPI-спека и Swagger UI](items/openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
- [Data-миграции не отбирают строки по обрезаемым спискам](items/data-migration-row-selection.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
|
||||
- [[idea] Отказ от heartbeatSeries](items/drop-heartbeat-series.md) — 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
|
||||
- [Пересборка держит весь журнал в памяти](items/rebuild-memory-footprint.md) — Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
|
||||
- [[idea] Пересекающиеся источники одной метрики](items/overlapping-sources.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
|
||||
- [[idea] Порог sealed: с какого возраста час считается запечатанным](items/sealed-threshold.md) — WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
|
||||
- [Порядок журнала при конкурентных приёмах](items/journal-order-on-ingest.md) — Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
|
||||
- [Предел на размер и число заголовков доставки](items/delivery-header-limits.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
|
||||
- [Пределы на размер сущности и потоковый расчёт формы](items/entity-size-limits.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
|
||||
- [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](items/workout-routes-table.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
|
||||
- [Read API: точки, выбор слоя, свёртка по сетке](items/read-api-points.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
- [Выведенные из данных схемы содержимого](items/derived-content-schemas.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
||||
- [[idea] Что считать сутками при смене часового пояса](items/day-boundary-timezone.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
|
||||
- [Сверка живой витрины с пересборкой](items/rebuild-comparison-check.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
|
||||
- [Тай-брейк при равной полноте точек](items/tie-break-equal-completeness.md) — При равной полноте порядок канонических форм берёт меньшее значение в 96% случаев — у накопительных это систематический недосчёт
|
||||
- [Устаревание нижнего слоя после экспорта](items/lower-layer-expiry.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
- [[idea] Выгрузка в parquet отдельной командой](items/parquet-export.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
|
||||
- [Заголовки доставки в архиве рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
|
||||
## Ядро
|
||||
|
||||
## инфра
|
||||
- [Активный алерт «данных нет N часов»](items/stream-silence-alert.md) — Пропажу потока сейчас замечает человек, а не сервис
|
||||
- [Деплой на rivendell](items/deploy-rivendell.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома
|
||||
- [Счётчики слияния переживают ротацию логов](items/merge-counters-in-db.md) — единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
|
||||
- [Остановка и миграция: раздельные бюджеты и следы в логе](items/shutdown-and-migration-traces.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
|
||||
- [Чем откатывать релиз после наката миграции](items/release-rollback-after-migration.md) — Страж версии схемы делает возврат старого бинаря отказом, а понизить схему нечем — аварийный путь пришлось бы изобретать в аварии
|
||||
- [Ретеншен сырого архива](items/raw-archive-retention.md) — Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает
|
||||
- [Наблюдаемость: /stats](items/stats-endpoint.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
||||
- [Умолчания конфига указывают на прежнюю раскладку](items/config-defaults-data-dir.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
|
||||
- [Управление токенами и секретами](items/token-and-secret-management.md) — Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
|
||||
- [✨ Проверять целостность собранной витрины до подмены](items/integrity-before-swap.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
|
||||
- [🐞 Снизить цену слияния на широкой доставке](items/merge-cost-wide-delivery.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
|
||||
- [🐞 Не терять сущность с id и неразобранной меткой](items/entity-without-parsed-label.md) — Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым
|
||||
- [✨ Не задваивать тренировки при импорте родного экспорта](items/workout-identity-on-import.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
|
||||
- [✨ Импортировать родной экспорт Apple Health](items/apple-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
|
||||
- [🐞 Не отбирать строки в data-миграциях по обрезаемым спискам](items/data-migration-row-selection.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
|
||||
- [🧹 Не держать весь журнал в памяти при пересборке](items/rebuild-memory-footprint.md) — Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
|
||||
- [🐞 Держать порядок журнала при конкурентных приёмах](items/journal-order-on-ingest.md) — Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
|
||||
- [✨ Ограничить размер и число заголовков доставки](items/delivery-header-limits.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
|
||||
- [✨ Ограничить размер сущности и считать форму потоково](items/entity-size-limits.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
|
||||
- [✨ Выводить схемы содержимого из данных](items/derived-content-schemas.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
||||
- [✨ Сверять живую витрину с пересборкой](items/rebuild-comparison-check.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
|
||||
- [✨ Помечать нижний слой устаревшим после экспорта](items/lower-layer-expiry.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
- [🐞 Класть заголовки доставки в архив рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
|
||||
- [✨ Поднять MCP-сервер поверх Read API](items/mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||
- [✨ Написать OpenAPI-спеку руками](items/openapi-spec.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
- [🧹 Ловить гейтом расхождение спеки с маршрутами](items/openapi-gate-check.md) — Рукописная спека — источник истины, а расходится она с кодом молча: без гейта решение о рукописной спеке не держится
|
||||
- [✨ Поднять Swagger UI без внешней сети](items/swagger-ui.md) — Контракт читается машиной, но человеку нечем выполнить запрос к живому сервису из браузера, а внешних CDN в локальной сети нет
|
||||
- [🔬 Измерить, нужно ли правило полноты рядом с LWW](items/last-wins-over-completeness.md) — Полнота решает 1,2% спорных координат, и неизвестно, была ли более полная точка более поздней — от этого зависит, нужна ли она вообще
|
||||
- [🔬 Человеческие аннотации поверх выведенных схем](items/schema-annotations.md) — Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
|
||||
- [🔬 Месячный проход по ручным секциям](items/monthly-manual-sections-pass.md) — Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
|
||||
- [🔬 NDJSON-поток для больших выборок Read API](items/ndjson-stream.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
|
||||
- [🔬 Отказ от heartbeatSeries](items/drop-heartbeat-series.md) — 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
|
||||
- [🔬 Пересекающиеся источники одной метрики](items/overlapping-sources.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
|
||||
- [🔬 Порог sealed: с какого возраста час считается запечатанным](items/sealed-threshold.md) — WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
|
||||
- [🔬 Разворачивание маршрутов тренировок в отдельную таблицу](items/workout-routes-table.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
|
||||
- [🔬 Что считать сутками при смене часового пояса](items/day-boundary-timezone.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
|
||||
- [🔬 Выгрузка в parquet отдельной командой](items/parquet-export.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
|
||||
|
||||
## Инфра
|
||||
|
||||
- [✨ Слать уведомление, когда данных нет N часов](items/stream-silence-alert.md) — Пропажу потока сейчас замечает человек, а не сервис
|
||||
- [✨ Выложить сервис на rivendell](items/deploy-rivendell.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома
|
||||
- [✨ Хранить счётчики слияния вне логов](items/merge-counters-in-db.md) — Единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
|
||||
- [🐞 Развести бюджеты остановки и оставить следы миграции в логе](items/shutdown-and-migration-traces.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
|
||||
- [✨ Назвать механизм отката релиза после наката миграции](items/release-rollback-after-migration.md) — Страж версии схемы делает возврат старого бинаря отказом, а понизить схему нечем — аварийный путь пришлось бы изобретать в аварии
|
||||
- [✨ Подчищать сырой архив до последнего проверенного экспорта](items/raw-archive-retention.md) — Архив не подчищается вовсе, а резать его раньше даты проверенного экспорта нельзя — в журнале останется дыра, которую нечем пересобрать
|
||||
- [✨ Отдавать состояние сервиса маршрутом /stats](items/stats-endpoint.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
||||
- [🐞 Свести умолчания конфига с рабочей раскладкой данных](items/config-defaults-data-dir.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
|
||||
- [✨ Развести токены контуров и убрать секреты из репозитория](items/token-and-secret-management.md) — Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
|
||||
|
||||
@@ -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
|
||||
@@ -8,3 +8,9 @@
|
||||
- 2026-08-01 `bekap-dannyh` — Резервное копирование ./data. Причина: бекап обеспечивает готовый механизм на сервере пет-проектов — своего заводить не нужно, задача снимается деплоем. Была секция: высокий.
|
||||
- 2026-08-01 `identichnost-epizodnyh-metrik` — Идентичность эпизодных метрик. Причина: решён измерением и prior art: ключ эпизода — метрика+слой+start+end (находка 47), вариант А; вернулся в scope razbor-metrik-v-obekty. Была секция: блокеры.
|
||||
- 2026-08-01 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Была секция: блокеры.
|
||||
- 2026-08-04 `mcp` — 🎯 MCP. Причина: поглощена целью read-api («Чтение данных клиентами»): MCP — не направление, а последний шаг того же направления; адаптер переводит вызовы в те же обработчики и собственной логики не несёт. Очередь «Read API перед MCP» стала порядком задач внутри цели. Задача mcp-server жива и перевешена на read-api. Была секция: порядок.
|
||||
- 2026-08-04 `read-api-points` — Read API: точки, выбор слоя, свёртка по сетке. Причина: разложена на read-api-envelope-and-points (конверт, точки за период, форма провода, условный запрос), read-api-bucketing (свёртка по сетке, предел размера ответа, порог неполного ведра) и read-api-workouts-and-records (тренировки и записи наружу). Одним заходом не мерджилась: десяток критериев приёмки и три развилки в одном файле. Была секция: ядро.
|
||||
- 2026-08-04 `read-api-envelope-and-points` — Конверт ответа и точки за период. Причина: разложена на read-api-wire-format (форма провода, мерджится первой и трогает только живой каталог), read-api-points-period (точки за период с конвертом) и read-api-points-conditional (условный запрос со scope-etag). Была секция: ядро.
|
||||
- 2026-08-04 `read-api-bucketing` — Свёртка по сетке и предел размера ответа. Причина: разложена на read-api-points-bucket (свёртка по сетке), read-api-partial-bucket (порог неполного ведра и его полярность) и read-api-response-limit (предел размера ответа, общий для всех маршрутов чтения). Была секция: ядро.
|
||||
- 2026-08-04 `read-api-workouts-and-records` — Тренировки и записи наружу. Причина: разложена на read-api-workouts и read-api-records: разные сущности и разные маршруты, независимые друг от друга. Была секция: ядро.
|
||||
- 2026-08-04 `openapi-swagger` — OpenAPI-спека и Swagger UI. Причина: разложена на openapi-spec (рукописная спека), openapi-gate-check (гейт красит расхождение спеки с маршрутами) и swagger-ui (UI без внешней сети). Была секция: ядро.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Роадмап
|
||||
|
||||
Что приложение уже умеет и чего ещё не умеет. Цель — возможность приложения,
|
||||
файл типа `goal` в `items/`; её задачи здесь **не перечисляются** — перечень даёт
|
||||
`tasks.py list --goal <слаг>`. Очередь значима только в «Запланировано» и
|
||||
обосновывается прозой рядом. В «Сопровождении» лежит то, чем держат проект —
|
||||
выкладка, инструмент, эксплуатация; граница проходит по тому, кто наблюдает:
|
||||
сообщает ли о состоянии приложение своему пользователю или дежурный смотрит на
|
||||
сервис снаружи.
|
||||
|
||||
## Запланировано
|
||||
|
||||
Очередь держится на двух зависимостях. **`healthlog import` идёт перед чисткой
|
||||
нижнего слоя:** пока импорт экспорта не написан, помечать что-либо устаревшим не
|
||||
на основании чего. **MCP входит в чтение, а не идёт отдельной целью:** адаптер
|
||||
собственной логики не несёт, он переводит вызовы в те же обработчики, и очередь
|
||||
осталась порядком задач внутри цели.
|
||||
|
||||
- [🎯 Клиенты читают данные через HTTP и MCP](items/read-api.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей, включая агента-медика, ничего прочитать не может
|
||||
- [🎯 Клиент узнаёт форму данных из ответа](items/self-description.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
||||
- [🎯 История из родного экспорта Apple лежит в хранилище](items/native-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
|
||||
- [🎯 Нижний слой чистится после проверенного экспорта](items/lower-layer-cleanup.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
- [🎯 Приложение сообщает о своём состоянии](items/observability.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
||||
|
||||
## Направления
|
||||
|
||||
- [🎯 Исход слияния не зависит от порядка элементов на проводе](items/merge-robustness.md) — Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
|
||||
- [🎯 Расхождение витрины с журналом не молчит](items/journal-and-rebuild.md) — Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
|
||||
- [🎯 У каждого входа есть названный предел](items/limits-and-load.md) — Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
|
||||
- [🎯 Новая форма от источника не теряется молча](items/parsing-completeness.md) — Пять секций HAE поток ещё не приносил, а ручные секции заводятся задним числом — разбор полон ровно до следующей новой формы
|
||||
|
||||
## Сопровождение
|
||||
|
||||
- [🎯 Сервис доступен телефону из любой сети](items/deploy.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
|
||||
|
||||
## Готово
|
||||
|
||||
- 2026-08-01 `ingest` — Сервис принимает доставки HAE и кладёт тела в архив.
|
||||
Приём отвечает `200` до разбора, свёртку ведёт фоновый воркер: код ответа
|
||||
отражает доставку, а не её понимание.
|
||||
- 2026-08-02 `reindex` — `healthlog reindex` проигрывает журнал в свежую витрину
|
||||
и печатает оба отпечатка. Повторный прогон ничего не меняет.
|
||||
- 2026-08-02 `catalog` — Клиент видит перечень разрезов с измеренным родом
|
||||
агрегации. Сверка минутного слоя с часовым разложила метрики живого корпуса на
|
||||
накопительные и мгновенные, не сойдясь ни на одной.
|
||||
- 2026-08-04 `parsing-and-storage` — Метрики, тренировки и записи со своими `id`
|
||||
разобраны и лежат в часовых объектах. Ни одна секция живого потока не числится
|
||||
неразобранной, категориальные значения несут стабильный код рядом с
|
||||
переведённой строкой, первая встреча незнакомой секции наблюдаема.
|
||||
|
||||
Разведка формата закончена там же и записана в
|
||||
[research/apple-health.md](../research/apple-health.md): правило вывода слоя,
|
||||
модель идентичности и формы точки проверены на живом потоке. Возможностью
|
||||
приложения она не была, поэтому строки среди достигнутых целей не занимает.
|
||||
+11
-4
@@ -1,9 +1,16 @@
|
||||
# Спринт
|
||||
|
||||
**Цель:** [[goal] Разбор и хранилище](items/parsing-and-storage.md) · **Начат:** 2026-08-03 · **Спринт:** `2026-08-03`
|
||||
- **Цель:** [🎯 Клиенты читают данные через 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 его нет
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Импорт родного экспорта Apple Health
|
||||
# ✨ Импортировать родной экспорт Apple Health
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
|
||||
- **Теги:** goal:native-export-import
|
||||
|
||||
@@ -37,9 +38,36 @@
|
||||
должен ничего менять;
|
||||
- `export_cda.xml` игнорируем — это клинический формат тех же данных.
|
||||
|
||||
Двигает строку «Завершения» цели: «Слой `sample` наполнен историей с 2019 года, повторный импорт того же экспорта ничего не меняет».
|
||||
|
||||
## Импорт выставляет пометку покрытия
|
||||
|
||||
Импорт — единственный, кто знает, какой период каким слоем обеспечен, поэтому
|
||||
пометку ставит он, а не отдельный проход задним числом.
|
||||
|
||||
Форма пометки решена в [lower-layer-expiry](lower-layer-expiry.md): **одна
|
||||
строка на диапазон** — `метрика + слой + период + «покрыто проверенным
|
||||
экспортом»`. Провенанс на каждую точку не заводим: вопрос диапазонный, а поле у
|
||||
точки стоило бы того же объёма, который устаревание нижнего слоя и приходит
|
||||
экономить.
|
||||
|
||||
Два условия, оба из ограничителей той задачи:
|
||||
|
||||
- пометка ставится **по проверенному** импорту, а не по факту запуска команды.
|
||||
Проверка та же, что уже названа в приёмке: непрерывность по дням и сходимость
|
||||
сумм с часовым слоем HAE на пересечении периодов. Не сошлось — пометки нет,
|
||||
и это не отказ импорта, а честный отказ от обещания;
|
||||
- пометка **ничего не удаляет**. Она только даёт устареванию нижнего слоя
|
||||
основание; само удаление включается отдельно и позже.
|
||||
|
||||
**`stateOfMind` пометку не получает никогда** — его в экспорте Apple нет ни
|
||||
одним типом (находка 42), источник у него единственный, и устаревание к нему
|
||||
неприменимо. Это надо записать явно, а не оставить следовать из отсутствия
|
||||
данных.
|
||||
|
||||
Готово, когда история за несколько лет лежит в слое `sample`, повторный импорт
|
||||
не меняет ничего, а суммы по слою сходятся с часовым слоем HAE на пересечении
|
||||
периодов.
|
||||
не меняет ничего, суммы по слою сходятся с часовым слоем HAE на пересечении
|
||||
периодов, а покрытые периоды помечены и видны без пересборки.
|
||||
|
||||
Архивы: `/home/av/MediaEverything/HealthData/apple_health/` — девять штук,
|
||||
2021-12 … 2026-08. Старые версии формата годятся как регрессионный набор.
|
||||
|
||||
@@ -1,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`.
|
||||
@@ -1,6 +1,7 @@
|
||||
# Умолчания конфига указывают на прежнюю раскладку
|
||||
# 🐞 Свести умолчания конфига с рабочей раскладкой данных
|
||||
|
||||
- **Секция:** инфра
|
||||
- **Тип:** fix
|
||||
- **Категория:** Инфра
|
||||
- **Зачем:** Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
|
||||
- **Теги:** goal:deploy
|
||||
|
||||
@@ -16,3 +17,5 @@
|
||||
|
||||
Готово, когда запуск без конфига использует `./data` и не создаёт ничего в
|
||||
корне репозитория. Тогда же снимается предупреждение из `config.example.toml`.
|
||||
|
||||
Двигает строку «Завершения» цели: «Запуск без конфига не заводит базу мимо `./data`».
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
# Data-миграции не отбирают строки по обрезаемым спискам
|
||||
# 🐞 Не отбирать строки в data-миграциях по обрезаемым спискам
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** fix
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
|
||||
- **Теги:** goal:journal-and-rebuild
|
||||
|
||||
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
|
||||
`dozakryt-nahodki-sushchnostej`).
|
||||
|
||||
Двигает строку «Завершения» цели: «Data-миграции не наследуют слепые зоны обрезаемых списков».
|
||||
|
||||
## Оракул: механизм доказан, дефект пока пустой
|
||||
|
||||
Миграция `00007` переводит в `pending` доставки, у которых имя ставшей покрытой
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# [idea] Что считать сутками при смене часового пояса
|
||||
# 🔬 Что считать сутками при смене часового пояса
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** research
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
|
||||
- **Теги:** goal:read-api
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Предел на размер и число заголовков доставки
|
||||
# ✨ Ограничить размер и число заголовков доставки
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
|
||||
- **Теги:** goal:limits-and-load
|
||||
|
||||
@@ -27,3 +28,5 @@
|
||||
не оставляя следа в базе, а обычная доставка проходит как раньше.
|
||||
|
||||
Связано: `internal/httpapi`, `internal/ingest`, `docs/architecture.md` → «Приём».
|
||||
|
||||
Двигает строку «Завершения» цели: «У заголовков доставки есть названный предел».
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Заголовки доставки в архиве рядом с телом
|
||||
# 🐞 Класть заголовки доставки в архив рядом с телом
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** fix
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
|
||||
- **Теги:** goal:journal-and-rebuild
|
||||
|
||||
@@ -32,7 +33,7 @@ Prior art прямой: **WARC** (формат веб-архивов) храни
|
||||
операциям, но появляется третья сущность.
|
||||
|
||||
Цена ошибки высокая: правится **путь приёма**, а доставка, не попавшая в архив,
|
||||
теряется навсегда. Значит профиль ревью — `deep`, и менять надо так, чтобы
|
||||
теряется навсегда. Значит менять надо так, чтобы
|
||||
старые тела без заголовков продолжали читаться.
|
||||
|
||||
Готово, когда пересборка на архиве, у которого рабочей базы нет вовсе, даёт то
|
||||
@@ -40,3 +41,5 @@ Prior art прямой: **WARC** (формат веб-архивов) храни
|
||||
|
||||
Связано: `docs/architecture.md` → «Сырой архив и восстановление состояния»,
|
||||
`internal/replay`.
|
||||
|
||||
Двигает строку «Завершения» цели: «Пересборка восстановима без базы: заголовки доставки лежат в архиве рядом с телом».
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Деплой на rivendell
|
||||
# ✨ Выложить сервис на rivendell
|
||||
|
||||
- **Секция:** инфра
|
||||
- **Тип:** feature
|
||||
- **Категория:** Инфра
|
||||
- **Зачем:** Сервис живёт на рабочей машине — телефон достаёт до него только дома
|
||||
- **Теги:** goal:deploy
|
||||
|
||||
@@ -30,3 +31,5 @@
|
||||
Том стоит смонтировать так, чтобы серверный бекап забирал его без отдельной
|
||||
настройки, и снимать копию SQLite через `VACUUM INTO`, а не `cp`: телефон шлёт
|
||||
непрерывно, и файл под записью копировать нельзя.
|
||||
|
||||
Двигает строку «Завершения» цели: «Телефон шлёт на публичный адрес из любой сети, агент читает по тому же домену».
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
# [goal] Деплой
|
||||
# 🎯 Сервис доступен телефону из любой сети
|
||||
|
||||
- **Секция:** порядок
|
||||
- **Тип:** goal
|
||||
- **Секция:** Сопровождение
|
||||
- **Зачем:** Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
|
||||
- **Теги:** decomposed
|
||||
|
||||
Сервис переезжает на rivendell и становится доступен телефону из любой сети.
|
||||
|
||||
Выведена из шага 11 плана.
|
||||
|
||||
Завершена, когда оба контура закрыты разными токенами, откат релиза имеет
|
||||
названный механизм, а запуск без конфига не заводит базу мимо данных.
|
||||
|
||||
## Завершение
|
||||
|
||||
Оба контура закрыты разными токенами, откат релиза имеет названный механизм,
|
||||
а запуск без конфига не заводит базу мимо данных.
|
||||
- Телефон шлёт на публичный адрес из любой сети, агент читает по тому же домену
|
||||
- Оба контура закрыты разными токенами, и без токенов сервис стартует только на
|
||||
localhost
|
||||
- Откат релиза после наката миграции имеет названный механизм
|
||||
- Запуск без конфига не заводит базу мимо `./data`
|
||||
- Остановка сервиса называет виновный этап честно, а накат миграций виден в логе
|
||||
старта
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Выведенные из данных схемы содержимого
|
||||
# ✨ Выводить схемы содержимого из данных
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
||||
- **Теги:** goal:self-description
|
||||
|
||||
@@ -25,3 +26,5 @@
|
||||
|
||||
Связано: `docs/architecture.md` → «Самоописание». Форму конверта API описывает
|
||||
не эта задача, а OpenAPI.
|
||||
|
||||
Двигает строку «Завершения» цели: «Формы содержимого метрик выведены из данных, а не описаны руками».
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# [idea] Отказ от heartbeatSeries
|
||||
# 🔬 Отказ от heartbeatSeries
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** research
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
|
||||
- **Теги:** goal:lower-layer-cleanup
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Пределы на размер сущности и потоковый расчёт формы
|
||||
# ✨ Ограничить размер сущности и считать форму потоково
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
|
||||
- **Теги:** goal:limits-and-load
|
||||
|
||||
@@ -10,6 +11,8 @@
|
||||
структурное: **предела на размер одной сущности нет вовсе**, а форма и хеш
|
||||
считаются материализацией значения целиком.
|
||||
|
||||
Двигает строку «Завершения» цели: «У тела, сущности и секции доставки есть названный предел».
|
||||
|
||||
## Оракул: измерено
|
||||
|
||||
Оракулы жили в `tmp/adv/mem_test.go` и `tmp/adv/lock_test.go`; числа снимались
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Сущность с id, но неразобранной меткой
|
||||
# 🐞 Не терять сущность с id и неразобранной меткой
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** fix
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым
|
||||
- **Теги:** goal:parsing-and-storage
|
||||
- **Теги:** goal:parsing-completeness
|
||||
|
||||
**Решение принято владельцем 2026-08-03: вариант (1) — хранить с NULL-меткой.**
|
||||
`start_utc`/`ts_utc` становятся NULLABLE, содержимое (включая маршрут) хранится,
|
||||
@@ -10,7 +11,7 @@
|
||||
отвергнут при постановке: подстановка метки доставки — выдуманное измерение в
|
||||
колонке, по которой идёт выборка.
|
||||
|
||||
**Берётся после [Read API по точкам и сущностям](read-api-points.md).** Правило
|
||||
**Берётся после [тренировок](read-api-workouts.md) и [записей](read-api-records.md) наружу.** Правило
|
||||
чтения — что выборка «за период» делает со строками без метки — обязано
|
||||
проектироваться вместе с читателем, иначе такие строки молча исчезнут из любого
|
||||
ответа. Порядок тот же, что у [journal-order-on-ingest](journal-order-on-ingest.md)
|
||||
@@ -22,6 +23,8 @@
|
||||
разбор кладёт сущность в `ts_utc`/`start_utc`, колонки `NOT NULL`, и сущность
|
||||
с неразбираемой меткой по-прежнему пропускается целиком.
|
||||
|
||||
Двигает строку «Завершения» цели: «Сущность с `id` и неразобранной меткой не пропадает целиком».
|
||||
|
||||
## Что известно
|
||||
|
||||
- Оракул: `internal/hae/entity_test.go`, случаи «метка в ином формате», «метка
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Проверка целостности собранной витрины перед подменой
|
||||
# ✨ Проверять целостность собранной витрины до подмены
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
|
||||
- **Теги:** goal:journal-and-rebuild
|
||||
|
||||
@@ -27,3 +28,5 @@
|
||||
называть результат годным, а на здоровом — не замедляется заметно.
|
||||
|
||||
Связано: `cmd/healthlog/reindex.go`, `docs/architecture.md` → «Пересборка».
|
||||
|
||||
Двигает строку «Завершения» цели: «Годность собранной витрины подтверждена до подмены файла».
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
# [goal] Журнал и пересборка
|
||||
# 🎯 Расхождение витрины с журналом не молчит
|
||||
|
||||
- **Секция:** темы
|
||||
- **Тип:** goal
|
||||
- **Секция:** Направления
|
||||
- **Зачем:** Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
|
||||
- **Теги:** decomposed
|
||||
|
||||
Тема: инвариант «`import` + `replay` даёт то же состояние» и всё, что его
|
||||
Направление: инвариант «`import` + `replay` даёт то же состояние» и всё, что его
|
||||
держит — архив, ретеншен, отпечаток витрины, расход памяти пересборки.
|
||||
|
||||
В порядок не встаёт: работа приходит находками и растёт вместе с
|
||||
В «Запланировано» не встаёт: работа приходит находками и растёт вместе с
|
||||
журналом.
|
||||
|
||||
Завершена не бывает: закрывается по мере того, как расхождение витрины с
|
||||
журналом перестаёт быть молчащим.
|
||||
|
||||
## Завершение
|
||||
|
||||
Завершена не бывает — это тема. Закрывается по мере того, как расхождение витрины
|
||||
с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с
|
||||
журналом.
|
||||
Завершена не бывает — это направление. Закрывается по мере того, как расхождение
|
||||
витрины с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с
|
||||
журналом. Открыто сегодня:
|
||||
|
||||
- Расхождение живой витрины с пересборкой замечает сервис, а не человек
|
||||
- Годность собранной витрины подтверждена до подмены файла
|
||||
- Порядок журнала держится при конкурентных приёмах
|
||||
- Пересборка восстановима без базы: заголовки доставки лежат в архиве рядом с
|
||||
телом
|
||||
- Сырой архив подчищается до последнего проверенного экспорта
|
||||
- Расход пересборки не растёт вместе с журналом
|
||||
- Data-миграции не наследуют слепые зоны обрезаемых списков
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Порядок журнала при конкурентных приёмах
|
||||
# 🐞 Держать порядок журнала при конкурентных приёмах
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** fix
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
|
||||
- **Теги:** goal:journal-and-rebuild
|
||||
- **Теги:** goal:journal-and-rebuild, question
|
||||
|
||||
**Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.**
|
||||
До появления наблюдаемости живём вариантом (г) с уже записанным в спеке
|
||||
@@ -13,6 +14,72 @@
|
||||
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
|
||||
`deep`, враждебный проход, находка с построенным путём и прогоном).
|
||||
|
||||
Двигает строку «Завершения» цели: «Порядок журнала держится при конкурентных приёмах».
|
||||
|
||||
## Вопросы
|
||||
|
||||
**Решение (в) порядок журнала не восстанавливает, а цена окна выросла.**
|
||||
Записано 2026-08-04 задачей `tie-break-equal-completeness`.
|
||||
|
||||
Что случилось. Тай-брейк точек при равной полноте сменён на «побеждает
|
||||
пришедшая»: байтовый порядок системно хранил меньшее значение и стоил
|
||||
`step_count` его рода. Плата за это названа и внесена — порядок свёртки
|
||||
приведён к порядку журнала: проход воркера прекращается на первой отложенной
|
||||
занятостью доставке, а не перешагивает её. Это закрыло ту половину окна,
|
||||
которая была во власти воркера.
|
||||
|
||||
Вторая половина осталась и закрывается только на приёме: `received_at`
|
||||
фиксируется при выпуске ULID, строка учёта становится видимой после записи тела
|
||||
(184 мс на 62 МиБ), поэтому при конкурентном приёме доставка с более ранней
|
||||
меткой сворачивается позже своей преемницы.
|
||||
|
||||
**Что изменилось по сравнению с постановкой ниже.** Прежде цена окна была узкой:
|
||||
доставка без плотных метрик не выводила слой и уходила в `failed` — класс редкий
|
||||
(только автоматизации без плотных метрик). Теперь та же перестановка оставляет в
|
||||
витрине значение не той доставки, что стоит в журнале последней, — **у любой
|
||||
метрики**. Расхождение живой витрины с пересборкой перестало быть свойством
|
||||
редкого класса и стало свойством любого столкновения равной полноты, то есть
|
||||
98,8% спорных координат (находка 54).
|
||||
|
||||
**Почему это вопрос, а не работа.** Выбранный вариант **(в)** — повторы при
|
||||
`ErrLayerUnknown` — лечит невыводимый слой, но порядок журнала не
|
||||
восстанавливает: доставка всё равно сворачивается после своей преемницы, просто
|
||||
не уходит в `failed`. Порядок восстанавливают только **(а)** (резервировать
|
||||
строку учёта в начале `Accept`) и **(б)** (выдержка перед свёрткой). То есть
|
||||
после реализации (в) заявленное равенство «пересборка = приём» останется
|
||||
недостижимым, а спека хранения будет обещать его условно.
|
||||
|
||||
**Что сделано вместо, чтобы не молчать.** Воркер перед свёрткой спрашивает
|
||||
журнал, есть ли доставка позже этой в статусе `parsed` или `partial`; есть —
|
||||
пишется `WARN` с идентификатором. Расхождение стало наблюдаемым и лечится
|
||||
`healthlog reindex`. Это страж окна, и его сносят вместе с окном.
|
||||
|
||||
**Варианты и цена — те же, что ниже, плюс четвёртый.**
|
||||
|
||||
- **(в), как решено** — окно живёт, наблюдается `WARN`, лечится пересборкой.
|
||||
Дёшево; цена — «витрина есть свёртка журнала» держится на прогоне, который в
|
||||
гейт не входит.
|
||||
- **(а)** — резервировать строку учёта до записи тела. Закрывает окно совсем.
|
||||
Цена: ломается инвариант «тело на диск раньше строки учёта», появляется
|
||||
состояние «строка есть, тела нет», которое обязаны понимать пересборка и
|
||||
ретеншен.
|
||||
- **(а′)** — не резервировать, а **сериализовать** выпуск ULID вместе с записью
|
||||
тела и вставкой строки: тогда видимость строк монотонна вместе с метками, а
|
||||
инвариант «тело раньше строки» сохраняется. Цена: приём становится
|
||||
последовательным, и батч-доставки HAE выстраиваются в очередь (184 мс на
|
||||
62 МиБ на доставку).
|
||||
- **Провенанс на объект** (не на точку) — колонка с позицией журнала у часового
|
||||
объекта, тай-брейк по ней, как у сущностей. Правило снова становится
|
||||
коммутативным, окно перестаёт быть дефектом, барьер и `WARN` не нужны. Цена:
|
||||
миграция и смена формата, которую решение владельца 2026-08-04 запретило по
|
||||
бюджету, — но запрет там назван бюджетным, а не принципиальным.
|
||||
|
||||
**Рекомендация.** Пересмотреть (в) в пользу **(а′)**: он единственный закрывает
|
||||
окно, не трогая ни схему, ни инвариант «тело раньше строки». Если
|
||||
последовательный приём неприемлем по задержке — тогда провенанс на объект, а не
|
||||
жизнь с условным равенством: сегодня его проверяет один прогон, который гоняют
|
||||
руками.
|
||||
|
||||
## Что происходит
|
||||
|
||||
Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# 🔬 Измерить, нужно ли правило полноты рядом с LWW
|
||||
|
||||
- **Тип:** research
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Полнота решает 1,2% спорных координат, и неизвестно, была ли более полная точка более поздней — от этого зависит, нужна ли она вообще
|
||||
- **Теги:** goal:merge-robustness, sprint:2026-08-03
|
||||
|
||||
Правило слияния перестаёт зависеть от того, чей набор полей богаче, — либо
|
||||
зависит ровно там, где замер показал, что без этого теряются данные. Сегодня
|
||||
неизвестно, какой из двух случаев верен.
|
||||
|
||||
Двигает строку «Завершения» цели: «Правило выбора между версиями измерено: полнота либо нужна, либо снята».
|
||||
|
||||
## Откуда задача
|
||||
|
||||
Владелец предложил 2026-08-04 держаться стратегии **LWW** («выигрывает
|
||||
последняя»): экспорт Apple
|
||||
Health — база снапшота, новые доставки HAE затирают предыдущие. Это отменяет
|
||||
`critical`-инвариант `CLAUDE.md` «при столкновении выигрывает более полная
|
||||
точка, а не последняя», и потому меняется не молча, а этой задачей.
|
||||
|
||||
Соседняя задача «Тай-брейк при равной полноте» двигала то же правило в ту же
|
||||
сторону, но осторожнее: она поменяла только тай-брейк при **равной** полноте,
|
||||
оставив саму полноту первичной. Она сделана 2026-08-04 — решение записано в
|
||||
[ADR о тай-брейке по порядку журнала](../../adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md).
|
||||
Эта задача решает, надо ли снимать и саму полноту.
|
||||
|
||||
## Замер — первый шаг, и от него ветвится всё остальное
|
||||
|
||||
Из 84 978 спорных координат живого архива (замер 2026-08-04, `tmp/diag`):
|
||||
|
||||
| | координат |
|
||||
| --- | --- |
|
||||
| решено полнотой | 1 022 (1,2%) |
|
||||
| упало на тай-брейк | 83 956 (98,8%) |
|
||||
|
||||
Вопрос ровно один: **в этих 1 022 случаях более полная точка была более поздней
|
||||
или более ранней?**
|
||||
|
||||
- **Всегда более поздней** — полнота ничего не решает сверх порядка, LWW
|
||||
строго проще и ничего не теряет. Ветка полноты удаляется, инвариант в
|
||||
`CLAUDE.md` переписывается.
|
||||
- **Иногда более ранней** — значит HAE присылает обеднённые версии задним
|
||||
числом, и LWW будет молча стирать поля. Тогда полнота остаётся, а
|
||||
граница её применения записывается числом: сколько таких случаев, у каких
|
||||
метрик, какие поля пропадали.
|
||||
|
||||
Замер обязан различать **точки метрик** и **сущности** (`workouts`,
|
||||
`stateOfMind`): у сущностей отношение другое — покрытие, код другой
|
||||
(`internal/store/winner.go`), и он не мерялся вовсе.
|
||||
|
||||
Оба исхода — законный результат задачи. Исход «полнота нужна» не считается
|
||||
провалом и не отменяет предложение владельца: он его уточняет границей.
|
||||
|
||||
## Две рамки, без которых «экспорт — источник правды» ломает работающее
|
||||
|
||||
Обе выведены при постановке и в замере не нуждаются:
|
||||
|
||||
1. **По времени.** Экспорт — снапшот на дату выгрузки; доставки HAE после этой
|
||||
даты обязаны его перекрывать, иначе новые данные не доедут. Совместимо с
|
||||
инвариантом «хранилище — свёртка по журналу»: `import(экспорт) +
|
||||
replay(доставки по received_at)`.
|
||||
2. **По типам.** `stateOfMind` в экспорте Apple отсутствует ни одним типом
|
||||
(измерено, `docs/research/apple-health.md`). Для него единственный источник —
|
||||
доставки HAE, и объявить экспорт источником правды для него нельзя.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- для 1 022 координат, где полнота решила исход, названо число: в скольких из
|
||||
них более полная точка была более поздней — оракул: прогон замера на живом
|
||||
архиве, число воспроизводится вторым прогоном
|
||||
- тот же вопрос отвечён отдельно для сущностей (`workouts`, `stateOfMind`) —
|
||||
оракул: тот же прогон, отдельная колонка
|
||||
- правило слияния приведено к исходу замера, и `CLAUDE.md` говорит то же, что
|
||||
делает код — оракул: глазами, сверка формулировки инварианта с реализацией
|
||||
- повторный прогон живого архива даёт тот же отпечаток, живая свёртка равна
|
||||
пересборке — оракул: `task verify:archive` дважды подряд
|
||||
- ни одна метрика не потеряла род из-за изменения правила — оракул:
|
||||
`task verify:archive`, ноль противоречащих часов
|
||||
|
||||
## Рамки
|
||||
|
||||
Схему не трогаем. Отпечаток витрины изменится — пересборка обязательна и
|
||||
делается человеком при остановленном сервисе; подмена файла базы необратима и в
|
||||
задаче не выполняется. Тай-брейк при равной полноте уже влит, поэтому замер
|
||||
отвечает про действующее правило, а не про снятое.
|
||||
|
||||
Связано: находки 10, 47, 49, 53; `docs/architecture.md` → «Разрешение
|
||||
столкновений»; `docs/review.md`, запись 2026-08-04.
|
||||
@@ -1,18 +1,20 @@
|
||||
# [goal] Пределы и поведение под объёмом
|
||||
# 🎯 У каждого входа есть названный предел
|
||||
|
||||
- **Секция:** темы
|
||||
- **Тип:** goal
|
||||
- **Секция:** Направления
|
||||
- **Зачем:** Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
|
||||
- **Теги:** decomposed
|
||||
|
||||
Тема: названные пределы на размер тела, сущности, заголовков и ответа плюс
|
||||
Направление: названные пределы на размер тела, сущности, заголовков и ответа плюс
|
||||
поведение под удерживаемой блокировкой.
|
||||
|
||||
В порядок не встаёт: пределы всплывают замерами, а не планом.
|
||||
|
||||
Завершена не бывает: закрывается по мере того, как каждый вход получает
|
||||
названный предел вместо подразумеваемого.
|
||||
В «Запланировано» не встаёт: предел находит замер, а не очередь.
|
||||
|
||||
## Завершение
|
||||
|
||||
Завершена не бывает — это тема. Закрывается по мере того, как каждый вход получает
|
||||
названный предел вместо подразумеваемого.
|
||||
Завершена не бывает — это направление. Закрывается по мере того, как каждый вход
|
||||
получает названный предел вместо подразумеваемого. Открыто сегодня:
|
||||
|
||||
- У тела, сущности и секции доставки есть названный предел
|
||||
- У заголовков доставки есть названный предел
|
||||
- Занятость базы не выводит доставку из очереди
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
# [goal] Устаревание нижнего слоя
|
||||
# 🎯 Нижний слой чистится после проверенного экспорта
|
||||
|
||||
- **Секция:** порядок
|
||||
- **Тип:** goal
|
||||
- **Секция:** Запланировано
|
||||
- **Зачем:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
- **Теги:** decomposed
|
||||
|
||||
После проверенного экспорта нижний слой HAE избыточен и подлежит чистке.
|
||||
После проверенного экспорта нижний слой HAE избыточен, и его можно чистить.
|
||||
|
||||
Выведена из шага 9 плана. Нижний слой растёт на ~100 тысяч координат в сутки.
|
||||
|
||||
Завершена, когда чистка идёт по правилу, а не по календарю, и решение о
|
||||
удалении опирается на колонку, отличающую ноль от «не измерялось».
|
||||
Нижний слой растёт на ~100 тысяч координат в сутки.
|
||||
|
||||
## Завершение
|
||||
|
||||
Чистка идёт по правилу «до следующего проверенного экспорта», а не по
|
||||
календарю, и решение об удалении опирается на колонку, отличающую ноль от
|
||||
«не измерялось».
|
||||
- Нижний слой помечен покрытым после проверенного экспорта
|
||||
- Чистка идёт по правилу «до следующего проверенного экспорта», а не по календарю
|
||||
- Решение об удалении опирается на колонку, отличающую ноль от «не измерялось»
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Устаревание нижнего слоя после экспорта
|
||||
# ✨ Помечать нижний слой устаревшим после экспорта
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
|
||||
- **Теги:** goal:lower-layer-cleanup
|
||||
|
||||
@@ -17,7 +18,42 @@
|
||||
- пометка ≠ удаление. Удаление включается только после того, как восстановление
|
||||
из экспорта отработает на живых данных хотя бы раз.
|
||||
|
||||
Двигает строку «Завершения» цели: «Нижний слой помечен покрытым после проверенного экспорта».
|
||||
|
||||
## Чем помечать: разряд на диапазон, а не провенанс на точку
|
||||
|
||||
Решено при постановке 2026-08-04. Пометка — **одна строка на диапазон**:
|
||||
`метрика + слой + период + «покрыто проверенным экспортом»`. Не поле у точки.
|
||||
|
||||
Основание — соотношение цены и потребности:
|
||||
|
||||
- **вопрос, на который надо ответить, диапазонный**: «за этот период нижний
|
||||
слой обеспечен настоящими сэмплами Apple, посекундную развёртку HAE можно
|
||||
выбросить». Он не требует знать, из какой доставки приехало конкретное число;
|
||||
- **цена совпадает с самой проблемой**: нижний слой растёт на ~100 тысяч
|
||||
координат в сутки, и поле у точки платит тем же объёмом, который задача и
|
||||
пришла экономить. Пометка на диапазон — десятки строк.
|
||||
|
||||
**Провенанс на точку рассмотрен и отвергнут по цене, а не по ненадобности.**
|
||||
Различать эти два основания важно: отказ по ненадобности закрывает вопрос
|
||||
навсегда, отказ по цене — только до появления потребителя. Появится тот, кому
|
||||
нужно «покажи, из какой конкретно доставки это число», — решение
|
||||
пересматривается. Сегодня такого потребителя нет: ни агент-медик, ни трекер, ни
|
||||
игра его не просят ([passport.md](../../passport.md)).
|
||||
|
||||
Отдельно стоит помнить, что **отделить старое от нового можно и без пометок**:
|
||||
состояние по определению есть `import(экспорт) + replay(доставок по
|
||||
received_at)`, порядок известен, происхождение значения выводится пересборкой.
|
||||
Пометка нужна ровно затем, чтобы отвечать на этот вопрос **при чтении**, не
|
||||
пересчитывая.
|
||||
|
||||
Смежное: у сущностей (`workouts`, `stateOfMind`) провенанс уже есть — колонки
|
||||
`delivery_id` и `delivery_received_at` (миграция `00007`). У часового объекта
|
||||
метрики есть `first_delivery_id` (миграция `00003`), но это **первая** доставка,
|
||||
а не источник каждой точки, и для этой задачи он не годится.
|
||||
|
||||
Приоритет низкий: пока история измеряется днями, экономить нечего. Задача
|
||||
станет актуальной, когда нижний слой перевалит за несколько гигабайт.
|
||||
|
||||
Зависит от импорта экспорта Apple — до него помечать нечем.
|
||||
Зависит от импорта экспорта Apple — до него помечать нечем; выставляет пометку
|
||||
[apple-export-import](apple-export-import.md).
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# MCP-сервер поверх Read API
|
||||
# ✨ Поднять MCP-сервер поверх Read API
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро — набор ограничен HTTP-слоем чтения после дробления; адаптер берётся следующим спринтом по той же цели
|
||||
- **Зачем:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||
- **Теги:** goal:mcp
|
||||
- **Теги:** goal:read-api
|
||||
|
||||
Агент-медик — первый заказчик проекта. Ему нужна актуальная сводка, а не срез
|
||||
на дату последнего ручного экспорта.
|
||||
|
||||
Транспорт — **Streamable HTTP**, не stdio: сервис живёт на VPS, агент ходит по
|
||||
сети. Отсюда: MCP — эндпоинт того же процесса и того же порта, аутентификация —
|
||||
сети. Отсюда: MCP — маршрут того же процесса и того же порта, аутентификация —
|
||||
тот же токен чтения, что у Read API. Отдельного контура доступа не заводим:
|
||||
MCP не даёт ничего, чего не даёт HTTP, и права обязаны совпадать.
|
||||
|
||||
@@ -21,4 +22,27 @@ MCP не даёт ничего, чего не даёт HTTP, и права об
|
||||
Готово, когда агент подключается по URL и отвечает на «как я спал на прошлой
|
||||
неделе» без промежуточного кода.
|
||||
|
||||
Связано: `docs/architecture.md` → «MCP», план → шаг «MCP».
|
||||
Двигает строку «Завершения» цели: «Агент-медик читает то же самое через MCP тем же токеном чтения».
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- живой агент подключается по URL и отвечает на «как я спал на прошлой неделе»
|
||||
без промежуточного кода — оракул: подключение реального MCP-клиента к
|
||||
поднятому сервису
|
||||
- вызов инструмента и соответствующий HTTP-запрос дают одни и те же данные —
|
||||
оракул: тест, сравнивающий выход инструмента с ответом маршрута на тех же
|
||||
параметрах
|
||||
- запрос без токена чтения отклоняется обоими транспортами одинаково — оракул:
|
||||
тест на паре «MCP без токена / HTTP без токена»
|
||||
- правило размера ответа действует и в MCP: слишком широкий запрос получает
|
||||
названную сетку или ошибку со списком, а не обрезанный ответ — оракул: тест на
|
||||
запросе за пределом
|
||||
|
||||
## Рамки
|
||||
|
||||
Схема не трогается, данные только читаются, сервис перезапускается. Собственной
|
||||
логики адаптер не несёт — новое поведение здесь признак того, что оно должно
|
||||
было появиться в маршруте чтения. Берётся последней в цели: переводить нечего,
|
||||
пока обработчиков нет.
|
||||
|
||||
Связано: `docs/architecture.md` → «MCP».
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# [goal] MCP
|
||||
|
||||
- **Секция:** порядок
|
||||
- **Зачем:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||
- **Теги:** decomposed
|
||||
|
||||
Агент-медик — первый заказчик проекта — подключается к хранилищу.
|
||||
|
||||
Выведена из шага 7 плана. Идёт после Read API намеренно: адаптер собственной
|
||||
логики не несёт, он переводит вызовы в те же обработчики, и переводить пока
|
||||
нечего.
|
||||
|
||||
Завершена, когда агент читает данные через MCP тем же токеном чтения.
|
||||
|
||||
## Завершение
|
||||
|
||||
Агент читает данные через MCP тем же токеном чтения, и собственной логики
|
||||
адаптер не несёт.
|
||||
@@ -1,12 +1,15 @@
|
||||
# Цена слияния на широкой доставке
|
||||
# 🐞 Снизить цену слияния на широкой доставке
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** fix
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
|
||||
- **Теги:** goal:limits-and-load
|
||||
|
||||
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный
|
||||
проход и независимая реализация — независимо друг от друга).
|
||||
|
||||
Двигает строку «Завершения» цели: «Занятость базы не выводит доставку из очереди».
|
||||
|
||||
## Оракул: измерено
|
||||
|
||||
Тело 63 МБ (в запросе ~200 КБ gzip — предел приёма 64 МиБ), 119 точек на ОДНОЙ
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
# Счётчики слияния переживают ротацию логов
|
||||
# ✨ Хранить счётчики слияния вне логов
|
||||
|
||||
- **Секция:** инфра
|
||||
- **Зачем:** единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
|
||||
- **Тип:** feature
|
||||
- **Категория:** Инфра
|
||||
- **Зачем:** Единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
|
||||
- **Теги:** goal:observability
|
||||
|
||||
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, проход
|
||||
негативного пространства, подтверждено эксплуатационным).
|
||||
|
||||
Двигает строку «Завершения» цели: «Счётчики слияния переживают ротацию логов».
|
||||
|
||||
## Что не так
|
||||
|
||||
Решение не реализовывать объединение полей при несравнимых наборах стоит на
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
# [goal] Прочность слияния и идентичности
|
||||
# 🎯 Исход слияния не зависит от порядка элементов на проводе
|
||||
|
||||
- **Секция:** темы
|
||||
- **Тип:** goal
|
||||
- **Секция:** Направления
|
||||
- **Зачем:** Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
|
||||
- **Теги:** decomposed
|
||||
|
||||
Тема: правила, по которым две версии одних данных превращаются в одну.
|
||||
В порядок не встаёт — работа приходит находками ревью и замерами на
|
||||
Направление: правила, по которым две версии одних данных превращаются в одну.
|
||||
В «Запланировано» не встаёт — очереди у направления нет: работа приходит находками ревью и замерами на
|
||||
живом корпусе.
|
||||
|
||||
Завершена не бывает: закрывается по мере того, как правила перестают зависеть
|
||||
от порядка на проводе.
|
||||
|
||||
## Завершение
|
||||
|
||||
Завершена не бывает — это тема. Закрывается по мере того, как правила выбора между
|
||||
версиями перестают зависеть от порядка элементов на проводе.
|
||||
Завершена не бывает — это направление. Закрывается по мере того, как правила
|
||||
выбора между версиями перестают зависеть от порядка элементов на проводе.
|
||||
Открыто сегодня:
|
||||
|
||||
- Правило выбора между версиями измерено: полнота либо нужна, либо снята
|
||||
- Порог `sealed` выбран по накопленной статистике досчёта
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# [idea] Месячный проход по ручным секциям
|
||||
# 🔬 Месячный проход по ручным секциям
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** research
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
|
||||
- **Теги:** goal:parsing-and-storage
|
||||
- **Теги:** goal:parsing-completeness
|
||||
|
||||
Окно досчёта не единое, и это измеренное различие, а не предположение.
|
||||
Количественные метрики (пульс, шаги, энергия) человек руками не правит — они
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
# [goal] Импорт родного экспорта Apple
|
||||
# 🎯 История из родного экспорта Apple лежит в хранилище
|
||||
|
||||
- **Секция:** порядок
|
||||
- **Тип:** goal
|
||||
- **Секция:** Запланировано
|
||||
- **Зачем:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
|
||||
- **Теги:** decomposed
|
||||
|
||||
`healthlog import`: снапшот всей истории из родного экспорта Apple Health
|
||||
ложится в хранилище перед проигрыванием хвоста доставок.
|
||||
|
||||
Выведена из шага 8 плана. Идёт перед устареванием нижнего слоя намеренно: пока
|
||||
Идёт перед чисткой нижнего слоя намеренно: пока
|
||||
импорт экспорта не написан, помечать что-либо устаревшим не на основании чего.
|
||||
|
||||
Завершена, когда слой `sample` наполнен историей с 2019 года, а повторный
|
||||
импорт того же экспорта ничего не меняет.
|
||||
|
||||
## Завершение
|
||||
|
||||
Слой `sample` наполнен историей с 2019 года, повторный импорт того же экспорта
|
||||
ничего не меняет, а тренировки из экспорта не задваивают приехавшие от HAE.
|
||||
- Слой `sample` наполнен историей с 2019 года
|
||||
- Повторный импорт того же экспорта ничего не меняет
|
||||
- Тренировки из экспорта не задваивают приехавшие от HAE
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# [idea] NDJSON-поток для больших выборок Read API
|
||||
# 🔬 NDJSON-поток для больших выборок Read API
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** research
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
|
||||
- **Теги:** goal:read-api
|
||||
|
||||
@@ -19,4 +20,4 @@ Read API отдаёт ответ одним JSON. Для выборок нижн
|
||||
последовательно или с возвратами.
|
||||
|
||||
Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача
|
||||
`read-api-points`.
|
||||
`read-api-response-limit` (правило размера ответа проектируется там).
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
# [goal] Наблюдаемость
|
||||
# 🎯 Приложение сообщает о своём состоянии
|
||||
|
||||
- **Секция:** порядок
|
||||
- **Тип:** goal
|
||||
- **Секция:** Запланировано
|
||||
- **Зачем:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
||||
- **Теги:** decomposed
|
||||
|
||||
Тихо сломавшаяся автоматизация — главный эксплуатационный риск: телефон шлёт
|
||||
молча, и молчание неотличимо от нормы.
|
||||
|
||||
Выведена из шага 10 плана.
|
||||
|
||||
Завершена, когда пропажа потока и расхождение витрины с журналом видны
|
||||
владельцу без чтения логов.
|
||||
|
||||
## Завершение
|
||||
|
||||
Пропажа потока и расхождение витрины с журналом видны владельцу без чтения
|
||||
логов и переживают ротацию логов.
|
||||
- Пропажа потока видна владельцу без чтения логов
|
||||
- Состояние сервиса — последняя доставка, счётчики, тишина — читается одним
|
||||
запросом
|
||||
- Счётчики слияния переживают ротацию логов
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# 🧹 Ловить гейтом расхождение спеки с маршрутами
|
||||
|
||||
- **Тип:** chore
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Рукописная спека — источник истины, а расходится она с кодом молча: без гейта решение о рукописной спеке не держится
|
||||
- **Теги:** goal:read-api, sprint:2026-08-04
|
||||
|
||||
Маршрут, которого нет в спеке, и поле ответа, которого спека не обещала, красят
|
||||
гейт — рукописный контракт перестаёт расходиться с кодом молча.
|
||||
|
||||
Это не украшение к спеке, а то, чем держится решение писать её руками. Без
|
||||
проверки рукописная спека расходится с первого же маршрута, и потребитель,
|
||||
сгенерировавший по ней клиент, узнаёт об этом последним.
|
||||
|
||||
Класс отказа тот же, что у остальных безусловных шагов гейта проекта: не виден
|
||||
глазами и стоит дорого. Место ему там же.
|
||||
|
||||
Двигает строку «Завершения» цели: «Контракт чтения читается машиной».
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- добавленный маршрут без правки спеки красит гейт — оракул: намеренно
|
||||
рассогласованный маршрут в прогоне гейта
|
||||
- переименованное поле ответа красит гейт — оракул: намеренное переименование в
|
||||
прогоне гейта
|
||||
- проверка укладывается в бюджет гейта — оракул: замер шага по логу
|
||||
`tmp/gate/`
|
||||
- проверка работает без внешней сети — оракул: прогон гейта в контейнере без
|
||||
доступа наружу
|
||||
|
||||
## Рамки
|
||||
|
||||
Трогает `Taskfile` и шаги гейта, кода маршрутов не касается. Берётся после
|
||||
спеки: проверять нечего, пока нет источника истины.
|
||||
@@ -0,0 +1,36 @@
|
||||
# ✨ Написать OpenAPI-спеку руками
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
- **Теги:** goal:read-api, sprint:2026-08-04
|
||||
|
||||
Контракт читается машиной: по спеке генерируется клиент, и сгенерированный
|
||||
клиент выполняет запрос к живому сервису.
|
||||
|
||||
**Решено владельцем 2026-08-04: спека пишется руками и она источник истины.**
|
||||
Для API из горстки ручек это честнее вывода из кода — контракт проектируется, а
|
||||
не фотографируется с того, что вышло: опечатка в имени поля иначе становится
|
||||
частью спеки. Совпадает с тем, как в проекте уже устроен OpenSpec: спека
|
||||
первична к коду. Плата названа — рукописная спека расходится с кодом молча, — и
|
||||
именно поэтому проверка расхождения вынесена в
|
||||
[отдельную задачу](openapi-gate-check.md), а не оставлена регламентом.
|
||||
|
||||
Заодно снимает вопрос, чем быть «схеме контракта API» из раздела самоописания:
|
||||
ею и будет OpenAPI-документ, а не собственный формат.
|
||||
|
||||
Двигает строку «Завершения» цели: «Контракт чтения читается машиной».
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- по спеке генерируется клиент, и он выполняет запрос к живому сервису — оракул:
|
||||
прогон генератора плюс запрос сгенерированным клиентом
|
||||
- спека покрывает все маршруты, которые сервис действительно регистрирует —
|
||||
оракул: сверка перечня путей спеки с обходом роутера поднятого сервиса
|
||||
(`chi.Walk`)
|
||||
- спека проходит валидатор OpenAPI 3.1 — оракул: прогон валидатора
|
||||
|
||||
## Рамки
|
||||
|
||||
Кода маршрутов не трогает: описывает то, что уже есть. `/stats` не описывается —
|
||||
его ещё нет, и его добавит [своя задача](stats-endpoint.md).
|
||||
@@ -1,25 +0,0 @@
|
||||
# OpenAPI-спека и Swagger UI
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Зачем:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
- **Теги:** goal:read-api
|
||||
|
||||
Потребителей три, и один из них — агент, который читает контракт машиной.
|
||||
Пересказывать форму ответа в чате не годится, а самоописание из плана покрывает
|
||||
только **содержимое** метрик; форма конверта, коды ответов и параметры запроса —
|
||||
это OpenAPI.
|
||||
|
||||
Заодно снимает вопрос, чем быть «схеме контракта API» из раздела самоописания:
|
||||
ею и будет OpenAPI-документ, а не собственный формат.
|
||||
|
||||
Шаги:
|
||||
- спека OpenAPI 3.1 на приём, каталог, точки, тренировки, записи, `/stats`;
|
||||
- Swagger UI на отдельном пути, отдаётся самим сервисом (без внешних CDN —
|
||||
он должен работать в локальной сети без интернета);
|
||||
- проверка актуальности спеки в гейте: контракт разъезжается молча.
|
||||
|
||||
Готово, когда по спеке можно сгенерировать клиент, а Swagger UI открывается
|
||||
локально и выполняет запрос к живому сервису.
|
||||
|
||||
Развилка на решение: спека пишется руками как источник истины или выводится из
|
||||
кода. Для маленького API рукописная спека честнее — но это стоит обсудить.
|
||||
@@ -1,6 +1,7 @@
|
||||
# [idea] Пересекающиеся источники одной метрики
|
||||
# 🔬 Пересекающиеся источники одной метрики
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** research
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
|
||||
- **Теги:** goal:read-api
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# [idea] Выгрузка в parquet отдельной командой
|
||||
# 🔬 Выгрузка в parquet отдельной командой
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** research
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
|
||||
- **Теги:** goal:read-api
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# [goal] Разбор и хранилище
|
||||
|
||||
- **Секция:** порядок
|
||||
- **Зачем:** Секции живого потока разобраны все, кроме словаря категориальных значений и пяти невиденных
|
||||
- **Теги:** decomposed
|
||||
|
||||
Метрики, тренировки и записи со своими `id` разбираются и ложатся в часовые
|
||||
объекты; тела перестали быть недифференцированной кучей.
|
||||
|
||||
Выведена из шага 3 плана. Сделано: разбор метрик в объекты, тренировки и
|
||||
записи, `reindex`. Осталось: словарь категориальных значений и секции, которых
|
||||
поток ещё не приносил.
|
||||
|
||||
Завершена, когда ни одна секция живого потока не числится неразобранной, а
|
||||
категориальные значения имеют стабильный код рядом с переведённой строкой.
|
||||
|
||||
## Завершение
|
||||
|
||||
Ни одна секция живого потока не числится неразобранной, а категориальные
|
||||
значения несут стабильный код рядом с переведённой строкой.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 🎯 Новая форма от источника не теряется молча
|
||||
|
||||
- **Тип:** goal
|
||||
- **Секция:** Направления
|
||||
- **Зачем:** Пять секций HAE поток ещё не приносил, а ручные секции заводятся задним числом — разбор полон ровно до следующей новой формы
|
||||
- **Теги:** decomposed
|
||||
|
||||
Направление: всё, что приезжает от источника, разобрано и доехало до витрины — не
|
||||
только сегодня, но и после того, как источник изменится.
|
||||
|
||||
Выделена из цели «Разбор и хранилище», когда та достигла своего критерия
|
||||
завершения: секции живого потока разобраны, категориальные значения несут
|
||||
стабильный код. Осталось то, что заканчиваться не умеет по природе — источник
|
||||
вправе прислать форму, которой раньше не было, а часть секций заводится
|
||||
человеком задним числом.
|
||||
|
||||
В «Запланировано» не встаёт: работа приходит от потока, а не от очереди. Первая встреча
|
||||
новой секции наблюдаема (`healthlog uncovered` и `WARN` на свёртке) — работа
|
||||
направления приходит от этих событий.
|
||||
|
||||
## Завершение
|
||||
|
||||
Завершена не бывает — это направление. Закрывается по мере того, как каждая
|
||||
приезжающая форма доезжает до витрины, а не теряется между «принято» и
|
||||
«разобрано». Открыто сегодня:
|
||||
|
||||
- Сущность с `id` и неразобранной меткой не пропадает целиком
|
||||
- Ручные секции, заведённые задним числом, доезжают до витрины
|
||||
@@ -1,7 +1,8 @@
|
||||
# Ретеншен сырого архива
|
||||
# ✨ Подчищать сырой архив до последнего проверенного экспорта
|
||||
|
||||
- **Секция:** инфра
|
||||
- **Зачем:** Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает
|
||||
- **Тип:** feature
|
||||
- **Категория:** Инфра
|
||||
- **Зачем:** Архив не подчищается вовсе, а резать его раньше даты проверенного экспорта нельзя — в журнале останется дыра, которую нечем пересобрать
|
||||
- **Теги:** goal:journal-and-rebuild
|
||||
|
||||
Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но
|
||||
@@ -28,6 +29,8 @@
|
||||
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
|
||||
глубину архива и дату снапшота, до которой он подрезан.
|
||||
|
||||
Двигает строку «Завершения» цели: «Сырой архив подчищается до последнего проверенного экспорта».
|
||||
|
||||
## Предусловие снова открыто
|
||||
|
||||
Признак «доставка с непокрытой секцией» появился в change
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# ✨ Отличать неполное ведро от полного
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Текущий час неполон всегда, и без порога свёртка отдаёт его наравне с полными — клиент видит провал вместо неизвестности
|
||||
- **Теги:** goal:read-api, sprint:2026-08-04
|
||||
|
||||
Ведро, в котором известна не вся сетка, отличимо от полного — а полярность
|
||||
порога названа вслух, а не выводится читателем из умолчания.
|
||||
|
||||
Измерению рода агрегации порог не понадобился: у него две конкурирующие
|
||||
гипотезы, и неполный час не сходится ни с одной сам собой. Свёртке в ответе он
|
||||
нужен — текущий час неполон **всегда**, и без порога накопительная метрика
|
||||
показывает за него провал вместо неизвестности.
|
||||
|
||||
**Готовые решения задают порог противоположно.** Graphite `xFilesFactor` — доля
|
||||
обязательно известных точек (умолчание 0.5 при свёртке на записи и 0 при
|
||||
отдаче ответа: один параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе
|
||||
величины выглядят как «0.5», означая разное. Полярность придётся назвать вслух,
|
||||
иначе через полгода два места кода поймут поле по-разному — и разойдутся молча.
|
||||
|
||||
Двигает строку «Завершения» цели: «Неполное ведро отличимо от полного, и полярность порога названа».
|
||||
|
||||
## Затрагивает
|
||||
|
||||
Форма ответа свёртки — признак неполного ведра рядом со значением. Конфиг и его
|
||||
образцы — порог с названной полярностью. Раздел о свёртке в
|
||||
`docs/architecture.md`. Схемы и формата на диске не трогает.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- полярность и умолчание порога названы в `docs/architecture.md` одной
|
||||
формулировкой, и там же сказано, у какого из двух прототипов взято — оракул:
|
||||
глазами по разделу
|
||||
- ведро ниже порога помечено неизвестным, а не отдано значением — оракул: тест
|
||||
на границе: ведро ровно на пороге и на единицу ниже
|
||||
- текущий незакрытый час не выглядит провалом накопительной метрики — оракул:
|
||||
запрос за сегодня на живом архиве
|
||||
|
||||
## Рамки
|
||||
|
||||
Схема не трогается, данные только читаются. Берётся после свёртки по сетке.
|
||||
@@ -0,0 +1,43 @@
|
||||
# ✨ Сворачивать точки по заданной сетке
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** «Шаги за неделю по дням» — базовый запрос трекера и игры, и сегодня его нечем задать
|
||||
- **Теги:** goal:read-api, sprint:2026-08-04
|
||||
|
||||
«Шаги за неделю по дням» отвечаются одним запросом `?from&to&bucket`, и род
|
||||
свёртки берётся измеренным, а не угаданным.
|
||||
|
||||
Род агрегации измерен каталогом (change `2026-08-02-katalog-i-rod-agregacii`):
|
||||
сверка минутного слоя с часовым разложила метрики живого корпуса на
|
||||
накопительные и мгновенные, не сойдясь ни на одной. Свёртка в ответе опирается
|
||||
на это измерение и **только** на него: род неизвестен — свёртки нет.
|
||||
|
||||
Инвариант, который здесь легче всего нарушить: **нижний слой HAE не
|
||||
суммируется** ни при какой сетке. Это интерполяция, а не сэмплы, и суммирование
|
||||
завышает втрое.
|
||||
|
||||
Порог неполного ведра и предел размера ответа — соседние задачи; здесь они
|
||||
берутся в том виде, в каком есть на момент вливания, и не проектируются.
|
||||
|
||||
Двигает строку «Завершения» цели: «Точки сворачиваются по заданной сетке измеренным родом агрегации».
|
||||
|
||||
## Затрагивает
|
||||
|
||||
Маршрут `GET /api/v1/metrics/{name}` — параметр запроса `bucket`, поля
|
||||
`bucket` и `aggregation` в конверте ответа, код отказа на метрике с неизвестным
|
||||
родом. Схемы и формата на диске не трогает.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- «шаги за неделю по дням» отвечаются одним запросом, и в ответе названы
|
||||
фактические `bucket` и `aggregation` — оракул: запрос к поднятому сервису на
|
||||
живом архиве
|
||||
- нижний слой HAE не суммируется ни при какой сетке — оракул: тест на
|
||||
накопительной метрике, у которой есть и нижний, и часовой слой
|
||||
- метрика с неизвестным родом не сворачивается вовсе и отвечает отказом с
|
||||
названной причиной, а не значением — оракул: тест
|
||||
|
||||
## Рамки
|
||||
|
||||
Схема не трогается, данные только читаются. Берётся после точек за период.
|
||||
@@ -0,0 +1,46 @@
|
||||
# ✨ Отвечать 304 на повторный запрос точек
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Агент опрашивает по расписанию, а каждый повтор стоит полного чтения: на каталоге это 693 мс и +153 МиБ
|
||||
- **Теги:** goal:read-api, sprint:2026-08-04
|
||||
|
||||
Повторный опрос точек с той же меткой стоит `304` вместо полного чтения, и метка
|
||||
не может ответить на другой набор данных.
|
||||
|
||||
Машинерия готова и берётся, а не пишется заново: `store.VersionedRead` держит
|
||||
правило «версией, снятой после чтения, не подписывать», `internal/httpapi/conditional.go`
|
||||
разбирает `If-None-Match` и отдаёт `304`. Новое здесь ровно одно — **область
|
||||
действия метки**. У каталога ответ есть функция версии витрины; у точек он ещё и
|
||||
функция параметров запроса, поэтому `etag(scope, version)` требует их
|
||||
канонизированной формы. Ошибиться тут значит ответить `304` на другой набор
|
||||
данных — молча и без следов.
|
||||
|
||||
Цена, которую это снимает, измерена на каталоге: 693 мс и +153 МиБ живой кучи на
|
||||
враждебном запросе. Агент опрашивает по расписанию, и без условного запроса
|
||||
каждый его повтор стоит полного чтения.
|
||||
|
||||
Двигает строку «Завершения» цели: «Повторный запрос тех же точек стоит `304`, а не полного чтения».
|
||||
|
||||
## Затрагивает
|
||||
|
||||
Маршрут `GET /api/v1/metrics/{name}` — заголовки `ETag` и `If-None-Match`,
|
||||
код ответа `304`. Область действия метки: набор параметров запроса (метрика,
|
||||
окно, слой) и версия витрины, из которых метка считается, и их каноническая
|
||||
форма. Схемы и формата на диске не трогает.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- повторный запрос с `If-None-Match` при неизменной витрине даёт `304` — оракул:
|
||||
тест
|
||||
- запрос, отличающийся любым параметром по очереди (метрика, окно, слой), при
|
||||
той же версии витрины даёт `200` и другое тело — оракул: тест, перебирающий
|
||||
параметры по одному
|
||||
- параметры, различающиеся только формой записи (порядок, регистр, эквивалентная
|
||||
запись времени), дают одну и ту же метку — оракул: тест на канонизации
|
||||
|
||||
## Рамки
|
||||
|
||||
Схема не трогается, данные только читаются. Берётся после точек за период.
|
||||
|
||||
Связано: `docs/architecture.md` → «Условный запрос».
|
||||
@@ -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».
|
||||
@@ -0,0 +1,38 @@
|
||||
# ✨ Отдавать записи со своим id за период
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** stateOfMind разобран и хранится, но наружу не отдаётся — а восстановить его нечем: в экспорте Apple его нет
|
||||
- **Теги:** goal:read-api, sprint:2026-08-04
|
||||
|
||||
Записи со своим `id` — сегодня это `stateOfMind` — достаются за период через
|
||||
`GET /records/{kind}`.
|
||||
|
||||
Разбор и хранение сделаны тем же изменением, что у тренировок; наружу не отдаётся
|
||||
ничего. У этих данных есть особенность, которой нет больше ни у чего в проекте:
|
||||
**`stateOfMind` нет в экспорте Apple**, он не восстанавливается пересборкой из
|
||||
снапшота, и единственный его источник — доставки HAE. Отдача наружу — не
|
||||
удобство, а единственный способ увидеть то, что иначе живёт только внутри базы.
|
||||
|
||||
Конверт наследуется от точек; собственной формы у записей нет.
|
||||
|
||||
Двигает строку «Завершения» цели: «Тренировки с маршрутом и записи со своим `id` отдаются за период».
|
||||
|
||||
## Затрагивает
|
||||
|
||||
Новый маршрут `GET /api/v1/records/{kind}` и код отказа на неизвестном `kind`.
|
||||
Публичный тип провода в `internal/httpapi` — конверт записей. Чтение таблицы
|
||||
записей; схемы и формата на диске не трогает.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- записи `stateOfMind` за период отдаются одним запросом — оракул: запрос к
|
||||
поднятому сервису на живом архиве
|
||||
- неизвестный `kind` отвечает отказом со списком известных, а не пустым списком:
|
||||
пустота и опечатка обязаны различаться — оракул: тест
|
||||
- конверт совпадает с конвертом точек и тренировок — оракул: тест, сравнивающий
|
||||
форму ответа трёх маршрутов
|
||||
|
||||
## Рамки
|
||||
|
||||
Схема не трогается, данные только читаются. Берётся после конверта.
|
||||
@@ -0,0 +1,53 @@
|
||||
# ✨ Ограничить размер ответа маршрутов чтения
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** У маршрутов чтения нет ни одного потолка: множители «метрики × окно × точки × одновременные запросы» ничем не ограничены
|
||||
- **Теги:** goal:read-api, sprint:2026-08-04
|
||||
|
||||
У маршрутов чтения появляется названный потолок: сетка не задана и ответ не
|
||||
влезает — сервер огрубляет её и **называет** в ответе; сетка задана явно и не
|
||||
влезает — ошибка со списком доступных, а не тихая подмена.
|
||||
|
||||
Различие существенно: иначе агент, попросивший минутную сетку, получит суточные
|
||||
суммы и не узнает об этом.
|
||||
|
||||
**Цена измерена и унаследована.** На каталоге враждебный запрос
|
||||
(20 метрик × 8 часов × 5000 точек) дал 693 мс и +153 МиБ живой кучи, при том что
|
||||
приём в том же процессе уже даёт пик 768 МиБ на теле 40 МиБ. Множители «метрики ×
|
||||
окно × точки × одновременные запросы» сегодня без потолка ни у одного маршрута —
|
||||
включая уже живой каталог, у которого предела нет намеренно: правило размера
|
||||
общее, и задавать его мимоходом на первом маршруте значило бы решить контракт до
|
||||
того, как известна форма тяжёлого ответа.
|
||||
|
||||
Правило распространяется на все маршруты чтения сразу — каталог, точки,
|
||||
тренировки, записи, — а не только на тот, где написано.
|
||||
|
||||
Двигает строку «Завершения» цели: «У ответа любого маршрута чтения есть объявленный предел размера».
|
||||
|
||||
## Затрагивает
|
||||
|
||||
Все маршруты чтения сразу — каталог, точки, а следом тренировки и записи: код и
|
||||
тело отказа на запросе за пределом, поле огрублённой сетки в ответе. Конфиг и
|
||||
его образцы — сам предел. Раздел о пределах в `docs/architecture.md`. Схемы и
|
||||
формата на диске не трогает.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- запрос без сетки, не влезающий в предел, отвечает огрублённой сеткой и
|
||||
называет её в ответе — оракул: враждебный запрос на живом архиве
|
||||
- явно заданная сетка за пределом даёт ошибку со списком доступных сеток —
|
||||
оракул: тест
|
||||
- предел читается из конфига: два разных значения дают две разные границы
|
||||
отказа — оракул: тест с подменой значения предела
|
||||
- предел назван в образцах конфига и в `docs/architecture.md` — оракул: шаг
|
||||
образцов конфига в гейте и глазами по разделу
|
||||
- каталог подчиняется тому же пределу, что и точки — оракул: тест на враждебном
|
||||
запросе к каталогу
|
||||
|
||||
## Рамки
|
||||
|
||||
Схема не трогается, данные только читаются. Берётся после свёртки по сетке.
|
||||
Собственный дедлайн маршрута сюда **не входит**: он в задаче
|
||||
[«Развести бюджеты остановки»](shutdown-and-migration-traces.md) вместе с `BaseContext`
|
||||
и раздельными бюджетами остановки.
|
||||
@@ -0,0 +1,45 @@
|
||||
# ✨ Отдавать тренировки вместе с маршрутом
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Тренировки с маршрутами разобраны и лежат в витрине, а маршрутов чтения нет — сценарий трекера не закрыт
|
||||
- **Теги:** goal:read-api, sprint:2026-08-04
|
||||
|
||||
Трекер забирает тренировку одним пакетом вместе с маршрутом: `GET /workouts` за
|
||||
период и `GET /workouts/{id}` поштучно.
|
||||
|
||||
Разбор и хранение тренировок сделаны (change `2026-08-02-trenirovki-i-zapisi`),
|
||||
маршрутов чтения нет: тренировка с маршрутом лежит в витрине и наружу не отдаётся.
|
||||
Второй сценарий паспорта — трекер тренировок — до тех пор не закрыт.
|
||||
|
||||
**Это первый по-настоящему тяжёлый ответ проекта.** Маршрут лежит блобом внутри
|
||||
тренировки, и общий предел размера ответа обязан распространяться и на него —
|
||||
иначе одна тренировка с длинным треком проходит мимо потолка, названного для
|
||||
точек. Отсюда же требование к списку: перечень за период не тянет треки, иначе
|
||||
неделя тренировок превращается в один неподъёмный ответ.
|
||||
|
||||
Конверт и форма провода наследуются, а не изобретаются.
|
||||
|
||||
Двигает строку «Завершения» цели: «Тренировки с маршрутом и записи со своим `id` отдаются за период».
|
||||
|
||||
## Затрагивает
|
||||
|
||||
Два новых маршрута: `GET /api/v1/workouts` за период и
|
||||
`GET /api/v1/workouts/{id}` поштучно. Публичные типы провода в
|
||||
`internal/httpapi` — конверт тренировки и элемент списка. Чтение таблицы
|
||||
тренировок; схемы и формата на диске не трогает.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- тренировка отдаётся одним пакетом вместе с маршрутом — оракул: запрос к
|
||||
поднятому сервису на живом архиве
|
||||
- список тренировок за период не тянет треки — оракул: тест, сравнивающий размер
|
||||
ответа списка с размером ответа одной тренировки
|
||||
- тренировка с длинным треком подчиняется общему пределу размера ответа —
|
||||
оракул: тест на синтетическом треке за пределом
|
||||
|
||||
## Рамки
|
||||
|
||||
Схема не трогается, данные только читаются. Берётся после конверта.
|
||||
Разворачивание маршрута в отдельную таблицу остаётся
|
||||
[идеей](workout-routes-table.md) и здесь не решается.
|
||||
@@ -1,19 +1,33 @@
|
||||
# [goal] Read API
|
||||
# 🎯 Клиенты читают данные через HTTP и MCP
|
||||
|
||||
- **Секция:** порядок
|
||||
- **Зачем:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
- **Тип:** goal
|
||||
- **Секция:** Запланировано
|
||||
- **Зачем:** Данные видны только через sqlite на хосте — ни один из трёх потребителей, включая агента-медика, ничего прочитать не может
|
||||
- **Теги:** decomposed
|
||||
|
||||
Потребители читают точки: выбор слоя, свёртка по сетке, предел размера ответа.
|
||||
Потребители читают данные: точки с выбором слоя и свёрткой по сетке, тренировки
|
||||
и записи, машиночитаемый контракт — и всё то же самое через MCP.
|
||||
|
||||
Выведена из шага 5 плана. Идёт после каталога и рода агрегации намеренно: без
|
||||
измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться здесь
|
||||
дорого — просуммировать нижний слой значит завысить втрое.
|
||||
Идёт после каталога и рода агрегации намеренно:
|
||||
без измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться
|
||||
здесь дорого — просуммировать нижний слой значит завысить втрое.
|
||||
|
||||
Завершена, когда любой из трёх потребителей получает точки за период без
|
||||
доступа к файлу базы.
|
||||
**MCP входит в эту цель, а не идёт отдельной.** Прежде их было две, и разделяла
|
||||
их очередь: адаптер собственной логики не несёт, он переводит вызовы в те же
|
||||
обработчики, и переводить было нечего. Очередь никуда не делась — она стала
|
||||
порядком задач внутри цели, — а вот отдельная цель под адаптер описывала не
|
||||
направление, а последний шаг этого же направления. Заказчик у обоих транспортов
|
||||
один: три потребителя, из которых первый — агент.
|
||||
|
||||
## Завершение
|
||||
|
||||
Любой из трёх потребителей получает точки за период без доступа к файлу базы,
|
||||
и предел размера ответа объявлен, а не подразумевается.
|
||||
- Точки метрики за период отдаются по HTTP без доступа к файлу базы
|
||||
- Повторный запрос тех же точек стоит `304`, а не полного чтения
|
||||
- Точки сворачиваются по заданной сетке измеренным родом агрегации
|
||||
- Неполное ведро отличимо от полного, и полярность порога названа
|
||||
- У ответа любого маршрута чтения есть объявленный предел размера
|
||||
- Тренировки с маршрутом и записи со своим `id` отдаются за период
|
||||
- Контракт чтения читается машиной: спека, гейт против её расхождения с
|
||||
маршрутами, UI без внешней сети
|
||||
- Агент-медик читает то же самое через MCP тем же токеном чтения, и собственной
|
||||
логики адаптер не несёт
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Сверка живой витрины с пересборкой
|
||||
# ✨ Сверять живую витрину с пересборкой
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
|
||||
- **Теги:** goal:journal-and-rebuild
|
||||
|
||||
@@ -10,7 +11,7 @@
|
||||
только тем, что кто-то вручную запустил пересборку и посмотрел на два числа.
|
||||
|
||||
Между тем расхождение — не гипотеза. Известный путь к нему записан блокером
|
||||
[«Порядок журнала при конкурентных приёмах»](journal-order-on-ingest.md):
|
||||
[«Держать порядок журнала при конкурентных приёмах»](journal-order-on-ingest.md):
|
||||
доставка, свёрнутая раньше своей предшественницы, уходит в `failed` навсегда, и
|
||||
живая витрина расходится с пересборкой молча. Пока тот предел не закрыт, сверка
|
||||
— единственный способ узнать, что он сработал.
|
||||
@@ -30,3 +31,5 @@
|
||||
|
||||
Связано: `cmd/healthlog/reindex.go`, [наблюдаемость](stats-endpoint.md),
|
||||
[деплой](deploy-rivendell.md).
|
||||
|
||||
Двигает строку «Завершения» цели: «Расхождение живой витрины с пересборкой замечает сервис, а не человек».
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Пересборка держит весь журнал в памяти
|
||||
# 🧹 Не держать весь журнал в памяти при пересборке
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** chore
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
|
||||
- **Теги:** goal:journal-and-rebuild
|
||||
|
||||
@@ -26,3 +27,5 @@
|
||||
памяти, не зависящим от его длины.
|
||||
|
||||
Связано: `internal/replay`, `cmd/healthlog/reindex.go`.
|
||||
|
||||
Двигает строку «Завершения» цели: «Расход пересборки не растёт вместе с журналом».
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Чем откатывать релиз после наката миграции
|
||||
# ✨ Назвать механизм отката релиза после наката миграции
|
||||
|
||||
- **Секция:** инфра
|
||||
- **Тип:** feature
|
||||
- **Категория:** Инфра
|
||||
- **Зачем:** Страж версии схемы делает возврат старого бинаря отказом, а понизить схему нечем — аварийный путь пришлось бы изобретать в аварии
|
||||
- **Теги:** goal:deploy
|
||||
|
||||
@@ -15,6 +16,8 @@
|
||||
Вынуто ревью кода задачи «Дозакрыть находки ревью по слиянию сущностей»
|
||||
(проходы `ops` и `negative`, профиль `deep`).
|
||||
|
||||
Двигает строку «Завершения» цели: «Откат релиза после наката миграции имеет названный механизм».
|
||||
|
||||
## Что именно решить
|
||||
|
||||
Та задача перенесла в `store.Open` стража версии схемы: база новее бинаря —
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# [idea] Человеческие аннотации поверх выведенных схем
|
||||
# 🔬 Человеческие аннотации поверх выведенных схем
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** research
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
|
||||
- **Теги:** goal:self-description
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# [idea] Порог sealed: с какого возраста час считается запечатанным
|
||||
# 🔬 Порог sealed: с какого возраста час считается запечатанным
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** research
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
|
||||
- **Теги:** goal:merge-robustness
|
||||
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
# [goal] Самоописание
|
||||
# 🎯 Клиент узнаёт форму данных из ответа
|
||||
|
||||
- **Секция:** порядок
|
||||
- **Тип:** goal
|
||||
- **Секция:** Запланировано
|
||||
- **Зачем:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
||||
- **Теги:** decomposed
|
||||
|
||||
Клиент узнаёт форму данных из ответа сервиса, а не угадывает её по выборке.
|
||||
|
||||
Выведена из шага 6 плана.
|
||||
|
||||
Завершена, когда контракт читается машиной, а формы содержимого метрик
|
||||
выведены из данных, а не описаны руками.
|
||||
|
||||
## Завершение
|
||||
|
||||
Контракт читается машиной, а формы содержимого метрик выведены из данных, а не
|
||||
описаны руками.
|
||||
- Формы содержимого метрик выведены из данных, а не описаны руками
|
||||
- Клиент узнаёт форму одной метрики и всего хранилища одним запросом
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Остановка и миграция: раздельные бюджеты и следы в логе
|
||||
# 🐞 Развести бюджеты остановки и оставить следы миграции в логе
|
||||
|
||||
- **Секция:** инфра
|
||||
- **Тип:** fix
|
||||
- **Категория:** Инфра
|
||||
- **Зачем:** Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
|
||||
- **Теги:** goal:deploy
|
||||
|
||||
@@ -49,3 +50,5 @@
|
||||
|
||||
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, change
|
||||
`2026-08-02-cena-chitayushchego-marshruta` (архив).
|
||||
|
||||
Двигает строку «Завершения» цели: «Остановка сервиса называет виновный этап честно, а накат миграций виден в логе старта».
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Наблюдаемость: /stats
|
||||
# ✨ Отдавать состояние сервиса маршрутом /stats
|
||||
|
||||
- **Секция:** инфра
|
||||
- **Тип:** feature
|
||||
- **Категория:** Инфра
|
||||
- **Зачем:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
||||
- **Теги:** goal:observability
|
||||
|
||||
@@ -59,3 +60,5 @@
|
||||
сколько страниц лежит и сколько перенесено) и — тем же полем — доля ответов
|
||||
чтения, которые удалось подписать `ETag`: механизм условного запроса может
|
||||
перестать окупаться под плотным потоком, и снаружи это неотличимо от нормы.
|
||||
|
||||
Двигает строку «Завершения» цели: «Состояние сервиса — последняя доставка, счётчики, тишина — читается одним запросом».
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Активный алерт «данных нет N часов»
|
||||
# ✨ Слать уведомление, когда данных нет N часов
|
||||
|
||||
- **Секция:** инфра
|
||||
- **Тип:** feature
|
||||
- **Категория:** Инфра
|
||||
- **Зачем:** Пропажу потока сейчас замечает человек, а не сервис
|
||||
- **Теги:** goal:observability
|
||||
|
||||
@@ -18,10 +19,12 @@
|
||||
После деплоя на rivendell поднимется.
|
||||
|
||||
|
||||
## Источник алерта не может жить внутри `serve`
|
||||
Двигает строку «Завершения» цели: «Пропажа потока видна владельцу без чтения логов».
|
||||
|
||||
## Источник уведомления не может жить внутри `serve`
|
||||
|
||||
Отказ стража версии схемы (база новее бинаря) останавливает процесс, а
|
||||
`restart: unless-stopped` даёт цикл перезапуска. Значит алерт «данных нет N
|
||||
`restart: unless-stopped` даёт цикл перезапуска. Значит уведомление «данных нет N
|
||||
часов», живущий внутри сервиса, на эту причину остановки не сработает **по
|
||||
построению** — он не поднимется вместе с ним. Обоснование стража («откат делает
|
||||
оператор, он в этот момент рядом») верно для ручного отката и не покрывает
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# ✨ Поднять Swagger UI без внешней сети
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Контракт читается машиной, но человеку нечем выполнить запрос к живому сервису из браузера, а внешних CDN в локальной сети нет
|
||||
- **Теги:** goal:read-api, sprint:2026-08-04
|
||||
|
||||
Человек открывает UI на отдельном пути и выполняет запрос к живому сервису — не
|
||||
имея интернета.
|
||||
|
||||
Сервис живёт в локальной сети и на VPS без гарантии выхода наружу, поэтому
|
||||
статика отдаётся самим сервисом и лежит в бинаре: внешние CDN здесь означают
|
||||
«работает, пока работает чужой сайт».
|
||||
|
||||
**UI — новый адресат недоверенного входа наизнанку:** он даёт человеку одним
|
||||
нажатием выполнить запрос к любому описанному маршруту, включая приём. Права
|
||||
на запись из браузера не должны появляться сами собой — это ровно та граница, которую
|
||||
описывает `docs/security.md`.
|
||||
|
||||
Двигает строку «Завершения» цели: «Контракт чтения читается машиной».
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- UI открывается и выполняет запрос при отключённой внешней сети — оракул:
|
||||
запуск контейнера без доступа наружу
|
||||
- бинарь работает без каталога со статикой рядом — оракул: запуск одного файла
|
||||
из пустого каталога
|
||||
- запрос к маршруту приёма без заголовка с токеном приёма отклоняется, откуда
|
||||
бы он ни пришёл, а отдаваемая UI статика токена приёма в себе не держит —
|
||||
оракул: тест на обработчике приёма плюс поиск токена в отдаваемой статике
|
||||
|
||||
## Рамки
|
||||
|
||||
Схема не трогается. Берётся после спеки. Наружу ничего не выкладывается — это
|
||||
решение человека и отдельная задача деплоя.
|
||||
@@ -1,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,7 @@
|
||||
# Управление токенами и секретами
|
||||
# ✨ Развести токены контуров и убрать секреты из репозитория
|
||||
|
||||
- **Секция:** инфра
|
||||
- **Тип:** feature
|
||||
- **Категория:** Инфра
|
||||
- **Зачем:** Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
|
||||
- **Теги:** goal:deploy
|
||||
|
||||
@@ -29,3 +30,5 @@
|
||||
|
||||
Готово, когда запуск без токенов возможен только на localhost, а на rivendell
|
||||
оба контура закрыты разными токенами.
|
||||
|
||||
Двигает строку «Завершения» цели: «Оба контура закрыты разными токенами, и без токенов сервис стартует только на localhost».
|
||||
|
||||
@@ -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,7 @@
|
||||
# Идентичность тренировок при импорте родного экспорта
|
||||
# ✨ Не задваивать тренировки при импорте родного экспорта
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** feature
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
|
||||
- **Теги:** goal:native-export-import
|
||||
|
||||
@@ -37,3 +38,5 @@ Prior art: `dogsheep/healthkit-to-sqlite` адресует тренировку
|
||||
|
||||
Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача
|
||||
`apple-export-import`.
|
||||
|
||||
Двигает строку «Завершения» цели: «Тренировки из экспорта не задваивают приехавшие от HAE».
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# [idea] Разворачивание маршрутов тренировок в отдельную таблицу
|
||||
# 🔬 Разворачивание маршрутов тренировок в отдельную таблицу
|
||||
|
||||
- **Секция:** ядро
|
||||
- **Тип:** research
|
||||
- **Категория:** Ядро
|
||||
- **Зачем:** Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
|
||||
- **Теги:** goal:read-api
|
||||
|
||||
|
||||
@@ -474,6 +474,24 @@ func keysOf(m map[string]json.RawMessage) map[string]struct{} {
|
||||
return out
|
||||
}
|
||||
|
||||
// CarriesKeyAbsentIn отвечает, есть ли у f ключ с НЕПУСТЫМ значением, которого
|
||||
// нет у g. Значения при этом не сравниваются вовсе.
|
||||
//
|
||||
// Заведено ради наблюдения, которого у правила слияния не было: разряд полноты
|
||||
// гаснет, когда значения общих содержательных ключей разошлись (см. Relate), и
|
||||
// тогда исход решает тай-брейк — а он может отдать победу точке, у которой
|
||||
// содержательного ключа нет. Событие редкое (на живом корпусе 2 координаты из
|
||||
// 80 129 спорных, обе несравнимые), но это единственное направление, в котором
|
||||
// новое правило способно потерять содержание, и молчать о нём нельзя.
|
||||
//
|
||||
// Отдельным методом, а не через Relate: Relate отвечает про СОДЕРЖАНИЕ (с
|
||||
// условием совпадения значений), здесь же нужен вопрос про имена, и смешение
|
||||
// этих двух вопросов однажды уже дало правило, которое считало пустое поле
|
||||
// содержанием.
|
||||
func (f Fields) CarriesKeyAbsentIn(g Fields) bool {
|
||||
return hasExtra(keysOf(f.full), keysOf(g.full))
|
||||
}
|
||||
|
||||
// relateKeys сравнивает два множества ключей по включению.
|
||||
func relateKeys(a, b map[string]struct{}) Fullness {
|
||||
aExtra := hasExtra(a, b)
|
||||
|
||||
+73
-37
@@ -63,12 +63,10 @@ func (s Style) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON отдаёт род строкой. Нулевое значение уезжает как `unknown`, а не
|
||||
// как пустая строка: клиент не должен видеть в ответе состояние, которого в
|
||||
// словаре нет.
|
||||
func (s Style) MarshalJSON() ([]byte, error) {
|
||||
return []byte(`"` + s.String() + `"`), nil
|
||||
}
|
||||
// Собственной сериализации у Style нет намеренно: строку в ответ кладёт
|
||||
// транспорт (`internal/httpapi`, форма провода). Домен владеет ЗНАЧЕНИЯМИ
|
||||
// словаря, а не их видом на проводе; `String()` при этом нужен и логам, и
|
||||
// сообщениям тестов, и проводу — второго словаря заводить незачем.
|
||||
|
||||
// Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова,
|
||||
// потому что от них зависят счётчики основания в ответе.
|
||||
@@ -149,13 +147,28 @@ func closeEnough(a, b float64) bool {
|
||||
return math.Abs(a-b)/math.Max(math.Abs(a), math.Abs(b)) <= tolerance
|
||||
}
|
||||
|
||||
func clipMetric(metric string) string {
|
||||
// ClipMetric обрезает имя метрики для записи лога.
|
||||
//
|
||||
// Экспортирована потому, что предел один на всех, кто пишет имя метрики в лог:
|
||||
// имя приходит из тела дословно при пределе приёма в 64 МиБ, а запись
|
||||
// повторяется на каждый запрос. Второй предел разошёлся бы с первым молча.
|
||||
func ClipMetric(metric string) string {
|
||||
if len(metric) <= maxMetricInLog {
|
||||
return metric
|
||||
}
|
||||
return metric[:maxMetricInLog] + "…"
|
||||
}
|
||||
|
||||
// ФОРМЫ ПРОВОДА В ЭТОМ ПАКЕТЕ НЕТ, и это решение, а не упущение.
|
||||
//
|
||||
// Типы ниже — форма ответа use-case, а не форма ответа HTTP: `json`-тегов они
|
||||
// не несут и до сериализации не доезжают. Публичный контракт чтения объявляет
|
||||
// транспорт (`internal/httpapi`), поэтому переименование поля здесь байты
|
||||
// ответа клиенту не меняет — оно ломает компиляцию перевода. Обратная цена
|
||||
// названа вслух: новое поле само в ответ не попадёт, его обязан перечислить
|
||||
// транспорт. Решение и цена обеих сторон — `docs/architecture.md`, раздел
|
||||
// «Read API», подраздел «Форма провода».
|
||||
|
||||
// Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их
|
||||
// разности были осмысленны: `Hours − Compared` — часы, отброшенные проверкой
|
||||
// пригодности, `Compared − Agreeing − Conflicting` — часы, не сошедшиеся ни с
|
||||
@@ -165,17 +178,21 @@ func clipMetric(metric string) string {
|
||||
// и «часов не было вовсе» — разные события, и клиент обязан различать их без
|
||||
// второго запроса.
|
||||
type Basis struct {
|
||||
Hours int `json:"hours"`
|
||||
Compared int `json:"compared"`
|
||||
Agreeing int `json:"agreeing"`
|
||||
Conflicting int `json:"conflicting"`
|
||||
FirstHour *time.Time `json:"first_hour"`
|
||||
LastHour *time.Time `json:"last_hour"`
|
||||
Hours int
|
||||
Compared int
|
||||
Agreeing int
|
||||
Conflicting int
|
||||
FirstHour *time.Time
|
||||
LastHour *time.Time
|
||||
}
|
||||
|
||||
// Aggregation — род вместе с основанием.
|
||||
//
|
||||
// Встраивание здесь — удобство домена, а не форма ответа: плоскость объекта
|
||||
// `aggregation` на проводе объявлена транспортом поимённо и от этого
|
||||
// встраивания не зависит.
|
||||
type Aggregation struct {
|
||||
Style Style `json:"style"`
|
||||
Style Style
|
||||
Basis
|
||||
}
|
||||
|
||||
@@ -186,22 +203,22 @@ type Aggregation struct {
|
||||
// часовых объектов здесь нет: объект — деталь хранения, клиент про него не
|
||||
// знает.
|
||||
type LayerRange struct {
|
||||
Layer string `json:"layer"`
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
Points int `json:"points"`
|
||||
Layer string
|
||||
From time.Time
|
||||
To time.Time
|
||||
Points int
|
||||
}
|
||||
|
||||
// Metric — запись каталога.
|
||||
type Metric struct {
|
||||
Metric string `json:"metric"`
|
||||
Metric string
|
||||
// Units — множество различных единиц метрики, отсортированное. Массив, а не
|
||||
// строка: на живом потоке единицы не менялись ни разу, но одна форма поля
|
||||
// для обоих случаев честнее строки, которая при расхождении молча выберет
|
||||
// одно из двух.
|
||||
Units []string `json:"units"`
|
||||
Aggregation Aggregation `json:"aggregation"`
|
||||
Layers []LayerRange `json:"layers"`
|
||||
Units []string
|
||||
Aggregation Aggregation
|
||||
Layers []LayerRange
|
||||
}
|
||||
|
||||
// Snapshot — каталог вместе с версией ответа.
|
||||
@@ -240,12 +257,38 @@ func (s *Service) Version(ctx context.Context) (string, error) {
|
||||
s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err)
|
||||
return "", err
|
||||
}
|
||||
return stamp(version, store.Now().Add(horizonSlack)), nil
|
||||
return Stamp(version, Horizon()), nil
|
||||
}
|
||||
|
||||
// stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой:
|
||||
// Horizon — верхняя граница окна измерения на текущий момент.
|
||||
//
|
||||
// Экспортирована потому, что горизонт нужен ВСЕМ, кто объявляет измеренный род:
|
||||
// маршрут точек снимает род и метку с одного горизонта, иначе метка подтвердит
|
||||
// неизменность ответа, в котором род уже перевернулся ходом часов.
|
||||
func Horizon() time.Time { return store.Now().Add(horizonSlack) }
|
||||
|
||||
// MeasureWindow — окно измерения рода для заданного горизонта.
|
||||
//
|
||||
// Одно на всех потребителей измерения. Второй экземпляр параметров разошёлся бы
|
||||
// с первым молча, а вердикт зависит от каждого из них: слои сверки, размер окна
|
||||
// и оба структурных порога уходят в предварительный отбор хранилища.
|
||||
func MeasureWindow(horizon time.Time) store.CatalogWindow {
|
||||
return store.CatalogWindow{
|
||||
Fine: string(hae.LayerMinute),
|
||||
Coarse: string(hae.LayerHour),
|
||||
Hours: Window,
|
||||
Horizon: horizon,
|
||||
CoarsePoints: coarsePoints,
|
||||
MinFinePoints: minFinePoints,
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой:
|
||||
// подписывать нечем — значит нечем, и горизонт этого не меняет.
|
||||
func stamp(version string, horizon time.Time) string {
|
||||
//
|
||||
// Экспортирована по той же причине, что и Horizon: правило «метка строится из
|
||||
// всего, от чего зависит ответ» держится ровно до тех пор, пока склейка одна.
|
||||
func Stamp(version string, horizon time.Time) string {
|
||||
if version == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -275,19 +318,12 @@ func New(st *store.Store, log *slog.Logger) *Service {
|
||||
// её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён:
|
||||
// версия, снятая после чтения, пометила бы устаревший снимок свежей меткой.
|
||||
func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
|
||||
horizon := store.Now().Add(horizonSlack)
|
||||
horizon := Horizon()
|
||||
|
||||
var snap store.CatalogSnapshot
|
||||
version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
snap, err = s.store.ReadCatalog(ctx, store.CatalogWindow{
|
||||
Fine: string(hae.LayerMinute),
|
||||
Coarse: string(hae.LayerHour),
|
||||
Hours: Window,
|
||||
Horizon: horizon,
|
||||
CoarsePoints: coarsePoints,
|
||||
MinFinePoints: minFinePoints,
|
||||
})
|
||||
snap, err = s.store.ReadCatalog(ctx, MeasureWindow(horizon))
|
||||
return err
|
||||
})
|
||||
if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
|
||||
@@ -319,7 +355,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
|
||||
if basis.Conflicting > 0 {
|
||||
s.log.WarnContext(ctx, "aggregation style conflict",
|
||||
"capability", "query",
|
||||
"metric", clipMetric(group.metric),
|
||||
"metric", ClipMetric(group.metric),
|
||||
"hours", basis.Hours,
|
||||
"compared", basis.Compared,
|
||||
"agreeing", basis.Agreeing,
|
||||
@@ -332,7 +368,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
|
||||
if to := group.latest(); to.After(horizon) {
|
||||
s.log.WarnContext(ctx, "future data",
|
||||
"capability", "query",
|
||||
"metric", clipMetric(group.metric),
|
||||
"metric", ClipMetric(group.metric),
|
||||
"last_ts", store.FormatTime(to),
|
||||
"horizon", store.FormatTime(horizon))
|
||||
}
|
||||
@@ -350,7 +386,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
|
||||
// механизм не окупается вовсе.
|
||||
s.log.DebugContext(ctx, "catalog unsigned", "capability", "query")
|
||||
}
|
||||
return Snapshot{Version: stamp(version, horizon), Metrics: out}, nil
|
||||
return Snapshot{Version: Stamp(version, horizon), Metrics: out}, nil
|
||||
}
|
||||
|
||||
type metricGroup struct {
|
||||
|
||||
@@ -520,3 +520,29 @@ func TestКаталогОтдаётсяСВерсиейВитрины(t *testing
|
||||
t.Error("каталог собран на стоящей витрине и остался без версии")
|
||||
}
|
||||
}
|
||||
|
||||
// Версия ответа каталога — это версия витрины ПЛЮС горизонт измерения.
|
||||
//
|
||||
// Утверждение прямое, потому что склейка теперь общая: её же зовёт маршрут
|
||||
// точек. Сломай её — и оба маршрута начнут подтверждать неизменность ответа,
|
||||
// чей род перевернулся ходом часов, а не коммитом.
|
||||
func TestВерсияКаталогаНесётГоризонт(t *testing.T) {
|
||||
st := openStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := service(t, st).Version(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Version: %v", err)
|
||||
}
|
||||
|
||||
bare, err := st.StateVersion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("StateVersion: %v", err)
|
||||
}
|
||||
if got == bare {
|
||||
t.Error("версия ответа равна версии витрины — горизонт в неё не вошёл")
|
||||
}
|
||||
if want := catalog.Stamp(bare, catalog.Horizon()); got != want {
|
||||
t.Errorf("версия ответа %q, ожидалась %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,22 +271,25 @@ func TestMeasureПротиворечиеСПеревесомМгновенной
|
||||
}
|
||||
}
|
||||
|
||||
// Словарь рода живёт в домене, а его вид на проводе объявляет транспорт: род
|
||||
// уезжает клиенту строкой, которую кладёт `internal/httpapi`, вызывая этот же
|
||||
// `String()`. Поэтому проверяется словарь, а не сериализация — второй словарь на
|
||||
// проводе разошёлся бы с этим молча.
|
||||
//
|
||||
// Незнакомое значение даёт `unknown`, а не пустую строку: клиент не должен
|
||||
// видеть состояние, которого в словаре нет.
|
||||
func TestStyleСловарь(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[catalog.Style]string{
|
||||
catalog.Cumulative: `"cumulative"`,
|
||||
catalog.Instant: `"instant"`,
|
||||
catalog.Unknown: `"unknown"`,
|
||||
catalog.Style(42): `"unknown"`,
|
||||
catalog.Cumulative: "cumulative",
|
||||
catalog.Instant: "instant",
|
||||
catalog.Unknown: "unknown",
|
||||
catalog.Style(42): "unknown",
|
||||
}
|
||||
for style, want := range cases {
|
||||
got, err := json.Marshal(style)
|
||||
if err != nil {
|
||||
t.Fatalf("сериализация %v: %v", style, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("род %d: получили %s, ждали %s", style, got, want)
|
||||
if got := style.String(); got != want {
|
||||
t.Errorf("род %d: получили %q, ждали %q", style, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,16 @@ func TestГоризонтВходитВВерсиюОтвета(t *testing.T) {
|
||||
|
||||
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("версия не изменилась при сдвиге горизонта на два часа")
|
||||
}
|
||||
// Огрубление до часа точное, а не приблизительное: `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("версия сдвинулась внутри одного часа — метка дребезжит на месте")
|
||||
}
|
||||
if stamp("", at) != "" {
|
||||
if Stamp("", at) != "" {
|
||||
t.Error("пустая версия витрины подписана горизонтом — подписывать нечем")
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user