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

- роадмап отвечает «что умеет и чего не умеет»: PLAN.md → ROADMAP.md, четыре
  канонические секции, достигнутые звенья строками в «Готово», цели
  переформулированы возможностями приложения
- задачи: род работы и «Затрагивает» набору спринта, 34 заголовка в форму
  действия, «Завершение» целей перечнями со ссылкой из каждой задачи
- вычитка проходами task-form и doc-wording, починены протухшие факты в README,
  паспорте и review.md
This commit is contained in:
av
2026-08-04 20:48:30 +03:00
parent b1d3b25827
commit d33f37249c
63 changed files with 489 additions and 346 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
[docs/passport.md](docs/passport.md) (цель, сценарии, референсы),
[README.md](README.md), [docs/architecture.md](docs/architecture.md),
[docs/conventions/README.md](docs/conventions/README.md),
[docs/security.md](docs/security.md) и [docs/tasks/PLAN.md](docs/tasks/PLAN.md).
[docs/security.md](docs/security.md) и [docs/tasks/ROADMAP.md](docs/tasks/ROADMAP.md).
Документация ведётся по канону `av-dev-pm` (версия в `docs/.pm.json`);
раскладку проверяет `av-dev-pm:canon`, содержимое ведёт `av-dev-pm:docs`.
+13 -7
View File
@@ -67,13 +67,17 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
пересборка воспроизводима и повторный прогон ничего не меняет.
Первый маршрут чтения открыт: **каталог разрезов** (`GET /api/v1/metrics`) под
Маршрутов чтения открыто два. **Каталог разрезов** (`GET /api/v1/metrics`) под
токеном чтения отдаёт слои с диапазонами и измеренный род агрегации, а повтор
неизменившегося отвечает `304` по `ETag` — снимок витрины при этом не
открывается. Журнал WAL разбирается фоновым чекпойнтом по таймеру.
открывается. **Точки метрики за период** (`GET /api/v1/metrics/{name}`) едут
одним запросом: слой выбирается по охвату точек внутри периода, а род агрегации
приезжает вместе с данными и с явным указанием, применим ли он к ряду. Журнал
WAL разбирается фоновым чекпойнтом по таймеру.
Чего ещё нет: **read API точек**, тренировок и записей — сами данные наружу
пока не отдаются. План в [docs/tasks/PLAN.md](docs/tasks/PLAN.md).
Чего ещё нет: свёртки по сетке, условного запроса по точкам, тренировок и
записей наружу. Что умеет и чего не умеет —
[docs/tasks/ROADMAP.md](docs/tasks/ROADMAP.md).
Разведка формата закончена: 50 находок на живом потоке, половина расходится с
документацией Health Auto Export — [docs/research/apple-health.md](docs/research/apple-health.md).
@@ -81,7 +85,7 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
## Команды
```
healthlog serve приём + read API + MCP
healthlog serve приём + read API (MCP — в планах)
healthlog import родной экспорт Apple Health (в планах)
healthlog reindex пересборка витрины из журнала
healthlog uncovered перечень секций, которых разбор не покрыл
@@ -154,7 +158,8 @@ curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
Если `auth.write_tokens` пуст, проверка токена выключена — для доверенной
локальной сети этого достаточно, сервис пишет об этом `write auth disabled`
на старте. Для доступа снаружи понадобится и токен, и TLS — это шаг «Деплой».
на старте. Для доступа снаружи понадобится и токен, и TLS — это цель
«Сервис доступен телефону из любой сети».
## Документация
@@ -170,7 +175,8 @@ curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
- [docs/conventions/](docs/conventions/README.md) — как пишем код
- [docs/security.md](docs/security.md) — периметр и модель угроз
- [docs/review.md](docs/review.md) — настройка конвейера ревью и журнал дефектов
- [docs/tasks/PLAN.md](docs/tasks/PLAN.md) — цели и обоснование их порядка
- [docs/tasks/ROADMAP.md](docs/tasks/ROADMAP.md) — что приложение уже умеет и
чего ещё не умеет
- [docs/tasks/BACKLOG.md](docs/tasks/BACKLOG.md) — что брать следующим, включая
отложенные идеи
- [docs/research/apple-health.md](docs/research/apple-health.md) — что показал реальный поток
+1 -1
View File
@@ -1,4 +1,4 @@
{
"canon": 2,
"canon": 3,
"migrations": "internal/store/migrations"
}
+17 -13
View File
@@ -1,8 +1,8 @@
# Паспорт проекта
Куда мы идём, как выглядит работа проекта в жизни и у кого подсматривать,
когда упёрлись. Самый верхний документ: [tasks/PLAN.md](tasks/PLAN.md) отвечает «в каком
порядке», [architecture.md](architecture.md) — «как устроено», паспорт —
когда упёрлись. Самый верхний документ: [tasks/ROADMAP.md](tasks/ROADMAP.md) отвечает «что
приложение умеет», [architecture.md](architecture.md) — «как устроено», паспорт —
**«зачем и для кого»**.
## Цель
@@ -51,23 +51,25 @@
## Типовые сценарии
Ситуации, ради которых всё написано. В скобках — шаги [tasks/PLAN.md](tasks/PLAN.md),
которыми сценарий закрывается; названы, а не пронумерованы, потому что план
живой и нумерация в нём поедет.
Ситуации, ради которых всё написано. В скобках — цели [tasks/ROADMAP.md](tasks/ROADMAP.md),
которыми сценарий закрывается: достигнутые названы слагом из «Готово», открытые —
заголовком цели. Названы, а не пронумерованы, потому что роадмап живой и
нумерация в нём сдвинется на первой же вставке.
**1. Молчаливый приём** (приём, разбор и хранилище). Телефон каждые 5 минут шлёт доставку;
**1. Молчаливый приём** (`ingest`, `parsing-and-storage` — сделаны). Телефон каждые 5 минут шлёт доставку;
сервис кладёт тело в архив, отвечает `200`, разбирает метрики в часовые
объекты. Никто ничего не спрашивает и не смотрит.
*Успех:* сутки работы не порождают ни одной строки лога уровня `WARN` и ни
одного действия человека.
**2. Дыра закрывается сама** (разбор и хранилище). Телефон был заблокирован ночью,
**2. Дыра закрывается сама** (`parsing-and-storage` — сделано). Телефон был заблокирован ночью,
автоматизация не отработала, часть дня отсутствует. Средний проход (сутки) и
глубокий (неделя) переприсылают окно целиком, точки доезжают.
*Успех:* дыра моложе недели закрывается без вмешательства; никто о ней даже не
узнаёт.
**3. Квартальный экспорт** (`healthlog import`, устаревание нижнего слоя).
**3. Квартальный экспорт** (История из родного экспорта Apple лежит в
хранилище; Нижний слой чистится после проверенного экспорта).
Изредка владелец выгружает
родной экспорт Apple Health и скармливает его `healthlog import`. Нижний слой
за прошлое становится честным (настоящие сэмплы вместо посекундной развёртки
@@ -75,32 +77,34 @@ HAE), а сырой архив получает право быть подчищ
*Успех:* экспорт разобран, покрытие периода проверено, объём архива вернулся к
норме, ничего не потеряно.
**4. Агент спрашивает про здоровье** (каталог и род агрегации, Read API, MCP). Агент-медик по MCP
**4. Агент спрашивает про здоровье** (`catalog` — сделан; Клиенты читают данные
через HTTP и MCP). Агент-медик по MCP
спрашивает каталог («что у тебя вообще есть»), затем «шаги по дням за месяц»
или «пульс за вчера». Получает свёрнутый ряд с честным указанием слоя, сетки и
рода агрегации.
*Успех:* ответ влезает в контекст агента, число не завышено вдвое, и агенту не
пришлось знать про слои, чтобы спросить правильно.
**5. Приложение берёт тренировки** (Read API). Разборщик тренировок запрашивает
**5. Приложение берёт тренировки** (Клиенты читают данные через HTTP и MCP). Разборщик тренировок запрашивает
заголовки за период, потом одну тренировку целиком — с маршрутом и рядом
пульса.
*Успех:* тренировка отдана одним пакетом в том виде, в каком её прислал Apple,
без нашей интерпретации того, что в ней главное.
**6. Разбор поменялся** (`healthlog reindex`). Мы начали разбирать секцию, которую
**6. Разбор поменялся** (`reindex` — сделан). Мы начали разбирать секцию, которую
раньше пропускали, или нашли ошибку в старом разборе. Запускается пересборка
по сырому архиву: `import(экспорт) + replay(доставки по received_at)`.
*Успех:* состояние пересобрано детерминированно, повтор даёт то же самое,
доставки со снятым статусом `partial` подобраны.
**7. Владелец проверяет, жив ли поток** (наблюдаемость). Раз в сколько-то дней —
**7. Владелец проверяет, жив ли поток** (Приложение сообщает о своём состоянии). Раз в сколько-то дней —
взгляд в `/stats`: когда была последняя доставка, сколько точек, есть ли
тишина, какие строки не легли в словарь кодов.
*Успех:* один экран отвечает «всё идёт» или «встало тогда-то», без залезания
в SQLite.
**8. Приехало незнакомое** (разбор и хранилище). HAE обновился и прислал новую метрику,
**8. Приехало незнакомое** (`parsing-and-storage` — сделано; Новая форма от
источника не теряется молча). HAE обновился и прислал новую метрику,
новую форму точки или новую секцию. Тело сохраняется, ответ — `200`, разбор
честно помечает доставку `partial` и перечисляет непокрытое.
*Успех:* данные в архиве и восстановимы, факт виден в логе и `/stats`, а
+34 -21
View File
@@ -34,8 +34,10 @@
сам же меняет, без границы по `received_at` разбираемой доставки.
- Правило выбора между версиями — функция множества версий либо явно функция
порядка журнала; третьего состояния нет.
- Столкновение разрешается полнотой, а не свежестью; изменение запечатанного
часа пишется `WARN`, но данные пишутся.
- Столкновение разрешается полнотой, а при равной полноте — положением в
журнале: побеждает стоящая позже
([ADR](adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md)). Изменение
запечатанного часа пишется `WARN`, но данные пишутся.
- Транзакция не держит блокировку дольше `busy_timeout`: канонизация и
сжатие — вне её.
@@ -52,7 +54,8 @@
- Расход памяти не растёт вместе с длиной журнала.
- Подмена базы — решение человека при остановленном сервисе, не команды.
**Обработчик чтения и адаптер MCP** (Read API, MCP — ещё не написаны)
**Обработчик чтения и адаптер MCP** (`internal/httpapi`: каталог и точки
написаны; свёртка по сетке, тренировки, записи и MCP — ещё нет)
- Агрегат считается только там, где род свёртки измерен; нижний слой HAE не
суммируется никогда.
@@ -105,31 +108,41 @@
- `triage`: перечислены ли запущенные проходы поимённо и с исходом; непущенный
проход идёт в границы покрытия строкой «не запускался» (запись 2026-08-02,
чекпоинт кода прошёл без трёх проходов).
- `specs`: считается ли внешним поведением **состояние, которое даёт
пересборка** — витрина наблюдаема через пересборку, поэтому расхождение с
журналом не внутренняя деталь, а поведение, которого спека не заказывала.
Внешнее здесь — ещё и код ответа приёма, форма ответа чтения и содержимое
архива (переселено из триггеров профиля, канон 3).
### Триггеры профиля
Уточняет умолчания конвейера, не отменяет их.
Уточняет умолчания конвейера, не отменяет их. Рабочее умолчание — `standard`:
миграция схемы, публичный контракт и инвариант ступень **не** поднимают, их
проверяют проходы, которые в `standard` и так есть.
- **`deep`** — изменения в правиле разбора, идентичности, слияния или вывода
слоя; миграции схемы; всё, что трогает `internal/store`, `internal/fold`,
`internal/replay`.
- **«Поведение, видимое снаружи»** здесь — код ответа приёма, форма ответа
чтения, содержимое архива и **состояние, которое даёт пересборка**: витрина
наблюдаема через пересборку, поэтому расхождение с журналом — внешнее
поведение, а не внутренняя деталь.
- **`reimpl`** запускается по триггеру «новое правило слияния, идентичности или
разбора». Единственный раз, когда триаж назвал его отсутствие дырой
покрытия, — это была задача с новым правилом слияния сущностей.
Второй замер (2026-08-03, словарь категориальных значений): триггер сработал
на новом правиле разбора и ключе реестра, проход **окупился** — он независимо
подтвердил замером две находки, до того имевшие только одно измерение (пик
памяти накопителя: 1002 МиБ против 780 на базе; единицы счётчика отброшенных),
и отдельно назвал семь мест, где существующее решение оказалось **лучше** его
собственного. Второе ценно не меньше первого: оно показывает, где проход
соглашается, а не только где спорит.
- **Новое понятие или структурная единица** (`wide`) — новый пакет в
`internal/`, новый род узла из перечня выше, новый тип провода в
`internal/httpapi`, новая единица хранения, входящая в отпечаток, новый
транспорт рядом с HTTP.
- **Правила идентичности, слияния и разбора** (`deep`) живут в трёх местах:
`internal/hae` — разбор пакета и вывод слоя; `internal/fold` — выбор между
версиями точки; `internal/store` — координатный ключ и запись часового
объекта. Правку правила в любом из них ступень поднимает; перенос кода без
правки правила — нет.
- **`quick`** — правка документов, конфигурации, сообщений; ничего, что меняет
хранимое.
`reimpl` живёт за барьером `deep` и по тому же триггеру — новое правило слияния,
идентичности или разбора. Замеры окупаемости: единственный раз, когда триаж
назвал его отсутствие дырой покрытия, — задача с новым правилом слияния
сущностей. Второй замер (2026-08-03, словарь категориальных значений): триггер
сработал на новом правиле разбора и ключе реестра, проход **окупился** — он
независимо подтвердил замером две находки, до того имевшие только одно измерение
(пик памяти накопителя: 1002 МиБ против 780 на базе; единицы счётчика
отброшенных), и отдельно назвал семь мест, где существующее решение оказалось
**лучше** его собственного. Второе ценно не меньше первого: оно показывает, где
проход соглашается, а не только где спорит.
### Недоступно проверке
**Не проверит ни один проход.** Реальный профиль нагрузки: телефон шлёт молча и
+34 -32
View File
@@ -1,51 +1,53 @@
# Беклог
Что **можно взять**. Одна задача = один файл `items/<slug>.md`
+ строка здесь. Целей тут нет — они в [PLAN.md](PLAN.md): беклог — то, что берут,
план — то, подо что берут. Порядка внутри секции нет: «что делать
+ строка здесь. Целей тут нет — они в [ROADMAP.md](ROADMAP.md): беклог — то, что
берут, роадмап — то, подо что берут. Порядка внутри секции нет: «что делать
дальше» отвечает набор спринта. Ведётся скиллом `tasks`.
Секции «блокеры» здесь нет и не заводится: блокер — это состояние
(спринт не может продолжаться ни одной задачей), оно живёт до ответа
человека, а его следы — вопросами в файлах задач.
## ядро
## Ядро
- [[idea] Человеческие аннотации поверх выведенных схем](items/schema-annotations.md) — Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
- [Проверка целостности собранной витрины перед подменой](items/integrity-before-swap.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
- [Цена слияния на широкой доставке](items/merge-cost-wide-delivery.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
- [Сущность с id, но неразобранной меткой](items/entity-without-parsed-label.md) — Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым
- [Идентичность тренировок при импорте родного экспорта](items/workout-identity-on-import.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
- [Импорт родного экспорта Apple Health](items/apple-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [Проверять целостность собранной витрины до подмены](items/integrity-before-swap.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
- [Снизить цену слияния на широкой доставке](items/merge-cost-wide-delivery.md) — 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
- [Не терять сущность с id и неразобранной меткой](items/entity-without-parsed-label.md) — Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым
- [Не задваивать тренировки при импорте родного экспорта](items/workout-identity-on-import.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
- [Импортировать родной экспорт Apple Health](items/apple-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [[idea] Месячный проход по ручным секциям](items/monthly-manual-sections-pass.md) — Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
- [[idea] NDJSON-поток для больших выборок Read API](items/ndjson-stream.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
- [Data-миграции не отбирают строки по обрезаемым спискам](items/data-migration-row-selection.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
- [Не отбирать строки в data-миграциях по обрезаемым спискам](items/data-migration-row-selection.md) — Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
- [[idea] Отказ от heartbeatSeries](items/drop-heartbeat-series.md) — 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
- [Пересборка держит весь журнал в памяти](items/rebuild-memory-footprint.md) — Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
- [Не держать весь журнал в памяти при пересборке](items/rebuild-memory-footprint.md) — Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
- [[idea] Пересекающиеся источники одной метрики](items/overlapping-sources.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
- [[idea] Порог sealed: с какого возраста час считается запечатанным](items/sealed-threshold.md) — WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
- [Порядок журнала при конкурентных приёмах](items/journal-order-on-ingest.md) — Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
- [Предел на размер и число заголовков доставки](items/delivery-header-limits.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
- [Пределы на размер сущности и потоковый расчёт формы](items/entity-size-limits.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
- [Держать порядок журнала при конкурентных приёмах](items/journal-order-on-ingest.md) — Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
- [Ограничить размер и число заголовков доставки](items/delivery-header-limits.md) — MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
- [Ограничить размер сущности и считать форму потоково](items/entity-size-limits.md) — Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
- [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](items/workout-routes-table.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
- [Выведенные из данных схемы содержимого](items/derived-content-schemas.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [Выводить схемы содержимого из данных](items/derived-content-schemas.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [[idea] Что считать сутками при смене часового пояса](items/day-boundary-timezone.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- [Сверка живой витрины с пересборкой](items/rebuild-comparison-check.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- [Устаревание нижнего слоя после экспорта](items/lower-layer-expiry.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [Сверять живую витрину с пересборкой](items/rebuild-comparison-check.md) — reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- [Помечать нижний слой устаревшим после экспорта](items/lower-layer-expiry.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [[idea] Выгрузка в parquet отдельной командой](items/parquet-export.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
- [Заголовки доставки в архиве рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
- [Правило полноты против last wins](items/last-wins-over-completeness.md) — Полнота решает 1,2% спорных координат, и неизвестно, была ли более полная точка более поздней — от этого зависит, нужна ли она вообще
- [MCP-сервер поверх Read API](items/mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [Рукописная OpenAPI-спека](items/openapi-spec.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [Гейт против расхождения спеки с маршрутами](items/openapi-gate-check.md) — Рукописная спека — источник истины, а расходится она с кодом молча: без гейта решение о рукописной спеке не держится
- [Swagger UI без внешней сети](items/swagger-ui.md) — Контракт читается машиной, но человеку нечем ткнуть в живой сервис, а внешних CDN в локальной сети нет
- [Класть заголовки доставки в архив рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
- [Измерить, нужно ли правило полноты рядом с LWW](items/last-wins-over-completeness.md) — Полнота решает 1,2% спорных координат, и неизвестно, была ли более полная точка более поздней — от этого зависит, нужна ли она вообще
- [Поднять MCP-сервер поверх Read API](items/mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [Написать OpenAPI-спеку руками](items/openapi-spec.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [Ловить гейтом расхождение спеки с маршрутами](items/openapi-gate-check.md) — Рукописная спека — источник истины, а расходится она с кодом молча: без гейта решение о рукописной спеке не держится
- [Поднять Swagger UI без внешней сети](items/swagger-ui.md) — Контракт читается машиной, но человеку нечем выполнить запрос к живому сервису из браузера, а внешних CDN в локальной сети нет
## инфра
- [Активный алерт «данных нет N часов»](items/stream-silence-alert.md) — Пропажу потока сейчас замечает человек, а не сервис
- [Деплой на rivendell](items/deploy-rivendell.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома
- [Счётчики слияния переживают ротацию логов](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 коммитится — так нельзя выезжать наружу
## Инфра
- [Слать уведомление, когда данных нет N часов](items/stream-silence-alert.md) — Пропажу потока сейчас замечает человек, а не сервис
- [Выложить сервис на rivendell](items/deploy-rivendell.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома
- [Хранить счётчики слияния вне логов](items/merge-counters-in-db.md) — Единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- [Развести бюджеты остановки и оставить следы миграции в логе](items/shutdown-and-migration-traces.md) — Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
- [Назвать механизм отката релиза после наката миграции](items/release-rollback-after-migration.md) — Страж версии схемы делает возврат старого бинаря отказом, а понизить схему нечем — аварийный путь пришлось бы изобретать в аварии
- [Подчищать сырой архив до последнего проверенного экспорта](items/raw-archive-retention.md) — Архив не подчищается вовсе, а резать его раньше даты проверенного экспорта нельзя — в журнале останется дыра, которую нечем пересобрать
- [Отдавать состояние сервиса маршрутом /stats](items/stats-endpoint.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- [Свести умолчания конфига с рабочей раскладкой данных](items/config-defaults-data-dir.md) — Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
- [Развести токены контуров и убрать секреты из репозитория](items/token-and-secret-management.md) — Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
-54
View File
@@ -1,54 +0,0 @@
# План
Оглавление целей. Цель — файл `[goal]` в `items/`; её задачи
здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.
В первой секции («порядок») очередь значима и обосновывается
прозой; в остальных порядка нет — это тематические цели.
## Что уже пройдено
Каркас и приём без разбора закрыты. Метрики, тренировки и записи со своими `id`
разбираются и ложатся в часовые объекты. `reindex` проигрывает журнал в свежую
витрину, отпечатки сравниваются, повторный прогон ничего не меняет. Род
агрегации **измерен**: сверка минутного слоя с часовым разложила метрики живого
корпуса на накопительные и мгновенные, не сойдясь ни на одной, и каталог
разрезов отдаётся первым маршрутом чтения. Разведка закончена — правило вывода
слоя, модель идентичности и формы точки проверены на живом потоке
([research/apple-health.md](../research/apple-health.md)).
**Разбор и хранилище закрыты 2026-08-04.** Ни одна секция живого потока не
числится неразобранной, категориальные значения несут стабильный код рядом с
переведённой строкой, а первая встреча незнакомой секции стала наблюдаемым
событием. То, что заканчиваться не умеет по природе — новые формы от источника
и ручные секции задним числом, — переехало в тему
[«Полнота разбора потока»](items/parsing-completeness.md).
Эти звенья целями не заведены: закрытая цель записи не оставляет, ей хватает
коммита и спеки.
## Почему в таком порядке
- **Каталог и род агрегации — перед Read API.** Без измеренного рода свёртка в
ответе неотличима от угадывания, а ошибиться здесь дорого: просуммировать
нижний слой значит завысить втрое. Это звено уже закрыто.
- **`healthlog import` — перед устареванием нижнего слоя.** Пока импорт
экспорта не написан, помечать что-либо устаревшим не на основании чего.
- **MCP — внутри чтения, а не отдельной целью.** Прежде было две цели, и
разделяла их очередь: адаптер собственной логики не несёт, он переводит
вызовы в те же обработчики, и переводить было нечего. Очередь осталась —
порядком задач внутри цели, — а отдельная цель под адаптер описывала не
направление, а последний шаг того же направления.
## порядок
- [[goal] Чтение данных клиентами](items/read-api.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей, включая агента-медика, ничего прочитать не может
- [[goal] Самоописание](items/self-description.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [[goal] Импорт родного экспорта Apple](items/native-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [[goal] Устаревание нижнего слоя](items/lower-layer-cleanup.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [[goal] Наблюдаемость](items/observability.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- [[goal] Деплой](items/deploy.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
## темы
- [[goal] Прочность слияния и идентичности](items/merge-robustness.md) — Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
- [[goal] Журнал и пересборка](items/journal-and-rebuild.md) — Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
- [[goal] Пределы и поведение под объёмом](items/limits-and-load.md) — Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
- [[goal] Полнота разбора потока](items/parsing-completeness.md) — Пять секций HAE поток ещё не приносил, а ручные секции заводятся задним числом — разбор полон ровно до следующей новой формы
+51
View File
@@ -0,0 +1,51 @@
# Роадмап
Что приложение уже умеет и чего ещё не умеет. Цель — возможность приложения,
файл `[goal]` в `items/`; её задачи здесь **не перечисляются** — перечень даёт
`tasks.py list --goal <слаг>`. Очередь значима только в «Запланировано» и
обосновывается прозой рядом.
## Готово
- 2026-08-01 `ingest` — Сервис принимает доставки HAE и кладёт тела в архив.
Приём отвечает `200` до разбора, свёртку ведёт фоновый воркер: код ответа
отражает доставку, а не её понимание.
- 2026-08-02 `reindex``healthlog reindex` проигрывает журнал в свежую витрину
и печатает оба отпечатка. Повторный прогон ничего не меняет.
- 2026-08-02 `catalog` — Клиент видит перечень разрезов с измеренным родом
агрегации. Сверка минутного слоя с часовым разложила метрики живого корпуса на
накопительные и мгновенные, не сойдясь ни на одной.
- 2026-08-04 `parsing-and-storage` — Метрики, тренировки и записи со своими `id`
разобраны и лежат в часовых объектах. Ни одна секция живого потока не числится
неразобранной, категориальные значения несут стабильный код рядом с
переведённой строкой, первая встреча незнакомой секции наблюдаема.
Разведка формата закончена там же и записана в
[research/apple-health.md](../research/apple-health.md): правило вывода слоя,
модель идентичности и формы точки проверены на живом потоке. Возможностью
приложения она не была, поэтому строки среди достигнутых целей не занимает.
## Запланировано
Очередь держится на двух зависимостях. **`healthlog import` идёт перед чисткой
нижнего слоя:** пока импорт экспорта не написан, помечать что-либо устаревшим не
на основании чего. **MCP входит в чтение, а не идёт отдельной целью:** адаптер
собственной логики не несёт, он переводит вызовы в те же обработчики, и очередь
осталась порядком задач внутри цели.
- [[goal] Клиенты читают данные через HTTP и MCP](items/read-api.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей, включая агента-медика, ничего прочитать не может
- [[goal] Клиент узнаёт форму данных из ответа](items/self-description.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [[goal] История из родного экспорта Apple лежит в хранилище](items/native-export-import.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [[goal] Нижний слой чистится после проверенного экспорта](items/lower-layer-cleanup.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [[goal] Приложение сообщает о своём состоянии](items/observability.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
## Направления
- [[goal] Исход слияния не зависит от порядка элементов на проводе](items/merge-robustness.md) — Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
- [[goal] Расхождение витрины с журналом не молчит](items/journal-and-rebuild.md) — Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
- [[goal] У каждого входа есть названный предел](items/limits-and-load.md) — Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
- [[goal] Новая форма от источника не теряется молча](items/parsing-completeness.md) — Пять секций HAE поток ещё не приносил, а ручные секции заводятся задним числом — разбор полон ровно до следующей новой формы
## Разработка
- [[goal] Сервис доступен телефону из любой сети](items/deploy.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
+9 -8
View File
@@ -1,15 +1,16 @@
# Спринт
- **Цель:** [[goal] Чтение данных клиентами](items/read-api.md)
- **Цель:** [[goal] Клиенты читают данные через HTTP и MCP](items/read-api.md)
- **Начат:** 2026-08-04
- **Спринт:** `2026-08-04`
Урожай спринта поднимается `tasks.py list --tag sprint:2026-08-04` это первая порция переоценки на сессии.
Урожай спринта перечисляет `tasks.py list --tag sprint:2026-08-04`; в наборе — первая порция задач, переоценённых в этой сессии.
## Набор
- [Условный запрос по точкам](items/read-api-points-conditional.md) — Агент опрашивает по расписанию, а каждый повтор стоит полного чтения: на каталоге это 693 мс и +153 МиБ
- [Свёртка по сетке](items/read-api-points-bucket.md) — «Шаги за неделю по дням» — базовый запрос трекера и игры, и сегодня его нечем задать
- [Порог неполного ведра](items/read-api-partial-bucket.md) — Текущий час неполон всегда, и без порога свёртка отдаёт его наравне с полными — клиент видит провал вместо неизвестности
- [Предел размера ответа](items/read-api-response-limit.md) — У маршрутов чтения нет ни одного потолка: множители «метрики × окно × точки × одновременные запросы» ничем не ограничены
- [Тренировки наружу](items/read-api-workouts.md) — Тренировки с маршрутами разобраны и лежат в витрине, а эндпоинтов нет — сценарий трекера не закрыт
- [Записи наружу](items/read-api-records.md) — stateOfMind разобран и хранится, но наружу не отдаётся — а восстановить его нечем: в экспорте Apple его нет
- [Отвечать 304 на повторный запрос точек](items/read-api-points-conditional.md) — Агент опрашивает по расписанию, а каждый повтор стоит полного чтения: на каталоге это 693 мс и +153 МиБ
- [Сворачивать точки по заданной сетке](items/read-api-points-bucket.md) — «Шаги за неделю по дням» — базовый запрос трекера и игры, и сегодня его нечем задать
- [Отличать неполное ведро от полного](items/read-api-partial-bucket.md) — Текущий час неполон всегда, и без порога свёртка отдаёт его наравне с полными — клиент видит провал вместо неизвестности
- [Ограничить размер ответа маршрутов чтения](items/read-api-response-limit.md) — У маршрутов чтения нет ни одного потолка: множители «метрики × окно × точки × одновременные запросы» ничем не ограничены
- [Отдавать тренировки вместе с маршрутом](items/read-api-workouts.md) — Тренировки с маршрутами разобраны и лежат в витрине, а маршрутов чтения нет — сценарий трекера не закрыт
- [Отдавать записи со своим id за период](items/read-api-records.md) — stateOfMind разобран и хранится, но наружу не отдаётся — а восстановить его нечем: в экспорте Apple его нет
+4 -2
View File
@@ -1,6 +1,6 @@
# Импорт родного экспорта Apple Health
# Импортировать родной экспорт Apple Health
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- **Теги:** goal:native-export-import
@@ -37,6 +37,8 @@
должен ничего менять;
- `export_cda.xml` игнорируем — это клинический формат тех же данных.
Двигает строку «Завершения» цели: «Слой `sample` наполнен историей с 2019 года, повторный импорт того же экспорта ничего не меняет».
## Импорт выставляет пометку покрытия
Импорт — единственный, кто знает, какой период каким слоем обеспечен, поэтому
+4 -2
View File
@@ -1,6 +1,6 @@
# Умолчания конфига указывают на прежнюю раскладку
# Свести умолчания конфига с рабочей раскладкой данных
- **Секция:** инфра
- **Секция:** Инфра
- **Зачем:** Запуск без конфига заведёт пустую базу в корне рядом с настоящей — тихая ловушка
- **Теги:** goal:deploy
@@ -16,3 +16,5 @@
Готово, когда запуск без конфига использует `./data` и не создаёт ничего в
корне репозитория. Тогда же снимается предупреждение из `config.example.toml`.
Двигает строку «Завершения» цели: «Запуск без конфига не заводит базу мимо `./data`».
@@ -1,12 +1,14 @@
# Data-миграции не отбирают строки по обрезаемым спискам
# Не отбирать строки в data-миграциях по обрезаемым спискам
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Миграция 00007 отбирает по uncovered_sections, который обрезается на 32 — следующая покрытая секция унаследует слепую зону
- **Теги:** goal:journal-and-rebuild
Остаток задачи «Дозакрыть находки ревью по слиянию сущностей» (архивный change
`dozakryt-nahodki-sushchnostej`).
Двигает строку «Завершения» цели: «Data-миграции не наследуют слепые зоны обрезаемых списков».
## Оракул: механизм доказан, дефект пока пустой
Миграция `00007` переводит в `pending` доставки, у которых имя ставшей покрытой
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Что считать сутками при смене часового пояса
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- **Теги:** goal:read-api
+4 -2
View File
@@ -1,6 +1,6 @@
# Предел на размер и число заголовков доставки
# Ограничить размер и число заголовков доставки
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** MaxHeaderBytes не задан, в базу заголовки пишутся целиком: дефект спит до деплоя, а просыпается вместе с ним
- **Теги:** goal:limits-and-load
@@ -27,3 +27,5 @@
не оставляя следа в базе, а обычная доставка проходит как раньше.
Связано: `internal/httpapi`, `internal/ingest`, `docs/architecture.md` → «Приём».
Двигает строку «Завершения» цели: «У заголовков доставки есть названный предел».
@@ -1,6 +1,6 @@
# Заголовки доставки в архиве рядом с телом
# Класть заголовки доставки в архив рядом с телом
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
- **Теги:** goal:journal-and-rebuild
@@ -32,7 +32,7 @@ Prior art прямой: **WARC** (формат веб-архивов) храни
операциям, но появляется третья сущность.
Цена ошибки высокая: правится **путь приёма**, а доставка, не попавшая в архив,
теряется навсегда. Значит профиль ревью — `deep`, и менять надо так, чтобы
теряется навсегда. Значит менять надо так, чтобы
старые тела без заголовков продолжали читаться.
Готово, когда пересборка на архиве, у которого рабочей базы нет вовсе, даёт то
@@ -40,3 +40,5 @@ Prior art прямой: **WARC** (формат веб-архивов) храни
Связано: `docs/architecture.md` → «Сырой архив и восстановление состояния»,
`internal/replay`.
Двигает строку «Завершения» цели: «Пересборка восстановима без базы: заголовки доставки лежат в архиве рядом с телом».
+4 -2
View File
@@ -1,6 +1,6 @@
# Деплой на rivendell
# Выложить сервис на rivendell
- **Секция:** инфра
- **Секция:** Инфра
- **Зачем:** Сервис живёт на рабочей машине — телефон достаёт до него только дома
- **Теги:** goal:deploy
@@ -30,3 +30,5 @@
Том стоит смонтировать так, чтобы серверный бекап забирал его без отдельной
настройки, и снимать копию SQLite через `VACUUM INTO`, а не `cp`: телефон шлёт
непрерывно, и файл под записью копировать нельзя.
Двигает строку «Завершения» цели: «Телефон шлёт на публичный адрес из любой сети, агент читает по тому же домену».
+9 -9
View File
@@ -1,17 +1,17 @@
# [goal] Деплой
# [goal] Сервис доступен телефону из любой сети
- **Секция:** порядок
- **Секция:** Разработка
- **Зачем:** Сервис живёт на рабочей машине — телефон достаёт до него только дома, и оба контура открыты
- **Теги:** decomposed
Сервис переезжает на rivendell и становится доступен телефону из любой сети.
Выведена из шага 11 плана.
Завершена, когда оба контура закрыты разными токенами, откат релиза имеет
названный механизм, а запуск без конфига не заводит базу мимо данных.
## Завершение
Оба контура закрыты разными токенами, откат релиза имеет названный механизм,
а запуск без конфига не заводит базу мимо данных.
- Телефон шлёт на публичный адрес из любой сети, агент читает по тому же домену
- Оба контура закрыты разными токенами, и без токенов сервис стартует только на
localhost
- Откат релиза после наката миграции имеет названный механизм
- Запуск без конфига не заводит базу мимо `./data`
- Остановка сервиса называет виновный этап честно, а накат миграций виден в логе
старта
+4 -2
View File
@@ -1,6 +1,6 @@
# Выведенные из данных схемы содержимого
# Выводить схемы содержимого из данных
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- **Теги:** goal:self-description
@@ -25,3 +25,5 @@
Связано: `docs/architecture.md` → «Самоописание». Форму конверта API описывает
не эта задача, а OpenAPI.
Двигает строку «Завершения» цели: «Формы содержимого метрик выведены из данных, а не описаны руками».
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Отказ от heartbeatSeries
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе
- **Теги:** goal:lower-layer-cleanup
+4 -2
View File
@@ -1,6 +1,6 @@
# Пределы на размер сущности и потоковый расчёт формы
# Ограничить размер сущности и считать форму потоково
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Тело 40 МиБ даёт 768 МиБ пика кучи, 63 МиБ держат блокировку 5.019 с — предела на одну сущность нет вовсе
- **Теги:** goal:limits-and-load
@@ -10,6 +10,8 @@
структурное: **предела на размер одной сущности нет вовсе**, а форма и хеш
считаются материализацией значения целиком.
Двигает строку «Завершения» цели: «У тела, сущности и секции доставки есть названный предел».
## Оракул: измерено
Оракулы жили в `tmp/adv/mem_test.go` и `tmp/adv/lock_test.go`; числа снимались
@@ -1,6 +1,6 @@
# Сущность с id, но неразобранной меткой
# Не терять сущность с id и неразобранной меткой
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым
- **Теги:** goal:parsing-completeness
@@ -22,6 +22,8 @@
разбор кладёт сущность в `ts_utc`/`start_utc`, колонки `NOT NULL`, и сущность
с неразбираемой меткой по-прежнему пропускается целиком.
Двигает строку «Завершения» цели: «Сущность с `id` и неразобранной меткой не пропадает целиком».
## Что известно
- Оракул: `internal/hae/entity_test.go`, случаи «метка в ином формате», «метка
+4 -2
View File
@@ -1,6 +1,6 @@
# Проверка целостности собранной витрины перед подменой
# Проверять целостность собранной витрины до подмены
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
- **Теги:** goal:journal-and-rebuild
@@ -27,3 +27,5 @@
называть результат годным, а на здоровом — не замедляется заметно.
Связано: `cmd/healthlog/reindex.go`, `docs/architecture.md` → «Пересборка».
Двигает строку «Завершения» цели: «Годность собранной витрины подтверждена до подмены файла».
+16 -10
View File
@@ -1,20 +1,26 @@
# [goal] Журнал и пересборка
# [goal] Расхождение витрины с журналом не молчит
- **Секция:** темы
- **Секция:** Направления
- **Зачем:** Расхождение витрины с журналом сейчас молчит: reindex печатает оба отпечатка, а сравнивать их некому
- **Теги:** decomposed
Тема: инвариант «`import` + `replay` даёт то же состояние» и всё, что его
Направление: инвариант «`import` + `replay` даёт то же состояние» и всё, что его
держит — архив, ретеншен, отпечаток витрины, расход памяти пересборки.
В порядок не встаёт: работа приходит находками и растёт вместе с
В «Запланировано» не встаёт: работа приходит находками и растёт вместе с
журналом.
Завершена не бывает: закрывается по мере того, как расхождение витрины с
журналом перестаёт быть молчащим.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как расхождение витрины
с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с
журналом.
Завершена не бывает — это направление. Закрывается по мере того, как расхождение
витрины с журналом перестаёт быть молчащим, а расход пересборки — расти вместе с
журналом. Открыто сегодня:
- Расхождение живой витрины с пересборкой замечает сервис, а не человек
- Годность собранной витрины подтверждена до подмены файла
- Порядок журнала держится при конкурентных приёмах
- Пересборка восстановима без базы: заголовки доставки лежат в архиве рядом с
телом
- Сырой архив подчищается до последнего проверенного экспорта
- Расход пересборки не растёт вместе с журналом
- Data-миграции не наследуют слепые зоны обрезаемых списков
+4 -2
View File
@@ -1,6 +1,6 @@
# Порядок журнала при конкурентных приёмах
# Держать порядок журнала при конкурентных приёмах
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда, и живая витрина молча расходится с пересборкой
- **Теги:** goal:journal-and-rebuild, question
@@ -13,6 +13,8 @@
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
`deep`, враждебный проход, находка с построенным путём и прогоном).
Двигает строку «Завершения» цели: «Порядок журнала держится при конкурентных приёмах».
## Вопросы
**Решение (в) порядок журнала не восстанавливает, а цена окна выросла.**
+15 -12
View File
@@ -1,6 +1,6 @@
# Правило полноты против last wins
# Измерить, нужно ли правило полноты рядом с LWW
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Полнота решает 1,2% спорных координат, и неизвестно, была ли более полная точка более поздней — от этого зависит, нужна ли она вообще
- **Теги:** goal:merge-robustness, sprint:2026-08-03
@@ -8,17 +8,21 @@
зависит ровно там, где замер показал, что без этого теряются данные. Сегодня
неизвестно, какой из двух случаев верен.
Двигает строку «Завершения» цели: «Правило выбора между версиями измерено: полнота либо нужна, либо снята».
## Откуда задача
Владелец предложил 2026-08-04 держаться стратегии **last wins**: экспорт Apple
Владелец предложил 2026-08-04 держаться стратегии **LWW** («выигрывает
последняя»): экспорт Apple
Health — база снапшота, новые доставки HAE затирают предыдущие. Это отменяет
`critical`-инвариант `CLAUDE.md` «при столкновении выигрывает более полная
точка, а не последняя», и потому меняется не молча, а этой задачей.
Соседняя задача [tie-break-equal-completeness](tie-break-equal-completeness.md)
двигает то же правило в ту же сторону, но осторожнее: она меняет только
тай-брейк при **равной** полноте, оставляя саму полноту первичной. Эта задача
решает, надо ли снимать и её.
Соседняя задача «Тай-брейк при равной полноте» двигала то же правило в ту же
сторону, но осторожнее: она поменяла только тай-брейк при **равной** полноте,
оставив саму полноту первичной. Она сделана 2026-08-04 — решение записано в
[ADR о тай-брейке по порядку журнала](../../adr/ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md).
Эта задача решает, надо ли снимать и саму полноту.
## Замер — первый шаг, и от него ветвится всё остальное
@@ -32,11 +36,11 @@ Health — база снапшота, новые доставки HAE затир
Вопрос ровно один: **в этих 1 022 случаях более полная точка была более поздней
или более ранней?**
- **Всегда более поздней** — полнота ничего не решает сверх порядка, `last wins`
- **Всегда более поздней** — полнота ничего не решает сверх порядка, LWW
строго проще и ничего не теряет. Ветка полноты удаляется, инвариант в
`CLAUDE.md` переписывается.
- **Иногда более ранней** — значит HAE присылает обеднённые версии задним
числом, и `last wins` будет молча стирать поля. Тогда полнота остаётся, а
числом, и LWW будет молча стирать поля. Тогда полнота остаётся, а
граница её применения записывается числом: сколько таких случаев, у каких
метрик, какие поля пропадали.
@@ -77,9 +81,8 @@ Health — база снапшота, новые доставки HAE затир
Схему не трогаем. Отпечаток витрины изменится — пересборка обязательна и
делается человеком при остановленном сервисе; подмена файла базы необратима и в
задаче не выполняется. Берётся **после**
[tie-break-equal-completeness](tie-break-equal-completeness.md): та меняет то же
место, и замер до её вливания ответит на вопрос про уже неактуальное правило.
задаче не выполняется. Тай-брейк при равной полноте уже влит, поэтому замер
отвечает про действующее правило, а не про снятое.
Связано: находки 10, 47, 49, 53; `docs/architecture.md` → «Разрешение
столкновений»; `docs/review.md`, запись 2026-08-04.
+10 -9
View File
@@ -1,18 +1,19 @@
# [goal] Пределы и поведение под объёмом
# [goal] У каждого входа есть названный предел
- **Секция:** темы
- **Секция:** Направления
- **Зачем:** Предела на одну сущность нет вовсе: 63 МиБ держат блокировку 5.019 с, соседние доставки уходят в failed
- **Теги:** decomposed
Тема: названные пределы на размер тела, сущности, заголовков и ответа плюс
Направление: названные пределы на размер тела, сущности, заголовков и ответа плюс
поведение под удерживаемой блокировкой.
В порядок не встаёт: пределы всплывают замерами, а не планом.
Завершена не бывает: закрывается по мере того, как каждый вход получает
названный предел вместо подразумеваемого.
В «Запланировано» не встаёт: предел находит замер, а не очередь.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как каждый вход получает
названный предел вместо подразумеваемого.
Завершена не бывает — это направление. Закрывается по мере того, как каждый вход
получает названный предел вместо подразумеваемого. Открыто сегодня:
- У тела, сущности и секции доставки есть названный предел
- У заголовков доставки есть названный предел
- Занятость базы не выводит доставку из очереди
+7 -10
View File
@@ -1,18 +1,15 @@
# [goal] Устаревание нижнего слоя
# [goal] Нижний слой чистится после проверенного экспорта
- **Секция:** порядок
- **Секция:** Запланировано
- **Зачем:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- **Теги:** decomposed
После проверенного экспорта нижний слой HAE избыточен и подлежит чистке.
После проверенного экспорта нижний слой HAE избыточен, и его можно чистить.
Выведена из шага 9 плана. Нижний слой растёт на ~100 тысяч координат в сутки.
Завершена, когда чистка идёт по правилу, а не по календарю, и решение о
удалении опирается на колонку, отличающую ноль от «не измерялось».
Нижний слой растёт на ~100 тысяч координат в сутки.
## Завершение
Чистка идёт по правилу «до следующего проверенного экспорта», а не по
календарю, и решение об удалении опирается на колонку, отличающую ноль от
«не измерялось».
- Нижний слой помечен покрытым после проверенного экспорта
- Чистка идёт по правилу «до следующего проверенного экспорта», а не по календарю
- Решение об удалении опирается на колонку, отличающую ноль от «не измерялось»
+4 -2
View File
@@ -1,6 +1,6 @@
# Устаревание нижнего слоя после экспорта
# Помечать нижний слой устаревшим после экспорта
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- **Теги:** goal:lower-layer-cleanup
@@ -17,6 +17,8 @@
- пометка ≠ удаление. Удаление включается только после того, как восстановление
из экспорта отработает на живых данных хотя бы раз.
Двигает строку «Завершения» цели: «Нижний слой помечен покрытым после проверенного экспорта».
## Чем помечать: разряд на диапазон, а не провенанс на точку
Решено при постановке 2026-08-04. Пометка — **одна строка на диапазон**:
+5 -3
View File
@@ -1,6 +1,6 @@
# MCP-сервер поверх Read API
# Поднять MCP-сервер поверх Read API
- **Секция:** ядро — набор ограничен HTTP-слоем чтения после дробления; адаптер берётся следующим спринтом по той же цели
- **Секция:** Ядро — набор ограничен HTTP-слоем чтения после дробления; адаптер берётся следующим спринтом по той же цели
- **Зачем:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- **Теги:** goal:read-api
@@ -8,7 +8,7 @@
на дату последнего ручного экспорта.
Транспорт — **Streamable HTTP**, не stdio: сервис живёт на VPS, агент ходит по
сети. Отсюда: MCP — эндпоинт того же процесса и того же порта, аутентификация —
сети. Отсюда: MCP — маршрут того же процесса и того же порта, аутентификация —
тот же токен чтения, что у Read API. Отдельного контура доступа не заводим:
MCP не даёт ничего, чего не даёт HTTP, и права обязаны совпадать.
@@ -21,6 +21,8 @@ MCP не даёт ничего, чего не даёт HTTP, и права об
Готово, когда агент подключается по URL и отвечает на «как я спал на прошлой
неделе» без промежуточного кода.
Двигает строку «Завершения» цели: «Агент-медик читает то же самое через MCP тем же токеном чтения».
## Критерии приёмки
- живой агент подключается по URL и отвечает на «как я спал на прошлой неделе»
+4 -2
View File
@@ -1,12 +1,14 @@
# Цена слияния на широкой доставке
# Снизить цену слияния на широкой доставке
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** 63 МБ на одной координате держат транзакцию 5.15 с при busy_timeout 5 с — соседние доставки уходят в failed
- **Теги:** goal:limits-and-load
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, враждебный
проход и независимая реализация — независимо друг от друга).
Двигает строку «Завершения» цели: «Занятость базы не выводит доставку из очереди».
## Оракул: измерено
Тело 63 МБ (в запросе ~200 КБ gzip — предел приёма 64 МиБ), 119 точек на ОДНОЙ
+5 -3
View File
@@ -1,12 +1,14 @@
# Счётчики слияния переживают ротацию логов
# Хранить счётчики слияния вне логов
- **Секция:** инфра
- **Зачем:** единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- **Секция:** Инфра
- **Зачем:** Единственный след несравнимых наборов — строка WARN в docker-логе с ротацией 3×10 МБ: событие может произойти и не оставить ничего
- **Теги:** goal:observability
Вынуто ревью кода задачи `pravilo-sliyaniya-tochek` (профиль `deep`, проход
негативного пространства, подтверждено эксплуатационным).
Двигает строку «Завершения» цели: «Счётчики слияния переживают ротацию логов».
## Что не так
Решение не реализовывать объединение полей при несравнимых наборах стоит на
+10 -9
View File
@@ -1,17 +1,18 @@
# [goal] Прочность слияния и идентичности
# [goal] Исход слияния не зависит от порядка элементов на проводе
- **Секция:** темы
- **Секция:** Направления
- **Зачем:** Правила слияния держатся на замерах, и каждый новый замер находит зависимость от порядка на проводе
- **Теги:** decomposed
Тема: правила, по которым две версии одних данных превращаются в одну.
В порядок не встаёт — работа приходит находками ревью и замерами на
Направление: правила, по которым две версии одних данных превращаются в одну.
В «Запланировано» не встаёт — очереди у направления нет: работа приходит находками ревью и замерами на
живом корпусе.
Завершена не бывает: закрывается по мере того, как правила перестают зависеть
от порядка на проводе.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как правила выбора между
версиями перестают зависеть от порядка элементов на проводе.
Завершена не бывает — это направление. Закрывается по мере того, как правила
выбора между версиями перестают зависеть от порядка элементов на проводе.
Открыто сегодня:
- Правило выбора между версиями измерено: полнота либо нужна, либо снята
- Порог `sealed` выбран по накопленной статистике досчёта
@@ -1,6 +1,6 @@
# [idea] Месячный проход по ручным секциям
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
- **Теги:** goal:parsing-completeness
+6 -8
View File
@@ -1,19 +1,17 @@
# [goal] Импорт родного экспорта Apple
# [goal] История из родного экспорта Apple лежит в хранилище
- **Секция:** порядок
- **Секция:** Запланировано
- **Зачем:** Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- **Теги:** decomposed
`healthlog import`: снапшот всей истории из родного экспорта Apple Health
ложится в хранилище перед проигрыванием хвоста доставок.
Выведена из шага 8 плана. Идёт перед устареванием нижнего слоя намеренно: пока
Идёт перед чисткой нижнего слоя намеренно: пока
импорт экспорта не написан, помечать что-либо устаревшим не на основании чего.
Завершена, когда слой `sample` наполнен историей с 2019 года, а повторный
импорт того же экспорта ничего не меняет.
## Завершение
Слой `sample` наполнен историей с 2019 года, повторный импорт того же экспорта
ничего не меняет, а тренировки из экспорта не задваивают приехавшие от HAE.
- Слой `sample` наполнен историей с 2019 года
- Повторный импорт того же экспорта ничего не меняет
- Тренировки из экспорта не задваивают приехавшие от HAE
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] NDJSON-поток для больших выборок Read API
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация
- **Теги:** goal:read-api
+6 -9
View File
@@ -1,18 +1,15 @@
# [goal] Наблюдаемость
# [goal] Приложение сообщает о своём состоянии
- **Секция:** порядок
- **Секция:** Запланировано
- **Зачем:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- **Теги:** decomposed
Тихо сломавшаяся автоматизация — главный эксплуатационный риск: телефон шлёт
молча, и молчание неотличимо от нормы.
Выведена из шага 10 плана.
Завершена, когда пропажа потока и расхождение витрины с журналом видны
владельцу без чтения логов.
## Завершение
Пропажа потока и расхождение витрины с журналом видны владельцу без чтения
логов и переживают ротацию логов.
- Пропажа потока видна владельцу без чтения логов
- Состояние сервиса — последняя доставка, счётчики, тишина — читается одним
запросом
- Счётчики слияния переживают ротацию логов
+8 -4
View File
@@ -1,6 +1,6 @@
# Гейт против расхождения спеки с маршрутами
# Ловить гейтом расхождение спеки с маршрутами
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Рукописная спека — источник истины, а расходится она с кодом молча: без гейта решение о рукописной спеке не держится
- **Теги:** goal:read-api, sprint:2026-08-04
@@ -14,14 +14,18 @@
Класс отказа тот же, что у остальных безусловных шагов гейта проекта: не виден
глазами и стоит дорого. Место ему там же.
Двигает строку «Завершения» цели: «Контракт чтения читается машиной».
## Критерии приёмки
- добавленный маршрут без правки спеки красит гейт — оракул: намеренно
рассогласованный маршрут в прогоне гейта
- переименованное поле ответа красит гейт — оракул: намеренное переименование в
прогоне гейта
- проверка не ходит в сеть и укладывается в бюджет гейта — оракул: замер шага по
логу `tmp/gate/`
- проверка укладывается в бюджет гейта — оракул: замер шага по логу
`tmp/gate/`
- проверка работает без внешней сети — оракул: прогон гейта в контейнере без
доступа наружу
## Рамки
+7 -5
View File
@@ -1,6 +1,6 @@
# Рукописная OpenAPI-спека
# Написать OpenAPI-спеку руками
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- **Теги:** goal:read-api, sprint:2026-08-04
@@ -18,13 +18,15 @@
Заодно снимает вопрос, чем быть «схеме контракта API» из раздела самоописания:
ею и будет OpenAPI-документ, а не собственный формат.
Двигает строку «Завершения» цели: «Контракт чтения читается машиной».
## Критерии приёмки
- по спеке генерируется клиент, и он выполняет запрос к живому сервису — оракул:
прогон генератора плюс запрос сгенерированным клиентом
- спека покрывает все существующие маршруты: приём, каталог, точки, тренировки,
записи — оракул: сверка перечня путей спеки с таблицей маршрутов в
`docs/architecture.md`
- спека покрывает все маршруты, которые сервис действительно регистрирует —
оракул: сверка перечня путей спеки с обходом роутера поднятого сервиса
(`chi.Walk`)
- спека проходит валидатор OpenAPI 3.1 — оракул: прогон валидатора
## Рамки
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Пересекающиеся источники одной метрики
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
- **Теги:** goal:read-api
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Выгрузка в parquet отдельной командой
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
- **Теги:** goal:read-api
+13 -8
View File
@@ -1,9 +1,10 @@
# [goal] Полнота разбора потока
# [goal] Новая форма от источника не теряется молча
- **Секция:** темы
- **Секция:** Направления
- **Зачем:** Пять секций HAE поток ещё не приносил, а ручные секции заводятся задним числом — разбор полон ровно до следующей новой формы
- **Теги:** decomposed
Тема: всё, что приезжает от источника, разобрано и доехало до витрины — не
Направление: всё, что приезжает от источника, разобрано и доехало до витрины — не
только сегодня, но и после того, как источник изменится.
Выделена из цели «Разбор и хранилище», когда та достигла своего критерия
@@ -12,11 +13,15 @@
вправе прислать форму, которой раньше не было, а часть секций заводится
человеком задним числом.
В порядок не встаёт: работа приходит от потока, а не от плана. Первая встреча
новой секции наблюдаема (`healthlog uncovered` и `WARN` на свёртке) — тема
кормится этими событиями.
В «Запланировано» не встаёт: работа приходит от потока, а не от очереди. Первая встреча
новой секции наблюдаема (`healthlog uncovered` и `WARN` на свёртке) — работа
направления приходит от этих событий.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как каждая приезжающая
форма доезжает до витрины, а не теряется между «принято» и «разобрано».
Завершена не бывает — это направление. Закрывается по мере того, как каждая
приезжающая форма доезжает до витрины, а не теряется между «принято» и
«разобрано». Открыто сегодня:
- Сущность с `id` и неразобранной меткой не пропадает целиком
- Ручные секции, заведённые задним числом, доезжают до витрины
+5 -3
View File
@@ -1,7 +1,7 @@
# Ретеншен сырого архива
# Подчищать сырой архив до последнего проверенного экспорта
- **Секция:** инфра
- **Зачем:** Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает
- **Секция:** Инфра
- **Зачем:** Архив не подчищается вовсе, а резать его раньше даты проверенного экспорта нельзя — в журнале останется дыра, которую нечем пересобрать
- **Теги:** goal:journal-and-rebuild
Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но
@@ -28,6 +28,8 @@
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
глубину архива и дату снапшота, до которой он подрезан.
Двигает строку «Завершения» цели: «Сырой архив подчищается до последнего проверенного экспорта».
## Предусловие снова открыто
Признак «доставка с непокрытой секцией» появился в change
+13 -5
View File
@@ -1,8 +1,8 @@
# Порог неполного ведра
# Отличать неполное ведро от полного
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Текущий час неполон всегда, и без порога свёртка отдаёт его наравне с полными — клиент видит провал вместо неизвестности
- **Теги:** goal:read-api, sprint:2026-08-04
- **Теги:** goal:read-api, sprint:2026-08-04, kind:feature
Ведро, в котором известна не вся сетка, отличимо от полного — а полярность
порога названа вслух, а не выводится читателем из умолчания.
@@ -13,11 +13,19 @@
показывает за него провал вместо неизвестности.
**Готовые решения задают порог противоположно.** Graphite `xFilesFactor` — доля
обязательно известных точек (умолчание 0.5 при роллапе и 0 при рендере: один
параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе
обязательно известных точек (умолчание 0.5 при свёртке на записи и 0 при
отдаче ответа: один параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе
величины выглядят как «0.5», означая разное. Полярность придётся назвать вслух,
иначе через полгода два места кода поймут поле по-разному — и разойдутся молча.
Двигает строку «Завершения» цели: «Неполное ведро отличимо от полного, и полярность порога названа».
## Затрагивает
Форма ответа свёртки — признак неполного ведра рядом со значением. Конфиг и его
образцы — порог с названной полярностью. Раздел о свёртке в
`docs/architecture.md`. Схемы и формата на диске не трогает.
## Критерии приёмки
- полярность и умолчание порога названы в `docs/architecture.md` одной
+12 -4
View File
@@ -1,8 +1,8 @@
# Свёртка по сетке
# Сворачивать точки по заданной сетке
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** «Шаги за неделю по дням» — базовый запрос трекера и игры, и сегодня его нечем задать
- **Теги:** goal:read-api, sprint:2026-08-04
- **Теги:** goal:read-api, sprint:2026-08-04, kind:feature
«Шаги за неделю по дням» отвечаются одним запросом `?from&to&bucket`, и род
свёртки берётся измеренным, а не угаданным.
@@ -17,7 +17,15 @@
завышает втрое.
Порог неполного ведра и предел размера ответа — соседние задачи; здесь они
берутся в том виде, в каком есть на момент мерджа, и не проектируются.
берутся в том виде, в каком есть на момент вливания, и не проектируются.
Двигает строку «Завершения» цели: «Точки сворачиваются по заданной сетке измеренным родом агрегации».
## Затрагивает
Маршрут `GET /api/v1/metrics/{name}` — параметр запроса `bucket`, поля
`bucket` и `aggregation` в конверте ответа, код отказа на метрике с неизвестным
родом. Схемы и формата на диске не трогает.
## Критерии приёмки
@@ -1,8 +1,8 @@
# Условный запрос по точкам
# Отвечать 304 на повторный запрос точек
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Агент опрашивает по расписанию, а каждый повтор стоит полного чтения: на каталоге это 693 мс и +153 МиБ
- **Теги:** goal:read-api, sprint:2026-08-04
- **Теги:** goal:read-api, sprint:2026-08-04, kind:feature
Повторный опрос точек с той же меткой стоит `304` вместо полного чтения, и метка
не может ответить на другой набор данных.
@@ -19,6 +19,15 @@
враждебном запросе. Агент опрашивает по расписанию, и без условного запроса
каждый его повтор стоит полного чтения.
Двигает строку «Завершения» цели: «Повторный запрос тех же точек стоит `304`, а не полного чтения».
## Затрагивает
Маршрут `GET /api/v1/metrics/{name}` — заголовки `ETag` и `If-None-Match`,
код ответа `304`. Область действия метки: набор параметров запроса (метрика,
окно, слой) и версия витрины, из которых метка считается, и их каноническая
форма. Схемы и формата на диске не трогает.
## Критерии приёмки
- повторный запрос с `If-None-Match` при неизменной витрине даёт `304` — оракул:
+12 -4
View File
@@ -1,13 +1,13 @@
# Записи наружу
# Отдавать записи со своим id за период
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** stateOfMind разобран и хранится, но наружу не отдаётся — а восстановить его нечем: в экспорте Apple его нет
- **Теги:** goal:read-api, sprint:2026-08-04
- **Теги:** goal:read-api, sprint:2026-08-04, kind:feature
Записи со своим `id` — сегодня это `stateOfMind` — достаются за период через
`GET /records/{kind}`.
Разбор и хранение сделаны тем же change, что у тренировок; наружу не отдаётся
Разбор и хранение сделаны тем же изменением, что у тренировок; наружу не отдаётся
ничего. У этих данных есть особенность, которой нет больше ни у чего в проекте:
**`stateOfMind` нет в экспорте Apple**, он не восстанавливается пересборкой из
снапшота, и единственный его источник — доставки HAE. Отдача наружу — не
@@ -15,6 +15,14 @@
Конверт наследуется от точек; собственной формы у записей нет.
Двигает строку «Завершения» цели: «Тренировки с маршрутом и записи со своим `id` отдаются за период».
## Затрагивает
Новый маршрут `GET /api/v1/records/{kind}` и код отказа на неизвестном `kind`.
Публичный тип провода в `internal/httpapi` — конверт записей. Чтение таблицы
записей; схемы и формата на диске не трогает.
## Критерии приёмки
- записи `stateOfMind` за период отдаются одним запросом — оракул: запрос к
+18 -7
View File
@@ -1,8 +1,8 @@
# Предел размера ответа
# Ограничить размер ответа маршрутов чтения
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** У маршрутов чтения нет ни одного потолка: множители «метрики × окно × точки × одновременные запросы» ничем не ограничены
- **Теги:** goal:read-api, sprint:2026-08-04
- **Теги:** goal:read-api, sprint:2026-08-04, kind:feature
У маршрутов чтения появляется названный потолок: сетка не задана и ответ не
влезает — сервер огрубляет её и **называет** в ответе; сетка задана явно и не
@@ -16,20 +16,31 @@
приём в том же процессе уже даёт пик 768 МиБ на теле 40 МиБ. Множители «метрики ×
окно × точки × одновременные запросы» сегодня без потолка ни у одного маршрута —
включая уже живой каталог, у которого предела нет намеренно: правило размера
общее, и задавать его мимоходом на первой ручке значило бы решить контракт до
общее, и задавать его мимоходом на первом маршруте значило бы решить контракт до
того, как известна форма тяжёлого ответа.
Правило распространяется на все маршруты чтения сразу — каталог, точки,
тренировки, записи, — а не только на тот, где написано.
Двигает строку «Завершения» цели: «У ответа любого маршрута чтения есть объявленный предел размера».
## Затрагивает
Все маршруты чтения сразу — каталог, точки, а следом тренировки и записи: код и
тело отказа на запросе за пределом, поле огрублённой сетки в ответе. Конфиг и
его образцы — сам предел. Раздел о пределах в `docs/architecture.md`. Схемы и
формата на диске не трогает.
## Критерии приёмки
- запрос без сетки, не влезающий в предел, отвечает огрублённой сеткой и
называет её в ответе — оракул: враждебный запрос на живом архиве
- явно заданная сетка за пределом даёт ошибку со списком доступных сеток —
оракул: тест
- предел объявлен в конфиге и в `docs/architecture.md`, а не зашит числом в
обработчике — оракул: образцы конфига в гейте
- предел читается из конфига: два разных значения дают две разные границы
отказа — оракул: тест с подменой значения предела
- предел назван в образцах конфига и в `docs/architecture.md` — оракул: шаг
образцов конфига в гейте и глазами по разделу
- каталог подчиняется тому же пределу, что и точки — оракул: тест на враждебном
запросе к каталогу
@@ -37,5 +48,5 @@
Схема не трогается, данные только читаются. Берётся после свёртки по сетке.
Собственный дедлайн маршрута сюда **не входит**: он в задаче
[«Остановка и миграция»](shutdown-and-migration-traces.md) вместе с `BaseContext`
[«Развести бюджеты остановки»](shutdown-and-migration-traces.md) вместе с `BaseContext`
и раздельными бюджетами остановки.
+14 -5
View File
@@ -1,14 +1,14 @@
# Тренировки наружу
# Отдавать тренировки вместе с маршрутом
- **Секция:** ядро
- **Зачем:** Тренировки с маршрутами разобраны и лежат в витрине, а эндпоинтов нет — сценарий трекера не закрыт
- **Теги:** goal:read-api, sprint:2026-08-04
- **Секция:** Ядро
- **Зачем:** Тренировки с маршрутами разобраны и лежат в витрине, а маршрутов чтения нет — сценарий трекера не закрыт
- **Теги:** goal:read-api, sprint:2026-08-04, kind:feature
Трекер забирает тренировку одним пакетом вместе с маршрутом: `GET /workouts` за
период и `GET /workouts/{id}` поштучно.
Разбор и хранение тренировок сделаны (change `2026-08-02-trenirovki-i-zapisi`),
эндпоинтов нет: тренировка с маршрутом лежит в витрине и наружу не отдаётся.
маршрутов чтения нет: тренировка с маршрутом лежит в витрине и наружу не отдаётся.
Второй сценарий паспорта — трекер тренировок — до тех пор не закрыт.
**Это первый по-настоящему тяжёлый ответ проекта.** Маршрут лежит блобом внутри
@@ -19,6 +19,15 @@
Конверт и форма провода наследуются, а не изобретаются.
Двигает строку «Завершения» цели: «Тренировки с маршрутом и записи со своим `id` отдаются за период».
## Затрагивает
Два новых маршрута: `GET /api/v1/workouts` за период и
`GET /api/v1/workouts/{id}` поштучно. Публичные типы провода в
`internal/httpapi` — конверт тренировки и элемент списка. Чтение таблицы
тренировок; схемы и формата на диске не трогает.
## Критерии приёмки
- тренировка отдаётся одним пакетом вместе с маршрутом — оракул: запрос к
+13 -7
View File
@@ -1,13 +1,13 @@
# [goal] Чтение данных клиентами
# [goal] Клиенты читают данные через HTTP и MCP
- **Секция:** порядок
- **Секция:** Запланировано
- **Зачем:** Данные видны только через sqlite на хосте — ни один из трёх потребителей, включая агента-медика, ничего прочитать не может
- **Теги:** decomposed
Потребители читают данные: точки с выбором слоя и свёрткой по сетке, тренировки
и записи, машиночитаемый контракт — и всё то же самое через MCP.
Выведена из шагов 5 и 7 плана. Идёт после каталога и рода агрегации намеренно:
Идёт после каталога и рода агрегации намеренно:
без измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться
здесь дорого — просуммировать нижний слой значит завысить втрое.
@@ -20,7 +20,13 @@
## Завершение
Любой из трёх потребителей получает точки, тренировки и записи за период без
доступа к файлу базы; предел размера ответа объявлен, а не подразумевается;
агент-медик читает то же самое через MCP тем же токеном чтения, и собственной
логики адаптер не несёт.
- Точки метрики за период отдаются по HTTP без доступа к файлу базы
- Повторный запрос тех же точек стоит `304`, а не полного чтения
- Точки сворачиваются по заданной сетке измеренным родом агрегации
- Неполное ведро отличимо от полного, и полярность порога названа
- У ответа любого маршрута чтения есть объявленный предел размера
- Тренировки с маршрутом и записи со своим `id` отдаются за период
- Контракт чтения читается машиной: спека, гейт против её расхождения с
маршрутами, UI без внешней сети
- Агент-медик читает то же самое через MCP тем же токеном чтения, и собственной
логики адаптер не несёт
+5 -3
View File
@@ -1,6 +1,6 @@
# Сверка живой витрины с пересборкой
# Сверять живую витрину с пересборкой
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** reindex печатает оба отпечатка, но сравнивать их некому — расхождение с журналом молчит
- **Теги:** goal:journal-and-rebuild
@@ -10,7 +10,7 @@
только тем, что кто-то вручную запустил пересборку и посмотрел на два числа.
Между тем расхождение — не гипотеза. Известный путь к нему записан блокером
[«Порядок журнала при конкурентных приёмах»](journal-order-on-ingest.md):
[«Держать порядок журнала при конкурентных приёмах»](journal-order-on-ingest.md):
доставка, свёрнутая раньше своей предшественницы, уходит в `failed` навсегда, и
живая витрина расходится с пересборкой молча. Пока тот предел не закрыт, сверка
— единственный способ узнать, что он сработал.
@@ -30,3 +30,5 @@
Связано: `cmd/healthlog/reindex.go`, [наблюдаемость](stats-endpoint.md),
[деплой](deploy-rivendell.md).
Двигает строку «Завершения» цели: «Расхождение живой витрины с пересборкой замечает сервис, а не человек».
+4 -2
View File
@@ -1,6 +1,6 @@
# Пересборка держит весь журнал в памяти
# Не держать весь журнал в памяти при пересборке
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Учёт доставок и список путей архива материализуются целиком: расход растёт вместе с журналом, а у журнала конца нет
- **Теги:** goal:journal-and-rebuild
@@ -26,3 +26,5 @@
памяти, не зависящим от его длины.
Связано: `internal/replay`, `cmd/healthlog/reindex.go`.
Двигает строку «Завершения» цели: «Расход пересборки не растёт вместе с журналом».
@@ -1,6 +1,6 @@
# Чем откатывать релиз после наката миграции
# Назвать механизм отката релиза после наката миграции
- **Секция:** инфра
- **Секция:** Инфра
- **Зачем:** Страж версии схемы делает возврат старого бинаря отказом, а понизить схему нечем — аварийный путь пришлось бы изобретать в аварии
- **Теги:** goal:deploy
@@ -15,6 +15,8 @@
Вынуто ревью кода задачи «Дозакрыть находки ревью по слиянию сущностей»
(проходы `ops` и `negative`, профиль `deep`).
Двигает строку «Завершения» цели: «Откат релиза после наката миграции имеет названный механизм».
## Что именно решить
Та задача перенесла в `store.Open` стража версии схемы: база новее бинаря —
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Человеческие аннотации поверх выведенных схем
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата
- **Теги:** goal:self-description
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Порог sealed: с какого возраста час считается запечатанным
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта
- **Теги:** goal:merge-robustness
+4 -9
View File
@@ -1,17 +1,12 @@
# [goal] Самоописание
# [goal] Клиент узнаёт форму данных из ответа
- **Секция:** порядок
- **Секция:** Запланировано
- **Зачем:** Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- **Теги:** decomposed
Клиент узнаёт форму данных из ответа сервиса, а не угадывает её по выборке.
Выведена из шага 6 плана.
Завершена, когда контракт читается машиной, а формы содержимого метрик
выведены из данных, а не описаны руками.
## Завершение
Контракт читается машиной, а формы содержимого метрик выведены из данных, а не
описаны руками.
- Формы содержимого метрик выведены из данных, а не описаны руками
- Клиент узнаёт форму одной метрики и всего хранилища одним запросом
@@ -1,6 +1,6 @@
# Остановка и миграция: раздельные бюджеты и следы в логе
# Развести бюджеты остановки и оставить следы миграции в логе
- **Секция:** инфра
- **Секция:** Инфра
- **Зачем:** Долгий запрос чтения съедает бюджет остановки, и WARN обвиняет воркер свёртки; миграция молчит и не прерывается SIGTERM
- **Теги:** goal:deploy
@@ -49,3 +49,5 @@
Связано: `cmd/healthlog/serve.go`, `internal/store/store.go`, change
`2026-08-02-cena-chitayushchego-marshruta` (архив).
Двигает строку «Завершения» цели: «Остановка сервиса называет виновный этап честно, а накат миграций виден в логе старта».
+4 -2
View File
@@ -1,6 +1,6 @@
# Наблюдаемость: /stats
# Отдавать состояние сервиса маршрутом /stats
- **Секция:** инфра
- **Секция:** Инфра
- **Зачем:** Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- **Теги:** goal:observability
@@ -59,3 +59,5 @@
сколько страниц лежит и сколько перенесено) и — тем же полем — доля ответов
чтения, которые удалось подписать `ETag`: механизм условного запроса может
перестать окупаться под плотным потоком, и снаружи это неотличимо от нормы.
Двигает строку «Завершения» цели: «Состояние сервиса — последняя доставка, счётчики, тишина — читается одним запросом».
+6 -4
View File
@@ -1,6 +1,6 @@
# Активный алерт «данных нет N часов»
# Слать уведомление, когда данных нет N часов
- **Секция:** инфра
- **Секция:** Инфра
- **Зачем:** Пропажу потока сейчас замечает человек, а не сервис
- **Теги:** goal:observability
@@ -18,10 +18,12 @@
После деплоя на rivendell поднимется.
## Источник алерта не может жить внутри `serve`
Двигает строку «Завершения» цели: «Пропажа потока видна владельцу без чтения логов».
## Источник уведомления не может жить внутри `serve`
Отказ стража версии схемы (база новее бинаря) останавливает процесс, а
`restart: unless-stopped` даёт цикл перезапуска. Значит алерт «данных нет N
`restart: unless-stopped` даёт цикл перезапуска. Значит уведомление «данных нет N
часов», живущий внутри сервиса, на эту причину остановки не сработает **по
построению** — он не поднимется вместе с ним. Обоснование стража («откат делает
оператор, он в этот момент рядом») верно для ручного отката и не покрывает
+11 -7
View File
@@ -1,7 +1,7 @@
# Swagger UI без внешней сети
# Поднять Swagger UI без внешней сети
- **Секция:** ядро
- **Зачем:** Контракт читается машиной, но человеку нечем ткнуть в живой сервис, а внешних CDN в локальной сети нет
- **Секция:** Ядро
- **Зачем:** Контракт читается машиной, но человеку нечем выполнить запрос к живому сервису из браузера, а внешних CDN в локальной сети нет
- **Теги:** goal:read-api, sprint:2026-08-04
Человек открывает UI на отдельном пути и выполняет запрос к живому сервису — не
@@ -11,18 +11,22 @@
статика отдаётся самим сервисом и лежит в бинаре: внешние CDN здесь означают
«работает, пока работает чужой сайт».
**UI — новый адресат недоверенного входа наизнанку:** он даёт человеку в один
клик дёрнуть любой описанный маршрут, включая приём. Права на запись из
браузера не должны появляться сами собой — это ровно та граница, которую
**UI — новый адресат недоверенного входа наизнанку:** он даёт человеку одним
нажатием выполнить запрос к любому описанному маршруту, включая приём. Права
на запись из браузера не должны появляться сами собой — это ровно та граница, которую
описывает `docs/security.md`.
Двигает строку «Завершения» цели: «Контракт чтения читается машиной».
## Критерии приёмки
- UI открывается и выполняет запрос при отключённой внешней сети — оракул:
запуск контейнера без доступа наружу
- бинарь работает без каталога со статикой рядом — оракул: запуск одного файла
из пустого каталога
- маршрут приёма из UI не вызывается без явного токена приёма — оракул: тест
- запрос к маршруту приёма без заголовка с токеном приёма отклоняется, откуда
бы он ни пришёл, а отдаваемая UI статика токена приёма в себе не держит —
оракул: тест на обработчике приёма плюс поиск токена в отдаваемой статике
## Рамки
@@ -1,6 +1,6 @@
# Управление токенами и секретами
# Развести токены контуров и убрать секреты из репозитория
- **Секция:** инфра
- **Секция:** Инфра
- **Зачем:** Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
- **Теги:** goal:deploy
@@ -29,3 +29,5 @@
Готово, когда запуск без токенов возможен только на localhost, а на rivendell
оба контура закрыты разными токенами.
Двигает строку «Завершения» цели: «Оба контура закрыты разными токенами, и без токенов сервис стартует только на localhost».
@@ -1,6 +1,6 @@
# Идентичность тренировок при импорте родного экспорта
# Не задваивать тренировки при импорте родного экспорта
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
- **Теги:** goal:native-export-import
@@ -37,3 +37,5 @@ Prior art: `dogsheep/healthkit-to-sqlite` адресует тренировку
Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача
`apple-export-import`.
Двигает строку «Завершения» цели: «Тренировки из экспорта не задваивают приехавшие от HAE».
+1 -1
View File
@@ -1,6 +1,6 @@
# [idea] Разворачивание маршрутов тренировок в отдельную таблицу
- **Секция:** ядро
- **Секция:** Ядро
- **Зачем:** Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом
- **Теги:** goal:read-api
+1 -1
View File
@@ -65,7 +65,7 @@ func TestAcceptStoresBodyVerbatim(t *testing.T) {
}
// Повтор того же тела пока принимается — отсев идентичных доставок отложен
// (docs/tasks/PLAN.md). Проверяем, что повтор не ломается и не затирает первую.
// (docs/tasks/ROADMAP.md). Проверяем, что повтор не ломается и не затирает первую.
func TestAcceptAllowsRepeatedBody(t *testing.T) {
svc, _, st := newService(t)
body := []byte(`{"data":{"metrics":[]}}`)