docs: документация переведена на канон av-dev-pm

- беклог и план переехали в docs/tasks (38 задач, 11 целей), слаги
  переименованы с транслита на английские, 85 ссылок поправлены
- conventions.md разобран в docs/conventions/, local-research.md — в
  docs/research/, review-journal.md — в docs/review.md с разделом настройки
  конвейера; заведены security.md, adr/ и .pm.json
- шаг docs.py check добавлен в task gate; поведение в architecture.md помечено
  девятью маркерами долга, database.md получил настройки с числовым значением
This commit is contained in:
av
2026-08-03 17:14:53 +03:00
parent de7b15d48c
commit d79189be18
94 changed files with 1234 additions and 566 deletions
+52
View File
@@ -0,0 +1,52 @@
# Беклог
Что **можно взять**. Одна задача = один файл `items/<slug>.md`
+ строка здесь. Целей тут нет — они в [PLAN.md](PLAN.md): беклог — то, что берут,
план — то, подо что берут. Порядка внутри секции нет: «что делать
дальше» отвечает набор спринта. Ведётся скиллом `tasks`.
Секции «блокеры» здесь нет и не заводится: блокер — это состояние
(спринт не может продолжаться ни одной задачей), оно живёт до ответа
человека, а его следы — вопросами в файлах задач.
## ядро
- [[idea] Человеческие аннотации поверх выведенных схем](items/schema-annotations.md) — Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
- [Проверка целостности собранной витрины перед подменой](items/integrity-before-swap.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
- [Цена слияния на широкой доставке](items/merge-cost-wide-delivery.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
- [Сущность с id, но неразобранной меткой](items/entity-without-parsed-label.md) — Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны
- [Идентичность тренировок при импорте родного экспорта](items/workout-identity-on-import.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
- [Импорт родного экспорта Apple Health](items/apple-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [MCP-сервер поверх Read API](items/mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [[idea] Месячный проход по ручным секциям](items/monthly-manual-sections-pass.md) — Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
- [[idea] NDJSON-поток для больших выборок Read API](items/ndjson-stream.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
- [OpenAPI-спека и Swagger UI](items/openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [Data-миграции не отбирают строки по обрезаемым спискам](items/data-migration-row-selection.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
- [[idea] Отказ от heartbeatSeries](items/drop-heartbeat-series.md) — 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
- [Пересборка держит весь журнал в памяти](items/rebuild-memory-footprint.md) — Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
- [[idea] Пересекающиеся источники одной метрики](items/overlapping-sources.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
- [[idea] Порог sealed: с какого возраста час считается запечатанным](items/sealed-threshold.md) — WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
- [Порядок журнала при конкурентных приёмах](items/journal-order-on-ingest.md) — Решено: повторы, но после /stats. Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда
- [Предел на размер и число заголовков доставки](items/delivery-header-limits.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
- [Пределы на размер сущности и потоковый расчёт формы](items/entity-size-limits.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
- [Проверка секций, которых поток ещё не приносил](items/unseen-sections-check.md) — Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую
- [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](items/workout-routes-table.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
- [Read API: точки, выбор слоя, свёртка по сетке](items/read-api-points.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [Выведенные из данных схемы содержимого](items/derived-content-schemas.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [Словарь категориальных значений → коды HealthKit](items/categorical-value-dictionary.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
- [[idea] Что считать сутками при смене часового пояса](items/day-boundary-timezone.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- [Сверка живой витрины с пересборкой](items/rebuild-comparison-check.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- [Тай-брейк при равной полноте точек](items/tie-break-equal-completeness.md) — Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт
- [Устаревание нижнего слоя после экспорта](items/lower-layer-expiry.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [[idea] Выгрузка в parquet отдельной командой](items/parquet-export.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
- [Заголовки доставки в архиве рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
## инфра
- [Активный алерт «данных нет N часов»](items/stream-silence-alert.md) — Пропажу потока сейчас замечает человек, а не сервис
- [Деплой на rivendell](items/deploy-rivendell.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома
- [Счётчики слияния переживают ротацию логов](items/merge-counters-in-db.md) — единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- [Остановка и миграция: раздельные бюджеты и следы в логе](items/shutdown-and-migration-traces.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
- [Чем откатывать релиз после наката миграции](items/release-rollback-after-migration.md) — Решено: копия файла базы перед накатом. Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем
- [Ретеншен сырого архива](items/raw-archive-retention.md) — Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает
- [Наблюдаемость: /stats](items/stats-endpoint.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- [Умолчания конфига указывают на прежнюю раскладку](items/config-defaults-data-dir.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
- [Управление токенами и секретами](items/token-and-secret-management.md) — Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
+45
View File
@@ -0,0 +1,45 @@
# План
Оглавление целей. Цель — файл `[goal]` в `items/`; её задачи
здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.
В первой секции («порядок») очередь значима и обосновывается
прозой; в остальных порядка нет — это тематические цели.
## Что уже пройдено
Каркас и приём без разбора закрыты. Метрики, тренировки и записи со своими `id`
разбираются и ложатся в часовые объекты. `reindex` проигрывает журнал в свежую
витрину, отпечатки сравниваются, повторный прогон ничего не меняет. Род
агрегации **измерен**: сверка минутного слоя с часовым разложила метрики живого
корпуса на накопительные и мгновенные, не сойдясь ни на одной, и каталог
разрезов отдаётся первым маршрутом чтения. Разведка закончена — правило вывода
слоя, модель идентичности и формы точки проверены на живом потоке
([research/apple-health.md](../research/apple-health.md)).
Эти звенья целями не заведены: закрытая цель записи не оставляет, ей хватает
коммита и спеки.
## Почему в таком порядке
- **Каталог и род агрегации — перед Read API.** Без измеренного рода свёртка в
ответе неотличима от угадывания, а ошибиться здесь дорого: просуммировать
нижний слой значит завысить втрое. Это звено уже закрыто.
- **`healthlog import` — перед устареванием нижнего слоя.** Пока импорт
экспорта не написан, помечать что-либо устаревшим не на основании чего.
- **Read API — перед MCP.** Адаптер собственной логики не несёт, он переводит
вызовы в те же обработчики; переводить пока нечего.
## порядок
- [[goal] Разбор и хранилище](items/parsing-and-storage.md) — Секции живого потока разобраны все, кроме словаря категориальных значений и пяти невиденных
- [[goal] Read API](items/read-api.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [[goal] Самоописание](items/self-description.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [[goal] MCP](items/mcp.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [[goal] Импорт родного экспорта Apple](items/native-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [[goal] Устаревание нижнего слоя](items/lower-layer-cleanup.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [[goal] Наблюдаемость](items/observability.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- [[goal] Деплой](items/deploy.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
## темы
- [[goal] Прочность слияния и идентичности](items/merge-robustness.md) — Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
- [[goal] Журнал и пересборка](items/journal-and-rebuild.md) — Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
- [[goal] Пределы и поведение под объёмом](items/limits-and-load.md) — Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
+10
View File
@@ -0,0 +1,10 @@
# Ушедшее без реализации
Задачи, покинувшие беклог **без реализации**, с причиной и датой.
Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них
есть коммит. Это первое место, куда смотрит дедупликация при заведении.
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->
- 2026-08-01 `bekap-dannyh` — Резервное копирование ./data. Причина: бекап обеспечивает готовый механизм на сервере пет-проектов — своего заводить не нужно, задача снимается деплоем. Была секция: высокий.
- 2026-08-01 `identichnost-epizodnyh-metrik` — Идентичность эпизодных метрик. Причина: решён измерением и prior art: ключ эпизода — метрика+слой+start+end (находка 47), вариант А; вернулся в scope razbor-metrik-v-obekty. Была секция: блокеры.
- 2026-08-01 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Была секция: блокеры.
+6
View File
@@ -0,0 +1,6 @@
# Спринт
Спринта нет. Цель называет человек, набор собирает агент:
`tasks.py sprint start --goal <слаг>`.
## Набор
+43
View File
@@ -0,0 +1,43 @@
# Импорт родного экспорта Apple Health
**Секция:** ядро · **Хук:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут · **Теги:** goal:native-export-import
Слой `sample` пуст: настоящих сэмплов HealthKit в потоке нет вовсе — HAE отдаёт
посекундную развёртку, а не измерения (находка 34). Полная история и точные
сэмплы лежат в zip родного экспорта.
Это же основание для устаревания нижнего слоя и единственный способ поднять
историю глубже недели: дыра старше недели проходами синхронизации не чинится.
Формат разобран на девяти экспортах за 5.5 лет (находки 42–45), гадать не
придётся:
- **версии 11 → 13 → 14**, на 14 стоит больше года; за всё время **ни один тип
записи не исчез**, только добавлялись. Значит незнакомый тип — новый тип, а
не сломанный парсер: падать на нём нельзя;
- **`Correlation`** — обёртка из двух записей, ею приезжает давление. Появилась
только в 2026 году. Парсер по одним `<Record>` разберёт давление как две
несвязанные метрики и потеряет их парность;
- **`WorkoutStatistics`** внутри тренировки — с 2024 года;
- **имя файла локализовано**: `экспорт.xml`, не `export.xml` — так во всех
девяти архивах;
- **DTD расходится с данными** (в v11 у `<Me>` на атрибут больше объявленного)
— валидировать документ его же DTD нельзя;
- объём: 1.6 ГБ XML и 3.6 млн записей в свежем экспорте — только потоковый
разбор, документ целиком в память не влезет.
Шаги:
- потоковый разбор `экспорт.xml` в слой `sample`, включая `Correlation`;
- `HeartRateVariabilityMetadataList` с `InstantaneousBeatsPerMinute` — это
тот же `heartbeatSeries`, что в HAE (находка 39), 1.25 млн ударов;
- маршруты GPX и ЭКГ отдельными файлами — их в XML нет;
- заливка кусками по годам, идемпотентно: повторный импорт того же архива не
должен ничего менять;
- `export_cda.xml` игнорируем — это клинический формат тех же данных.
Готово, когда история за несколько лет лежит в слое `sample`, повторный импорт
не меняет ничего, а суммы по слою сходятся с часовым слоем HAE на пересечении
периодов.
Архивы: `/home/av/MediaEverything/HealthData/apple_health/` — девять штук,
2021-12 … 2026-08. Старые версии формата годятся как регрессионный набор.
@@ -0,0 +1,39 @@
# Словарь категориальных значений → коды HealthKit
**Секция:** ядро · **Хук:** Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить · **Теги:** goal:parsing-and-storage
HAE отдаёт перечислимые значения строками локали телефона: «БДГ», «Сидячий
образ жизни», «В помещении Ходьба». Родной экспорт Apple при этом говорит
кодами (`HKCategoryValueSleepAnalysisAsleepREM`) — источники несопоставимы
(находка 37).
Три следствия, и третье решающее: клиент угадывает словарь; смена языка
телефона молча расколет историю; сверить покрытие экспортом нечем — а на этой
сверке стоит устаревание нижнего слоя.
Решение (вариант «б»): строка хранится **дословно**, рядом кладётся выведенный
код. Словарь ключуется парой `(локаль, строка)`, локаль берётся из
`Accept-Language`. Незнакомая строка → пустой код, а не догадка.
**Словарь фаз сна уже выведен** сопоставлением потока с экспортом за тот же
период (находка 43) — составлять руками не нужно:
```
Основная → AsleepCore Бодрствование → Awake БДГ → AsleepREM
Глубокий → AsleepDeep В кровати → InBed Во сне → AsleepUnspecified
```
Тем же способом добираются `heart_rate.context` и типы тренировок.
Осложнение, всплывшее на истории экспортов: **коды тоже не вечны.** Одни и те
же записи сна приезжают как `…Asleep` в экспорте 2021 года и как
`…AsleepUnspecified` в экспорте 2026-го: Apple переименовала значение и
переписывает историю при выгрузке (находка 43). Значит словарь должен
переживать переименование самих кодов, иначе после обновления iOS история
расколется вторично — уже на «стабильной» стороне. Простейшее решение: хранить код как есть, а
эквивалентность старых и новых имён держать отдельной таблицей синонимов.
Готово, когда фазы сна из потока и из экспорта Apple сравниваются напрямую, а
`/stats` показывает строки, для которых кода ещё нет.
`stateOfMind` в словаре не нуждается — он и так шлёт коды HealthKit.
@@ -0,0 +1,16 @@
# Умолчания конфига указывают на прежнюю раскладку
**Секция:** инфра · **Хук:** Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка · **Теги:** goal:deploy
Данные переехали в `./data` (база + сырой архив, он же том контейнера), а
умолчания в `internal/config` остались прежними: `./healthlog.db` и `./raw`.
Отказ тихий и оттого неприятный: `task run` без `config.toml` не падает, а
заводит **пустую** базу в корне репозитория рядом с настоящей. Дальше человек
смотрит на пустое хранилище и делает неверный вывод о том, что поток сломан.
Чинится одной строкой, но требует прогона гейта: шаг `config-samples` следит,
чтобы `config.example.toml` и `config.docker.toml` не разъехались со структурой.
Готово, когда запуск без конфига использует `./data` и не создаёт ничего в
корне репозитория. Тогда же снимается предупреждение из `config.example.toml`.
@@ -0,0 +1,45 @@
# Data-миграции не отбирают строки по обрезаемым спискам
**Секция:** ядро · **Хук:** Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону · **Теги:** goal:journal-and-rebuild
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`).
## Оракул: механизм доказан, дефект пока пустой
Миграция `00007` переводит в `pending` доставки, у которых имя ставшей покрытой
секции стоит в `uncovered_sections`:
```sql
WHERE EXISTS (SELECT 1 FROM json_each(delivery.uncovered_sections)
WHERE json_each.value IN ('workouts', 'stateOfMind'))
```
Список `uncovered_sections` обрезается на 32 имени **в порядке встречи**
(`hae.maxUncovered`, счётчик `UncoveredDropped`). Секция, стоящая в теле после
тридцати двух незнакомых ключей, в список не попадает — и отбор миграции её не
найдёт. Оракул жил в `tmp/adv/uncovered_test.go`: тело с 32 ключами `junk` и
секцией `ecg` за ними даёт список без `ecg`.
Для `00007` дефект **пустой**: HAE шлёт одну секцию за доставку
(`docs/research/apple-health.md`, находка 50), секций восемь, тела с 32 незнакомыми
ключами в архиве не существует. Но следующая покрытая секция унаследует ту же
слепую зону, а к тому времени причину никто не вспомнит.
## Что делать
Записать принцип и выбрать форму отбора:
- **Принцип:** data-миграция не отбирает строки по списку, который где-то
обрезается. Отбирать надо по признаку, который обрезке не подлежит, —
например «эту доставку смотрел разбор старше версии N».
- Практическое следствие для существующего кода: `UncoveredDropped > 0` обязан
означать безусловное пересворачивание — доставка, у которой список обрезан,
про своё покрытие ничего достоверного не говорит.
- Кандидат в `docs/conventions/README.md` (раздел про миграции), если форма отбора
окажется общей.
## Связано
- [Проверка секций, которых поток ещё не приносил](unseen-sections-check.md) —
именно она следующей сделает секцию покрытой и напишет такую миграцию.
+19
View File
@@ -0,0 +1,19 @@
# [idea] Что считать сутками при смене часового пояса
**Секция:** ядро · **Хук:** Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено · **Теги:** goal:read-api
«Шаги за день» — базовый запрос трекера и фитнес-игры. Но точка несёт метку с
офсетом исходной зоны, и при перелёте сутки перестают быть однозначными: день
по локальному времени в момент измерения, по текущей зоне телефона или по
фиксированной зоне пользователя — три разных числа.
Apple эту неоднозначность не решает, а перекладывает: в экспорте у записи есть
и время, и офсет. Мы храним так же — значит выбор всплывает ровно в момент
свёртки.
Почему идея, а не задача: непонятно, что должно стать наблюдаемо иначе. Нужно
решить, чей это выбор — сервера (одна зона в конфиге), клиента (параметр
запроса) или обоих (умолчание плюс переопределение).
Вопрос не гипотетический: походы и хайкинг из второго сценария — это как раз
поездки со сменой зоны.
@@ -0,0 +1,27 @@
# Предел на размер и число заголовков доставки
**Секция:** ядро · **Хук:** MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним · **Теги:** goal:limits-and-load
У тела доставки предел есть (`max_body`), у заголовков — нет ни одного:
`MaxHeaderBytes` серверу не задан, а `delivery.headers` пишутся в базу целиком,
сколько бы их ни пришло. В лог они с недавних пор обрезаются, в базу — нет.
Сегодня отправитель один и он свой, поэтому дефект спит. Просыпается он
**вместе с [деплоем](deploy-rivendell.md)**: у приёма, торчащего наружу,
отправитель перестаёт быть своим по определению. Оценка сверху при доставке раз
в пять минут — сотни мегабайт в сутки в таблице, которую никто не подчищает; а
растёт вместе с ней и стоимость пересборки, которая учёт материализует целиком.
Чинится дёшево и в двух местах сразу: `MaxHeaderBytes` у `http.Server` и предел
на то, что уходит в колонку. Разумно делать одной правкой с
[управлением токенами](token-and-secret-management.md) — оба пункта про одно и то же:
приём перестаёт доверять тому, кто с ним говорит.
Осторожно: это путь приёма, а доставка, не попавшая в архив, теряется навсегда.
Отказ по превышению обязан наступать **до** записи тела, а не после, и быть
отличим в логе от отказа обстоятельств.
Готово, когда доставка с заведомо раздутыми заголовками получает внятный отказ,
не оставляя следа в базе, а обычная доставка проходит как раньше.
Связано: `internal/httpapi`, `internal/ingest`, `docs/architecture.md` → «Приём».
@@ -0,0 +1,40 @@
# Заголовки доставки в архиве рядом с телом
**Секция:** ядро · **Хук:** Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке · **Теги:** goal:journal-and-rebuild
Состояние объявлено свёрткой по журналу, а журналом — сырой архив. Но в архиве
лежит только **тело**: заголовки запроса (`automation-id`,
`automation-aggregation`, `Accept-Language` и всё незадокументированное) живут
единственной копией — в колонке `delivery.headers`.
Отсюда дыра, которую пересборка обнажила, а не создала. `healthlog reindex`
читает учёт из рабочей базы именно потому, что восстановить заголовки неоткуда.
Пока база цела, это работает. Если базу потерять, весь журнал становится
«телами без учётной записи»: `automation-id` пуст, наследовать слой не от чего,
заголовок не подтверждает ничего — и доставки без плотных метрик не сохранятся
никогда, сколько ни пересобирай. То есть «пересобираемо из архива» верно с
оговоркой, которой в инварианте нет.
Prior art прямой: **WARC** (формат веб-архивов) хранит запрос вместе с его
заголовками именно потому, что тело без метаданных запроса события не
воспроизводит. Смотреть у него стоит на устройство записи «заголовки + тело» и
на то, что заголовки лежат рядом текстом, а не в отдельной базе.
Развилка формы (решать при взятии, не сейчас):
- заголовки внутрь того же `.json.gz` отдельным первым объектом — одна запись и
одна операция, но файл перестаёт быть «телом как пришло»;
- файл-спутник `<ulid>.headers.json` — тело остаётся дословным, зато на доставку
два файла и два fsync, а атомарность пары надо обеспечивать самому;
- отдельный журнал заголовков (файл на сутки, дописыванием) — дешевле всего по
операциям, но появляется третья сущность.
Цена ошибки высокая: правится **путь приёма**, а доставка, не попавшая в архив,
теряется навсегда. Значит профиль ревью — `deep`, и менять надо так, чтобы
старые тела без заголовков продолжали читаться.
Готово, когда пересборка на архиве, у которого рабочей базы нет вовсе, даёт то
же состояние, что пересборка с базой.
Связано: `docs/architecture.md` → «Сырой архив и восстановление состояния»,
`internal/replay`.
+30
View File
@@ -0,0 +1,30 @@
# Деплой на rivendell
**Секция:** инфра · **Хук:** Сервис живёт на рабочей машине — телефон достаёт до него только дома · **Теги:** goal:deploy
Сервис живёт в контейнере на рабочей машине, телефон достаёт до него только
дома. Вне дома экспорт копится и уезжает пачкой при возвращении — работает, но
это не то, ради чего заводился всегда доступный VPS.
Есть готовый образец: jellybit собирает образ локально и отправляет на сервер
через `docker save`/`load`, Caddy впереди терминирует TLS. Здесь то же самое.
Шаги:
- сборка образа локально, доставка на rivendell;
- Caddy: поддомен приёма и поддомен чтения (плюс MCP на нём же);
- тома под `./data`, конфиг с токенами отдельно, права `0600`.
Готово, когда телефон шлёт на публичный адрес из любой сети, а агент читает по
тому же домену.
Зависит от задачи про секреты: выезжать наружу с выключенной проверкой токенов
нельзя.
Этим же переездом закрывается резервное копирование: на сервере пет-проектов
механизм уже готов, своего заводить не нужно (задача `bekap-dannyh` снята,
см. `CLOSED.md`). Побочное следствие — до деплоя история существует в одном
экземпляре на рабочей машине; это и есть цена ожидания.
Том стоит смонтировать так, чтобы серверный бекап забирал его без отдельной
настройки, и снимать копию SQLite через `VACUUM INTO`, а не `cp`: телефон шлёт
непрерывно, и файл под записью копировать нельзя.
+15
View File
@@ -0,0 +1,15 @@
# [goal] Деплой
**Секция:** порядок · **Хук:** Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
Сервис переезжает на rivendell и становится доступен телефону из любой сети.
Выведена из шага 11 плана.
Завершена, когда оба контура закрыты разными токенами, откат релиза имеет
названный механизм, а запуск без конфига не заводит базу мимо данных.
## Завершение
Оба контура закрыты разными токенами, откат релиза имеет названный механизм,
а запуск без конфига не заводит базу мимо данных.
@@ -0,0 +1,25 @@
# Выведенные из данных схемы содержимого
**Секция:** ядро · **Хук:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке · **Теги:** goal:self-description
Метрик у Apple больше сотни, формы точек разные, и рукописный каталог описывал
бы документацию HAE, а не то, что он реально прислал. Схема содержимого
**выводится из данных** тем же проходом разбора: незнакомая метрика описывает
себя сама, без релиза.
Схема отдаётся вместе со статистикой — для потребителя она важнее формального
типа: сколько точек, первая и последняя метка, единицы, доля присутствия поля.
Вывод ограничивается по глубине вложенности, иначе схема тренировки с маршрутом
разрастётся до размеров самих данных.
**Глубину вывода для тренировок выбираем по факту**, когда увидим, как приходят
маршруты: структура тренировки разнородна, и заранее назначенный предел либо
срежет полезное, либо не срежет ничего. Точка маршрута при этом описываться
должна — блоб трека не непрозрачен, это массив однотипных объектов.
Готово, когда клиент по `/api/v1/metrics/{name}/schema` видит поля, их типы и
присутствие, не выкачивая выборку.
Связано: `docs/architecture.md` → «Самоописание». Форму конверта API описывает
не эта задача, а OpenAPI.
+19
View File
@@ -0,0 +1,19 @@
# [idea] Отказ от heartbeatSeries
**Секция:** ядро · **Хук:** 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе · **Теги:** goal:lower-layer-cleanup
`heart_rate_variability` приезжает вместе с `heartbeatSeries` — рядом
межударных интервалов внутри точки. Это **93% объёма метрики** (находка 39)
ради данных, которых нет ни в одном из планируемых запросов: ни агент, ни
трекер, ни игра межударными интервалами не оперируют.
Отбросить их означало бы нарушить инвариант «точки хранятся дословно» — и это
не мелочь: срок жизни сырого архива держится ровно на том, что объект является
полной копией. Поэтому вопрос не «выбросить или нет», а «когда цена хранения
нижнего слоя станет заметной».
Почему идея, а не задача: цена пока не измерена в годовом масштабе, а решение
необратимо — выброшенные ряды не вернуть иначе как из экспорта Apple, где их
может не быть вовсе.
Связано: `docs/architecture.md` → «Открытые вопросы», находка 39.
+70
View File
@@ -0,0 +1,70 @@
# Пределы на размер сущности и потоковый расчёт формы
**Секция:** ядро · **Хук:** Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе · **Теги:** goal:limits-and-load
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`). Та задача убрала канонизацию приехавшей
сущности из транзакции и перестала считать каноническую форму дважды. Осталось
структурное: **предела на размер одной сущности нет вовсе**, а форма и хеш
считаются материализацией значения целиком.
## Оракул: измерено
Оракулы жили в `tmp/adv/mem_test.go` и `tmp/adv/lock_test.go`; числа снимались
на теле в пределах приёма (64 МиБ):
```
тело 40 МиБ → пик HeapAlloc 768.3 МиБ
тело 63 МиБ → повторная доставка держит блокировку 5.019 с при busy_timeout 5000
```
При `_txlock=immediate` конкурентный `CreateDelivery` получает `SQLITE_BUSY`,
`inTx` повторяет до пяти раз и на исчерпании отдаёт `store.ErrBusy` — приём
отвечает 500 по доставке, тело которой уже в архиве. Осиротевшее тело подберёт
`reindex`, но узнать о нём можно только из лога.
## Что делать
1. Предел на размер **одной сущности** и на суммарный размер секции, отдельно
от предела тела (64 МиБ). Сегодня одна тренировка законно может занять всё
тело целиком. Вход, превышающий предел, обязан отклоняться **до**
канонизации, а не после.
2. Потоковый расчёт канонической формы и хеша: `canon.Form` разворачивает
значение в дерево `any`, из-за чего пик кучи кратен размеру входа (замер даёт
множитель около 19×). Хеш считается по потоку; форма нужна целиком только для
сравнения, и только когда хеш разошёлся.
3. Разбор **сохранённой** версии всё ещё идёт внутри транзакции: её содержимое
читается оттуда же. Убрать это можно оптимистичным чтением до транзакции —
но только с перепроверкой хеша и провенанса **внутри** транзакции, иначе две
конкурентные свёртки одного `id` дадут потерянное обновление и исход снова
станет функцией порядка коммитов, а не журнала.
## Условия, пришедшие из закрывающей задачи
1. Мягкое чтение заголовка сущности увеличило долю тел, доходящих до
канонизации: сущность, которая раньше отсекалась на `json.Unmarshal`
заголовка почти бесплатно, теперь разбирается и канонизируется целиком. То
есть худший случай по памяти стал достижим на входах, которые до него не
доходили, — предел из пункта 1 после этого **обязателен**, а не желателен.
2. Каноническая форма и множества ключей всех версий доставки теперь
**удерживаются** до конца транзакции слияния (раньше считались лениво и на
одной доставке из сорока четырёх). Расход стал пропорционален размеру
ДОСТАВКИ, а не самой большой её сущности; предел обязан считать суммарный
размер секции, а не только одной сущности.
3. **Потолок на число версий одного ключа в одной доставке.** Выбор победителя
квадратичен по числу кандидатов; версии с совпавшей канонической формой
схлопываются, но различных тело вмещает сколько угодно. Отмена цикл
прерывает (дедлайн свёртки снова работает), но доставка при этом уходит в
`failed` — то есть отравленное тело стоит полного дедлайна воркера. Тот же
вопрос открыт для точек на одной координате: `merge-cost-wide-delivery.md`,
пункт 4.
## Связано
- [Цена слияния на широкой доставке](merge-cost-wide-delivery.md) —
та же плата со стороны **точек** (`hashPoints` пересчитывает форму всех точек
часа). Задачи делать вместе: половина решения общая — `canon`.
- Из того же ревью: «хеш без полного прохода по содержимому не посчитать» —
отброшено как предел по конструкции, но условием ложится сюда.
@@ -0,0 +1,37 @@
# Сущность с id, но неразобранной меткой
**Секция:** ядро · **Хук:** Тренировка с меткой в неизвестном формате пропадает целиком — а её id и содержимое разобраны · **Теги:** goal:parsing-and-storage, question
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`). Та задача сделала мягким чтение заголовка:
поле не той формы стоит одного поля, а не сущности. Но метка исключение —
разбор кладёт сущность в `ts_utc`/`start_utc`, колонки `NOT NULL`, и сущность
с неразбираемой меткой по-прежнему пропускается целиком.
## Что известно
- Оракул: `internal/hae/entity_test.go`, случаи «метка в ином формате», «метка
Unix-эпохой», «метки нет вовсе» — сущность в результат разбора не попадает,
счётчик `SkippedEntityNoTime` растёт.
- После той задачи пропуск виден в базе: у доставки есть `skipped_entities`,
и ретеншен получает честный ответ «терять есть что». То есть событие больше
не молчит — но содержимое всё ещё не хранится.
- Достижимость из реального потока: замер на 118 доставках дал **ноль**
пропусков всех трёх классов. Дрейф формата дат у HAE при этом
задокументирован (`docs/research/apple-health.md`), то есть вход не выдуман.
## Вопросы
Хранить ли сущность с разобранным `id` и неразобранной меткой. Цена:
1. **Хранить с NULL-меткой** — правка схемы (`start_utc`/`ts_utc` становятся
NULLABLE) плюс правила чтения витрины: выборка «за период» обязана сказать,
что делает с такими строками, иначе они молча исчезнут из любого ответа.
Зато содержимое (маршрут!) сохраняется, а метку восстановит пересборка,
когда разбор научится читать формат.
2. **Не хранить** — как сейчас. Тело живёт в архиве до ретеншена, доставку
вернёт `reindex`. После включения ретеншена окно становится необратимым.
3. **Хранить, подставив метку доставки** — отвергается сразу: это выдуманное
измерение в колонке, по которой идёт выборка.
Рекомендация — (1), но не раньше, чем появится Read API по сущностям: правило
чтения без читателя проектируется вслепую.
+27
View File
@@ -0,0 +1,27 @@
# Проверка целостности собранной витрины перед подменой
**Секция:** ядро · **Хук:** Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе · **Теги:** goal:journal-and-rebuild
`healthlog reindex` собирает витрину в отдельный файл и снимает с него
отпечаток, а подмену делает человек: остановить сервис, переименовать файл,
поднять обратно. Это **единственный необратимый шаг** всей операции — и
единственный, перед которым мы ничего не проверяем.
Отпечаток сегодня снимается на **ещё открытом дескрипторе**: он говорит, что
свёртка сошлась, но не говорит, что файл на диске корректен как база SQLite.
Между «свёртка сошлась» и «файл цел» помещается всё, о чём предупреждает
[How To Corrupt An SQLite Database](https://www.sqlite.org/howtocorrupt.html):
оборванный `fsync`, полный диск, ФС, соврала о записи. Человек в этот момент
уже удалил рабочую витрину.
Что делать: после закрытия файла и **до** того, как команда объявит результат
годным к подмене, открыть его заново и прогнать `PRAGMA integrity_check`.
Не прошёл — команда завершается отказом и прямо говорит, что подменять нечем.
Дёшево: одна страница кода, один прогон по готовому файлу. Ценно ровно в тот
момент, когда всё остальное уже пошло не так.
Готово, когда `reindex` на заведомо испорченном выходном файле отказывается
называть результат годным, а на здоровом — не замедляется заметно.
Связано: `cmd/healthlog/reindex.go`, `docs/architecture.md` → «Пересборка».
+18
View File
@@ -0,0 +1,18 @@
# [goal] Журнал и пересборка
**Секция:** темы · **Хук:** Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
Тема: инвариант «`import` + `replay` даёт то же состояние» и всё, что его
держит — архив, ретеншен, отпечаток витрины, расход памяти пересборки.
В порядок не встаёт: работа приходит находками и растёт вместе с
журналом.
Завершена не бывает: закрывается по мере того, как расхождение витрины с
журналом перестаёт быть молчащим.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как расхождение витрины
с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с
журналом.
@@ -0,0 +1,79 @@
# Порядок журнала при конкурентных приёмах
**Секция:** ядро · **Хук:** Решено: повторы, но после /stats. Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда · **Теги:** goal:journal-and-rebuild, question
**Решение принято владельцем 2026-08-02: вариант (в), но не раньше `/stats`.**
До появления наблюдаемости живём вариантом (г) с уже записанным в спеке
приёма пределом — иначе повторы лечат болезнь, которую никто не наблюдает.
Задача берётся после [наблюдаемости](stats-endpoint.md); ниже — исходная
постановка блокера, она же ТЗ.
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
`deep`, враждебный проход, находка с построенным путём и прогоном).
## Вопросы
Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи
тела в архив и до вставки строки учёта. Порядок, в котором строки становятся
видимыми воркеру, порядку меток не подчиняется: между выпуском идентификатора и
коммитом строки проходит запись тела (измерено 184 мс на 62 МиБ) плюс ожидание
занятой базы (до пяти секунд, а с повторами транзакции дольше).
Путь построен и прогнан:
1. Широкая доставка **A** автоматизации X получает `received_at = T1` и уходит
писать тело.
2. Узкая доставка **B** той же автоматизации (`T2 > T1`, только `sleep_analysis`,
плотных метрик нет) успевает закоммитить строку первой и будит воркер.
3. Воркер видит только B, сворачивает её, наследовать слой не от кого →
`ErrLayerUnknown``failed`.
4. `failed` фоновая свёртка не подбирает никогда. Точки B в витрину не попадут.
Измерено на фикстурах: живой приём даёт `B=failed` и ноль часов
`sleep_analysis/minute`; журнальный порядок — `B=parsed` и два часа. То есть
живое состояние расходится с тем, что даст `healthlog reindex`, и расхождение
молчит: уровень лога у этого исхода `WARN`, такой же, как у штатного «у этой
автоматизации плотных метрик не бывает».
**Это не регресс** — прежде свёртка шла в порядке завершения обработчиков, то
есть было хуже. Изменение окно сузило и назвало предел в спеке приёма; вопрос в
том, закрывать ли его совсем.
## Варианты и цена
**а. Резервировать строку учёта в начале `Accept`** (до записи тела), дописывая
`raw_path`/`bytes`/`sha256` после. Тогда видимость строки монотонна вместе с
`received_at`. Цена: ломается инвариант «тело на диск раньше строки учёта»,
заведённый ровно затем, чтобы не было учтённой доставки без данных; появляется
новое состояние «строка есть, тела ещё нет», которое обязаны понимать пересборка
и ретеншен.
**б. Откладывать свёртку доставки, пока она не «устоялась»** — не сворачивать
моложе N секунд. Цена: задержка N на каждую доставку и произвольное N: окно
занятости базы измерено до пяти секунд и зависит от нагрузки, так что N честно
не выбрать.
**в. `ErrLayerUnknown` в живом пути не выводит доставку из очереди**
ограниченное число повторов, потом `failed`. Цена: колонка счётчика попыток
(миграция) и политика «сколько попыток достаточно»; зато лечит и прочие случаи
«предшественница ещё не доехала». Требует правки спеки хранения («отказ разбора
`failed`»).
**г. Ничего не делать**, оставив предел названным в спеке. Цена: редкая,
молчаливая потеря точек у автоматизаций без плотных метрик; лечится
`healthlog reindex` с остановкой сервиса и ручной подменой базы, но узнать о
необходимости неоткуда — счётчика `failed` в рантайме нет.
## Что заблокировано
Ничего: задача про разнесение ответа и свёртки доведена до конца в объявленных
границах, предел записан в спеке приёма. Заблокировано только **закрытие**
предела.
Смежно: пока предел жив, полезно уметь сверять живую витрину с пересборкой —
`reindex` уже печатает оба отпечатка, но по расписанию их никто не сравнивает.
## Рекомендация
**(в)**, но не раньше `/stats`: сперва должно стать видно, сколько доставок
числится `failed` и как давно, — иначе повторы будут лечить болезнь, которую
никто не наблюдает. До тех пор — (г) с уже записанным пределом.
+16
View File
@@ -0,0 +1,16 @@
# [goal] Пределы и поведение под объёмом
**Секция:** темы · **Хук:** Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
Тема: названные пределы на размер тела, сущности, заголовков и ответа плюс
поведение под удерживаемой блокировкой.
В порядок не встаёт: пределы всплывают замерами, а не планом.
Завершена не бывает: закрывается по мере того, как каждый вход получает
названный предел вместо подразумеваемого.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как каждый вход получает
названный предел вместо подразумеваемого.
+16
View File
@@ -0,0 +1,16 @@
# [goal] Устаревание нижнего слоя
**Секция:** порядок · **Хук:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
После проверенного экспорта нижний слой HAE избыточен и подлежит чистке.
Выведена из шага 9 плана. Нижний слой растёт на ~100 тысяч координат в сутки.
Завершена, когда чистка идёт по правилу, а не по календарю, и решение о
удалении опирается на колонку, отличающую ноль от «не измерялось».
## Завершение
Чистка идёт по правилу «до следующего проверенного экспорта», а не по
календарю, и решение об удалении опирается на колонку, отличающую ноль от
«не измерялось».
+21
View File
@@ -0,0 +1,21 @@
# Устаревание нижнего слоя после экспорта
**Секция:** ядро · **Хук:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен · **Теги:** goal:lower-layer-cleanup
Нижний слой растёт примерно на 100 тысяч координат в сутки против ~3 700 у
минутного и ~100 у часового — разница в три порядка (находка 41). Всё давление
по объёму создаёт он один, и ровно там родной экспорт Apple оказывается
настоящим надмножеством.
Два ограничителя, без которых правило опасно:
- пометка вешается по **загруженному и проверенному** экспорту, а не по
сделанному: проверка — непрерывность по дням и сходимость сумм с часовым
слоем;
- пометка ≠ удаление. Удаление включается только после того, как восстановление
из экспорта отработает на живых данных хотя бы раз.
Приоритет низкий: пока история измеряется днями, экономить нечего. Задача
станет актуальной, когда нижний слой перевалит за несколько гигабайт.
Зависит от импорта экспорта Apple — до него помечать нечем.
+22
View File
@@ -0,0 +1,22 @@
# MCP-сервер поверх Read API
**Секция:** ядро · **Хук:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем · **Теги:** goal:mcp
Агент-медик — первый заказчик проекта. Ему нужна актуальная сводка, а не срез
на дату последнего ручного экспорта.
Транспорт — **Streamable HTTP**, не stdio: сервис живёт на VPS, агент ходит по
сети. Отсюда: MCP — эндпоинт того же процесса и того же порта, аутентификация —
тот же токен чтения, что у Read API. Отдельного контура доступа не заводим:
MCP не даёт ничего, чего не даёт HTTP, и права обязаны совпадать.
Инструментов три: каталог разрезов, значения за период, значения с разбивкой.
Собственной логики в адаптере нет.
Правило размера ответа здесь не украшение, а необходимость: у сетевого агента
нет способа «посмотреть поближе» иначе, чем повторным вызовом.
Готово, когда агент подключается по URL и отвечает на «как я спал на прошлой
неделе» без промежуточного кода.
Связано: `docs/architecture.md` → «MCP», план → шаг «MCP».
+16
View File
@@ -0,0 +1,16 @@
# [goal] MCP
**Секция:** порядок · **Хук:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
Агент-медик — первый заказчик проекта — подключается к хранилищу.
Выведена из шага 7 плана. Идёт после Read API намеренно: адаптер собственной
логики не несёт, он переводит вызовы в те же обработчики, и переводить пока
нечего.
Завершена, когда агент читает данные через MCP тем же токеном чтения.
## Завершение
Агент читает данные через MCP тем же токеном чтения, и собственной логики
адаптер не несёт.
@@ -0,0 +1,62 @@
# Цена слияния на широкой доставке
**Секция:** ядро · **Хук:** 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed · **Теги:** goal:limits-and-load
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный
проход и независимая реализация — независимо друг от друга).
## Оракул: измерено
Тело 63 МБ (в запросе ~200 КБ gzip — предел приёма 64 МиБ), 119 точек на ОДНОЙ
координате:
```
транзакция держалась 5.149 с, аллоцировано 6108 МБ, пик кучи 189 МБ
```
При `busy_timeout` 5000 мс и `_txlock=immediate` параллельная доставка
получает `SQLITE_BUSY`, `inTx` повторяет её до пяти раз (каждый повтор —
полный пересчёт слияния), и на исчерпании попыток доставка уходит в
`parse_status=failed`. Тело при этом в архиве остаётся, но пересборки, которая
его подберёт, ещё нет.
Отдельный вклад, измеренный бенчмарком на часовом объекте нижнего слоя
(3 600 точек):
```
HashAll на объект 9.46 мс 6.7 МБ аллокаций
Form одной точки 2.2 мкс
```
`hashPoints` пересчитывает каноническую форму ВСЕХ точек часа на каждое
слияние, включая неизменившиеся. Утверждение `docs/architecture.md` о
дешевизне глубокого прохода («4400 сравнений хеша, почти все сойдутся»)
стоимости самих сравнений не учитывает: чтобы сравнить хеш, его надо посчитать.
Часть цены задачей `pravilo-sliyaniya-tochek` уже снята: `isEmpty` больше не
материализует значение (было 410 мс на точку 16.5 МБ), а разбор точки
переиспользуется через `canon.Fields`. Осталось структурное.
## Что делать
1. Держать хеш каждой точки в `payload` рядом с координатами (`storedPoint`
уже есть) и пересчитывать форму только для новых и выигравших столкновение
— стоимость станет пропорциональна дельте, а не объёму часа.
2. Считать `canon.Form` точки один раз на столкновение и переиспользовать в
`Equal`, `Relate` и порядке вместо трёх независимых разборов.
3. Проверять `ctx.Err()` в цикле по точкам: сейчас дедлайн свёртки неисполним,
прервать слияние нечем.
4. Оценить потолок числа кандидатов на одной координате: выбор победителя
квадратичен по ним, и 119 точек на одну метку — вход, который приём
принимает.
## Связано
- Разнесение ответа приёма и свёртки **сделано** (архив change
`2026-08-02-otvet-i-svyortka`): воркер убрал влияние на время ответа, но не на
блокировку записи — длинная транзакция слияния держит её по-прежнему. Заодно
оттуда взято главное смягчение: занятость базы больше не выводит доставку из
очереди, она остаётся `pending` и пересворачивается. Оракул окна —
`task verify:busy`.
- Пересборка (`healthlog reindex`) подбирает доставки, ушедшие в `failed` по
другим причинам.
+42
View File
@@ -0,0 +1,42 @@
# Счётчики слияния переживают ротацию логов
**Секция:** инфра · **Хук:** единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего · **Теги:** goal:observability
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, проход
негативного пространства, подтверждено эксплуатационным).
## Что не так
Решение не реализовывать объединение полей при несравнимых наборах стоит на
одном аргументе: «вместо реализации — счётчик, который скажет, если событие
наступит». Сказать он может только в одну сторону — строкой `WARN` в stdout
контейнера.
`docker-compose.yml` держит `json-file` с `max-size: 10m, max-file: 3`. При
~288 доставках в сутки это порядка кварталов, а ожидаемая частота события —
«ни разу за 99 доставок». Штатный сценарий: событие происходит ночью, логи
никто не грепал в этот месяц, строка уходит в ротацию, и в системе не остаётся
ни одного свидетельства. Ни колонки в `delivery`, ни `/stats`, ни файла.
То есть обещание «событие будет видно, а не додумано» на практике не
выполняется.
## Что делать
Положить `overwrites` и `incomparable` в строку `delivery` — миграция плюс
запись в `FinishParse`, которая эту строку всё равно трогает. Тогда «было ли
когда-нибудь несравнимо» это один `SELECT`, живущий столько же, сколько
витрина.
Заодно стоит решить смежное, найденное тем же проходом: сегодня в логе
неразличимы «правило полноты сработало» и «всё ушло в тай-брейк». Два разных
состояния мира дают одинаковую картину `overwrites=N, incomparable=0`, то есть
отказ правила выглядит как здоровая работа. Отдельный счётчик исходов
`Superset`/`Subset` рядом с `Overwrites` это закрывает.
## Связано
- [stats-endpoint](stats-endpoint.md) — то же наблюдение нужно и там.
- [rod-agregacii-i-katalog](rod-agregacii-i-katalog.md) — придёт к вопросу о
тай-брейке и потребует эксплуатационной истории, которой без этой задачи не
будет: мерить придётся снова по архиву, а он к тому моменту подрезан.
+15
View File
@@ -0,0 +1,15 @@
# [goal] Прочность слияния и идентичности
**Секция:** темы · **Хук:** Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
Тема: правила, по которым две версии одних данных превращаются в одну.
В порядок не встаёт — работа приходит находками ревью и замерами на
живом корпусе.
Завершена не бывает: закрывается по мере того, как правила перестают зависеть
от порядка на проводе.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как правила выбора между
версиями перестают зависеть от порядка элементов на проводе.
@@ -0,0 +1,21 @@
# [idea] Месячный проход по ручным секциям
**Секция:** ядро · **Хук:** Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит · **Теги:** goal:parsing-and-storage
Окно досчёта не единое, и это измеренное различие, а не предположение.
Количественные метрики (пульс, шаги, энергия) человек руками не правит — они
опаздывают на часы, и недельного глубокого прохода им хватает. Ручные записи
(`symptoms`, `medications`, `stateOfMind`, `cycleTracking`) заводятся задним
числом на недели и месяцы: симптом или приём лекарства можно отметить за
прошлую дату.
Растянуть общий глубокий проход на месяц нельзя: тела запросов доходили до
42 МБ (находка 23), а месяц минутных данных — это десятки мегабайт на каждую
доставку. Отсюда решение: редкий широкий проход **только по ручным секциям**
их единицы записей, и месячное окно там почти ничего не стоит.
Почему идея, а не задача: этих секций в живом потоке ещё не было. Заводить,
когда они появятся, — иначе проход пишется вслепую и проверяется не на чем.
Связано: `docs/architecture.md` → «Досчёт задним числом», задача
`unseen-sections-check`.
+17
View File
@@ -0,0 +1,17 @@
# [goal] Импорт родного экспорта Apple
**Секция:** порядок · **Хук:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
`healthlog import`: снапшот всей истории из родного экспорта Apple Health
ложится в хранилище перед проигрыванием хвоста доставок.
Выведена из шага 8 плана. Идёт перед устареванием нижнего слоя намеренно: пока
импорт экспорта не написан, помечать что-либо устаревшим не на основании чего.
Завершена, когда слой `sample` наполнен историей с 2019 года, а повторный
импорт того же экспорта ничего не меняет.
## Завершение
Слой `sample` наполнен историей с 2019 года, повторный импорт того же экспорта
ничего не меняет, а тренировки из экспорта не задваивают приехавшие от HAE.
+20
View File
@@ -0,0 +1,20 @@
# [idea] NDJSON-поток для больших выборок Read API
**Секция:** ядро · **Хук:** Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация · **Теги:** goal:read-api
Read API отдаёт ответ одним JSON. Для выборок нижнего слоя за длинный период
это не работает: `heart_rate` в слое `raw` — порядка сотни тысяч координат в
сутки, и месяц такого ряда не влезет ни в память клиента, ни в разумный ответ.
Сейчас проблема закрыта с другой стороны — правилом размера ответа: сервер сам
берёт сетку погрубее, когда разбивка не задана, и отвечает ошибкой со списком
доступных сеток, когда задана явно. Это защищает агента с ограниченным
контекстом, но не помогает клиенту, которому действительно нужен весь ряд —
например, разовой выгрузке в другой инструмент.
Почему идея, а не задача: неизвестно, появится ли такой клиент. Если появится,
выбор между NDJSON-потоком и курсорной пагинацией зависит от того, читает он
последовательно или с возвратами.
Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача
`read-api-points`.
+16
View File
@@ -0,0 +1,16 @@
# [goal] Наблюдаемость
**Секция:** порядок · **Хук:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
Тихо сломавшаяся автоматизация — главный эксплуатационный риск: телефон шлёт
молча, и молчание неотличимо от нормы.
Выведена из шага 10 плана.
Завершена, когда пропажа потока и расхождение витрины с журналом видны
владельцу без чтения логов.
## Завершение
Пропажа потока и расхождение витрины с журналом видны владельцу без чтения
логов и переживают ротацию логов.
+23
View File
@@ -0,0 +1,23 @@
# OpenAPI-спека и Swagger UI
**Секция:** ядро · **Хук:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате · **Теги:** goal:read-api
Потребителей три, и один из них — агент, который читает контракт машиной.
Пересказывать форму ответа в чате не годится, а самоописание из плана покрывает
только **содержимое** метрик; форма конверта, коды ответов и параметры запроса —
это OpenAPI.
Заодно снимает вопрос, чем быть «схеме контракта API» из раздела самоописания:
ею и будет OpenAPI-документ, а не собственный формат.
Шаги:
- спека OpenAPI 3.1 на приём, каталог, точки, тренировки, записи, `/stats`;
- Swagger UI на отдельном пути, отдаётся самим сервисом (без внешних CDN —
он должен работать в локальной сети без интернета);
- проверка актуальности спеки в гейте: контракт разъезжается молча.
Готово, когда по спеке можно сгенерировать клиент, а Swagger UI открывается
локально и выполняет запрос к живому сервису.
Развилка на решение: спека пишется руками как источник истины или выводится из
кода. Для маленького API рукописная спека честнее — но это стоит обсудить.
+19
View File
@@ -0,0 +1,19 @@
# [idea] Пересекающиеся источники одной метрики
**Секция:** ядро · **Хук:** Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь · **Теги:** goal:read-api
Одну метрику пишут несколько источников: сон — часы и стороннее приложение
AutoSleep, шаги — часы и телефон одновременно. Поле `source` при этом не
источник, а множество вкладчиков (`Apple Watch Ultra 3|iPhone (Anton)`), и
состав меняется от группировки (находка 36).
По координатному ключу столкновений почти нет — источники пишут в разные метки.
Но по **времени** интервалы пересекаются, и сумма по обоим задвоит ночь сна или
дневные шаги.
Почему идея: неясно, чья это ответственность. Варианты — отдавать как есть и
предупреждать в каталоге, выбирать источник по приоритету, отдавать разбивку по
источникам отдельным разрезом. Первое честнее всего, третье полезнее всего.
Для агента-медика вопрос практический: «сколько я спал» не должно давать
двойной ответ.
+18
View File
@@ -0,0 +1,18 @@
# [idea] Выгрузка в parquet отдельной командой
**Секция:** ядро · **Хук:** Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно · **Теги:** goal:read-api
Отдельная команда, выгружающая хранилище в parquet, — дверь для тяжёлой
аналитики снаружи, без миграции самого хранилища.
Контекст решения: DuckDB рассматривался как основное хранилище и отложен —
чистого Go-драйвера нет, любой требует cgo, а это стоит нам `CGO_ENABLED=0` и
одного статического бинаря. Но дверь при этом осталась открытой: DuckDB читает
и parquet, и файл SQLite напрямую. Значит спешить некуда — выгрузка добавляется
тогда, когда появится тяжёлый аналитический запрос, а не заранее.
Почему идея, а не задача: такого запроса пока нет. Ни один из трёх потребителей
(агент-медик, трекер, игра) в аналитике по всей истории не нуждается.
Связано: `docs/architecture.md` → «Открытые вопросы» → «Хранилище под
аналитику».
+18
View File
@@ -0,0 +1,18 @@
# [goal] Разбор и хранилище
**Секция:** порядок · **Хук:** Секции живого потока разобраны все, кроме словаря категориальных значений и пяти невиденных
Метрики, тренировки и записи со своими `id` разбираются и ложатся в часовые
объекты; тела перестали быть недифференцированной кучей.
Выведена из шага 3 плана. Сделано: разбор метрик в объекты, тренировки и
записи, `reindex`. Осталось: словарь категориальных значений и секции, которых
поток ещё не приносил.
Завершена, когда ни одна секция живого потока не числится неразобранной, а
категориальные значения имеют стабильный код рядом с переведённой строкой.
## Завершение
Ни одна секция живого потока не числится неразобранной, а категориальные
значения несут стабильный код рядом с переведённой строкой.
+80
View File
@@ -0,0 +1,80 @@
# Ретеншен сырого архива
**Секция:** инфра · **Хук:** Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает · **Теги:** goal:journal-and-rebuild
Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но
удаления нет: архив растёт бесконечно. Пока это 16 МБ и проблемой не является.
**Само правило изменилось.** Экспорт Apple — снапшот всей истории, доставки
после его даты — события поверх снапшота, и состояние всегда пересобираемо
свёрткой. Значит доставки должны жить **до следующего проверенного экспорта**,
а не фиксированные две недели: иначе между концом ретеншена и датой снапшота
образуется дыра в журнале, и пересобрать этот отрезок будет нечем.
Цена измерена: ~23 МБ архива в сутки, то есть ~2 ГБ за квартал между
экспортами. Дёшево за возможность пересобрать что угодно.
Отдельное исключение: `stateOfMind` в экспорт не попадает вовсе (проверено на
свежем архиве). Для него доставки — не хвост журнала, а единственный источник,
и под общее правило удаления он не подпадает.
Включать **после** того, как разбор устоится и пересборка докажет, что
хранилище действительно восстанавливается: иначе страховка исчезнет раньше, чем
перестанет быть нужна.
Готово, когда удаляются только доставки старше последнего проверенного
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
глубину архива и дату снапшота, до которой он подрезан.
## Предусловие снова открыто
Признак «доставка с непокрытой секцией» появился в change
`2026-08-01-nerazobrannye-sekcii-dostavki` и работал заодно защитой
`stateOfMind`: такие доставки числились `partial`, и ретеншен их не тронул бы.
Change `2026-08-02-trenirovki-i-zapisi` покрыл `stateOfMind` разбором, и защита
исчезла: доставка из одного состояния разума теперь получает `parsed` с пустым
списком непокрытых, то есть **побайтово неотличима** от доставки из метрик — а
метрики восстановимы из экспорта Apple, состояние разума нет (находка 46).
Ретеншен, написанный по правилу «удаляем всё, что не `partial`», сотрёт ровно те
тела, которых в экспорте не существует, и первая же пересборка потеряет историю
состояния разума навсегда.
Значит признак невосстановимости нужен **не производный от «непокрытости»**.
Варианты:
- **Перечень покрытых секций, которых нет в экспорте Apple** рядом с доставкой
(сегодня — ровно `stateOfMind`). Цена: колонка и строка в свёртке; читается
так же, как `uncovered_sections`, и одним запросом.
- **Признак у доставки «тело — единственный источник»**, выставляемый разбором.
Цена та же, но смысл шире и требует решения, что считать единственным
источником для будущих секций.
- **Никогда не подрезать тела доставок, у которых есть строки в `record`.**
Цена нулевая по схеме, но неточная: провенанс записи указывает на доставку
её **текущей** версии, а копий у записи бывает по 26.
Рекомендация — первый вариант: он прямо отвечает на вопрос «что останется
потерянным», как это уже делает `uncovered_sections`, и не требует додумывать
семантику.
Вместе с этим действует правило: задача, которая начинает разбирать секцию, тем
же изменением переводит `partial`-строки с этим ключом в `pending` (так сделала
миграция `00007`). Ретеншену позволено смотреть на `partial` только пока правило
соблюдается.
## Что читать перед удалением тела
Две колонки учётной записи, и обе обязательны:
- `uncovered_sections` — непустой список означает, что в теле есть секции,
которых разбор не покрывает; удалять нельзя;
- `skipped_entities` — число сущностей с собственным `id`, которые разбор не
понял. **`NULL` означает «не измерялось» и нулю не равен**: так выглядят
доставки, свёрнутые разбором, который пропусков не считал, и те, чей разбор не
досчитал. `NULL` — «не удалять». Прочитать его как ноль значит удалить тело
тренировки, маршрута которой нет больше нигде: в экспорте Apple его не
существует.
Правило пришло из задачи «Дозакрыть находки ревью по слиянию сущностей»
(миграция `00008`), где колонка и заведена — без `DEFAULT` именно ради этого
различия.
+76
View File
@@ -0,0 +1,76 @@
# 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».
+17
View File
@@ -0,0 +1,17 @@
# [goal] Read API
**Секция:** порядок · **Хук:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
Потребители читают точки: выбор слоя, свёртка по сетке, предел размера ответа.
Выведена из шага 5 плана. Идёт после каталога и рода агрегации намеренно: без
измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться здесь
дорого — просуммировать нижний слой значит завысить втрое.
Завершена, когда любой из трёх потребителей получает точки за период без
доступа к файлу базы.
## Завершение
Любой из трёх потребителей получает точки за период без доступа к файлу базы,
и предел размера ответа объявлен, а не подразумевается.
@@ -0,0 +1,30 @@
# Сверка живой витрины с пересборкой
**Секция:** ядро · **Хук:** reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит · **Теги:** goal:journal-and-rebuild
`healthlog reindex` печатает отпечаток собранной витрины и отпечаток рабочей —
то есть данные для сверки уже есть, и **сравнивать их некому**. Расхождение
живого состояния с тем, что даёт проигрывание журнала, сегодня обнаруживается
только тем, что кто-то вручную запустил пересборку и посмотрел на два числа.
Между тем расхождение — не гипотеза. Известный путь к нему записан блокером
[«Порядок журнала при конкурентных приёмах»](journal-order-on-ingest.md):
доставка, свёрнутая раньше своей предшественницы, уходит в `failed` навсегда, и
живая витрина расходится с пересборкой молча. Пока тот предел не закрыт, сверка
— единственный способ узнать, что он сработал.
Инвариант «состояние есть свёртка журнала» проверяем ровно этим: пересобрать в
отдельный файл (рабочая база не трогается — это уже так и устроено), сверить
отпечатки, расхождение — событие, которое видно. Прогон не бесплатный
(на квартальном журнале десятки минут), поэтому это регламент, а не фоновая
задача сервиса.
Развилка при взятии: кто запускает — `cron` на хосте рядом с деплоем или сам
сервис по расписанию. Первое честнее (пересборка уже сейчас команда, а не
режим сервиса), но требует места под второй файл базы.
Готово, когда расхождение витрины с пересборкой перестаёт зависеть от того,
догадался ли человек посмотреть.
Связано: `cmd/healthlog/reindex.go`, [наблюдаемость](stats-endpoint.md),
[деплой](deploy-rivendell.md).
@@ -0,0 +1,26 @@
# Пересборка держит весь журнал в памяти
**Секция:** ядро · **Хук:** Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет · **Теги:** goal:journal-and-rebuild
`healthlog reindex` материализует целиком две вещи: учёт доставок из базы и
список путей архива. На сегодняшнем объёме (сотня тел) это незаметно, на
квартальном (~12 тысяч) — терпимо, а дальше растёт линейно и без предела:
журнал по определению не подчищается до следующего проверенного экспорта.
Отдельно к этому примешивается **размер заголовков**: `MaxHeaderBytes` у
сервера не задан, то есть верхней границы у колонки `delivery.headers` нет
вовсе. Раздутый заголовок множится на число доставок.
Порог, за которым это перестаёт быть теорией, не измерен — с него и стоит
начинать, если задача берётся. Лечится потоковым перечислением журнала
(курсор по учёту, обход каталога партиями по суткам) вместо двух срезов в
памяти.
Сегодня недостижимо, поэтому приоритет низкий. Естественно склеивается с
[ретеншеном сырого архива](raw-archive-retention.md): та задача задаёт, где
у журнала конец, эта — как его читать, не поднимая целиком.
Готово, когда пересборка на журнале в десятки тысяч доставок идёт с потреблением
памяти, не зависящим от его длины.
Связано: `internal/replay`, `cmd/healthlog/reindex.go`.
@@ -0,0 +1,63 @@
# Чем откатывать релиз после наката миграции
**Секция:** инфра · **Хук:** Решено: копия файла базы перед накатом. Страж версии схемы делает возврат бинаря отказом, а понизить схему нечем · **Теги:** goal:deploy, question
**Решение принято владельцем 2026-08-02: вариант (2) — копия файла базы перед
накатом.** Entrypoint контейнера копирует файл базы рядом до старта бинаря,
откат = подмена файла. `Down`-блоки миграций при этом честно называются
декорацией для локальной разработки, а не аварийным путём: ни один из них не
исполнялся ни разу. Осталось решить при взятии — сколько копий держим и где.
Задача естественно склеивается с [деплоем](deploy-rivendell.md). Ниже —
исходная постановка блокера, она же ТЗ.
Вынуто ревью кода задачи «Дозакрыть находки ревью по слиянию сущностей»
(проходы `ops` и `negative`, профиль `deep`).
## Что именно решить
Та задача перенесла в `store.Open` стража версии схемы: база новее бинаря —
отказ на старте. Решение принято владельцем и здесь не пересматривается. Но у
него есть следствие, которое до сих пор нигде не было записано:
**после того как новый бинарь накатил миграцию, возврат старого бинаря приёма
не чинит.** Он теперь отказывается стартовать, а понизить схему нечем:
- подкоманды миграции у бинаря нет (`serve`, `reindex`, `healthcheck`);
- `goose` CLI в образ не кладётся;
- блоки `-- +goose Down` в миграциях написаны, но ни один тест их не исполняет,
и на рабочей базе они не выполнялись ни разу (`DROP COLUMN` в SQLite через
`modernc.org/sqlite` не проверялся вовсе);
- `restart: unless-stopped` превращает отказ в цикл перезапуска, а телефон всё
это время шлёт в закрытый порт и **не перешлёт** потом.
То есть аварийный путь придётся изобретать в момент аварии, при остановленном
приёме. Цена простоя для метрик закрывается широким и глубоким проходами
синхронизации; для `stateOfMind` не закрывается ничем — у него доставки HAE
единственный источник.
## Вопросы
1. **Подкоманда `healthlog migrate --down-to N`.** Цена: новая поверхность CLI
плюс тест на `Down` каждой миграции (сейчас их нет, и `DROP COLUMN` в SQLite
ведёт себя не так, как в постгресе). Зато откат становится операцией, а не
импровизацией.
2. **Копия файла базы перед накатом** — entrypoint контейнера делает `cp` рядом,
откат = подмена файла. Цена: место (база растёт), плюс правило «сколько копий
держим». Зато не требует ни кода, ни доверия к `Down`, а база производна от
архива — потеря копии не смертельна.
3. **`goose` CLI в образ.** Цена: образ перестаёт быть одним статическим
бинарём, появляется вторая точка, знающая про схему.
4. **Ничего, но записать вслух**: «понижение схемы не поддерживается, лечение —
только выкатка вперёд». Цена: в аварии выбора нет.
## Рекомендация
(2) плюс уже сделанная запись из (4). Копия файла — единственный вариант,
который не требует доверять непроверенному коду ровно в тот момент, когда
проверять некогда; а `Down`-блоки при этом честно называются декорацией для
локальной разработки.
## Что стоит, пока решения нет
Ничего: страж работает, и это правильно. Стоит только аварийный сценарий —
он существует ровно в том виде, в каком описан выше. Строка «понижение схемы не
поддерживается» уже записана в `docs/architecture.md` (раздел «Деплой»).
+21
View File
@@ -0,0 +1,21 @@
# [idea] Человеческие аннотации поверх выведенных схем
**Секция:** ядро · **Хук:** Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата · **Теги:** goal:self-description
Схема содержимого выводится из данных и говорит **форму** — какие поля есть,
какого типа, с какой заполненностью. Чего она не говорит — что метрика значит,
в каких единицах разумны значения и чем `apple_stand_hour` отличается от
`apple_exercise_time`.
Два пути, и выбор между ними преждевременен:
- **аннотации поверх выведенных схем** — человеческое описание рядом с
машинным выводом, дописывается по мере надобности;
- **рукописный каталог метрик** — полнее, но описывал бы документацию HAE, а не
то, что он реально прислал.
Почему идея, а не задача: выбор зависит от того, насколько стабильным окажется
формат. Меняться он может только с обновлением Health Auto Export, а это
отслеживается — значит ответ придёт сам.
Связано: `docs/architecture.md` → «Самоописание», задача `derived-content-schemas`.
+17
View File
@@ -0,0 +1,17 @@
# [idea] Порог sealed: с какого возраста час считается запечатанным
**Секция:** ядро · **Хук:** WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта · **Теги:** goal:merge-robustness
Флаг `sealed` отмечает часы, которые уже не должны меняться. Механика готова:
изменение запечатанного объекта не отвергается, а пишется `WARN`, и данные
всё равно сохраняются. Не выбрано одно — **с какого возраста** ставить флаг.
Почему идея, а не задача: правильный порог выводится из эксплуатации, а не из
рассуждения. Наблюдалась глубина досчёта до 22 минут (находка 10), но одного
наблюдения мало — ручные секции правятся задним числом на недели, а
количественные метрики опаздывают на часы. Ставить порог сейчас значит угадать.
Что нужно, чтобы стало задачей: статистика `WARN` за несколько недель живого
потока и распределение возраста изменённых часов по классам метрик.
Связано: `docs/architecture.md` → «Часовые объекты метрик».
+15
View File
@@ -0,0 +1,15 @@
# [goal] Самоописание
**Секция:** порядок · **Хук:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
Клиент узнаёт форму данных из ответа сервиса, а не угадывает её по выборке.
Выведена из шага 6 плана.
Завершена, когда контракт читается машиной, а формы содержимого метрик
выведены из данных, а не описаны руками.
## Завершение
Контракт читается машиной, а формы содержимого метрик выведены из данных, а не
описаны руками.
@@ -0,0 +1,49 @@
# Остановка и миграция: раздельные бюджеты и следы в логе
**Секция:** инфра · **Хук:** Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM · **Теги:** goal:deploy
Две находки эксплуатационного и идиоматического проходов ревью каталога. Обе
существовали и раньше, но достижимыми их сделал первый маршрут чтения:
`GET /api/v1/metrics` — первый обработчик, способный законно работать заметное
время.
**Бюджет остановки один на оба этапа.** `shutdownCtx` в `runServe` передаётся и
в `srv.Shutdown`, и в ожидание фонового воркера. `Shutdown` ждёт, пока
обработчики вернутся; контексты обработчиков он при этом не отменяет
(`BaseContext` не задан), так что долгий запрос каталога может съесть бюджет
целиком. Дальше `select` видит два готовых случая и выбирает равновероятно: база
закрывается или нет от запуска к запуску, а в лог уходит
`shutdown budget exceeded stage=fold-worker` — обвинение воркеру, который бюджета
не превышал. Цена именно в диагнозе: этот `WARN` означает «доставка осталась
`pending`, данные под вопросом», и ложное срабатывание обесценивает настоящее.
Чинится двумя движениями: собственный `context.WithTimeout` второму этапу вместо
исчерпанного первого, и `BaseContext`, производный от контекста жизненного цикла,
чтобы долгий запрос об остановке узнавал.
**Цена этой ветки выросла** (change `cena-chitayushchego-marshruta`): база в ней
не закрывается, а значит не закрывается и закреплённое соединение версии
витрины — последнего соединения к базе не наступает, SQLite не делает финального
чекпойнта, и рядом с базой остаётся неразобранный `-wal` до 64 МиБ. Данные целы
(следующее открытие проиграет журнал), но файл базы в этом состоянии нельзя
переносить без его `-wal`. Обвинение в логе при этом стало честнее: этап
называется `background`, а не `fold-worker`, потому что ждут двоих.
**Миграция молчит и не прерывается штатной остановкой.** `store.migrate` не
пишет ни одной записи — ни «начал», ни «закончил», ни длительность, — а первая
строка в логе появляется уже после успешного открытия базы. Если миграция идёт
долго, владелец не отличит «ещё мигрирует» от «зависло» и от «упало»: тишина
одинакова во всех трёх случаях. Плюс `migrate` работает на `context.Background()`,
то есть `SIGTERM` она не видит и ждать придётся 30-секундного `SIGKILL`.
Порчи данных при этом нет: goose оборачивает миграцию в транзакцию, обрыв
откатывает её целиком, и следующий старт повторяет с нуля. Замер на синтетической
копии годового объёма (260 тысяч объектов, 483 МБ): `CREATE INDEX` миграции
`00009` — 297 мс тёплым кешем. То есть сегодня окно тишины — доли секунды;
опасность в том, что оно растёт вместе с витриной незаметно.
Готово, когда `WARN` о превышении бюджета называет виновный этап честно, а в логе
старта видно, что миграции накатывались и сколько это заняло.
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, change
`2026-08-02-cena-chitayushchego-marshruta` (архив).
+59
View File
@@ -0,0 +1,59 @@
# Наблюдаемость: /stats
**Секция:** инфра · **Хук:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах · **Теги:** goal:observability
Тихо сломавшаяся автоматизация — главный эксплуатационный риск коллектора:
данные просто перестают приходить, и заметить это можно только по молчанию.
Расписание HAE — пожелание, а не гарантия (находка 28), так что молчание
случается штатно.
`/stats` отвечает на «жив ли поток» без чтения логов: последняя доставка по
каждой автоматизации, счётчики за сутки, тишина в часах, доля доставок с
ошибкой разбора, строки без кода в словаре категориальных значений.
Готово, когда по одному запросу видно, какая из автоматизаций замолчала и
когда.
Отдельной строкой — **отставание фоновой свёртки**: длина очереди
(`parse_status = 'pending'`) и возраст самой старой неразобранной доставки.
Сегодня об этом говорят только две метки в логе (`WARN` «доставка ждала свёртки
дольше пяти минут» и `INFO` о размере задолженности при старте), а `/healthz`
статичен и здорового сервиса от сервиса с сотней несвёрнутых тел не отличает.
Пришло из задачи «Разнести ответ приёма и свёртку доставки»: там числа
намеренно не заводились, чтобы не предрешать форму счётчиков этой задачи.
Длина очереди обязана быть видна **и без `WARN`**. После миграции, переводящей
доставки в `pending`, весь исторический бэклог встаёт в очередь перед свежими
доставками, а `warnLag` на это время намеренно подавлен (`startupDone`) — то
есть отставание по конструкции не WARN-ится ровно тогда, когда оно максимально,
и бэклог идёт молча при зелёном `/healthz`. Пришло из дозакрытия находок ревью
по слиянию сущностей (проход `ops`, находка O1); оракула нет — он потребовал бы
десятков тысяч доставок.
Активное уведомление — отдельная задача, здесь только факт.
**Что добавил каталог рода агрегации.** Реальный сценарий поломки измерения — не
противоречие свидетельств (его на корпусе не бывает), а их исчезновение: владелец
переставил автоматизацию HAE, минутный слой перестал приходить, метрики одна за
другой уезжают в `unknown`, Read API перестаёт агрегировать — и в логах ноль
событий. Сюда же вторая половина: пять разных причин непригодности часа
(две точки у часового объекта, невыровненная метка, нет числа, мало минутных,
неразличимость) схлопнуты в одну разность `hours compared`, поэтому «HAE
переименовал поле точки» неотличимо от «данных мало». Оба сигнала естественно
живут в `/stats`: число метрик по родам и число метрик с `compared == 0` при
непустом окне.
**Корреляция у контура чтения.** В записи `http request` нет ни идентификатора
запроса, ни адреса клиента: жалобу потребителя не сопоставить с записью, а
выгрузку каталога посторонним — не отличить от планового опроса агента. У приёма
корреляция есть (`delivery_id`), у чтения аналога нет.
**Обслуживание журнала WAL тоже спрашивается здесь.** Признак «журнал не
разбирается» (чекпойнт по таймеру, change `cena-chitayushchego-marshruta`)
живёт одной строкой `WARN` в ротируемом docker-логе: состояние держится днями, а
сказано о нём один раз. Вопрос «журнал сейчас разбирается?» сегодня не имеет
ответа нигде, кроме `df`. В `/stats` просятся последний исход чекпойнта (когда,
сколько страниц лежит и сколько перенесено) и — тем же полем — доля ответов
чтения, которые удалось подписать `ETag`: механизм условного запроса может
перестать окупаться под плотным потоком, и снаружи это неотличимо от нормы.
+29
View File
@@ -0,0 +1,29 @@
# Активный алерт «данных нет N часов»
**Секция:** инфра · **Хук:** Пропажу потока сейчас замечает человек, а не сервис · **Теги:** goal:observability
Пропажу потока сейчас замечает человек. `/stats` покажет факт, но только если
туда заглянуть — а заглядывают ровно тогда, когда уже что-то заподозрили.
Активное уведомление закрывает разрыв: сервис сам сообщает, что данных нет
дольше порога. Канал — тот же, что у остальных моих проектов.
Порог не единый: быстрый проход идёт каждые 5 минут, но ночью телефон
заблокирован и тишина штатна (находка 28). Значит порог считается по времени
суток или по последней успешной доставке каждой автоматизации отдельно.
Приоритет низкий, пока сервис на рабочей машине и я вижу его каждый день.
После деплоя на rivendell поднимется.
## Источник алерта не может жить внутри `serve`
Отказ стража версии схемы (база новее бинаря) останавливает процесс, а
`restart: unless-stopped` даёт цикл перезапуска. Значит алерт «данных нет N
часов», живущий внутри сервиса, на эту причину остановки не сработает **по
построению** — он не поднимется вместе с ним. Обоснование стража («откат делает
оператор, он в этот момент рядом») верно для ручного отката и не покрывает
перезапуск хоста или откат деплоя.
Пришло из задачи «Дозакрыть находки ревью по слиянию сущностей» (проход `ops`,
`negative`).
@@ -0,0 +1,91 @@
# Тай-брейк при равной полноте точек
**Секция:** ядро · **Хук:** Решено: брать бо́льшее значение. Порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт · **Теги:** goal:merge-robustness, question
**Решение принято владельцем 2026-08-02: вариант (б) — брать бо́льшее значение
точки.** Ниже — исходная постановка блокера, она же ТЗ; рекомендация в конце
файла и есть выбранный вариант.
Что важно не потерять при реализации: правило обязано остаться **тотальным**
числа у точки нет, значит откат на порядок канонических форм, — и обязано
остаться полурешёткой: `max` коммутативен, ассоциативен и идемпотентен, поэтому
воспроизводимость свёртки не страдает. Род агрегации в правило **не входит**:
род есть функция витрины, и правило слияния, читающее собственную выдачу,
повторяет дефект наследования слоя «из будущего» (`docs/review.md`,
2026-08-01).
Приёмка та, что названа ниже: на прогоне живого архива отпечаток витрины обязан
**измениться** (иначе правило не сработало), а число столкновений с равной
полнотой — остаться прежним.
## Вопросы
Какое правило выбирает победителя, когда по одним координатам приехали две точки
с **равными** наборами содержательных полей и разными значениями. Структурная
часть правила слияния закрыта (`pravilo-sliyaniya-tochek`); открыт только этот
разряд.
Сегодня это порядок канонических форм, и он измеримо смещён: из 1912 случаев, где
сравнение чисел определено, лексикографический порядок берёт **меньшее** значение
в 1847 — 96% (находка 49). Столкновений с равной полнотой 1916 из 444 256
координат, то есть 0.43% координат.
## Что стало известно
Задача «Измеренный род агрегации и каталог разрезов» закрыла посылку, ради
которой тай-брейк откладывали: род метрик теперь **измерен**, а не угадан
(находка 53). Четыре из шести метрик, где тай-брейк системно берёт меньшее
(`step_count`, `walking_running_distance`, `active_energy`,
`basal_energy_burned`), измерены как **накопительные** — там «меньшее» это
систематический недосчёт порядка 0.4% координат, ровно тот, что HAE досчитывает
задним числом (находка 10). Самая крупная группа, `heart_rate`, измерена как
**мгновенная**, и там выбор безразличен: это пересэмплирование, а не досчёт.
И тем же измерением закрылся напрашивавшийся ответ: **сделать тай-брейк
зависящим от измеренного рода нельзя**. Род есть функция витрины, витрина —
результат слияния, и правило слияния, читающее собственную выдачу, повторяет
ровно тот дефект, на котором свёртка уже переставала быть функцией префикса
журнала (`docs/review.md`, 2026-08-01, наследование слоя «из будущего»).
## Варианты и цена
**а. Оставить порядок канонических форм.** Цена: систематический недосчёт 0.4%
координат у накопительных метрик, невидимый до сверки с родным экспортом Apple,
то есть месяцами. Плюс: ноль работы, правило остаётся структурным и не знает
ничего о значениях.
**б. Брать бо́льшее значение точки.** Правильно для накопительных (досчёт растёт,
находка 10, и набор полей у версий тренировки ни разу не уменьшался) и безвредно
для мгновенных (пересэмплирование). Цена: слияние перестаёт быть структурным —
оно начинает знать, какое поле точки несёт число (`hae.PointValue` уже есть).
Метрика, у которой «большее» неверно, в потоке не наблюдалась, но и не
исключена; правило приходится делать тотальным (нет числа — откат на порядок
канонических форм), то есть в нём появляется вторая ветка.
**в. Провенанс у точки и тай-брейк по позиции в журнале** — как у сущностей.
Цена: колонка провенанса на точку (или на объект) и рост объёма нижнего слоя;
плюс это не работает для столкновений **внутри одной доставки**, где
`received_at` общий, а таких четверть (находка 47: 33 столкновения внутри
доставки на эпизодах сна). То есть вариант не самодостаточен и всё равно требует
второго разряда.
## Что заблокировано
Ничего срочного: сегодняшнее правило детерминировано и воспроизводимо, витрина
остаётся свёрткой журнала. Блокирован только сам недосчёт — он копится молча.
Сверить его величину можно будет после `healthlog import`: родной экспорт Apple
даст независимый эталон по тем же периодам.
## Рекомендация
**Вариант б.** Он чинит измеренное смещение там, где оно есть, и не трогает
там, где его нет; цена — одна ветка в правиле слияния и признание, что слияние
знает про число точки (а оно уже знает — `hae.PointValue` живёт в разборе). От
варианта «а» отличается тем, что перестаёт систематически терять данные;
от «в» — тем, что не требует ни колонки, ни решения для внутридоставочных
столкновений.
Проверять на прогоне живого архива: отпечаток витрины обязан измениться (иначе
правило не сработало), а число столкновений с равной полнотой — остаться прежним.
Связано: `docs/architecture.md` → «Разрешение столкновений», находки 10, 47, 49,
53.
@@ -0,0 +1,29 @@
# Управление токенами и секретами
**Секция:** инфра · **Хук:** Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу · **Теги:** goal:deploy
Сейчас проверка токенов выключена сознательно — доверенная локальная сеть, — и
`config.docker.toml` коммитится без секретов. Для локальной разработки это
правильно, но это же делает выезд наружу опасным: одна забытая настройка
открывает историю здоровья всему интернету.
**Контуров теперь два, а не один.** С появлением каталога
(`GET /api/v1/metrics`, change `2026-08-02-katalog-i-rod-agregacii`) заработал
токен чтения, и цена у контуров разная: открытый приём означает мусор во входе,
открытое чтение — выгрузку всей истории здоровья любому, кто нашёл порт. Сервис
предупреждает на старте обоими сообщениями (`write auth disabled`,
`read auth disabled`), образцы конфига цену называют комментарием — но отказа
старта нет, и это решение осталось здесь.
Решается перед деплоем, не раньше — так договорились.
Шаги:
- раздельные токены приёма и чтения, генерация и хранение вне репозитория;
- сервис громко предупреждает на старте, если проверка выключена (уже есть),
и **отказывается стартовать**, если адрес прослушивания публичный, а токенов
нет;
- проверка в гейте, что в коммит не уехал файл с токеном (частично закрыта
`gitleaks`).
Готово, когда запуск без токенов возможен только на localhost, а на rivendell
оба контура закрыты разными токенами.
+45
View File
@@ -0,0 +1,45 @@
# Проверка секций, которых поток ещё не приносил
**Секция:** ядро · **Хук:** Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую · **Теги:** goal:parsing-and-storage
Разбор пишется по тем данным, что видел поток, а он приносил только `metrics`,
`workouts` и `stateOfMind`. Не виденны живьём: `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`, а также вес — а вес
агенту-медику нужен наверняка.
Пользователь настраивает оставшиеся метрики на телефоне, так что данные
появятся сами. Задача — не пропустить момент: убедиться, что новые секции
разбираются, а не молча падают в `parse_status`.
Часть вопроса закрыта разбором экспортов (находка 42): в Health эти данные
**есть** и в экспорте они присутствуют — `BodyMass` (1127 записей),
`BloodPressureSystolic`/`Diastolic` (по 18), `BodyTemperature` (11), `Headache`
(36), `SexualActivity` (46), `Dietary*` (по 88). Значит вопрос не «есть ли
данные», а «доедут ли они через HAE и в какой форме».
Остаётся непроверенным `stateOfMind`: в экспорте его нет ни одним типом. Если
подтвердится, что Apple его не выгружает, то экспорт ему не источник истины —
устаревание нижнего слоя к нему неприменимо, держим всегда.
Давление приезжает обёрткой `Correlation` из двух записей (находка 44) — в
экспорте точно, а вот как его отдаёт HAE, неизвестно. Это первое, на что
смотреть, когда данные появятся.
Готово, когда каждая новая секция либо разобрана, либо явно описана в
`docs/research/apple-health.md` как не пришедшая, и ни одна не числится в ошибках
разбора.
## Что уже сделано
Разбор перечисляет непокрытые секции и пишет их в `delivery.uncovered_sections`
(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Момент, когда поток принесёт
секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться
замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что
поток приносил, и сравнение с известным набором закрывает задачу.
Модель под секции с собственным `id` заложена (change
`2026-08-02-trenirovki-i-zapisi`): таблица `record` ключуется парой
`род + id`, и новая секция добавляется **одной строкой** в множество покрытых
имён разбора, а не миграцией. Покрыты `workouts` и `stateOfMind`; остались
`ecg`, `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`
их формы никто не видел, и разбор вслепую сознательно не писался.
@@ -0,0 +1,37 @@
# Идентичность тренировок при импорте родного экспорта
**Секция:** ядро · **Хук:** В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE · **Теги:** goal:native-export-import
Тренировка в витрине адресуется своим `id` из HealthKit — его шлёт HAE. В
`export.xml` этого идентификатора **нет вовсе**: у элемента `Workout` только
тип, источник, даты и статистика. Значит `healthlog import` не сможет сопоставить
тренировку из снапшота с той же тренировкой, уже приехавшей от HAE, и они
задвоятся.
Это ровно та дыра, что была у точек, и там её закрыли ключом `start + end`
(находка 47): `HKObject.uuid` в выгрузку не попадает, поэтому модель
идентичности обязана выражаться через интервал.
Prior art: `dogsheep/healthkit-to-sqlite` адресует тренировку **хешем
содержимого** (`hash_id` в sqlite-utils) — ровно потому, что идентификатора в
экспорте нет. Нам это не подходит в лоб: у нас половина тренировок уже лежит под
настоящим `id`, и хеш содержимого дал бы третий ключ рядом с двумя.
Варианты, которые надо будет сравнить:
- **Второй уникальный ключ `(start_utc, end_utc)`** у тренировки: импорт ищет по
нему, HAE — по `id`. Цена: индекс и вопрос, что делать при столкновении двух
разных тренировок с одним интервалом (бывает ли такое — неизвестно).
- **Сопоставление на стадии импорта**, без изменения схемы: импорт читает уже
сохранённые тренировки за период и приписывает найденным их `id`. Цена: логика
сопоставления живёт в импорте и не проверяется ничем, кроме него.
- **Считать тренировки из экспорта отдельным родом** и не сопоставлять вовсе.
Цена: потребитель видит две тренировки вместо одной и обязан схлопывать сам —
ровно то, чего проект старается не делать.
Решать до появления формы `healthlog import` значит угадывать: неизвестно,
понадобятся ли тренировки из экспорта вообще (у HAE они полнее — с маршрутом и
рядами, а в экспорте маршрут лежит отдельными GPX).
Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача
`apple-export-import`.
+17
View File
@@ -0,0 +1,17 @@
# [idea] Разворачивание маршрутов тренировок в отдельную таблицу
**Секция:** ядро · **Хук:** Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом · **Теги:** goal:read-api
Тренировка хранится нераскрытой: заголовок — колонками, всё остальное, включая
маршрут и внутренние ряды, — блобом `payload`. Решение осознанное: структура
тренировки разнородна и избыточна (сводки дублируют ряды, находка 15), и
раскладывать её в таблицы значило бы решить за Apple, что в ней главное.
Разворачивание маршрута в отдельную таблицу точек имело бы смысл для запросов
вида «все пробежки, проходившие через эту область» или «набор высоты по
сегментам» — то есть когда маршрут нужен не целиком, а выборочно.
Почему идея, а не задача: такого клиента нет. Трекер тренировок берёт
тренировку целиком одним пакетом, и этого ему достаточно.
Связано: `docs/architecture.md` → «Тренировки и прочие секции».