diff --git a/docs/tasks/BACKLOG.md b/docs/tasks/BACKLOG.md index ffd9bbd..487878e 100644 --- a/docs/tasks/BACKLOG.md +++ b/docs/tasks/BACKLOG.md @@ -34,6 +34,10 @@ - [[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 в локальной сети нет ## инфра - [Активный алерт «данных нет N часов»](items/stream-silence-alert.md) — Пропажу потока сейчас замечает человек, а не сервис diff --git a/docs/tasks/REJECTED.md b/docs/tasks/REJECTED.md index 0e3269f..18a66c5 100644 --- a/docs/tasks/REJECTED.md +++ b/docs/tasks/REJECTED.md @@ -10,3 +10,7 @@ - 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 `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 без внешней сети). Была секция: ядро. diff --git a/docs/tasks/SPRINT.md b/docs/tasks/SPRINT.md index f3dcd60..4947be8 100644 --- a/docs/tasks/SPRINT.md +++ b/docs/tasks/SPRINT.md @@ -7,8 +7,11 @@ Урожай спринта поднимается `tasks.py list --tag sprint:2026-08-04` — это первая порция переоценки на сессии. ## Набор -- [Конверт ответа и точки за период](items/read-api-envelope-and-points.md) — Точки лежат в витрине и наружу не отдаются: ни один потребитель не может спросить «вес за год» иначе как sqlite3 на хосте -- [Свёртка по сетке и предел размера ответа](items/read-api-bucketing.md) — Враждебный запрос к каталогу стоил 693 мс и +153 МиБ кучи, а у точек множители те же и потолка нет ни у одного -- [Тренировки и записи наружу](items/read-api-workouts-and-records.md) — Тренировки с маршрутами и stateOfMind разобраны и лежат в витрине, а эндпоинтов нет — второй сценарий паспорта не закрыт -- [OpenAPI-спека и Swagger UI](items/openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате -- [MCP-сервер поверх Read API](items/mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем +- [Форма провода маршрутов чтения](items/read-api-wire-format.md) — Переименование поля в internal/catalog меняет публичный контракт без касания httpapi, и держит это один байтовый тест +- [Точки за период](items/read-api-points-period.md) — Точки лежат в витрине и наружу не отдаются: «вес за год» достаётся только sqlite3 на хосте +- [Условный запрос по точкам](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 его нет diff --git a/docs/tasks/items/entity-without-parsed-label.md b/docs/tasks/items/entity-without-parsed-label.md index 4a7095b..2a4284d 100644 --- a/docs/tasks/items/entity-without-parsed-label.md +++ b/docs/tasks/items/entity-without-parsed-label.md @@ -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) diff --git a/docs/tasks/items/mcp-server.md b/docs/tasks/items/mcp-server.md index d6c8e30..8afce09 100644 --- a/docs/tasks/items/mcp-server.md +++ b/docs/tasks/items/mcp-server.md @@ -1,6 +1,6 @@ # MCP-сервер поверх Read API -- **Секция:** ядро +- **Секция:** ядро — набор ограничен HTTP-слоем чтения после дробления; адаптер берётся следующим спринтом по той же цели - **Зачем:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем - **Теги:** goal:read-api diff --git a/docs/tasks/items/ndjson-stream.md b/docs/tasks/items/ndjson-stream.md index cff77f7..75485f0 100644 --- a/docs/tasks/items/ndjson-stream.md +++ b/docs/tasks/items/ndjson-stream.md @@ -19,4 +19,4 @@ Read API отдаёт ответ одним JSON. Для выборок нижн последовательно или с возвратами. Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача -`read-api-bucketing` (правило размера ответа проектируется там). +`read-api-response-limit` (правило размера ответа проектируется там). diff --git a/docs/tasks/items/openapi-gate-check.md b/docs/tasks/items/openapi-gate-check.md new file mode 100644 index 0000000..53f0d58 --- /dev/null +++ b/docs/tasks/items/openapi-gate-check.md @@ -0,0 +1,29 @@ +# Гейт против расхождения спеки с маршрутами + +- **Секция:** ядро +- **Зачем:** Рукописная спека — источник истины, а расходится она с кодом молча: без гейта решение о рукописной спеке не держится +- **Теги:** goal:read-api, sprint:2026-08-04 + +Маршрут, которого нет в спеке, и поле ответа, которого спека не обещала, красят +гейт — рукописный контракт перестаёт расходиться с кодом молча. + +Это не украшение к спеке, а то, чем держится решение писать её руками. Без +проверки рукописная спека расходится с первого же маршрута, и потребитель, +сгенерировавший по ней клиент, узнаёт об этом последним. + +Класс отказа тот же, что у остальных безусловных шагов гейта проекта: не виден +глазами и стоит дорого. Место ему там же. + +## Критерии приёмки + +- добавленный маршрут без правки спеки красит гейт — оракул: намеренно + рассогласованный маршрут в прогоне гейта +- переименованное поле ответа красит гейт — оракул: намеренное переименование в + прогоне гейта +- проверка не ходит в сеть и укладывается в бюджет гейта — оракул: замер шага по + логу `tmp/gate/` + +## Рамки + +Трогает `Taskfile` и шаги гейта, кода маршрутов не касается. Берётся после +спеки: проверять нечего, пока нет источника истины. diff --git a/docs/tasks/items/openapi-spec.md b/docs/tasks/items/openapi-spec.md new file mode 100644 index 0000000..81b6ca7 --- /dev/null +++ b/docs/tasks/items/openapi-spec.md @@ -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). diff --git a/docs/tasks/items/openapi-swagger.md b/docs/tasks/items/openapi-swagger.md deleted file mode 100644 index 5e350f0..0000000 --- a/docs/tasks/items/openapi-swagger.md +++ /dev/null @@ -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` - -## Рамки - -Схема не трогается, данные только читаются, сервис перезапускается. Берётся -после того, как маршруты чтения существуют: спека рукописная, но описывать -нечего, пока конверт не задан. diff --git a/docs/tasks/items/read-api-bucketing.md b/docs/tasks/items/read-api-bucketing.md deleted file mode 100644 index a74ab45..0000000 --- a/docs/tasks/items/read-api-bucketing.md +++ /dev/null @@ -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) закрывает другую сторону того же предела. diff --git a/docs/tasks/items/read-api-envelope-and-points.md b/docs/tasks/items/read-api-envelope-and-points.md deleted file mode 100644 index bffa803..0000000 --- a/docs/tasks/items/read-api-envelope-and-points.md +++ /dev/null @@ -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», «Условный запрос», «Измерение рода -агрегации». diff --git a/docs/tasks/items/read-api-partial-bucket.md b/docs/tasks/items/read-api-partial-bucket.md new file mode 100644 index 0000000..a6672b2 --- /dev/null +++ b/docs/tasks/items/read-api-partial-bucket.md @@ -0,0 +1,33 @@ +# Порог неполного ведра + +- **Секция:** ядро +- **Зачем:** Текущий час неполон всегда, и без порога свёртка отдаёт его наравне с полными — клиент видит провал вместо неизвестности +- **Теги:** goal:read-api, sprint:2026-08-04 + +Ведро, в котором известна не вся сетка, отличимо от полного — а полярность +порога названа вслух, а не выводится читателем из умолчания. + +Измерению рода агрегации порог не понадобился: у него две конкурирующие +гипотезы, и неполный час не сходится ни с одной сам собой. Свёртке в ответе он +нужен — текущий час неполон **всегда**, и без порога накопительная метрика +показывает за него провал вместо неизвестности. + +**Готовые решения задают порог противоположно.** Graphite `xFilesFactor` — доля +обязательно известных точек (умолчание 0.5 при роллапе и 0 при рендере: один +параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе +величины выглядят как «0.5», означая разное. Полярность придётся назвать вслух, +иначе через полгода два места кода поймут поле по-разному — и разойдутся молча. + +## Критерии приёмки + +- полярность и умолчание порога названы в `docs/architecture.md` одной + формулировкой, и там же сказано, у какого из двух прототипов взято — оракул: + глазами по разделу +- ведро ниже порога помечено неизвестным, а не отдано значением — оракул: тест + на границе: ведро ровно на пороге и на единицу ниже +- текущий незакрытый час не выглядит провалом накопительной метрики — оракул: + запрос за сегодня на живом архиве + +## Рамки + +Схема не трогается, данные только читаются. Берётся после свёртки по сетке. diff --git a/docs/tasks/items/read-api-points-bucket.md b/docs/tasks/items/read-api-points-bucket.md new file mode 100644 index 0000000..aa7af83 --- /dev/null +++ b/docs/tasks/items/read-api-points-bucket.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 не суммируется ни при какой сетке — оракул: тест на + накопительной метрике, у которой есть и нижний, и часовой слой +- метрика с неизвестным родом не сворачивается вовсе и отвечает отказом с + названной причиной, а не значением — оракул: тест + +## Рамки + +Схема не трогается, данные только читаются. Берётся после точек за период. diff --git a/docs/tasks/items/read-api-points-conditional.md b/docs/tasks/items/read-api-points-conditional.md new file mode 100644 index 0000000..900ca19 --- /dev/null +++ b/docs/tasks/items/read-api-points-conditional.md @@ -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` → «Условный запрос». diff --git a/docs/tasks/items/read-api-points-period.md b/docs/tasks/items/read-api-points-period.md new file mode 100644 index 0000000..a2c3873 --- /dev/null +++ b/docs/tasks/items/read-api-points-period.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`. diff --git a/docs/tasks/items/read-api-records.md b/docs/tasks/items/read-api-records.md new file mode 100644 index 0000000..18f8192 --- /dev/null +++ b/docs/tasks/items/read-api-records.md @@ -0,0 +1,29 @@ +# Записи наружу + +- **Секция:** ядро +- **Зачем:** stateOfMind разобран и хранится, но наружу не отдаётся — а восстановить его нечем: в экспорте Apple его нет +- **Теги:** goal:read-api, sprint:2026-08-04 + +Записи со своим `id` — сегодня это `stateOfMind` — достаются за период через +`GET /records/{kind}`. + +Разбор и хранение сделаны тем же change, что у тренировок; наружу не отдаётся +ничего. У этих данных есть особенность, которой нет больше ни у чего в проекте: +**`stateOfMind` нет в экспорте Apple**, он не восстанавливается пересборкой из +снапшота, и единственный его источник — доставки HAE. Отдача наружу — не +удобство, а единственный способ увидеть то, что иначе живёт только внутри базы. + +Конверт наследуется от точек; собственной формы у записей нет. + +## Критерии приёмки + +- записи `stateOfMind` за период отдаются одним запросом — оракул: запрос к + поднятому сервису на живом архиве +- неизвестный `kind` отвечает отказом со списком известных, а не пустым списком: + пустота и опечатка обязаны различаться — оракул: тест +- конверт совпадает с конвертом точек и тренировок — оракул: тест, сравнивающий + форму ответа трёх маршрутов + +## Рамки + +Схема не трогается, данные только читаются. Берётся после конверта. diff --git a/docs/tasks/items/read-api-response-limit.md b/docs/tasks/items/read-api-response-limit.md new file mode 100644 index 0000000..8980323 --- /dev/null +++ b/docs/tasks/items/read-api-response-limit.md @@ -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` +и раздельными бюджетами остановки. diff --git a/docs/tasks/items/read-api-wire-format.md b/docs/tasks/items/read-api-wire-format.md new file mode 100644 index 0000000..9d8d67b --- /dev/null +++ b/docs/tasks/items/read-api-wire-format.md @@ -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` с + записанным решением + +## Рамки + +Трогается только каталог и его транспорт, схема не трогается. Публичный контракт +каталога при этом может измениться — потребителей у него сегодня нет, и это +единственный момент, когда такая правка бесплатна. diff --git a/docs/tasks/items/read-api-workouts-and-records.md b/docs/tasks/items/read-api-workouts-and-records.md deleted file mode 100644 index cbc44b7..0000000 --- a/docs/tasks/items/read-api-workouts-and-records.md +++ /dev/null @@ -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). diff --git a/docs/tasks/items/read-api-workouts.md b/docs/tasks/items/read-api-workouts.md new file mode 100644 index 0000000..b5cec5d --- /dev/null +++ b/docs/tasks/items/read-api-workouts.md @@ -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) и здесь не решается. diff --git a/docs/tasks/items/swagger-ui.md b/docs/tasks/items/swagger-ui.md new file mode 100644 index 0000000..65f2d35 --- /dev/null +++ b/docs/tasks/items/swagger-ui.md @@ -0,0 +1,30 @@ +# Swagger UI без внешней сети + +- **Секция:** ядро +- **Зачем:** Контракт читается машиной, но человеку нечем ткнуть в живой сервис, а внешних CDN в локальной сети нет +- **Теги:** goal:read-api, sprint:2026-08-04 + +Человек открывает UI на отдельном пути и выполняет запрос к живому сервису — не +имея интернета. + +Сервис живёт в локальной сети и на VPS без гарантии выхода наружу, поэтому +статика отдаётся самим сервисом и лежит в бинаре: внешние CDN здесь означают +«работает, пока работает чужой сайт». + +**UI — новый адресат недоверенного входа наизнанку:** он даёт человеку в один +клик дёрнуть любой описанный маршрут, включая приём. Права на запись из +браузера не должны появляться сами собой — это ровно та граница, которую +описывает `docs/security.md`. + +## Критерии приёмки + +- UI открывается и выполняет запрос при отключённой внешней сети — оракул: + запуск контейнера без доступа наружу +- бинарь работает без каталога со статикой рядом — оракул: запуск одного файла + из пустого каталога +- маршрут приёма из UI не вызывается без явного токена приёма — оракул: тест + +## Рамки + +Схема не трогается. Берётся после спеки. Наружу ничего не выкладывается — это +решение человека и отдельная задача деплоя.