tasks: закрыт разбор и хранилище, начат спринт по чтению данных клиентами

- цель parsing-and-storage закрыта по своему критерию; незакрываемый остаток
  (новые формы от источника, ручные секции задним числом) переехал в тему
  parsing-completeness
- цель mcp поглощена целью read-api, переименованной в «Чтение данных
  клиентами»: адаптер — последний шаг того же направления, а не своё
- read-api-points разложена на конверт с точками, свёртку по сетке и
  тренировки с записями; спринт 2026-08-04 набран пятью задачами
This commit is contained in:
av
2026-08-04 14:01:38 +03:00
parent 637eb38bce
commit 79331ac670
17 changed files with 276 additions and 144 deletions
@@ -2,7 +2,7 @@
- **Секция:** ядро
- **Зачем:** Тренировка с меткой в неизвестном формате пропадает целиком, а после включения ретеншена окно становится необратимым
- **Теги:** goal:parsing-and-storage
- **Теги:** goal:parsing-completeness
**Решение принято владельцем 2026-08-03: вариант (1) — хранить с NULL-меткой.**
`start_utc`/`ts_utc` становятся NULLABLE, содержимое (включая маршрут) хранится,
@@ -10,7 +10,7 @@
отвергнут при постановке: подстановка метки доставки — выдуманное измерение в
колонке, по которой идёт выборка.
**Берётся после [Read API по точкам и сущностям](read-api-points.md).** Правило
**Берётся после [тренировок и записей наружу](read-api-workouts-and-records.md).** Правило
чтения — что выборка «за период» делает со строками без метки — обязано
проектироваться вместе с читателем, иначе такие строки молча исчезнут из любого
ответа. Порядок тот же, что у [journal-order-on-ingest](journal-order-on-ingest.md)
+23 -2
View File
@@ -2,7 +2,7 @@
- **Секция:** ядро
- **Зачем:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- **Теги:** goal:mcp
- **Теги:** goal:read-api
Агент-медик — первый заказчик проекта. Ему нужна актуальная сводка, а не срез
на дату последнего ручного экспорта.
@@ -21,4 +21,25 @@ MCP не даёт ничего, чего не даёт HTTP, и права об
Готово, когда агент подключается по URL и отвечает на «как я спал на прошлой
неделе» без промежуточного кода.
Связано: `docs/architecture.md` → «MCP», план → шаг «MCP».
## Критерии приёмки
- живой агент подключается по URL и отвечает на «как я спал на прошлой неделе»
без промежуточного кода — оракул: подключение реального MCP-клиента к
поднятому сервису
- вызов инструмента и соответствующий HTTP-запрос дают одни и те же данные —
оракул: тест, сравнивающий выход инструмента с ответом маршрута на тех же
параметрах
- запрос без токена чтения отклоняется обоими транспортами одинаково — оракул:
тест на паре «MCP без токена / HTTP без токена»
- правило размера ответа действует и в MCP: слишком широкий запрос получает
названную сетку или ошибку со списком, а не обрезанный ответ — оракул: тест на
запросе за пределом
## Рамки
Схема не трогается, данные только читаются, сервис перезапускается. Собственной
логики адаптер не несёт — новое поведение здесь признак того, что оно должно
было появиться в маршруте чтения. Берётся последней в цели: переводить нечего,
пока обработчиков нет.
Связано: `docs/architecture.md` → «MCP».
-18
View File
@@ -1,18 +0,0 @@
# [goal] MCP
- **Секция:** порядок
- **Зачем:** Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- **Теги:** decomposed
Агент-медик — первый заказчик проекта — подключается к хранилищу.
Выведена из шага 7 плана. Идёт после Read API намеренно: адаптер собственной
логики не несёт, он переводит вызовы в те же обработчики, и переводить пока
нечего.
Завершена, когда агент читает данные через MCP тем же токеном чтения.
## Завершение
Агент читает данные через MCP тем же токеном чтения, и собственной логики
адаптер не несёт.
@@ -2,7 +2,7 @@
- **Секция:** ядро
- **Зачем:** Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит
- **Теги:** goal:parsing-and-storage
- **Теги:** goal:parsing-completeness
Окно досчёта не единое, и это измеренное различие, а не предположение.
Количественные метрики (пульс, шаги, энергия) человек руками не правит — они
+1 -1
View File
@@ -19,4 +19,4 @@ Read API отдаёт ответ одним JSON. Для выборок нижн
последовательно или с возвратами.
Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача
`read-api-points`.
`read-api-bucketing` (правило размера ответа проектируется там).
+24 -2
View File
@@ -18,8 +18,30 @@
он должен работать в локальной сети без интернета);
- проверка актуальности спеки в гейте: контракт разъезжается молча.
**Решено владельцем 2026-08-04: спека пишется руками и она источник истины.**
Для API из шести ручек это честнее вывода из кода: контракт проектируется, а не
фотографируется с того, что вышло, — опечатка в имени поля иначе становится
частью спеки. Совпадает с тем, как в проекте уже устроен OpenSpec: спека
первична к коду. Плата названа: спека расходится с кодом молча, и именно поэтому
проверка её актуальности идёт в гейт третьим шагом, а не остаётся регламентом.
Готово, когда по спеке можно сгенерировать клиент, а Swagger UI открывается
локально и выполняет запрос к живому сервису.
Развилка на решение: спека пишется руками как источник истины или выводится из
кода. Для маленького API рукописная спека честнее — но это стоит обсудить.
## Критерии приёмки
- по спеке генерируется клиент, и сгенерированный клиент выполняет запрос к
живому сервису — оракул: прогон генератора плюс запрос сгенерированным
клиентом
- Swagger UI открывается и выполняет запрос **без внешней сети** — оракул:
запуск с отключённым интернетом
- маршрут, разошедшийся со спекой, красит гейт — оракул: намеренно
рассогласованный маршрут в прогоне гейта
- спека покрывает приём, каталог, точки, тренировки, записи и `/stats`
оракул: сверка перечня путей спеки с таблицей маршрутов в `architecture.md`
## Рамки
Схема не трогается, данные только читаются, сервис перезапускается. Берётся
после того, как маршруты чтения существуют: спека рукописная, но описывать
нечего, пока конверт не задан.
-20
View File
@@ -1,20 +0,0 @@
# [goal] Разбор и хранилище
- **Секция:** порядок
- **Зачем:** Секции живого потока разобраны все, кроме словаря категориальных значений и пяти невиденных
- **Теги:** decomposed
Метрики, тренировки и записи со своими `id` разбираются и ложатся в часовые
объекты; тела перестали быть недифференцированной кучей.
Выведена из шага 3 плана. Сделано: разбор метрик в объекты, тренировки и
записи, `reindex`. Осталось: словарь категориальных значений и секции, которых
поток ещё не приносил.
Завершена, когда ни одна секция живого потока не числится неразобранной, а
категориальные значения имеют стабильный код рядом с переведённой строкой.
## Завершение
Ни одна секция живого потока не числится неразобранной, а категориальные
значения несут стабильный код рядом с переведённой строкой.
+22
View File
@@ -0,0 +1,22 @@
# [goal] Полнота разбора потока
- **Секция:** темы
- **Зачем:** Пять секций HAE поток ещё не приносил, а ручные секции заводятся задним числом — разбор полон ровно до следующей новой формы
Тема: всё, что приезжает от источника, разобрано и доехало до витрины — не
только сегодня, но и после того, как источник изменится.
Выделена из цели «Разбор и хранилище», когда та достигла своего критерия
завершения: секции живого потока разобраны, категориальные значения несут
стабильный код. Осталось то, что заканчиваться не умеет по природе — источник
вправе прислать форму, которой раньше не было, а часть секций заводится
человеком задним числом.
В порядок не встаёт: работа приходит от потока, а не от плана. Первая встреча
новой секции наблюдаема (`healthlog uncovered` и `WARN` на свёртке) — тема
кормится этими событиями.
## Завершение
Завершена не бывает — это тема. Закрывается по мере того, как каждая приезжающая
форма доезжает до витрины, а не теряется между «принято» и «разобрано».
+59
View File
@@ -0,0 +1,59 @@
# Свёртка по сетке и предел размера ответа
- **Секция:** ядро
- **Зачем:** Враждебный запрос к каталогу стоил 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) закрывает другую сторону того же предела.
@@ -0,0 +1,56 @@
# Конверт ответа и точки за период
- **Секция:** ядро
- **Зачем:** Точки лежат в витрине и наружу не отдаются: ни один потребитель не может спросить «вес за год» иначе как 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», «Условный запрос», «Измерение рода
агрегации».
-78
View File
@@ -1,78 +0,0 @@
# Read API: точки, выбор слоя, свёртка по сетке
- **Секция:** ядро
- **Зачем:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- **Теги:** goal:read-api
Сейчас данные достаются только `sqlite3` на хосте. Все три сценария —
агент-медик, трекер тренировок, фитнес-игра — упираются в отсутствие чтения.
Формы запроса ровно две, и это один запрос с необязательным параметром:
`?from&to` — все значения за период (вес, лекарства, симптомы), `?from&to&bucket`
— с разбивкой (шаги, энергия).
Решение по размеру ответа (вариант «б»): разбивка не задана и ответ не влезает —
сервер сам берёт сетку погрубее и **называет её в ответе**; разбивка задана явно
и не влезает — ошибка со списком доступных сеток, а не тихая подмена. Различие
существенно: иначе агент, попросивший минутную сетку, получит суточные суммы.
**Отдача тренировок и записей входит сюда же.** Разбор и хранение сущностей с
собственным `id` сделаны (change `2026-08-02-trenirovki-i-zapisi`), а эндпоинтов
нет: тренировка с маршрутом и записи `stateOfMind` лежат в витрине и наружу не
отдаются. Вводить их раньше конверта ответа значило бы задать контракт
мимоходом, поэтому `GET /workouts`, `GET /workouts/{id}` и
`GET /records/{kind}` закрываются этой задачей — вместе с формой конверта и
правилом размера ответа. Второй сценарий паспорта (трекер) до тех пор не закрыт.
Готово, когда «шаги за неделю по дням» и «вес за год» отвечаются одним запросом
каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда
видно `layer`, `bucket` и `aggregation`.
**Порог неполного ведра решается здесь, и вместе с ним — его полярность.**
Каталог и род агрегации сделаны (change `2026-08-02-katalog-i-rod-agregacii`), и
измерению порог заполненности не понадобился: у него две конкурирующие гипотезы,
и неполный час не сходится ни с одной сам собой. Свёртке в ответе он нужен, а
готовые решения задают его **противоположно**: Graphite `xFilesFactor` — доля
обязательно известных точек (умолчание 0.5 при роллапе и 0 при рендере, один
параметр с двумя умолчаниями), RRDtool `xff` — доля допустимо неизвестных. Обе
величины выглядят как «0.5», означая разное; полярность придётся назвать вслух в
`architecture.md`, иначе через полгода два места кода поймут поле по-разному.
**Предел размера ответа тоже здесь, и он унаследовал измеренную цену.** У
каталога предела нет намеренно: правило размера — общее для маршрутов чтения, и
задавать его мимоходом на первой ручке значило бы решить контракт до того, как
известна форма тяжёлого ответа. Каталог станет первым его потребителем.
Цена измерена на каталоге (задача «цена читающего маршрута», закрыта чекпойнтом
WAL и условным запросом): 693 мс и +153 МиБ живой кучи на враждебном запросе
(20 метрик × 8 часов × 5000 точек), при том что приём в том же процессе уже даёт
пик 768 МиБ на теле 40 МиБ. Условный запрос снял повтор, но первый запрос стоит
столько же, а множители «метрики × окно × точки × одновременные запросы»
по-прежнему без потолка. Сюда же уезжают отложенные варианты той задачи:
собственный дедлайн маршрута и потоковое измерение по метрике (второе — только
если счётчик заговорит).
**Машинерия условного запроса готова, и её надо взять, а не написать заново.**
`store.VersionedRead` держит правило «версией, снятой после чтения, не
подписывать»; `httpapi` — разбор `If-None-Match` и `304`. Метка обязана нести
**область действия**: у точек ответ есть функция параметров запроса, и
`etag(scope, version)` требует их канонизированную форму — иначе `304` ответит
на другой набор данных. Детали — `docs/architecture.md`, «Условный запрос».
**Форма провода наследуется от каталога, и это надо решить один раз.** Сегодня
типы `internal/catalog` сами несут json-теги, а транспорт владеет только
обёрткой: переименование поля в домене меняет публичный контракт без касания
`httpapi`. Держит это один байтовый тест непустого ответа. Либо объявить в
`architecture.md`, что типы чтения и есть форма провода для всех транспортов
(HTTP и MCP отдают её байт в байт), либо завести DTO в транспорте — но выбрать до
того, как образец скопирует эта задача.
**Клиент обязан смотреть на границы окна измерения.** Род метрики измерен по
48 самым свежим ОБЩИМ часам, а не по последним 48 часам календаря: если минутная
автоматизация HAE выключена, множество общих часов не пополняется и окно
замирает. Род при этом продолжает объявляться, и единственный след — `last_hour`
в ответе. Правило выбора свёртки в Read API обязано это учитывать (или явно
объявить, что не учитывает).
Связано: `docs/architecture.md` → «Read API», «Измерение рода агрегации»,
план → шаг «Read API».
@@ -0,0 +1,46 @@
# Тренировки и записи наружу
- **Секция:** ядро
- **Зачем:** Тренировки с маршрутами и 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).
+17 -10
View File
@@ -1,19 +1,26 @@
# [goal] Read API
# [goal] Чтение данных клиентами
- **Секция:** порядок
- **Зачем:** Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- **Зачем:** Данные видны только через sqlite на хосте — ни один из трёх потребителей, включая агента-медика, ничего прочитать не может
- **Теги:** decomposed
Потребители читают точки: выбор слоя, свёртка по сетке, предел размера ответа.
Потребители читают данные: точки с выбором слоя и свёрткой по сетке, тренировки
и записи, машиночитаемый контракт — и всё то же самое через MCP.
Выведена из шага 5 плана. Идёт после каталога и рода агрегации намеренно: без
измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться здесь
дорого — просуммировать нижний слой значит завысить втрое.
Выведена из шагов 5 и 7 плана. Идёт после каталога и рода агрегации намеренно:
без измеренного рода свёртка в ответе неотличима от угадывания, а ошибиться
здесь дорого — просуммировать нижний слой значит завысить втрое.
Завершена, когда любой из трёх потребителей получает точки за период без
доступа к файлу базы.
**MCP входит в эту цель, а не идёт отдельной.** Прежде их было две, и разделяла
их очередь: адаптер собственной логики не несёт, он переводит вызовы в те же
обработчики, и переводить было нечего. Очередь никуда не делась — она стала
порядком задач внутри цели, — а вот отдельная цель под адаптер описывала не
направление, а последний шаг этого же направления. Заказчик у обоих транспортов
один: три потребителя, из которых первый — агент.
## Завершение
Любой из трёх потребителей получает точки за период без доступа к файлу базы,
и предел размера ответа объявлен, а не подразумевается.
Любой из трёх потребителей получает точки, тренировки и записи за период без
доступа к файлу базы; предел размера ответа объявлен, а не подразумевается;
агент-медик читает то же самое через MCP тем же токеном чтения, и собственной
логики адаптер не несёт.