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

- каждая запись каталога задач получила тип вместо тега kind: и префикса
  заголовка; секция роадмапа «Разработка» стала «Сопровождением», порядок
  секций канонический
- поправлены протухшие факты: нереализованные маршруты Read API, MCP и
  `healthlog import`, словарь слоёв в инварианте, семантика гейта по покрытию
  диффа, периметр перестал дублировать security.md
- замер слияния переведён с находки 49 на находку 54, заполнены Purpose спек
  storage и parsing
This commit is contained in:
av
2026-08-05 19:09:35 +03:00
parent e4f62785d8
commit 3d24248075
66 changed files with 322 additions and 227 deletions
+54 -32
View File
@@ -46,12 +46,14 @@ healthlog принимает выгрузки Apple Health из приложен
с переведённой строкой (см. «Категориальные значения»): он приписывается, а
не подменяет.
- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в тех
разрезах подробности, в которых пришла (`sample`/`raw`/`minute`/`hour`);
переагрегирования при записи не происходит никогда.
- **Агрегация в ответе — только измеренная.** Read API умеет свести метрику к
запрошенной сетке, но род свёртки (сумма или среднее) выведен сверкой слоёв
между собой, а не проставлен вручную. Где род неизвестен, агрегация не
предлагается: отдаются значения как есть.
разрезах подробности, в которых пришла (перечень слоёв —
[database.md](database.md), таблица `bucket`); переагрегирования при записи
не происходит никогда.
- **Агрегация в ответе — только измеренная.** Род свёртки (сумма или среднее)
выведен сверкой слоёв между собой, а не проставлен вручную. Где род
неизвестен, агрегация не предлагается: отдаются значения как есть. Свёртка к
запрошенной сетке объявлена контрактом и **ещё не реализована** — параметр
`bucket` отвергается `400` (задача `read-api-points-bucket`).
- **Минимум компонентов** — один процесс, SQLite, файлы. Без очередей и
внешних зависимостей.
@@ -170,7 +172,7 @@ HRV); у накопительных — только `date`. Поэтому то
```
дыра моложе суток → закроется в течение часа
дыра моложе недели → закроется в течение суток
дыра старше недели → не закроется; лечится `healthlog import`
дыра старше недели → не закроется; лечится только `healthlog import` (ещё не написан)
```
Широкие проходы почти бесплатны именно из-за часовых объектов: глубокий проход
@@ -225,7 +227,7 @@ capability**, и здесь стоит ссылка, а не пересказ т
| `ident` | генерация и разбор ULID | — |
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) |
| `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) |
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
| `ingest` | use-case приёма, общий для HTTP и будущего CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md), [`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md) |
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
@@ -235,6 +237,8 @@ capability**, и здесь стоит ссылка, а не пересказ т
## Приём
<!-- канон: поведение → openspec/specs/ingest -->
```
запрос → токен → лимит тела, gzip → проверка формы JSON
→ запись тела в архив → строка в delivery → 200
@@ -264,7 +268,8 @@ capability**, и здесь стоит ссылка, а не пересказ т
- **200** — тело сохранено в архив. Дальше даже полный провал разбора
(незнакомая метрика, новая форма точки) не меняет ответ: данные уже в
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
`/stats`, а доразобрать их можно командой `reindex`.
`/stats` (маршрут — задача `stats-endpoint`), а доразобрать их можно командой
`reindex`.
#### Очередь свёртки — таблица, а не структура в памяти
@@ -942,8 +947,11 @@ hour метки выровнены на час heart_rate 00:00:00
#### Разрешение столкновений
По одним координатам приезжают разные содержимые: 2 897 случаев из 444 256
координат, 0.65% (находка 49). Выигрывает **более полная** точка, и полнота
По одним координатам приезжают разные содержимые: спорных координат 80 129 из
460 995 (17,4%), и полнота отбрасывает кого-то лишь в 981 из них (1,2%)
остальное решает тай-брейк ([research/apple-health.md](research/apple-health.md),
находка 54; прежняя оценка «0,65%» из находки 49 считала ключ без слоя).
Выигрывает **более полная** точка, и полнота —
это сравнение **множеств** ключей с непустым значением, а не их числа.
Число сравнимо всегда и потому отвечает там, где ответа нет: точка
@@ -981,9 +989,9 @@ hour метки выровнены на час heart_rate 00:00:00
отдельно (см. ниже).
**Несравнимые множества не сливаются, а считаются.** Объединение полей — самая
дорогая часть правила — на живом потоке не потребовалось ни разу (0 из 2 897),
поэтому вместо реализации стоит счётчик и `WARN` с координатами объекта. Если
событие наступит, оно будет видно, а не додумано заранее.
дорогая часть правила — на живом корпусе наступило дважды на 155 доставок
(находка 54), поэтому вместо реализации стоит счётчик и `WARN` с координатами
объекта. Событие видно, а не додумано заранее.
**Тай-брейк при равной полноте — пришедшая доставка.** Порядок канонических
форм отвергнут замером: он берёт меньшее значение в 96% случаев (находка 49) и
@@ -1445,18 +1453,26 @@ MongoDB, и так просилось из слова «перезаписыва
## Read API
<!-- канон: поведение → openspec/specs/read-api -->
```
GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами
GET /api/v1/metrics/{name}?from&to&layer точки метрики за период (bucket — соседняя задача, пока 400)
GET /api/v1/workouts?from&to заголовки тренировок
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
GET /api/v1/records/{kind}?from&to прочие секции
GET /api/v1/schema схемы всего, что есть в хранилище
GET /api/v1/metrics/{name}/schema схема и статистика одной метрики
GET /stats последняя доставка, счётчики, тишина по потоку
GET /healthz
```
**Целевая поверхность шире реализованной.** Маршрутов ниже в роутере ещё нет,
и запрос к ним получает `404`:
```
GET /api/v1/workouts?from&to заголовки тренировок → read-api-workouts
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом → read-api-workouts
GET /api/v1/records/{kind}?from&to прочие секции → read-api-records
GET /api/v1/schema схемы всего, что есть в хранилище → цель self-description
GET /api/v1/metrics/{name}/schema схема и статистика одной метрики → цель self-description
GET /stats последняя доставка, счётчики, тишина → stats-endpoint
```
Хранение пачками на контракт не влияет: `GET /metrics/{name}` собирает ответ
из часовых объектов, попавших в диапазон, и отдаёт точки. Клиент про объекты
не знает — это деталь хранения, а не API.
@@ -1687,8 +1703,9 @@ Docker и go-kit; версионирование с конверсией у Kube
### MCP
Поверх Read API адаптер MCP, чтобы агент подключался без промежуточного
кода. Инструментов ровно два, по числу форм запроса выше, плюс каталог.
Поверх Read API **встанет** адаптер MCP, чтобы агент подключался без
промежуточного кода — кода адаптера сегодня нет, это задача `mcp-server` цели
`read-api`. Инструментов ровно два, по числу форм запроса выше, плюс каталог.
Собственной логики в адаптере нет: он переводит вызовы в те же обработчики.
**Транспорт — HTTP** (Streamable HTTP), не stdio: сервис живёт на VPS, и агент
@@ -1741,18 +1758,18 @@ Docker и go-kit; версионирование с конверсией у Kube
## Аутентификация
Статический токен в заголовке `Authorization: Bearer …`; список допустимых
токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно.
Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий
данные, не может писать. MCP пользуется токеном чтения — отдельного контура
у него нет, см. «MCP».
Наружу открыты два контура: приём (телефон) и чтение вместе с MCP (агенты и
приложения). Оба через Caddy с TLS, оба с разными токенами.
Периметр, модель угроз и разграничение контуров — [security.md](security.md),
разделы «Периметр» и «Что разграничивает доступ»; сегодняшний контур отличается
от целевого, и сказано это там. Здесь важно одно следствие для компоновки: MCP —
эндпоинт того же процесса и того же контура чтения, отдельного контура доступа у
него нет (см. «MCP»).
## Деплой
**Целевая** раскладка; сегодняшний контур — [security.md](security.md),
«Периметр», статус работ — [tasks/ROADMAP.md](tasks/ROADMAP.md),
«Сопровождение».
VPS **rivendell** (Timeweb), доступен всегда. Перед сервисом — **Caddy**, он
терминирует TLS; сам сервис слушает plain HTTP. Приём открыт наружу на
отдельном поддомене — телефон должен доставать до него из любой сети, иначе
@@ -1764,7 +1781,12 @@ VPS **rivendell** (Timeweb), доступен всегда. Перед серв
Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг
(с токенами) — отдельно, `0600`.
**Откат бинаря поверх новой схемы отказывает на старте.** Версия схемы базы выше
<!-- канон: поведение → openspec/specs/storage -->
**Откат бинаря поверх новой схемы отказывает на старте** — правило нормировано в
[`storage`](../openspec/specs/storage/spec.md), требование «Открытие базы
отказывает при схеме из будущего»; здесь только следствия для деплоя. Версия
схемы базы выше
версии, вшитой в бинарь, — отказ, а не повод мигрировать; в контейнере это
выглядит циклом перезапуска, и лечится возвратом бинаря вперёд. Версию читает
сам goose (`Provider.GetVersions`), а не собственный запрос: имя таблицы учёта и