## Context Каталог (`GET /api/v1/metrics`) отвечает, **что** лежит в витрине. Значений он не отдаёт: «вес за год» сегодня достаётся только `sqlite3` на хосте. Что уже решено и берётся, а не выбирается заново: - **Форма провода принадлежит транспорту** — [ADR-2026-08-04](../../../../docs/adr/ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md). Типы с `json`-тегами живут в `internal/httpapi`, перевод — присваивание поле в поле, доменные типы до сериализации не доезжают. Новый маршрут копирует образец `internal/httpapi/catalog.go` и добавляет строку в таблицу `wire_internal_test.go`. - **Пустая коллекция — `[]`, отсутствующее значение — `null`** (`openspec/specs/read-api/spec.md`). - **Род агрегации измеряется, а не объявляется** (`openspec/specs/catalog/spec.md`): сверка минутного слоя с часовым по окну в `catalog.Window` = 48 самых свежих **общих** часов, порог `MinAgreeing` = 3, единогласие. Правило живёт в `catalog.Measure` и здесь не повторяется. - **Метка ответа строится из всего, от чего ответ зависит** — версии витрины **и горизонта измерения**, огрублённого до часа (`catalog.stamp`). Механизм берётся тот же, второго экземпляра не заводится. - **Версия ответа снимается двумя пробами вокруг чтения** (`store.VersionedRead`), а согласованность самого тела держится **транзакцией чтения**, а не пробами. Ограничения, из которых растут решения ниже: - `bucket` объявлена `WITHOUT ROWID` с ключом `(metric, layer, hour_utc)`, то есть сжатый `payload` лежит в дереве ключа: любой запрос, читающий строки ради учётных колонок, тащит содержимое. Для этого и заведён покрывающий индекс `bucket_catalog (metric, layer, hour_utc, first_ts, last_ts, points, units)` — в нём есть и **точные границы точек объекта**, что решает вопрос охвата ниже. - Точки хранятся дословно, значения наружу уходят сырым JSON. - Ответ ничем не ограничен по размеру: предел — соседняя задача `read-api-response-limit`. Здесь цена **измеряется и называется числом**, но потолок не вводится. ## Goals / Non-Goals **Goals:** - Одним запросом получить все точки метрики за период без доступа к файлу базы. - Конверт самоописателен: из него видно слой, род свёртки, **его применимость к отданному ряду** и границу окна, в котором род измерен. - Форма конверта объявлена так, чтобы соседние задачи (свёртка по сетке, порог неполного ведра, условный запрос, предел ответа) **заполняли** её поля, а не меняли форму. **Non-Goals:** - Свёртка по сетке (`bucket`), порог неполного ведра, предел размера ответа, разбор `If-None-Match` и ответ `304`, пагинация — соседние задачи спринта. - Тренировки, записи, MCP — свои задачи, копирующие этот же образец. - Схема базы и миграции: назначенный номер `00012` не понадобился. ## Decisions ### Решение 1. Род свёртки объявляется в конверте самого ответа, а не оставляется каталогу **Взято:** конверт ответа несёт объект `aggregation` с измеренным родом и границей окна измерения — при том, что тот же род уже отдаёт каталог. **Prior art.** - **Google Cloud Monitoring** объявляет `metricKind` (`GAUGE`/`DELTA`/`CUMULATIVE`) и `valueType` **в каждом объекте `TimeSeries`** ответа с данными, а не только в дескрипторе метрики. Взято прямо: род едет вместе с данными, второй запрос за смыслом числа не нужен. - **Prometheus** делает наоборот: `/api/v1/query_range` отдаёт `{resultType, result}` без единого слова о типе метрики и о разрешении, а тип живёт в отдельном `/api/v1/metadata`. **Отвергнуто:** клиент обязан сделать второй запрос, а до тех пор не отличает «род известен» от «род не измерен». - **Graphite render API** отдаёт `{target, datapoints}` и не объявляет вообще ничего. **Отвергнуто** по той же причине, что и Prometheus. - **HealthKit `HKStatistics`** возвращает `nil` на свёртку, не отвечающую стилю метрики. Принцип взят (род не тот — свёртки нет), механизм неприменим: у нас стиль не объявлен источником. - **Home Assistant** `statistics_during_period`: набор полей зависит от запрошенных `types`, но `start` и `end` присутствуют **всегда**. Взято: конверт не выражает исход наличием или отсутствием поля. **Почему не «клиент сходит в каталог».** Каталог отвечает по всем метрикам сразу и стоит 45 мс на живом корпусе; агент, читающий одну метрику, платил бы за все. И главное — род есть функция **окна**, а окно едет: между запросом каталога и запросом точек род способен смениться без единой доставки за спрошенный период. Два запроса дали бы клиенту согласованность, которой нет. ### Решение 2. `aggregation` — объект `{style, applicable, last_hour}` `docs/architecture.md` обещал в конверте точек `"aggregation": "sum"` — строку с **применённой** свёрткой. Этого мало: инвариант требует, чтобы клиент видел не только применённое, но и **на каком основании** применять было можно. ```json "aggregation": {"style": "instant", "applicable": true, "last_hour": "2026-08-02T14:00:00Z"} ``` - `style` — измеренный род; имя и словарь те же, что у каталога, потому что смысл тот же. Второе имя для того же понятия развело бы два маршрута молча. - `applicable` — применим ли объявленный род к **отданному ряду**. Поле заведено находкой ревью и закрывает разрыв, который иначе стоил бы потребителю завышения втрое: род — свойство **метрики**, слой — свойство **ряда**, и конверт `{"layer": "raw", "style": "cumulative"}` законен, штатен и прямо приглашает агента сложить интерполяцию самому. Инвариант «нижний слой HAE не суммируется никогда» система соблюдает, ничего не складывая, — но потребитель об инварианте не знает, а `applicable: false` ему об этом говорит. Prior art формы — **CloudWatch `GetMetricData`**, где `MetricDataResult` несёт `StatusCode` (`Complete` / `PartialData`): оговорка едет вместе с данными, а не оставляется клиенту на вывод. - `last_hour` — **ярлык самого свежего общего часа окна измерения**, `null` при пустом окне. Это то самое поле, ради которого задача существует: окно измеряется в общих часах, а не в часах календаря, и при выключенной минутной автоматизации HAE оно замирает, продолжая объявлять род. **Поле `applied` отвергнуто** (архитектурный проход, `Действие: развилка` — закрыто здесь). Оно детерминированно выводится из `style` и `bucket` тем же инвариантом, ради которого существует `applicable`; в этом изменении оно всегда `null`; и как **строка** оно к тому же неверно описало бы свёртку мгновенной метрики, которая по архитектуре есть «среднее с `min`/`max` рядом», а не одна операция. Имя применённой свёртки называет задача, которая её применяет. **Почему только `last_hour`, а не всё основание каталога.** `hours`, `compared`, `agreeing`, `conflicting`, `first_hour` в конверте точек не повторяются: полное основание принадлежит каталогу и живёт там в одном экземпляре. Клиенту точек нужен ответ на вопрос «насколько свежо то, на чём объявлен род», и это одно число. Цена названа: чтобы разобрать *почему* род `unknown`, придётся спросить каталог. **Плоскость против вложенности.** Объект, а не три плоских поля: у каталога `aggregation` уже объект, и разная форма одного понятия на двух маршрутах — та же ошибка, что разные имена. ### Решение 3. Слой выбирается по охвату **точек** внутри периода `docs/architecture.md` формулировал правило как «самый мелкий слой, покрывающий весь запрошенный диапазон» и тут же оговаривал, что границы слоя — границы **данных**, а не обещание покрытия: внутри диапазона законно есть дыры, и слой, покрывающий диапазон целиком, может не существовать вовсе. Взято правило, определённое на любом входе: > Охват слоя — длина пересечения отрезка `[первая метка слоя, последняя метка > слоя]` с запрошенным периодом. Слой с пустым пересечением выбывает. Среди > оставшихся берётся слой с наибольшим охватом, при равенстве — самый мелкий > (порядок `sample` → `raw` → `minute` → `hour` → `day`). Почему охват, а не число часов с объектами: `body_mass` в нижнем слое за три плотных дня даёт больше объектов, чем часовой слой за год с еженедельным взвешиванием, — и «вес за год» вернул бы три дня, не сказав об этом ни словом. **Почему охват меряется метками точек, а не часами объектов** — находка ревью, и она стоила бы пустого ответа на непустых данных. Объекты адресуются часом, а ряд отбирается точной меткой; выборка объектов **обязана** быть шире запроса (точка `10:59` живёт в объекте `10:00`). На периоде `[10:30, 10:45)` слой `hour` имеет объект `10:00` с единственной точкой в `10:00`, слой `minute` — объект `10:00` с точками `10:31…10:44`. По часам объектов охваты равны, побеждает `hour` — и после точного отбора ответ уходит пустым при непустых минутных данных. По меткам точек `hour` выбывает сразу. Цена мере названа: границы `first_ts`/`last_ts` — свойства **объекта**, поэтому краевой объект, у которого есть точки и до, и после периода, но ни одной внутри, свой слой из выбора не выведет. Остаток узкий и честный: слой в ответе назван, а `points` пуст. Мера почти ничего не стоит, и «почти» здесь измерено. `first_ts` и `last_ts` лежат в покрывающем индексе `bucket_catalog`, то есть содержимое объектов не читается вовсе. Но покрывающий индекс сам по себе цену не ограничивает: индекс идёт `(metric, layer, hour_utc, …)`, и **без предиката по слою** SQLite не сужает поиск по `hour_utc` — он просматривает все строки метрики за всю историю, применяя период построчным фильтром, а план при этом выглядит успешным (`SEARCH … USING COVERING INDEX`). Поймано эксплуатационным проходом ревью и измерено на копии схемы: 2.06 мс при 52 560 строках метрики против 13.9 мс при 350 400 — то есть цена росла бы вместе с возрастом сервиса при любой ширине запроса. Поэтому слои перечислены в запросе явно, словарём из домена: план становится `(metric=? AND layer=? AND hour_utc>? AND hour_utc= to` — `400`. - **`bucket` отвергается `400`, а не игнорируется.** Архитектура обещает этот параметр, реализует его соседняя задача; молчаливое игнорирование отдало бы клиенту, попросившему суточную сетку, полный минутный ряд — зеркало ровно того промаха, ради которого архитектура различает «сетка задана явно» как защиту. Прочие незнакомые параметры игнорируются, как принято в HTTP. - **Принадлежность интервальной точки периоду — по началу.** То же правило, каким час объекта берётся по началу точки. Цена названа вслух: «сон за ночь с полуночи» не увидит эпизод, начавшийся в 23:40. Альтернатива — отбор по пересечению `[ts, ts_end)` с периодом — требует сканировать объекты назад на неизвестную глубину: длительность эпизода ничем не ограничена, а индекса по концу координаты нет. Развилка вынесена вопросом владельцу (см. ниже), работа доведена на остаток. ### Решение 5. Точка на проводе — `{ts, ts_end, tz_offset, units, values}` `values` — **сырой JSON точки, как её прислал HAE**, без переименований и пересчётов: прямое следствие инварианта «форма Apple не транслируется» и единственное исключение сторожа графа типов (`json.RawMessage`). **Дословность требует выключить HTML-экранирование сериализатора** — находка ревью с прогнанным оракулом: `encoding/json` по умолчанию превращает `&`, `<`, `>` в `&`, `<`, `>`, а имя источника приходит с телефона пользовательской строкой и законно содержит `&`. Хранилище этот капкан уже проходило и обезвредило тем же способом (`store.encodePayload` — кодировщик с `SetEscapeHTML(false)` вместо `json.Marshal`). Правка идёт в общий `writeJSON`, поэтому нормируется не здесь, а в `read-api`: механизм один на все читающие маршруты, и решать его заново каждому — тот же второй способ. Фикстуры `testdata` символов `&<>` не содержат вовсе, то есть проверка на них зелена и будучи сломанной — случай заводится отдельным входом. `ts_end` добавлен к обещанной архитектурой форме: идентичность точки — координаты `(метрика, слой, начало, конец)`, под одной меткой `date` лежит до трёх записей сна (замер: 174 координаты против 170 по метке). Конверт с одним `ts` отдал бы три точки с одинаковой меткой и предложил бы различать их, копаясь в дословном содержимом, — нормализованный слой ответа терял бы то, что хранилище хранит. У точки-измерения `ts_end` равен `ts`. `units` и `tz_offset` лежат **на точке**: единицы хранятся на часовом объекте, и метрика, чьи объекты разошлись единицами, обязана показать это строкой, а не выбрать одно из двух молча; смещение зоны — собственное свойство точки. Порядок точек — по `(ts, ts_end)` возрастанию. Он однозначен не по соглашению, а по построению: пара `(начало, конец)` внутри слоя одной метрики есть ключ идентичности, двух точек с равной парой в витрине не существует. **Отвергнуто: класть в конверт охват отданного ряда** (предложение прохода `rubric`). Первая и последняя метка ряда вычислимы клиентом из самих точек, а поле, вычислимое из соседнего поля того же ответа, — вторая копия факта, обязанная с ним сходиться. Пустой ряд при непустом `layer` эту же историю рассказывает сам. ### Решение 6. Метрика без данных за период — `200`, а не `404` `points: []`; `layer` — `null`, только если выбирала система. Так отвечают и CloudWatch, и Prometheus: «нет данных за окно» — не «нет такого ресурса». Отличать опечатку в имени метрики от честной пустоты — работа каталога; `404` здесь означал бы, что маршрут знает список метрик, а он его не знает и знать не должен (имя приходит из тела доставки дословно). Имя берётся из пути после процентного декодирования. **Метрика с пустым именем маршрутом недостижима** — путь её не выражает, а каталог её показывает намеренно. Цена названа, а не замолчана: адресация именем в пути этого случая не покрывает, и лечится он не здесь. ### Решение 7. Use-case живёт в новом пакете `internal/points` Каталог отвечает на вопрос «что у тебя есть», ряд точек — на вопрос «дай значения». Смешивать их в `internal/catalog` значило бы получить пакет с двумя несвязанными сборками ответа; смешивать с `internal/store` — вернуть правило выбора слоя в хранилище, откуда его специально убирали. Имя пакета и имя capability совпадают — `points` (архитектурный проход: «одно понятие — два новых имени» дороже спора сейчас; в проекте пакет и capability сходятся по имени или очевидной паре). `internal/points` зависит от `store` (одна выборка), от `catalog` (`Measure`, `Horizon`, `Stamp` — второй экземпляр правила измерения или правила метки был бы прямым нарушением инварианта) и от `hae` (словарь слоёв). **Область действия метки живёт в транспорте**, рядом с `etag` и `scopeMetrics` каталога, а не в домене: у одного понятия иначе оказалось бы два дома, и три следующих маршрута выбирали бы между ними монетой. Домен отдаёт только версию ответа — ровно как каталог. Форма метки (префикс, hex) есть форма провода, а её объявляет транспорт (ADR). **Словарь слоёв один — `hae.Layers`.** Из него выводятся и порядок (`Rank` — индекс), и перечень слоёв для выборки охватов, и проверка параметра запроса, и текст отказа клиенту. Четыре списка не сверял бы ни компилятор, ни тест: новая константа слоя скомпилировалась бы, получила ранг «крупнее всех», не попала бы в выборку охватов и отвергалась бы маршрутом как незнакомая — а симптомом был бы пустой ряд при непустых данных. Случай не гипотетический: слой `sample` наполнится импортом родного экспорта Apple. **Граница с соседней задачей названа явно.** Отсюда уезжает `ETag` с областью действия, включающей канонизированную форму запроса **и горизонт измерения**, и `Cache-Control: private, no-cache`. Разбор `If-None-Match` и ответ `304` остаются задаче `read-api-points-conditional`. Горизонт в метке — находка трёх проходов ревью сразу: без него первый же `304` соседней задачи подтвердил бы клиенту ответ, чей род уже перевернулся ходом часов, без единого коммита. ### Решение 8. Один вход в хранилище, одна транзакция чтения, миграции нет Ответ снимается **одной транзакцией чтения** — как у каталога, где это нормировано отдельным требованием. Пара проб версии противоречивое тело обнаруживает, но не предотвращает: она снимает метку, а тело всё равно уезжает. Поэтому вход один: `store.ReadSeries(ctx, window, pick)`. Правило выбора слоя при этом **остаётся в домене**: хранилище получает его функцией-параметром `pick([]LayerSpan) string`, вызываемой внутри транзакции. Форма не изобретена — `store.VersionedRead` уже принимает работу колбэком, а `store.ReadCatalog` уже получает параметры правила структурой `CatalogWindow`. Альтернатива «три метода домена под одной `VersionedRead`» отвергнута находкой ревью; альтернатива «перенести правило в SQL» отвергнута тем же доводом, каким измерение рода живёт в домене, а не в хранилище. Внутри транзакции: 1. **Охваты слоёв** — `SELECT layer, min(first_ts), max(last_ts) FROM bucket WHERE metric = ? AND layer IN (…) AND hour_utc BETWEEN ? AND ? GROUP BY layer`. Отвечает по покрывающему `bucket_catalog`, содержимого не касается; словарь слоёв приходит из домена, и пустой словарь — отказ, а не пустой ответ: молчаливая деградация цены хуже отказа. 2. **Точки выбранного слоя** — `SELECT hour_utc, units, payload FROM bucket WHERE metric = ? AND layer = ? AND hour_utc BETWEEN ? AND ? ORDER BY hour_utc`. Точный префикс первичного ключа; `payload` здесь и нужен. 3. **Окно измерения** — существующие `commonHours` и `readHourPairs` по одной метрике; второго правила отбора не заводится. Границы по часам берутся **шире запроса** — `[trunc(from), trunc(to)]`: точка `10:59` живёт в объекте `10:00`. Отбор до точной границы `[from, to)` делается по меткам точек, после разжатия. Схема не трогается: назначенный номер миграции `00012` не израсходован. ## Risks / Trade-offs - **Ответ не ограничен ничем, а правило выбора слоя размер не минимизирует, а максимизирует** (при равном охвате берётся самый мелкий слой) → предел вводит соседняя задача `read-api-response-limit`; здесь цена **измеряется на трёх режимах, включая худший** (см. `tasks.md`, шаг 5.3), а не оценивается на глаз. Инверсия умолчания («при равном охвате брать самый крупный») — развилка, вынесенная вопросом: она меняет правило, записанное в `docs/architecture.md` до этой задачи, и сцеплена с соседней задачей о пределе. - **Полный ряд собирается в памяти целиком** (`[]SeriesPoint` → `[]Point` → `[]pointWire` + буфер энкодера) → потоковая выдача (NDJSON) — отдельная задача беклога `ndjson-stream`; пока предел не введён, потоковая выдача сняла бы симптом и спрятала причину. Цена измерена дважды и обе цифры названы: на доменном слое неделя нижнего слоя (604 800 точек) стоит 1.64 с и 1375 МиБ суммарных выделений, **под HTTP вместе с сериализацией** — 2.89 с, 279.7 МиБ тела и 1335 МиБ живой кучи; четыре одновременных запроса дают 4322 МиБ. - **Транзакция чтения держится всё время сборки ряда**, а собственного бюджета у маршрута нет: `WriteTimeout` сервера контекст обработчика не отменяет (измерено эксплуатационным проходом). Долгий читатель не даёт продвинуться пассивному чекпойнту WAL — механизм измерен проектом раньше (51 МБ при лимите 8 МиБ). Собственный дедлайн маршрута рамками этой задачи **исключён**: он принадлежит задаче «Остановка и миграция» вместе с `BaseContext`. Записано вопросом, а не замолчано. - **Интервальная точка, начавшаяся до `from`, теряется** → названо в спеке, вынесено вопросом владельцу; альтернатива требует неограниченного сканирования назад или индекса по концу координаты. - **Краевой объект без точек внутри периода может увести выбор слоя** → остаток меры охвата, назван выше; наблюдаемый исход честен (`layer` назван, ряд пуст). - **Выключение HTML-экранирования меняет байты всех ответов**, а не только точек → в существующих телах (каталог, учёт приёма) этих символов не бывает по форме данных, но изменение нормировано в `read-api` и покрыто входом с `&<>`. - **Род в конверте точек и род в каталоге считаются в разные моменты** и могут разойтись у клиента, сравнивающего два ответа → это свойство измерения, а не дефект: род есть функция окна. Ровно поэтому `last_hour` едет вместе с родом. - **Опечатка в имени метрики неотличима от пустого периода** → смягчено `layer: null`; полностью лечится каталогом. ## Open Questions Записано здесь, а не только в файле задачи: файл закрытой задачи удаляется. - **Инвертировать ли умолчание выбора слоя при равном охвате.** Сегодня берётся самый мелкий — это правило `docs/architecture.md` до этой задачи, и оно максимизирует размер ответа («пульс за неделю» в `raw` — сотни тысяч точек). (а) оставить как есть, предел вводит `read-api-response-limit`; (б) при равном охвате брать самый **крупный**, мелкий — только по явному `layer`. **Рекомендация:** (а) до тех пор, пока замер не покажет, что цена худшего режима неприемлема; решение сцеплено с формой предела и принимать его мимоходом на первой ручке — то же, от чего отказались на каталоге. - **Отбирать ли интервальные точки по пересечению с периодом.** Сегодня отбор по началу, и «сон за ночь» теряет эпизод, начавшийся до полуночи. (а) оставить (ноль стоимости, названная потеря); (б) сканировать объекты назад на фиксированную глубину (появляется магическое число «максимальная длительность эпизода»); (в) индекс по концу координаты (миграция, рост витрины). **Рекомендация:** (а) сейчас, (в) — когда появится сценарий сна как отдельная задача; (б) отвергнуть: магическое число молча теряет длинные эпизоды. - **Машинно-различимый код причины отказа.** Сегодня тело отказа несёт только человекочитаемую строку, и агент не отличит «зона не указана» от «слой незнаком» иначе, чем разбором русского текста. Правило общее для всех маршрутов и меняет `errorWire`, то есть и контракт приёма; сюда не взято. - **Обещание гейта расходится с его кодом.** `CLAUDE.md` объявляет, что непокрытая изменённая строка красит гейт безусловно; `scripts/diff-coverage.py` всегда возвращает `0`, и шаг печатает `OK` при любом покрытии (найдено проходом `gate`, подтверждено триажем). Текущий прогон — живая демонстрация: 30 непокрытых строк при зелёном гейте. Чинить это здесь нельзя: починка скрипта немедленно красит гейт **этой** задачи, то есть решение о том, что именно обещано — полное покрытие, порог или ничего, — принимает владелец. - **Метка года 10000 (унаследовано, вне рамок).** Одна принятая доставка с датой `9999-12-31 23:00:00 -0700` роняет каталог в `500`: `store.FormatTime` даёт `10000-01-01T06:00:00Z`, который `ParseTime` уже не разбирает (прогнано). Маршрут точек наследует **тот же** путь разбора времени (`ParseTime` при разжатии `payload` и при чтении границ), то есть та же доставка уронит и его. Чинить здесь нельзя: дефект лежит в общем слое времени и задевает свёртку и пересборку. **Свою половину задача закрыла:** граница запроса, уезжающая за четырёхзначный год, теперь отвергается `400`, а не отдаёт молча пустой ряд (найдено триажем). Осталась половина на стороне приёма — доставка с такой меткой в теле.