tasks: задачи спринта раздроблены до восьми гранулярных

- конверт и точки разложены на форму провода, точки за период и условный
  запрос; свёртка — на сетку, порог неполного ведра и предел размера ответа;
  тренировки и записи разъехались на два независимых маршрута
- openapi-swagger разложена на спеку, гейт против расхождения и Swagger UI —
  все три вне набора, вместе с mcp-server
- набор спринта 2026-08-04 — весь HTTP-слой чтения, восемь задач
This commit is contained in:
av
2026-08-04 14:21:57 +03:00
parent 79331ac670
commit bd832337df
21 changed files with 393 additions and 216 deletions
+4
View File
@@ -34,6 +34,10 @@
- [[idea] Выгрузка в parquet отдельной командой](items/parquet-export.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно - [[idea] Выгрузка в parquet отдельной командой](items/parquet-export.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно
- [Заголовки доставки в архиве рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке - [Заголовки доставки в архиве рядом с телом](items/delivery-headers-in-archive.md) — Заголовки живут только в базе — потеря базы навсегда ломает вывод слоя при пересборке
- [Правило полноты против last wins](items/last-wins-over-completeness.md) — Полнота решает 1,2% спорных координат, и неизвестно, была ли более полная точка более поздней — от этого зависит, нужна ли она вообще - [Правило полноты против 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 в локальной сети нет
## инфра ## инфра
- [Активный алерт «данных нет N часов»](items/stream-silence-alert.md) — Пропажу потока сейчас замечает человек, а не сервис - [Активный алерт «данных нет N часов»](items/stream-silence-alert.md) — Пропажу потока сейчас замечает человек, а не сервис
+4
View File
@@ -10,3 +10,7 @@
- 2026-08-01 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Была секция: блокеры. - 2026-08-01 `edinicy-metriki-v-razreze` — Единицы метрики: часть координаты или свойство объекта. Причина: измерено: на 99 доставках единицы не менялись ни у одной из 30 метрик (находка 49 → 48); реализованное правило «сохранённое побеждает + WARN + счётчик» делает событие наблюдаемым. Была секция: блокеры.
- 2026-08-04 `mcp` — [goal] MCP. Причина: поглощена целью read-api («Чтение данных клиентами»): MCP — не направление, а последний шаг того же направления; адаптер переводит вызовы в те же обработчики и собственной логики не несёт. Очередь «Read API перед MCP» стала порядком задач внутри цели. Задача mcp-server жива и перевешена на read-api. Была секция: порядок. - 2026-08-04 `mcp` — [goal] MCP. Причина: поглощена целью read-api («Чтение данных клиентами»): MCP — не направление, а последний шаг того же направления; адаптер переводит вызовы в те же обработчики и собственной логики не несёт. Очередь «Read API перед MCP» стала порядком задач внутри цели. Задача mcp-server жива и перевешена на read-api. Была секция: порядок.
- 2026-08-04 `read-api-points` — Read API: точки, выбор слоя, свёртка по сетке. Причина: разложена на read-api-envelope-and-points (конверт, точки за период, форма провода, условный запрос), read-api-bucketing (свёртка по сетке, предел размера ответа, порог неполного ведра) и read-api-workouts-and-records (тренировки и записи наружу). Одним заходом не мерджилась: десяток критериев приёмки и три развилки в одном файле. Была секция: ядро. - 2026-08-04 `read-api-points` — Read API: точки, выбор слоя, свёртка по сетке. Причина: разложена на read-api-envelope-and-points (конверт, точки за период, форма провода, условный запрос), read-api-bucketing (свёртка по сетке, предел размера ответа, порог неполного ведра) и read-api-workouts-and-records (тренировки и записи наружу). Одним заходом не мерджилась: десяток критериев приёмки и три развилки в одном файле. Была секция: ядро.
- 2026-08-04 `read-api-envelope-and-points` — Конверт ответа и точки за период. Причина: разложена на read-api-wire-format (форма провода, мерджится первой и трогает только живой каталог), read-api-points-period (точки за период с конвертом) и read-api-points-conditional (условный запрос со scope-etag). Была секция: ядро.
- 2026-08-04 `read-api-bucketing` — Свёртка по сетке и предел размера ответа. Причина: разложена на read-api-points-bucket (свёртка по сетке), read-api-partial-bucket (порог неполного ведра и его полярность) и read-api-response-limit (предел размера ответа, общий для всех маршрутов чтения). Была секция: ядро.
- 2026-08-04 `read-api-workouts-and-records` — Тренировки и записи наружу. Причина: разложена на read-api-workouts и read-api-records: разные сущности и разные маршруты, независимые друг от друга. Была секция: ядро.
- 2026-08-04 `openapi-swagger` — OpenAPI-спека и Swagger UI. Причина: разложена на openapi-spec (рукописная спека), openapi-gate-check (гейт красит расхождение спеки с маршрутами) и swagger-ui (UI без внешней сети). Была секция: ядро.
+8 -5
View File
@@ -7,8 +7,11 @@
Урожай спринта поднимается `tasks.py list --tag sprint:2026-08-04` — это первая порция переоценки на сессии. Урожай спринта поднимается `tasks.py list --tag sprint:2026-08-04` — это первая порция переоценки на сессии.
## Набор ## Набор
- [Конверт ответа и точки за период](items/read-api-envelope-and-points.md) — Точки лежат в витрине и наружу не отдаются: ни один потребитель не может спросить «вес за год» иначе как sqlite3 на хосте - [Форма провода маршрутов чтения](items/read-api-wire-format.md) — Переименование поля в internal/catalog меняет публичный контракт без касания httpapi, и держит это один байтовый тест
- [Свёртка по сетке и предел размера ответа](items/read-api-bucketing.md) — Враждебный запрос к каталогу стоил 693 мс и +153 МиБ кучи, а у точек множители те же и потолка нет ни у одного - [Точки за период](items/read-api-points-period.md) — Точки лежат в витрине и наружу не отдаются: «вес за год» достаётся только sqlite3 на хосте
- [Тренировки и записи наружу](items/read-api-workouts-and-records.md) — Тренировки с маршрутами и stateOfMind разобраны и лежат в витрине, а эндпоинтов нет — второй сценарий паспорта не закрыт - [Условный запрос по точкам](items/read-api-points-conditional.md) — Агент опрашивает по расписанию, а каждый повтор стоит полного чтения: на каталоге это 693 мс и +153 МиБ
- [OpenAPI-спека и Swagger UI](items/openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате - [Свёртка по сетке](items/read-api-points-bucket.md) — «Шаги за неделю по дням» — базовый запрос трекера и игры, и сегодня его нечем задать
- [MCP-сервер поверх Read API](items/mcp-server.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 его нет
@@ -10,7 +10,7 @@
отвергнут при постановке: подстановка метки доставки — выдуманное измерение в отвергнут при постановке: подстановка метки доставки — выдуманное измерение в
колонке, по которой идёт выборка. колонке, по которой идёт выборка.
**Берётся после [тренировок и записей наружу](read-api-workouts-and-records.md).** Правило **Берётся после [тренировок](read-api-workouts.md) и [записей](read-api-records.md) наружу.** Правило
чтения — что выборка «за период» делает со строками без метки — обязано чтения — что выборка «за период» делает со строками без метки — обязано
проектироваться вместе с читателем, иначе такие строки молча исчезнут из любого проектироваться вместе с читателем, иначе такие строки молча исчезнут из любого
ответа. Порядок тот же, что у [journal-order-on-ingest](journal-order-on-ingest.md) ответа. Порядок тот же, что у [journal-order-on-ingest](journal-order-on-ingest.md)
+1 -1
View File
@@ -1,6 +1,6 @@
# MCP-сервер поверх Read API # MCP-сервер поверх Read API
- **Секция:** ядро - **Секция:** ядро — набор ограничен HTTP-слоем чтения после дробления; адаптер берётся следующим спринтом по той же цели
- **Зачем:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем - **Зачем:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- **Теги:** goal:read-api - **Теги:** goal:read-api
+1 -1
View File
@@ -19,4 +19,4 @@ Read API отдаёт ответ одним JSON. Для выборок нижн
последовательно или с возвратами. последовательно или с возвратами.
Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача
`read-api-bucketing` (правило размера ответа проектируется там). `read-api-response-limit` (правило размера ответа проектируется там).
+29
View File
@@ -0,0 +1,29 @@
# Гейт против расхождения спеки с маршрутами
- **Секция:** ядро
- **Зачем:** Рукописная спека — источник истины, а расходится она с кодом молча: без гейта решение о рукописной спеке не держится
- **Теги:** goal:read-api, sprint:2026-08-04
Маршрут, которого нет в спеке, и поле ответа, которого спека не обещала, красят
гейт — рукописный контракт перестаёт расходиться с кодом молча.
Это не украшение к спеке, а то, чем держится решение писать её руками. Без
проверки рукописная спека расходится с первого же маршрута, и потребитель,
сгенерировавший по ней клиент, узнаёт об этом последним.
Класс отказа тот же, что у остальных безусловных шагов гейта проекта: не виден
глазами и стоит дорого. Место ему там же.
## Критерии приёмки
- добавленный маршрут без правки спеки красит гейт — оракул: намеренно
рассогласованный маршрут в прогоне гейта
- переименованное поле ответа красит гейт — оракул: намеренное переименование в
прогоне гейта
- проверка не ходит в сеть и укладывается в бюджет гейта — оракул: замер шага по
логу `tmp/gate/`
## Рамки
Трогает `Taskfile` и шаги гейта, кода маршрутов не касается. Берётся после
спеки: проверять нечего, пока нет источника истины.
+33
View File
@@ -0,0 +1,33 @@
# Рукописная OpenAPI-спека
- **Секция:** ядро
- **Зачем:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- **Теги:** goal:read-api, sprint:2026-08-04
Контракт читается машиной: по спеке генерируется клиент, и сгенерированный
клиент выполняет запрос к живому сервису.
**Решено владельцем 2026-08-04: спека пишется руками и она источник истины.**
Для API из горстки ручек это честнее вывода из кода — контракт проектируется, а
не фотографируется с того, что вышло: опечатка в имени поля иначе становится
частью спеки. Совпадает с тем, как в проекте уже устроен OpenSpec: спека
первична к коду. Плата названа — рукописная спека расходится с кодом молча, — и
именно поэтому проверка расхождения вынесена в
[отдельную задачу](openapi-gate-check.md), а не оставлена регламентом.
Заодно снимает вопрос, чем быть «схеме контракта API» из раздела самоописания:
ею и будет OpenAPI-документ, а не собственный формат.
## Критерии приёмки
- по спеке генерируется клиент, и он выполняет запрос к живому сервису — оракул:
прогон генератора плюс запрос сгенерированным клиентом
- спека покрывает все существующие маршруты: приём, каталог, точки, тренировки,
записи — оракул: сверка перечня путей спеки с таблицей маршрутов в
`docs/architecture.md`
- спека проходит валидатор OpenAPI 3.1 — оракул: прогон валидатора
## Рамки
Кода маршрутов не трогает: описывает то, что уже есть. `/stats` не описывается —
его ещё нет, и его добавит [своя задача](stats-endpoint.md).
-47
View File
@@ -1,47 +0,0 @@
# OpenAPI-спека и Swagger UI
- **Секция:** ядро
- **Зачем:** Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- **Теги:** goal:read-api
Потребителей три, и один из них — агент, который читает контракт машиной.
Пересказывать форму ответа в чате не годится, а самоописание из плана покрывает
только **содержимое** метрик; форма конверта, коды ответов и параметры запроса —
это OpenAPI.
Заодно снимает вопрос, чем быть «схеме контракта API» из раздела самоописания:
ею и будет OpenAPI-документ, а не собственный формат.
Шаги:
- спека OpenAPI 3.1 на приём, каталог, точки, тренировки, записи, `/stats`;
- Swagger UI на отдельном пути, отдаётся самим сервисом (без внешних CDN —
он должен работать в локальной сети без интернета);
- проверка актуальности спеки в гейте: контракт разъезжается молча.
**Решено владельцем 2026-08-04: спека пишется руками и она источник истины.**
Для API из шести ручек это честнее вывода из кода: контракт проектируется, а не
фотографируется с того, что вышло, — опечатка в имени поля иначе становится
частью спеки. Совпадает с тем, как в проекте уже устроен OpenSpec: спека
первична к коду. Плата названа: спека расходится с кодом молча, и именно поэтому
проверка её актуальности идёт в гейт третьим шагом, а не остаётся регламентом.
Готово, когда по спеке можно сгенерировать клиент, а Swagger UI открывается
локально и выполняет запрос к живому сервису.
## Критерии приёмки
- по спеке генерируется клиент, и сгенерированный клиент выполняет запрос к
живому сервису — оракул: прогон генератора плюс запрос сгенерированным
клиентом
- Swagger UI открывается и выполняет запрос **без внешней сети** — оракул:
запуск с отключённым интернетом
- маршрут, разошедшийся со спекой, красит гейт — оракул: намеренно
рассогласованный маршрут в прогоне гейта
- спека покрывает приём, каталог, точки, тренировки, записи и `/stats`
оракул: сверка перечня путей спеки с таблицей маршрутов в `architecture.md`
## Рамки
Схема не трогается, данные только читаются, сервис перезапускается. Берётся
после того, как маршруты чтения существуют: спека рукописная, но описывать
нечего, пока конверт не задан.
-59
View File
@@ -1,59 +0,0 @@
# Свёртка по сетке и предел размера ответа
- **Секция:** ядро
- **Зачем:** Враждебный запрос к каталогу стоил 693 мс и +153 МиБ кучи, а у точек множители те же и потолка нет ни у одного
- **Теги:** goal:read-api
Потребитель просит разбивку (`?from&to&bucket`) и получает свёртку по сетке —
либо честную ошибку вместо тихо подменённой сетки, — а сервис получает
названный предел на то, сколько он готов отдать за один запрос.
Вторая из трёх частей `read-api-points`. Берётся после
[конверта ответа](read-api-envelope-and-points.md): сетка — параметр того же
маршрута.
**Решение по размеру ответа принято, вариант «б».** Разбивка не задана и ответ
не влезает — сервер сам берёт сетку погрубее и **называет её в ответе**;
разбивка задана явно и не влезает — ошибка со списком доступных сеток, а не
тихая подмена. Различие существенно: иначе агент, попросивший минутную сетку,
получит суточные суммы и не узнает об этом.
**Порог неполного ведра решается здесь, и вместе с ним — его полярность.**
Измерению рода агрегации порог не понадобился, свёртке в ответе он нужен, а
готовые решения задают его **противоположно**: Graphite `xFilesFactor` — доля
обязательно известных точек (умолчание 0.5 при роллапе и 0 при рендере, один
параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе
величины выглядят как «0.5», означая разное; полярность придётся назвать вслух в
`architecture.md`, иначе через полгода два места кода поймут поле по-разному.
**Цена измерена, и она унаследована.** На каталоге враждебный запрос
(20 метрик × 8 часов × 5000 точек) дал 693 мс и +153 МиБ живой кучи, при том что
приём в том же процессе уже даёт пик 768 МиБ на теле 40 МиБ. Условный запрос
снял повтор, но первый запрос стоит столько же, а множители «метрики × окно ×
точки × одновременные запросы» по-прежнему без потолка. Сюда же уезжают
отложенные варианты задачи «цена читающего маршрута»: собственный дедлайн
маршрута и потоковое измерение по метрике — второе только если счётчик
заговорит.
Инвариант, который здесь легче всего нарушить: **нижний слой HAE не
суммируется** ни при какой сетке — это интерполяция, а не сэмплы.
## Критерии приёмки
- «шаги за неделю по дням» отвечаются одним запросом, и в ответе названа
фактическая сетка — оракул: запрос к поднятому сервису на живом архиве
- явно заданная сетка, которая не влезает в предел, даёт ошибку со списком
доступных сеток, а не подменённый ответ — оракул: тест
- неполное ведро обрабатывается объявленным порогом, и полярность порога
названа в `docs/architecture.md` вслух — оракул: тест на границе плюс глазами
по разделу
- нижний слой HAE не суммируется ни при какой сетке — оракул: тест на
накопительной метрике, у которой есть и нижний, и часовой слой
## Рамки
Схема не трогается, данные только читаются, сервис перезапускается. Берётся
после конверта ответа. Против `./data` — только `task up` / `task run`.
Связано: `docs/architecture.md` → «Свёртка и размер ответа»; идея
[NDJSON-потока](ndjson-stream.md) закрывает другую сторону того же предела.
@@ -1,56 +0,0 @@
# Конверт ответа и точки за период
- **Секция:** ядро
- **Зачем:** Точки лежат в витрине и наружу не отдаются: ни один потребитель не может спросить «вес за год» иначе как sqlite3 на хосте
- **Теги:** goal:read-api
Потребитель получает значения метрики за период одним запросом
`?from&to`, и из ответа видно, что именно ему отдали: слой, род свёртки и
границу окна, в котором род измерен.
Первая из трёх частей, на которые разложена `read-api-points`. Здесь решается
**конверт** — форма, которую унаследуют все остальные маршруты чтения и оба
транспорта.
**Форма провода решается здесь, один раз.** Сегодня типы `internal/catalog`
сами несут json-теги, а транспорт владеет только обёрткой: переименование поля
в домене меняет публичный контракт без касания `httpapi`, и держит это один
байтовый тест непустого ответа. Либо объявить в `architecture.md`, что типы
чтения и есть форма провода для всех транспортов (HTTP и MCP отдают её байт в
байт), либо завести DTO в транспорте. Выбрать надо **до** того, как образец
скопируют соседние задачи.
**Машинерия условного запроса готова — её берут, а не пишут заново.**
`store.VersionedRead` держит правило «версией, снятой после чтения, не
подписывать»; `httpapi` умеет `If-None-Match` и `304`. Новое здесь одно: метка
обязана нести **область действия** — у точек ответ есть функция параметров
запроса, поэтому `etag(scope, version)` требует их канонизированной формы, иначе
`304` ответит на другой набор данных. Детали — `docs/architecture.md`,
«Условный запрос».
**Клиент обязан видеть границы окна измерения.** Род метрики измерен по 48 самым
свежим **общим** часам, а не по последним 48 часам календаря: выключенная
минутная автоматизация HAE останавливает пополнение множества общих часов, и
окно замирает, продолжая объявлять род. Единственный след — `last_hour` в
ответе; конверт обязан его нести.
## Критерии приёмки
- «вес за год» отвечается одним запросом без доступа к файлу базы — оракул:
запрос к поднятому сервису на живом архиве
- в ответе всегда видны `layer`, `aggregation` и `last_hour` — оракул: тест на
форме ответа
- повторный запрос с `If-None-Match` даёт `304`, а тот же запрос с другими
параметрами — `200` с другим телом — оракул: тест на паре запросов с разной
областью действия метки
- форма провода объявлена в `docs/architecture.md` — доменные типы или DTO, —
и соседние маршруты ссылаются на это решение, а не повторяют выбор — оракул:
глазами по разделу
## Рамки
Схема не трогается, данные только читаются, сервис перезапускается. Против
`./data` — только `task up` / `task run`.
Связано: `docs/architecture.md` → «Read API», «Условный запрос», «Измерение рода
агрегации».
@@ -0,0 +1,33 @@
# Порог неполного ведра
- **Секция:** ядро
- **Зачем:** Текущий час неполон всегда, и без порога свёртка отдаёт его наравне с полными — клиент видит провал вместо неизвестности
- **Теги:** goal:read-api, sprint:2026-08-04
Ведро, в котором известна не вся сетка, отличимо от полного — а полярность
порога названа вслух, а не выводится читателем из умолчания.
Измерению рода агрегации порог не понадобился: у него две конкурирующие
гипотезы, и неполный час не сходится ни с одной сам собой. Свёртке в ответе он
нужен — текущий час неполон **всегда**, и без порога накопительная метрика
показывает за него провал вместо неизвестности.
**Готовые решения задают порог противоположно.** Graphite `xFilesFactor` — доля
обязательно известных точек (умолчание 0.5 при роллапе и 0 при рендере: один
параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе
величины выглядят как «0.5», означая разное. Полярность придётся назвать вслух,
иначе через полгода два места кода поймут поле по-разному — и разойдутся молча.
## Критерии приёмки
- полярность и умолчание порога названы в `docs/architecture.md` одной
формулировкой, и там же сказано, у какого из двух прототипов взято — оракул:
глазами по разделу
- ведро ниже порога помечено неизвестным, а не отдано значением — оракул: тест
на границе: ведро ровно на пороге и на единицу ниже
- текущий незакрытый час не выглядит провалом накопительной метрики — оракул:
запрос за сегодня на живом архиве
## Рамки
Схема не трогается, данные только читаются. Берётся после свёртки по сетке.
@@ -0,0 +1,34 @@
# Свёртка по сетке
- **Секция:** ядро
- **Зачем:** «Шаги за неделю по дням» — базовый запрос трекера и игры, и сегодня его нечем задать
- **Теги:** goal:read-api, sprint:2026-08-04
«Шаги за неделю по дням» отвечаются одним запросом `?from&to&bucket`, и род
свёртки берётся измеренным, а не угаданным.
Род агрегации измерен каталогом (change `2026-08-02-katalog-i-rod-agregacii`):
сверка минутного слоя с часовым разложила метрики живого корпуса на
накопительные и мгновенные, не сойдясь ни на одной. Свёртка в ответе опирается
на это измерение и **только** на него: род неизвестен — свёртки нет.
Инвариант, который здесь легче всего нарушить: **нижний слой HAE не
суммируется** ни при какой сетке. Это интерполяция, а не сэмплы, и суммирование
завышает втрое.
Порог неполного ведра и предел размера ответа — соседние задачи; здесь они
берутся в том виде, в каком есть на момент мерджа, и не проектируются.
## Критерии приёмки
- «шаги за неделю по дням» отвечаются одним запросом, и в ответе названы
фактические `bucket` и `aggregation` — оракул: запрос к поднятому сервису на
живом архиве
- нижний слой HAE не суммируется ни при какой сетке — оракул: тест на
накопительной метрике, у которой есть и нижний, и часовой слой
- метрика с неизвестным родом не сворачивается вовсе и отвечает отказом с
названной причиной, а не значением — оракул: тест
## Рамки
Схема не трогается, данные только читаются. Берётся после точек за период.
@@ -0,0 +1,36 @@
# Условный запрос по точкам
- **Секция:** ядро
- **Зачем:** Агент опрашивает по расписанию, а каждый повтор стоит полного чтения: на каталоге это 693 мс и +153 МиБ
- **Теги:** goal:read-api, sprint:2026-08-04
Повторный опрос точек с той же меткой стоит `304` вместо полного чтения, и метка
не может ответить на другой набор данных.
Машинерия готова и берётся, а не пишется заново: `store.VersionedRead` держит
правило «версией, снятой после чтения, не подписывать», `internal/httpapi/conditional.go`
разбирает `If-None-Match` и отдаёт `304`. Новое здесь ровно одно — **область
действия метки**. У каталога ответ есть функция версии витрины; у точек он ещё и
функция параметров запроса, поэтому `etag(scope, version)` требует их
канонизированной формы. Ошибиться тут значит ответить `304` на другой набор
данных — молча и без следов.
Цена, которую это снимает, измерена на каталоге: 693 мс и +153 МиБ живой кучи на
враждебном запросе. Агент опрашивает по расписанию, и без условного запроса
каждый его повтор стоит полного чтения.
## Критерии приёмки
- повторный запрос с `If-None-Match` при неизменной витрине даёт `304` — оракул:
тест
- запрос, отличающийся любым параметром по очереди (метрика, окно, слой), при
той же версии витрины даёт `200` и другое тело — оракул: тест, перебирающий
параметры по одному
- параметры, различающиеся только формой записи (порядок, регистр, эквивалентная
запись времени), дают одну и ту же метку — оракул: тест на канонизации
## Рамки
Схема не трогается, данные только читаются. Берётся после точек за период.
Связано: `docs/architecture.md` → «Условный запрос».
@@ -0,0 +1,36 @@
# Точки за период
- **Секция:** ядро
- **Зачем:** Точки лежат в витрине и наружу не отдаются: «вес за год» достаётся только sqlite3 на хосте
- **Теги:** goal:read-api, sprint:2026-08-04
Потребитель получает значения метрики за период одним запросом `?from&to`, и из
конверта видно, что именно ему отдали: слой, род свёртки и границу окна, в
котором род измерен.
Форм запроса ровно две, и это один маршрут с необязательным параметром: здесь
только первая — все значения за период (вес, лекарства, симптомы). Разбивка —
соседняя задача.
**Клиент обязан видеть границы окна измерения.** Род метрики измерен по 48 самым
свежим **общим** часам, а не по последним 48 часам календаря: выключенная
минутная автоматизация HAE останавливает пополнение множества общих часов, и
окно замирает, продолжая объявлять род. Единственный след — `last_hour`; конверт
обязан его нести.
Форма конверта наследует решение задачи
[о форме провода](read-api-wire-format.md), а не принимает его заново.
## Критерии приёмки
- «вес за год» отвечается одним запросом без доступа к файлу базы — оракул:
запрос к поднятому сервису на живом архиве
- в ответе всегда видны `layer`, `aggregation` и `last_hour` — оракул: тест на
форме ответа
- метрика, у которой род не измерен, отдаётся без свёртки и говорит об этом, а
не молчит и не досчитывает — оракул: тест на метрике с неизвестным родом
## Рамки
Схема не трогается, данные только читаются, сервис перезапускается. Берётся
после формы провода. Против `./data` — только `task up` / `task run`.
+29
View File
@@ -0,0 +1,29 @@
# Записи наружу
- **Секция:** ядро
- **Зачем:** stateOfMind разобран и хранится, но наружу не отдаётся — а восстановить его нечем: в экспорте Apple его нет
- **Теги:** goal:read-api, sprint:2026-08-04
Записи со своим `id` — сегодня это `stateOfMind` — достаются за период через
`GET /records/{kind}`.
Разбор и хранение сделаны тем же change, что у тренировок; наружу не отдаётся
ничего. У этих данных есть особенность, которой нет больше ни у чего в проекте:
**`stateOfMind` нет в экспорте Apple**, он не восстанавливается пересборкой из
снапшота, и единственный его источник — доставки HAE. Отдача наружу — не
удобство, а единственный способ увидеть то, что иначе живёт только внутри базы.
Конверт наследуется от точек; собственной формы у записей нет.
## Критерии приёмки
- записи `stateOfMind` за период отдаются одним запросом — оракул: запрос к
поднятому сервису на живом архиве
- неизвестный `kind` отвечает отказом со списком известных, а не пустым списком:
пустота и опечатка обязаны различаться — оракул: тест
- конверт совпадает с конвертом точек и тренировок — оракул: тест, сравнивающий
форму ответа трёх маршрутов
## Рамки
Схема не трогается, данные только читаются. Берётся после конверта.
@@ -0,0 +1,41 @@
# Предел размера ответа
- **Секция:** ядро
- **Зачем:** У маршрутов чтения нет ни одного потолка: множители «метрики × окно × точки × одновременные запросы» ничем не ограничены
- **Теги:** goal:read-api, sprint:2026-08-04
У маршрутов чтения появляется названный потолок: сетка не задана и ответ не
влезает — сервер огрубляет её и **называет** в ответе; сетка задана явно и не
влезает — ошибка со списком доступных, а не тихая подмена.
Различие существенно: иначе агент, попросивший минутную сетку, получит суточные
суммы и не узнает об этом.
**Цена измерена и унаследована.** На каталоге враждебный запрос
(20 метрик × 8 часов × 5000 точек) дал 693 мс и +153 МиБ живой кучи, при том что
приём в том же процессе уже даёт пик 768 МиБ на теле 40 МиБ. Множители «метрики ×
окно × точки × одновременные запросы» сегодня без потолка ни у одного маршрута —
включая уже живой каталог, у которого предела нет намеренно: правило размера
общее, и задавать его мимоходом на первой ручке значило бы решить контракт до
того, как известна форма тяжёлого ответа.
Правило распространяется на все маршруты чтения сразу — каталог, точки,
тренировки, записи, — а не только на тот, где написано.
## Критерии приёмки
- запрос без сетки, не влезающий в предел, отвечает огрублённой сеткой и
называет её в ответе — оракул: враждебный запрос на живом архиве
- явно заданная сетка за пределом даёт ошибку со списком доступных сеток —
оракул: тест
- предел объявлен в конфиге и в `docs/architecture.md`, а не зашит числом в
обработчике — оракул: образцы конфига в гейте
- каталог подчиняется тому же пределу, что и точки — оракул: тест на враждебном
запросе к каталогу
## Рамки
Схема не трогается, данные только читаются. Берётся после свёртки по сетке.
Собственный дедлайн маршрута сюда **не входит**: он в задаче
[«Остановка и миграция»](shutdown-and-migration-traces.md) вместе с `BaseContext`
и раздельными бюджетами остановки.
+38
View File
@@ -0,0 +1,38 @@
# Форма провода маршрутов чтения
- **Секция:** ядро
- **Зачем:** Переименование поля в internal/catalog меняет публичный контракт без касания httpapi, и держит это один байтовый тест
- **Теги:** goal:read-api, sprint:2026-08-04
Публичный контракт чтения перестаёт меняться от переименования поля в домене —
либо потому, что доменные типы объявлены формой провода намеренно, либо потому,
что между ними и транспортом появился DTO.
Сегодня типы `internal/catalog` сами несут json-теги, а `internal/httpapi/catalog.go`
(66 строк) владеет только обёрткой: переименование поля в домене меняет
публичный контракт без касания транспорта, и держит это один байтовый тест
непустого ответа. Пока маршрут чтения был один и без потребителей, цена была
нулевой; со следующей задачи образец копируют точки, тренировки, записи и MCP.
Мерджится первой из всей цели и трогает только уже живой каталог. Решение
принимается **до** того, как его скопируют, — потом это будет не решение, а
археология. Развилка не изобретается с нуля: как разделять доменные типы и форму
провода, решено во множестве чужих проектов, и правило проекта требует начать с
их разбора.
## Критерии приёмки
- решение записано в `docs/architecture.md` с названной ценой обеих сторон, а не
только выбранной — оракул: глазами по разделу
- переименование поля доменного типа либо не меняет байты ответа, либо меняет их
намеренно, и тест утверждает это прямо, а не проверяет непустоту — оракул:
тест на переименование
- маршрут каталога приведён к решению, и следующий маршрут копирует образец, а
не выбирает заново — оракул: гейт зелёный плюс сверка `httpapi/catalog.go` с
записанным решением
## Рамки
Трогается только каталог и его транспорт, схема не трогается. Публичный контракт
каталога при этом может измениться — потребителей у него сегодня нет, и это
единственный момент, когда такая правка бесплатна.
@@ -1,46 +0,0 @@
# Тренировки и записи наружу
- **Секция:** ядро
- **Зачем:** Тренировки с маршрутами и stateOfMind разобраны и лежат в витрине, а эндпоинтов нет — второй сценарий паспорта не закрыт
- **Теги:** goal:read-api
Трекер тренировок забирает тренировку одним пакетом вместе с маршрутом, а
записи `stateOfMind` достаются за период — без доступа к файлу базы.
Третья из трёх частей `read-api-points`. Разбор и хранение сущностей с
собственным `id` сделаны (change `2026-08-02-trenirovki-i-zapisi`), эндпоинтов
нет: данные лежат в витрине и наружу не отдаются. Второй сценарий паспорта до
тех пор не закрыт.
Маршруты: `GET /workouts`, `GET /workouts/{id}`, `GET /records/{kind}`.
**Конверт не изобретается заново** — наследуется от
[конверта ответа](read-api-envelope-and-points.md) вместе с формой провода и
условным запросом. Вводить эти маршруты раньше конверта значило бы задать
контракт мимоходом; именно поэтому они здесь, а не в задаче о разборе.
**Маршрут — первый по-настоящему тяжёлый ответ проекта.** Он лежит блобом внутри
тренировки, и правило размера ответа
([свёртка по сетке](read-api-bucketing.md)) обязано распространяться и на него —
иначе одна тренировка с длинным треком проходит мимо предела, названного для
точек. Разворачивание маршрута в отдельную таблицу остаётся идеей и этой задачей
не решается.
## Критерии приёмки
- тренировка отдаётся одним пакетом вместе с маршрутом — оракул: запрос к
поднятому сервису на живом архиве
- записи `stateOfMind` за период достаются `GET /records/{kind}` — оракул: тест
на пакетах `internal/hae/testdata`
- конверт, форма провода и условный запрос те же, что у точек, а не собственные
— оракул: тест, сравнивающий форму ответа обоих маршрутов
- тренировка с длинным маршрутом подчиняется тому же пределу размера ответа,
что и точки — оракул: тест на синтетическом треке за пределом
## Рамки
Схема не трогается, данные только читаются, сервис перезапускается. Берётся
после конверта ответа. Против `./data` — только `task up` / `task run`.
Связано: `docs/architecture.md` → «Read API»; идея
[разворачивания маршрутов в таблицу](workout-routes-table.md).
+35
View File
@@ -0,0 +1,35 @@
# Тренировки наружу
- **Секция:** ядро
- **Зачем:** Тренировки с маршрутами разобраны и лежат в витрине, а эндпоинтов нет — сценарий трекера не закрыт
- **Теги:** goal:read-api, sprint:2026-08-04
Трекер забирает тренировку одним пакетом вместе с маршрутом: `GET /workouts` за
период и `GET /workouts/{id}` поштучно.
Разбор и хранение тренировок сделаны (change `2026-08-02-trenirovki-i-zapisi`),
эндпоинтов нет: тренировка с маршрутом лежит в витрине и наружу не отдаётся.
Второй сценарий паспорта — трекер тренировок — до тех пор не закрыт.
**Это первый по-настоящему тяжёлый ответ проекта.** Маршрут лежит блобом внутри
тренировки, и общий предел размера ответа обязан распространяться и на него —
иначе одна тренировка с длинным треком проходит мимо потолка, названного для
точек. Отсюда же требование к списку: перечень за период не тянет треки, иначе
неделя тренировок превращается в один неподъёмный ответ.
Конверт и форма провода наследуются, а не изобретаются.
## Критерии приёмки
- тренировка отдаётся одним пакетом вместе с маршрутом — оракул: запрос к
поднятому сервису на живом архиве
- список тренировок за период не тянет треки — оракул: тест, сравнивающий размер
ответа списка с размером ответа одной тренировки
- тренировка с длинным треком подчиняется общему пределу размера ответа —
оракул: тест на синтетическом треке за пределом
## Рамки
Схема не трогается, данные только читаются. Берётся после конверта.
Разворачивание маршрута в отдельную таблицу остаётся
[идеей](workout-routes-table.md) и здесь не решается.
+30
View File
@@ -0,0 +1,30 @@
# Swagger UI без внешней сети
- **Секция:** ядро
- **Зачем:** Контракт читается машиной, но человеку нечем ткнуть в живой сервис, а внешних CDN в локальной сети нет
- **Теги:** goal:read-api, sprint:2026-08-04
Человек открывает UI на отдельном пути и выполняет запрос к живому сервису — не
имея интернета.
Сервис живёт в локальной сети и на VPS без гарантии выхода наружу, поэтому
статика отдаётся самим сервисом и лежит в бинаре: внешние CDN здесь означают
«работает, пока работает чужой сайт».
**UI — новый адресат недоверенного входа наизнанку:** он даёт человеку в один
клик дёрнуть любой описанный маршрут, включая приём. Права на запись из
браузера не должны появляться сами собой — это ровно та граница, которую
описывает `docs/security.md`.
## Критерии приёмки
- UI открывается и выполняет запрос при отключённой внешней сети — оракул:
запуск контейнера без доступа наружу
- бинарь работает без каталога со статикой рядом — оракул: запуск одного файла
из пустого каталога
- маршрут приёма из UI не вызывается без явного токена приёма — оракул: тест
## Рамки
Схема не трогается. Берётся после спеки. Наружу ничего не выкладывается — это
решение человека и отдельная задача деплоя.