httpapi: точки метрики за период отдаются одним запросом

- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
This commit is contained in:
av
2026-08-04 18:46:45 +03:00
parent b819b77f62
commit 29ca8d415c
36 changed files with 4721 additions and 58 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-04
@@ -0,0 +1,417 @@
## 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<?)`, а замер
на том же корпусе — 0.026 мс.
Смены слоя **внутри одного ответа не бывает**: ряд, склеенный из двух слоёв,
поехал бы незаметно для клиента, а вместе с ним поехала бы и будущая свёртка.
Явный `layer` отменяет правило целиком и **всегда уезжает в ответе** — в том
числе при пустом ряде: клиент, спросивший разрез поимённо, обязан отличать «за
период этого разреза нет» от «параметр проигнорирован». `layer: null` остаётся
исключительно за случаем «выбирала система, и выбирать было не из чего».
**Prior art:** **Netdata** объявляет в конверте `update_every` (разрешение
хранения) отдельно от `view_update_every` (разрешение выдачи) и рядом
`first_entry`/`last_entry` — границы того, что вообще есть в базе. Взято
разделение: `layer` (что лежит) и `bucket` (что сделано в ответе) — разные поля,
а не одно. Не взяты `first_entry`/`last_entry` в конверт точек: границы данных
метрики отдаёт каталог, а границы **отданного ряда** клиент читает по первой и
последней точке — второй их экземпляр в конверте был бы полем, вычислимым из
соседнего поля того же ответа.
### Решение 4. Период — `[from, to)`, RFC 3339, оба параметра обязательны, `bucket` отвергается
- **Полуинтервал.** Соседние окна склеиваются без двойного счёта. Prometheus
`query_range` включает оба конца, CloudWatch `GetMetricData` — левый
включительно, правый исключительно; взят второй.
- **Только RFC 3339 с явной зоной.** Голая дата (`2026-01-01`) отвергается с
`400`: у неё нет зоны, а вопрос «в какой зоне считать сутки» в проекте открыт
отдельной задачей (`day-boundary-timezone`). Принять голую дату значило бы
**материализовать нерешённое** — молча выбрать зону за клиента.
- **Оба обязательны.** Умолчание «весь год» было бы ответом, размера которого
клиент не заказывал, а предела ответа ещё нет.
- `from >= 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`, а не отдаёт молча пустой ряд
(найдено триажем). Осталась половина на стороне приёма — доставка с такой
меткой в теле.
@@ -0,0 +1,73 @@
## Why
Точки лежат в витрине и наружу не отдаются: «вес за год» достаётся только
`sqlite3` на хосте. Каталог уже отвечает, **что** есть, — но ни один из трёх
потребителей не может получить сами значения.
Ответ обязан быть самоописательным. Метрика лежит сразу в нескольких слоях
подробности, а род её свёртки не объявлен источником, а **измерен** окном в 48
самых свежих **общих** часов — окном, которое замирает при выключенной минутной
автоматизации HAE и продолжает объявлять род. Клиент, не видящий ни слоя, ни
границы этого окна, принимает решение по числу, происхождения которого не знает.
## What Changes
- Новый читающий маршрут `GET /api/v1/metrics/{name}?from&to&layer` — все точки
метрики за период, одним запросом, без доступа к файлу базы.
- Конверт ответа объявляет **фактические** параметры выдачи: `metric`, `from`,
`to`, `layer`, `bucket`, `aggregation` (род, его применимость к отданному ряду
и граница окна измерения), `points`. Поля присутствуют всегда — клиент не
выводит их наличием или отсутствием.
- Слой выбирается правилом, а не молча: параметр `layer` задаёт разрез явно, без
него берётся слой с наибольшим охватом **точек** внутри запрошенного периода,
при равенстве — самый мелкий. Смены слоя внутри одного ответа не бывает.
- Род свёртки в конверте — тот же измеренный `catalog.Style`, что у каталога, и
измеряется он тем же правилом и тем же окном. Род неизвестен — так и сказано
словом `unknown`, а не молчанием. Рядом едет применимость: `cumulative` на
нижнем слое HAE объявляется неприменимым, иначе конверт приглашает потребителя
сложить интерполяцию и завысить втрое.
- Свёртка в этом изменении **не выполняется**: `bucket` всегда `null`, а
присутствие параметра `bucket` в запросе отвергается `400`, а не игнорируется
молча.
- Ответ снимается **одной транзакцией чтения**, а метка ответа включает
канонизированную форму запроса **и горизонт измерения** — иначе соседняя
задача условного запроса подтвердит `304` на сменившемся роде.
- Форма провода — по образцу каталога (ADR
`ADR-2026-08-04-forma-provoda-prinadlezhit-transportu`): типы `*Wire` в
`internal/httpapi`, перевод присваиванием, строка в таблице образцов
`wire_internal_test.go`.
Схема базы не трогается: обе выборки отвечают по существующим ключу и
покрывающему индексу `bucket_catalog`.
## Capabilities
### New Capabilities
- `points`: точки метрики за период — форма запроса и его разбор, правило выбора
слоя, состав конверта ответа, объявление измеренного рода вместе с границей
окна измерения, дословность значений точки.
### Modified Capabilities
- `read-api`: добавляются два общих правила читающих маршрутов — сериализация
ответа не экранирует содержимое (иначе `&` внутри дословно сохранённой точки
уезжает как `&`) и ответ чтения помечается непригодным для разделяемого
кеша. Оба механизма общие (`writeJSON`, `setReadHeaders`), и решать их заново
на каждом маршруте — тот же второй способ.
`catalog` (правило измерения рода) применяется новым маршрутом без изменения его
требований.
## Impact
- `internal/httpapi` — маршрут, разбор параметров, форма провода точек,
выключение HTML-экранирования в общем `writeJSON`.
- `internal/points` (новый) — use-case «точки метрики за период»: выбор слоя,
сборка ряда, измерение рода и метка ответа по одной метрике.
- `internal/store` — один вход чтения ряда (`ReadSeries`) в одной транзакции:
охваты слоёв в периоде, точки выбранного слоя, объекты окна измерения.
- `docs/architecture.md` — разделы «Read API» (форма ответа, правило выбора
слоя) и таблица компонентов.
- Потребители: HTTP-клиенты; следом этот же конверт копируют свёртка по сетке,
условный запрос по точкам, тренировки, записи и MCP.
@@ -0,0 +1,268 @@
# Триаж ревью: точки метрики за период
## Сводка
- **Профиль:** `deep`, режим прогона — по графу. Гейт **зелёный** (383
изменённые исполняемые строки, 30 не покрыто, 92% — но см. находку 3: шаг
покрытия не красит по построению).
- **Расхождения состава прогона с профилем нет** (сверено с `docs/review.md`:
`deep` = 6 обязательных проходов + `reimpl` по триггеру + `rubric` только в
`design`).
**Проходы поимённо с исходом:**
Фаза `design` (до кода):
- `review-specs` (режим «дизайн до кода») — отработал, находки ушли правкой спек;
- `review-rubric` (фаза 1) — отработал, свойства легли критериями в `tasks.md`;
- `review-architecture` (на предложении) — отработал, под его находки код писался.
Фаза `deep` (по коду):
- `review-gate` — зелёный; отдельно нашёл дефект самого гейта (находка 3);
- `review-specs` — отработал (декодирование имени, доли секунды, пустой
`?layer=`, границы в тексте ошибки — исправлено инлайн; развилка про
интервальную точку — находка 2);
- `review-code` — отработал (имя метрики в `ETag` — исправлено инлайн);
- `review-adversary` — отработал (обрыв тела, двойное декодирование —
исправлено; размер ответа — находка 1; `nosniff` — гипотеза);
- `review-ops` — отработал (предикат по слою: 13.9 мс → 0.026 мс; WARN вместо
DEBUG; `ReadOnly` не запрещает запись — исправлено/названо; транзакция без
бюджета — находка 1);
- `review-architecture` — отработал (единый словарь слоёв, `Scope` в
транспорте, `Spans` убран — исправлено инлайн);
- `review-reimpl`**не запускался**: проектный триггер (`docs/review.md`,
«новое правило слияния, идентичности или разбора») не сработал — изменение
только читает витрину; независимый взгляд на предложение дал профиль `design`.
**Счёт:** на вход триажа пришло 8 отложенных находок (плюс 13 позиций,
заявленных как исправленные инлайн). Все 13 инлайн-позиций **проверены по коду и
подтверждены** (`internal/httpapi/points.go`, `internal/points/points.go`,
`internal/store/series.go`, `internal/hae/hae.go`, `internal/catalog/catalog.go`,
`internal/httpapi/httpapi.go`; тесты пяти затронутых пакетов зелёные, включая
`TestТочкиНеПерехватываютКаталог` и `TestСловарьСлоёвОдин`). Из 8 отложенных:
4 в основном списке (одна — слияние двух причин), 2 понижены в гипотезы, 2
правила ушли в promote. Ничего не выброшено молча — судьба каждой названа.
Дедупликация: «размер ответа» найден `adversary` и `ops` независимо — это один
источник, высказавшийся дважды; приоритет поднят, `Confidence` — нет.
## Блокирует мердж
Пусто. Кандидат был один — ресурсный бюджет маршрута (находка 1), но обе его
половины уже разложены владельцем в задачи текущего спринта
(`read-api-response-limit`, `shutdown-and-migration-traces`), а риск
материализуется только на деплое, который в этом проекте спрашивается всегда.
Блокировать мердж значило бы отменить декомпозицию спринта решением триажа.
## Стоит исправить сейчас
### 1. Один широкий читатель способен уронить процесс вместе с приёмом — и доставки этого окна потеряны навсегда
- Файл: internal/store/series.go:134-190, internal/httpapi/points.go:188, docker-compose.yml
- Severity: major
- Confidence: high
- Оракул: замеры проходов `adversary`+`ops`: неделя `raw` — 604 800 точек,
1.75 с сборки, 1375 МиБ суммарных выделений; под HTTP — 2.89 с, тело
279.7 МиБ, 1335 МиБ живой кучи; 4 одновременных запроса — 4322 МиБ.
`docker-compose.yml` прочитан: `mem_limit` нет (есть `restart:
unless-stopped`, что ограничивает окно простоя, но не отменяет его).
`WriteTimeout` контекст запроса не отменяет — измерено проходом `ops`.
Прецедент WAL: 51 МБ при лимите 8 МиБ.
- Последствие: авторизованный читатель (свой же агент, опрашивающий по
расписанию) четырьмя широкими запросами доводит процесс до OOM. Падает не
маршрут — падает весь бинарь, включая приём; телефон шлёт молча и не
перешлёт: доставки окна простоя теряются необратимо (`CLAUDE.md`: «Поток не
останавливается»). Побочно: перекрывающиеся долгие читатели не дают
продвинуться пассивному чекпойнту WAL. Вероятность низкая (клиенты — свои),
ущерб — высший класс: необратимая потеря данных.
- Предложение: работу не дублировать — обе половины уже в спринте
(`read-api-response-limit` — предел размера, там же форма умолчания слоя;
`shutdown-and-migration-traces` — дедлайн маршрута). Решить надо порядок:
(а) мерджить сейчас, деплой — только после `read-api-response-limit` того же
спринта (ноль работы, дисциплина деплоя и так требует вопроса к человеку);
(б) то же плюс `mem_limit` в `docker-compose.yml` одной строкой уже сейчас —
дешёвый стопор, превращающий OOM хоста в перезапуск контейнера;
(в) сцепить мердж этой ветки с веткой предела в один батч.
Сюда же слита развилка `rubric`: правило «при равном охвате — самый мелкий
слой» максимизирует размер ответа; инверсия меняет правило, записанное в
`docs/architecture.md` до этой задачи, и решается вместе с формой предела в
`read-api-response-limit`, а не здесь.
- Найдено проходом: adversary + ops (один источник дважды; оракулы — замеры)
- Действие: развилка
### 2. Запрос «сон за ночь» молча не увидит эпизод, начавшийся до полуночи
- Файл: internal/store/series.go:233-256; openspec/changes/tochki-metriki-za-period/specs/points/spec.md:257-260
- Severity: major
- Confidence: high
- Оракул: положение кода (`series.go:253`: `p.Start.Before(w.From)`
пропуск) и дословный пункт спеки: «точка-интервал, начавшаяся раньше `from`,
в ответ не входит… запрос "сон за ночь с полуночи" не увидит эпизод,
начавшийся до неё».
- Последствие: потребитель получает ряд, выглядящий полным, — эпизод сна с
23:40 отсутствует без единого признака усечения. Класс «молчание»: сон —
профильный сценарий проекта, и решение потребителя принимается по неполным
данным. Спека цену честно называет, поэтому это не сломанное требование, а
открытая развилка дизайна.
- Предложение: вопрос владельцу, варианты: (а) оставить как есть — правило
симметрично адресации объектов, потребитель расширяет `from` сам (цена:
каждый потребитель обязан знать про максимальную длину эпизода); (б) при
выборке захватывать N часов до `from` и отбирать по пересечению интервала с
периодом (цена: магическое число глубины N); (в) индекс по концу координаты
(цена: миграция схемы и её сопровождение). Вариант (а) стоит нуля кода, но
тогда правило обязано попасть в контракт read-api так же явно, как в спеку
points.
- Найдено проходом: specs
- Действие: развилка
### 3. Гейт обещает красить непокрытую изменённую строку — и не красит никогда
- Файл: scripts/diff-coverage.py:88-96; scripts/gate.py:167-177; CLAUDE.md («Что красит безусловно»)
- Severity: major
- Confidence: high
- Оракул: положение кода: `diff-coverage.py` `main()` возвращает `0` и при
`uncovered > 0` (строка 96), `gate.py:172-173` пишет `OK` по нулевому коду
возврата. Текущий прогон — живая демонстрация: 30 непокрытых строк, шаг
`OK`, гейт зелёный.
- Последствие: пункт `CLAUDE.md` «что красит безусловно: …непокрытая изменённая
строка» ложен. Класс — молчание конвейера, тот самый, что в журнале ревью
трижды за три дня («у проверки, которую гейт не гоняет, краснота никому не
видна» — здесь хуже: проверка гоняется и молчит). Непокрытые строки будут
копиться, а все читатели `CLAUDE.md` — включая проходы ревью — считают их
невозможными.
- Предложение: вопрос, потому что починка меняет состояние текущего прогона:
(а) `return 1` при `uncovered > 0` — гейт этой задачи немедленно красный, и
надо покрывать 30 строк веток ошибок драйвера (правки, которых никто не
заказывал) либо гнать их через `//nolint`-аналог для покрытия; (б) привести
`CLAUDE.md` к реальности: покрытие диффа — информационный шаг, печатает и не
красит; (в) красить только строки вне уже признанных классов (ветки ошибок
драйвера) — потребует механики исключений, которой нет. Дешевле всего (б) +
отдельная задача на (а); выбирать не триажу — обещание записано владельцем.
- Найдено проходом: gate
- Действие: развилка
### 4. Граница, переваленная офсетом за 9999 год, молча опустошает ряд
- Файл: internal/httpapi/points.go:254-265; internal/store/store.go:263
- Severity: minor
- Confidence: high
- Оракул: прогнан тест (временный, удалён): `9999-12-31T23:00:00-08:00` → UTC
год 10000 → `FormatTime` даёт `10000-01-01T07:00:00Z`; `ParseTime` его не
разбирает (`cannot parse "0-01-01…"`); лексикографически
`'10000-…' < '2026-…'` — истина, значит `hour_utc BETWEEN '2020-…' AND
'10000-…'` пуст всегда.
- Последствие: уточнено против входной формулировки «роняет маршрут» — маршрут
не падает: запрос «с 2020 до конца времён» с `to=9999-12-31T23:00:00-08:00`
отвечает `200` и пустым рядом при непустых данных. Класс «молчание», но вход
экзотический (нужен офсет, переваливающий год), клиенты — свои. Унаследовано
от `store.FormatTime`, однако этот маршрут — первый, где клиент задаёт
временные границы, то есть первый внешний путь к дефекту.
- Предложение: в `parseBound` отвергать границу, чьё UTC-представление выходит
за год 9999 (и симметрично — раньше года 1), с `400` и текстом без значений
из запроса. Локально, однозначно, right-size.
- Найдено проходом: наследие, названо оркестратором; оракул добыт триажем
- Действие: инлайн
## Гипотезы без доказательства
- **`X-Content-Type-Options: nosniff` на читающих маршрутах** (adversary,
minor, понижено). Сам проход назвал это «свойством без пути»: API отдаёт
JSON под Bearer-токеном, браузерного потребителя нет, путь эксплуатации не
построен. Одна строка в `setReadHeaders`
(`internal/httpapi/conditional.go:145`) — но без пути это гигиена, а не
находка; пусть едет с первой задачей, трогающей `conditional.go`.
- **Машинно-различимый код причины отказа** (rubric, minor, понижено). Ни
одного потребителя, различающего причины программно, ещё нет, а правка
трогает общий `errorWire` — то есть контракт приёма, на который уже
завязан телефон. Цена сейчас выше пользы; момент — появление первого
программного потребителя ошибок (адаптер MCP). Правило — в promote.
## Promote candidates
- **«Обещание гейта подкрепляется кодом возврата шага»**: шаг, чей исход не
влияет на код возврата `gate.py`, называется в `CLAUDE.md` информационным, а
не «красящим безусловно». Кандидат в правило для `scripts/gate.py` и раздел
«Гейт» `CLAUDE.md` (следствие находки 3 — расхождение обещания и кода прожило
молча неизвестно сколько задач).
- **«Тексты отказов — не контракт»**: клиенты не парсят человекочитаемые
сообщения; при первом программном потребителе причин вводится машинный код
единым решением для всех маршрутов (следствие пониженной находки rubric).
## Границы покрытия
**Профиль и режим:** `deep`, по графу; фаза `design` прогнана до кода. Гейт
зелёный (с оговоркой находки 3 про шаг покрытия).
**Не запускалось и почему:**
- `review-reimpl` — триггер `docs/review.md` («новое правило слияния,
идентичности или разбора») не сработал: изменение читает витрину, правила
хранения не тронуты; независимый взгляд на замысел дал профиль `design`.
Если промахнёмся классом «второй способ прочитать то же самое» — искать
здесь.
- `task verify:archive` — не прогнан: в worktree нет `./data`, живой архив
основного репозитория запрещён оркестратором; триггер `CLAUDE.md` («перед
изменением правила разбора, идентичности или слияния») не сработал —
изменение сходимость журнала не трогает. Журнал ревью при этом трижды
фиксирует красноту этого прогона от роста корпуса — очередной запуск остаётся
за человеком на основной машине.
- `task verify:busy`**прогнан вручную, зелёный** (fold 24.08 с, replay
23.60 с): долгий читатель этой задачи не развёл живую витрину с пересборкой.
**Что запущенные проходы не могли проверить в принципе (charter):**
- `specs` — соответствие спеки реальности формата за пределами `testdata`;
- `code` — поведение под конкуренцией и на реальном потоке;
- `adversary` — реальный профиль нагрузки (замеры сняты на синтетике по
мотивам живых плотностей);
- `ops` — поведение на боевом железе и на горизонте недель (WAL-чекпоинт под
непрерывным опросом агента — экстраполяция из прецедента, не замер);
- `architecture` — исполнение против замысла (судит форму, не поведение);
- триаж — ничего нового не находит по определению; пропуск любого прохода —
и его пропуск тоже.
**Непокрытые изменённые строки (30 из 383):** ветки распространения ошибок
драйвера в `internal/store/series.go` (тот же класс, что уже непокрыт в
`internal/store/catalog.go`) и ветка «ответ не подписался» (тот же класс, что в
`internal/catalog/catalog.go`). Связка с находкой 3: гейт эти строки не красил
бы в любом случае — класс признан, но признание нигде не записано, кроме этой
секции.
**Подмена оракула критерия К1:** живой архив заменён реальными пакетами
`internal/hae/testdata` на изолированном сервисе `:18080`. Пакеты реальные, но
корпус — не архив: плотности и возраст данных другие, и оценка «на живых
данных» остаётся на человеке.
**Не проверит ни один проход** (из `docs/review.md`): реальный профиль
нагрузки — телефон шлёт молча и непрерывно, объём и частота меряются только по
факту; поведение приложения HAE за пределами наблюдённого (расписание
автоматизаций — пожелание, не гарантия); полнота словаря переводов после
обновления iOS; секции, которых поток ещё не приносил (`symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`) — разбор писался
вслепую. Плюс общее: история инцидентов, поведение внешних систем в их версиях,
завязка будущих потребителей (MCP-агентов) на текущую форму ответа и вопрос
«нужна ли эта функциональность вообще».
**Перестали проверять сознательно** (отдельным списком — при следующем промахе
первый вопрос «не тот ли это класс»):
- `task verify:archive` / `task verify:busy` вне гейта (в этой задаче: первый
не прогнан с причиной выше, второй прогнан вручную);
- класс «в Go так не пишут» — поимённая сверка со стайлгайдами не покрыта вовсе
после упразднения `idiom`; в этой задаче никто не спросил «идиоматичен ли
колбэк `pick` внутри транзакции» — судили форму и поведение, не идиому;
- класс «чего нет в зрелой реализации такого узла» — вне профиля `design`;
здесь `design`-фаза была, так что класс покрыт частично, на замысле, не на
коде.
**Документы проекта:** нехватки нет — все входы триажа были на месте и
непустые: инварианты с severity в `CLAUDE.md` (по ним ранжировано), «Типовые
ложноположительные» в `docs/review.md` (ни одна из 8 отложенных находок под них
не подпала — проверено поимённо), оба подраздела «Недоступно проверке»
(разнесены выше), `docs/security.md` (периметр использован находкой про
`ETag`), `docs/research/apple-health.md` (находки 34 и 36 использованы кодом).
**Потолок:** не потребовался — в основной список вошло 4 пункта из 4
кандидатов; слияние «умолчание самого мелкого слоя» в находку 1 и понижение
двух minor в гипотезы названы поимённо выше.
@@ -0,0 +1,433 @@
## ADDED Requirements
### Requirement: Точки метрики за период отдаются одним запросом
Система SHALL отдавать значения одной метрики за запрошенный период по
`GET /api/v1/metrics/{name}` с обязательными параметрами `from` и `to`, не
требуя от потребителя доступа к файлу базы и не требуя второго запроса за
смыслом отданных чисел.
Имя метрики берётся из пути **ровно один раз декодированным** и далее
дословно: система его не нормализует и не сверяет со списком известных — имена
приходят из тела доставки и хранилищу не принадлежат. Повторное декодирование
MUST NOT происходить: имя, само содержащее процентную последовательность
(`a%41b`), после второго декодирования становится именем **другой** метрики, и
маршрут отвечает `200` с её данными. Имя, которое путём не
выражается (пустое), маршрутом недостижимо; каталог такую метрику показывает, и
это названная цена адресации именем в пути, а не молчание.
Маршрут закрыт токеном чтения тем же контуром, что и каталог, и MUST NOT
перехватывать маршрут каталога `GET /api/v1/metrics`.
#### Scenario: Период запрошен
- **GIVEN** метрика, у которой в витрине есть объекты внутри периода
- **WHEN** потребитель запрашивает `GET /api/v1/metrics/{name}?from=…&to=…` с
действующим токеном чтения
- **THEN** ответ `200` несёт значения этой метрики за этот период
#### Scenario: Каталог остаётся достижим
- **WHEN** потребитель запрашивает `GET /api/v1/metrics`
- **THEN** отвечает каталог, а не маршрут точек
#### Scenario: Имя метрики закодировано в пути
- **GIVEN** метрика, чьё имя содержит символ, требующий процентного кодирования
- **WHEN** потребитель запрашивает её точки, закодировав имя
- **THEN** отвечают точки этой метрики, а не пустой ряд
#### Scenario: Имя метрики само содержит процентную последовательность
- **GIVEN** метрики с именами `a%41b` и `aAb` в витрине
- **WHEN** потребитель запрашивает `a%41b`, закодировав имя
- **THEN** отвечают точки `a%41b`, а не точки `aAb`
#### Scenario: Токен чтения отсутствует
- **WHEN** запрос точек приходит без действующего токена чтения при непустом
списке токенов чтения
- **THEN** ответ `401`, и тело ответа собрано объявленным типом транспорта
### Requirement: Конверт ответа объявляет слой, род свёртки, его применимость и границу окна измерения
Система SHALL сопровождать точки конвертом, в котором ВСЕГДА присутствуют поля
`metric`, `from`, `to`, `layer`, `bucket`, `aggregation` и `points`, а объект
`aggregation` MUST всегда нести поля `style`, `applicable` и `last_hour`. Поле,
которому нечего сообщить, MUST уезжать как `null` и MUST NOT исчезать из ответа
и MUST NOT подменяться нулевым значением своего типа.
Смысл полей:
- `metric` — имя метрики, как оно пришло путём после декодирования;
- `from` и `to` — фактически применённые границы периода, нормализованные к UTC
в RFC 3339;
- `layer` — слой, из которого собран ряд;
- `bucket` — сетка свёртки; `null`, когда свёртки не было;
- `aggregation.style` — измеренный род метрики (`cumulative` / `instant` /
`unknown`) тем же правилом и тем же окном, что у каталога;
- `aggregation.applicable` — применим ли объявленный род к **отданному ряду**;
- `aggregation.last_hour` — ярлык самого свежего часа окна измерения; `null`,
когда окно пусто;
- `points` — ряд, пустой коллекцией `[]`, а не `null`.
`last_hour` обязателен именно потому, что окно измерения считается в **общих
часах**, а не в часах календаря: выключенная минутная автоматизация HAE
останавливает пополнение общих часов, окно замирает и продолжает объявлять род.
Без `last_hour` у клиента нет ни одного способа это увидеть.
#### Scenario: Свёртки не было
- **WHEN** маршрут отвечает на запрос без сетки
- **THEN** в ответе присутствуют `metric`, `from`, `to`, `layer`, `bucket`,
`aggregation` со всеми тремя полями и `points`, причём `bucket` равен `null`
#### Scenario: Окно измерения замерло
- **GIVEN** метрика, чьи самые свежие общие часы старше конца запрошенного
периода
- **WHEN** маршрут отвечает
- **THEN** `aggregation.last_hour` называет ярлык самого свежего общего часа, а
не конец периода и не текущее время
#### Scenario: Окна измерения нет вовсе
- **GIVEN** метрика, у которой нет ни одного общего часа минутного и часового
слоёв
- **WHEN** маршрут отвечает
- **THEN** `aggregation.style` равен `"unknown"`, а `aggregation.last_hour`
равен `null`
### Requirement: Род, объявленный в ответе, едет вместе со своей применимостью к отданному ряду
Система SHALL объявлять `aggregation.applicable` равным `false` всегда, когда
объявленный род нельзя применить к отданному ряду, и MUST считать неприменимым
род `cumulative` на **нижнем слое HAE** (`raw`), род `unknown` и любой род при
отсутствии выбранного слоя. Система MUST NOT досчитывать свёртку по
неизмеренному роду и MUST NOT выражать неизвестность отсутствием поля.
Поле существует потому, что род — свойство **метрики**, а слой — свойство
**отданного ряда**, и их сочетание бывает опасным: нижний слой HAE это
интерполяция, а не сэмплы, и сумма по нему завышает втрое. Конверт, объявляющий
`cumulative` рядом с рядом из `raw` и молчащий о неприменимости, приглашает
потребителя сложить интерполяцию самостоятельно — система при этом не
складывает ничего, а решение у потребителя уже принято по завышенному числу.
#### Scenario: Род метрики не измерен
- **GIVEN** метрика, у которой род агрегации не измерен
- **WHEN** потребитель запрашивает её точки за период
- **THEN** ответ `200` несёт точки как есть, `aggregation.style` равен
`"unknown"`, а `aggregation.applicable` равен `false`
#### Scenario: Накопительная метрика отдана нижним слоем
- **GIVEN** метрика с измеренным родом `cumulative`, ряд которой собран из слоя
`raw`
- **WHEN** маршрут отвечает
- **THEN** `aggregation.style` равен `"cumulative"`, а
`aggregation.applicable` равен `false`
#### Scenario: Род применим
- **GIVEN** метрика с измеренным родом, ряд которой собран из слоя `minute`,
`hour` или `sample`
- **WHEN** маршрут отвечает
- **THEN** `aggregation.applicable` равен `true`
### Requirement: Слой выбирается по охвату точек внутри периода и не меняется внутри ответа
Система SHALL собирать ряд ровно из одного слоя и MUST NOT склеивать в одном
ответе точки разных слоёв. При отсутствии параметра `layer` система SHALL брать
слой с наибольшим **охватом** внутри запрошенного периода, а при равном охвате —
самый мелкий слой в порядке `sample`, `raw`, `minute`, `hour`, `day`.
**Охват меряется метками точек, а не часами объектов.** Охват слоя есть длина
пересечения отрезка `[первая метка слоя, последняя метка слоя]` с запрошенным
периодом; слой с пустым пересечением из выбора MUST выбывать. Мера названа
именно так потому, что объекты адресуются часом, а ряд отбирается точной меткой:
на периоде короче часа множество «слоёв с объектами» и множество «слоёв с
точками» расходятся, и слой, выбранный по часам, отдал бы пустой ряд при
непустых данных соседнего слоя.
Охват сравнивается между слоями, а не с запрошенным периодом: границы данных
законно короче запроса и законно имеют дыры внутри.
Заданный параметр `layer` отменяет правило целиком: система SHALL отдавать
запрошенный разрез, в том числе пустым, и SHALL называть его в ответе.
#### Scenario: Мелкий слой охватывает меньше крупного
- **GIVEN** метрика, у которой внутри периода нижний слой покрывает несколько
дней, а часовой — весь период
- **WHEN** потребитель запрашивает период без параметра `layer`
- **THEN** ряд собран из часового слоя, и `layer` называет его
#### Scenario: Охваты равны
- **GIVEN** метрика, у которой два слоя охватывают внутри периода одно и то же
- **WHEN** потребитель запрашивает период без параметра `layer`
- **THEN** ряд собран из более мелкого слоя
#### Scenario: Период короче часа
- **GIVEN** период внутри одного часа, в котором у крупного слоя есть объект без
единой точки внутри периода, а у мелкого — точки внутри периода
- **WHEN** потребитель запрашивает период без параметра `layer`
- **THEN** ряд собран из мелкого слоя и не пуст
#### Scenario: Слой задан явно
- **WHEN** потребитель задаёт `layer` явно
- **THEN** ряд собран из этого слоя, даже если другой слой охватывает период
шире, и `layer` в ответе равен запрошенному
#### Scenario: Имя слоя незнакомо
- **WHEN** параметр `layer` присутствует, а его значение не является одним из
`sample`, `raw`, `minute`, `hour`, `day`
- **THEN** ответ `400`, и умолчание молча не подставляется
#### Scenario: Значение слоя пусто
- **WHEN** параметр `layer` присутствует с пустым значением
- **THEN** ответ `400`, а не автоматический выбор слоя
### Requirement: Период задаётся явно и разбирается строго
Система SHALL требовать оба параметра `from` и `to`, SHALL принимать их только в
формате RFC 3339 с явным смещением зоны и SHALL толковать период как
полуинтервал `[from, to)`. Отсутствующий параметр, неразбираемое значение,
значение без явной зоны, `from >= to` и граница, чей год после приведения к UTC
выходит за диапазон 1–9999, MUST давать `400` с человекочитаемым сообщением,
которое MUST NOT содержать значений из запроса, и MUST NOT подменяться
умолчанием.
Граница за пределами четырёхзначного года отвергается потому, что объекты
адресуются строкой RFC 3339 и границы сравниваются лексикографически:
`9999-12-31T23:00:00-07:00` становится `10000-01-01T06:00:00Z`, который как
строка меньше любой настоящей метки, — и запрос молча отдал бы пустой ряд при
непустых данных.
Полуинтервал взят потому, что соседние окна обязаны склеиваться без двойного
счёта граничной точки. Явная зона обязательна потому, что вопрос «в какой зоне
считать сутки» в проекте открыт: принять голую дату значило бы выбрать зону за
клиента молча.
Параметр `bucket` этой версией маршрута не поддержан: система SHALL отвечать
`400` на его присутствие с любым значением и MUST NOT игнорировать его молча.
Молчаливое игнорирование дало бы клиенту, попросившему суточную сетку, полный
минутный ряд — зеркало того самого промаха, ради которого соседняя задача
различает «указали сетку» как информацию и как защиту. Прочие незнакомые
параметры запроса система игнорирует.
#### Scenario: Параметр периода отсутствует
- **WHEN** в запросе нет `from` или нет `to`
- **THEN** ответ `400`, и период умолчанием не подставляется
#### Scenario: Метка времени без зоны
- **WHEN** значение `from` или `to` записано без явного смещения зоны
- **THEN** ответ `400`
#### Scenario: Границы периода вывернуты
- **WHEN** `from` не раньше `to`
- **THEN** ответ `400`
#### Scenario: Граница выходит за четырёхзначный год
- **WHEN** граница периода после приведения к UTC попадает в год за пределами
диапазона 19999
- **THEN** ответ `400`, а не `200` с пустым рядом
#### Scenario: Запрошена сетка свёртки
- **WHEN** в запросе присутствует параметр `bucket`
- **THEN** ответ `400`, называющий, что свёртка ещё не поддержана
#### Scenario: Точка стоит ровно на границе
- **GIVEN** точки с метками ровно в `from` и ровно в `to`
- **WHEN** маршрут отвечает
- **THEN** точка на `from` в ответе есть, а точка на `to` — нет
### Requirement: Значение точки уезжает дословно, нормализовано только время
Система SHALL отдавать каждую точку объектом `{ts, ts_end, tz_offset, units,
values}`, где `values` MUST быть содержимым точки ровно в том виде, в каком его
сохранило хранилище, без переименования полей, пересчёта единиц, отбрасывания
незнакомого и **без экранирования**. `ts` и `ts_end` MUST быть нормализованными
к UTC метками начала и конца координаты точки; у точки-измерения `ts_end` равен
`ts`. `tz_offset` и `units` MUST принадлежать самой точке: смещение исходной
зоны — её собственное, единицы — того объекта, из которого точка прочитана.
Точки MUST идти по возрастанию `(ts, ts_end)`. Порядок этим определён
однозначно: пара `(начало, конец)` внутри одного слоя одной метрики есть ключ
идентичности, и двух точек с равной парой в витрине не существует.
Принадлежность точки периоду определяется её **началом**: точка-интервал,
начавшаяся раньше `from`, в ответ не входит, даже если её конец лежит внутри
периода. Правило то же, каким час объекта берётся по началу точки; цена названа
вслух — запрос «сон за ночь с полуночи» не увидит эпизод, начавшийся до неё.
`ts_end` присутствует потому, что идентичность точки — интервал, а не метка: под
одной меткой лежит до трёх записей сна, и конверт с одним `ts` предлагал бы
клиенту различать их, разбирая дословное содержимое.
#### Scenario: Точка-интервал
- **GIVEN** точка, у которой конец координаты отличается от начала
- **WHEN** маршрут отвечает
- **THEN** `ts_end` отличается от `ts` и называет конец координаты
#### Scenario: Содержимое не переписывается
- **WHEN** точка уезжает клиенту
- **THEN** `values` побайтово совпадает с сохранённым содержимым точки
#### Scenario: Содержимое несёт символы, которые сериализатор склонен экранировать
- **GIVEN** точка, чьё содержимое несёт `&`, `<` или `>`
- **WHEN** маршрут отвечает
- **THEN** эти символы уезжают как есть, а не escape-последовательностями
### Requirement: Пустота периода — успех, а не отсутствие ресурса
Система SHALL отвечать `200` на запрос метрики, у которой нет данных в
запрошенном периоде, и MUST отдавать `points` пустым списком. Система MUST NOT
отвечать `404` по признаку «нет данных».
`layer` равен `null` **только** тогда, когда слой выбирала система и выбирать
было не из чего. Заданный клиентом `layer` уезжает в ответе всегда, даже когда
ряд пуст: иначе клиент, спросивший разрез поимённо, не отличил бы «этого разреза
за период нет» от «маршрут проигнорировал параметр».
Различать опечатку в имени метрики и честную пустоту — работа каталога: список
имён маршруту точек не принадлежит.
#### Scenario: Данных за период нет и слой не задан
- **WHEN** у метрики нет ни одной точки внутри периода и параметр `layer` не
задан
- **THEN** ответ `200`, `points` равен `[]`, `layer` равен `null`
#### Scenario: Данных за период нет, а слой задан
- **WHEN** у метрики нет точек запрошенного слоя внутри периода
- **THEN** ответ `200`, `points` равен `[]`, `layer` равен запрошенному
#### Scenario: Имени метрики в витрине нет вовсе
- **WHEN** запрошено имя метрики, которого в витрине нет
- **THEN** ответ `200` с пустым `points`, а не `404`
### Requirement: Ответ снимается одним снимком витрины
Система SHALL читать охваты слоёв, точки выбранного слоя и объекты окна
измерения **в одной транзакции чтения**. Смесь «слой выбран до коммита свёртки,
точки прочитаны после» и «род измерен на третьем состоянии» дала бы ответ,
внутренне противоречивый и неотличимый от обычного свежего; пара проб версии
такой ответ обнаруживает, но не предотвращает — она снимает метку, а тело всё
равно уезжает.
#### Scenario: Свёртка коммитит во время чтения
- **WHEN** фоновая свёртка коммитит в витрину между выбором слоя и чтением точек
- **THEN** ответ собран из одного снимка витрины, а не из двух состояний
### Requirement: Метка ответа включает канонизированную форму запроса и горизонт измерения
Система SHALL помечать ответ меткой, область действия которой MUST быть
функцией канонизированной формы запроса — имени метрики, границ периода и слоя —
и которая MUST включать **горизонт измерения**, огрублённый до часа, наравне с
версией витрины. При невозможности подписать ответ система SHALL отдавать его
без метки, а не отказом.
Область MUST быть **ограничена по длине и не выносить значения запроса наружу**.
Имя метрики приходит из чужого тела дословно и ничем не ограничено: положенное
в метку как есть, оно даёт заголовок в тысячи байт, а кавычка внутри имени по
HTTP кончает метку — разбор обрежет её там, и условный запрос по такой метрике
не сработает никогда. Тот же вход в лог уезжает обрезанным, и у метки предел
обязан быть по той же причине.
Канонизация границ MUST сохранять точность, по которой отбираются точки: два
запроса, различающиеся долей секунды, дают разные ряды, и одна метка на них
подтвердила бы неизменность чужого набора данных.
Горизонт входит в метку по той же причине, по какой он входит в метку каталога:
род объявляется по окну, ограниченному сверху `текущее время + запас`, и метка
из будущего, лежащая в витрине, въезжает в окно **без единого коммита**, меняя
`aggregation.style` и `last_hour`. Метка без горизонта подтвердила бы
неизменность ответа, в котором род уже перевернулся. Форма запроса входит в
область действия потому, что ответ этого маршрута есть функция параметров: метка
без них однажды подтвердила бы неизменность чужого набора данных. Род и метка
MUST сниматься с одного горизонта.
Цена названа: один полный ответ в час на потребителя при неизменившейся витрине.
#### Scenario: Запросы различаются параметром
- **WHEN** два запроса при одном состоянии витрины различаются метрикой,
границей периода или слоем
- **THEN** метки их ответов различны
#### Scenario: Запросы различаются только формой записи
- **WHEN** два запроса при одном состоянии витрины задают одно и то же разной
записью смещения зоны
- **THEN** метки их ответов совпадают
#### Scenario: Границы различаются долей секунды
- **WHEN** два запроса при одном состоянии витрины различаются границей периода
на долю секунды и дают разные ряды
- **THEN** метки их ответов различны
#### Scenario: Имя метрики длинное или содержит кавычку
- **WHEN** запрошена метрика, чьё имя длиной в тысячи символов или содержит
кавычку
- **THEN** метка ответа остаётся короткой и не содержит имени метрики
#### Scenario: Горизонт перешёл через час
- **WHEN** горизонт измерения перешёл через границу часа при неизменной версии
витрины
- **THEN** метка ответа изменилась
#### Scenario: Витрина изменилась во время чтения
- **WHEN** пробы версии витрины разошлись
- **THEN** ответ уходит целиком и без метки
### Requirement: Исход маршрута точек наблюдаем, а данные о здоровье в лог не уезжают
Система SHALL писать один логирующий чекпоинт исхода на доменной границе
маршрута и MUST NOT доводить до записи лога значения точек, содержимое `values`
и границы запроса; имя метрики MUST быть обрезано по тому же пределу, что у
каталога. Отмена запроса клиентом и занятость базы MUST писаться уровнем
`DEBUG`, а настоящий отказ хранилища — уровнем `ERROR`.
Предупреждения измерения — противоречащий род и данные, помеченные будущим, —
остаются привилегией каталога: маршрут точек их MUST NOT повторять. Агент
опрашивает по расписанию, и `WARN` на каждый опрос обесценил бы уровень ровно
так же, как обесценила бы его строка на каждый `304`.
#### Scenario: Клиент оборвал запрос
- **WHEN** потребитель обрывает запрос точек по своему тайм-ауту
- **THEN** запись об этом уходит уровнем `DEBUG`, а не `ERROR`
#### Scenario: Метрика с противоречащим родом запрошена многократно
- **WHEN** потребитель повторно запрашивает точки метрики, у которой род
противоречив
- **THEN** маршрут точек предупреждений об этом не пишет
#### Scenario: Тело ответа оборвалось на середине
- **WHEN** запись тела ответа отказала после того, как код ответа уже отдан
- **THEN** отказ записи оставляет собственный чекпоинт и не проходит молча под
видом успешного ответа
@@ -0,0 +1,40 @@
## ADDED Requirements
### Requirement: Сериализация ответа чтения не экранирует содержимое
Система SHALL сериализовать тела ответов читающих маршрутов **без экранирования
HTML-символов**: `&`, `<` и `>` внутри значений MUST уезжать клиенту как есть и
MUST NOT подменяться escape-последовательностями `&`, `<`, `>`.
Правило общее для всех читающих маршрутов, а не частное для точек, потому что
общим является механизм: тело собирает один помощник сериализации, и умолчание
`encoding/json` экранирует эти три символа молча. На маршруте, отдающем
**дословно сохранённое** содержимое, это прямо ломает обещание дословности:
имя источника приходит с телефона пользовательской строкой и законно содержит
`&`. Хранилище этот же капкан уже проходило и обезвредило тем же способом —
кодировщик с выключенным экранированием вместо `json.Marshal`.
Проверка обязана стоять на содержимом, реально несущем эти символы: набор
фикстур, в котором их нет, зелен и будучи сломанным.
#### Scenario: Значение несёт символ, который сериализатор склонен экранировать
- **GIVEN** значение ответа читающего маршрута, содержащее `&`, `<` или `>`
- **WHEN** маршрут отвечает
- **THEN** эти символы присутствуют в теле ответа как есть
### Requirement: Ответ чтения непригоден для разделяемого кеша
Система SHALL помечать ответы читающих маршрутов заголовком
`Cache-Control: private, no-cache`.
Правило общее, потому что цена у него одна на все маршруты чтения: с появлением
валидатора ответ становится штатно кешируемым, а при выключенной проверке
токенов — законной конфигурации для доверенной локальной сети — в запросе нет и
`Authorization`. Тогда выгрузку истории здоровья вправе сохранить любой прокси
на пути. Маршрут, решающий это заново, однажды решит иначе.
#### Scenario: Читающий маршрут ответил
- **WHEN** читающий маршрут отдаёт тело
- **THEN** ответ несёт `Cache-Control: private, no-cache`
@@ -0,0 +1,140 @@
## Критерии приёмки (из постановки, переживают удаление файла задачи)
Задача `docs/tasks/items/read-api-points-period.md`, «Точки за период».
- **К1.** «вес за год» отвечается одним запросом без доступа к файлу базы —
**оракул:** запрос к поднятому сервису на живом архиве.
*Подмена оракула на этой машине:* рабочая база `./data` принадлежит живому
контейнеру, читать её мимо `task up`/`task run` запрещено. Вместо неё сервис
поднимается в worktree на своём порту и своей базе, наполненной **реальными
пакетами** `internal/hae/testdata`. Подмена названа строкой и не выдаётся за
исходный оракул: она проверяет маршрут целиком (HTTP, токен, разбор
параметров, чтение витрины, форма ответа), но не объём живого архива.
- **К2.** в ответе всегда видны `layer`, `aggregation` и `last_hour`
**оракул:** тест на форме ответа.
- **К3.** метрика, у которой род не измерен, отдаётся без свёртки и говорит об
этом, а не молчит и не досчитывает — **оракул:** тест на метрике с неизвестным
родом.
## Приёмочные критерии из рубрики (проход `review-rubric`, профиль `design`)
Свойства узла «HTTP-обработчик чтения временного ряда», порождённые до чтения
предложения. Проверяются наравне с К1–К3.
- **Р1.** Род, объявленный в ответе, применим к отданному ряду: сочетания, из
которого клиент выведет разрешённой операцию, запрещённую инвариантом, в
конверте нет.
- **Р2.** Ответ есть функция того, что уже произошло, а не момента взгляда: всё,
что способно измениться **без коммита в базу** (горизонт, параметры запроса),
входит в область действия метки; повтор того же запроса при том же состоянии и
тех же часах даёт побайтово тот же ответ.
- **Р3.** Ряд собран ровно из одного слоя, слой назван всегда, правило выбора
детерминировано на любом входе; предикат выбора слоя и предикат отбора точек
используют **одну границу**.
- **Р4.** Отсутствие предела размера — осознанное решение, стоящее на замере
**того режима, ради которого предел заводится**, а не на замере доступного
корпуса.
- **Р5.** Период — полуинтервал; точка на границе попадает ровно в один из двух
соседних ответов; правило принадлежности интервальной точки названо.
- **Р6.** Пустой результат — `200`, пустая коллекция списком, метаданные на
месте; «данных нет» отличимо от «ресурса нет» без второго запроса.
- **Р7.** Невозможный запрос отвергается до чтения витрины, кодом `4xx`, и текст
отказа не содержит значений из запроса.
- **Р8.** Время нормализовано и однозначно; значения точки уезжают дословно —
без переименования, пересчёта, отбрасывания и **экранирования**.
- **Р9.** Ответ не выглядит полнее, чем он есть: «данных не было» отличимо от
«слой выбран по охвату меньше периода» без пересчёта точек.
- **Р10.** Выборка идёт по существующему индексу без полного скана витрины,
`context` протянут до драйвера, отмена клиента не считается отказом.
- **Р11.** Транспорт не несёт доменной логики; доменный тип до сериализации не
доезжает.
- **Р12.** Ни значения здоровья, ни токен не доводятся до лога выше `DEBUG` и до
тела отказа ни одним путём.
## 1. Чтение витрины
- [x] 1.1 `store.ReadSeries(ctx, window, pick)` — **один вход, одна транзакция
чтения**: охваты слоёв, точки выбранного слоя, объекты окна измерения.
Правило выбора слоя приходит функцией-параметром и остаётся в домене
- [x] 1.2 Охваты слоёв: `min(first_ts)`, `max(last_ts)`, охваты по
`GROUP BY layer` в границах часов `[trunc(from), trunc(to)]` — по покрывающему
индексу `bucket_catalog`, без чтения содержимого (Р10)
- [x] 1.3 Точки выбранного слоя: объекты по точному префиксу первичного ключа,
разжатие, отбор по началу координаты до `[from, to)`
- [x] 1.4 Окно измерения одной метрики: переиспользует `commonHours` и
`readHourPairs`, второго правила отбора не заводит
- [x] 1.5 Тесты хранилища: точка `10:59` из объекта `10:00` не теряется; точка
ровно на `from` есть, ровно на `to` — нет; период короче часа, где крупный
слой имеет объект без точек внутри, а мелкий — точки (Р3); пустой период
## 2. Use-case «ряд точек» (`internal/points`)
- [x] 2.1 Пакет `internal/points`: тип запроса, тип ответа, `Service` над
`store` и `catalog`
- [x] 2.2 Правило выбора слоя: охват = длина пересечения `[первая метка,
последняя метка]` слоя с периодом; пустое пересечение выбывает; наибольший
охват, при равенстве — самый мелкий; явный слой отменяет правило и всегда
уезжает в ответ (Р3)
- [x] 2.3 Род через `catalog.Measure`, применимость — `false` при `unknown`, при
`cumulative` на слое `raw` и при отсутствии выбранного слоя (Р1)
- [x] 2.4 Метка ответа: версия витрины + горизонт, огрублённый до часа
(`catalog.Stamp`, не второй экземпляр) + канонизированная форма запроса; род и
метка снимаются с одного горизонта (Р2)
- [x] 2.5 Логирующий чекпоинт исхода один и на доменной границе; отмена и
занятость базы — `DEBUG`, настоящий отказ — `ERROR`; значений точек и границ
запроса в записи нет, имя метрики обрезано; предупреждения измерения маршрут
точек не повторяет (Р12)
- [x] 2.6 Тесты домена: охваты различаются; охваты равны; ни одного слоя;
явный слой пуст; период короче часа; применимость рода на четырёх слоях
## 3. Транспорт и форма провода
- [x] 3.1 Маршрут `GET /api/v1/metrics/{name}` под токеном чтения; тест, что он
не перехватывает `GET /api/v1/metrics`; имя метрики берётся процентно
декодированным
- [x] 3.2 Разбор параметров: `from`/`to` обязательны, RFC 3339 с явной зоной,
`from < to`, `layer` из словаря, присутствие `bucket` — `400`; каждый отказ
до чтения витрины, с человекочитаемым сообщением без значений из запроса (Р7)
- [x] 3.3 Форма провода точек по образцу `catalog.go`: типы `*Wire` с
`json`-тегами, перевод присваиванием поле в поле, `values` — `json.RawMessage`
- [x] 3.4 Строка в таблице образцов `wire_internal_test.go` (Р11)
- [x] 3.5 `writeJSON` перестаёт экранировать HTML-символы (общее правило
`read-api`); тест на значении точки с `&`, `<`, `>` (Р8)
- [x] 3.6 Метка и `Cache-Control` через существующий `setReadHeaders`; тесты:
различие меток по каждому параметру по очереди, совпадение при эквивалентной
записи времени, различие при переходе горизонта через час (Р2)
- [x] 3.7 Байтовое утверждение формы ответа на **фиксированной** витрине с
часами в прошлом — ни одно поле литерала не зависит от хода часов (прецедент
`docs/review.md`, 2026-08-03)
- [x] 3.8 Тесты маршрута: **К2** (все поля конверта присутствуют всегда),
**К3** (род `unknown` назван словом, `applicable: false`), точка-интервал
(`ts_end`), дословность `values`, пустой период без слоя (`layer: null`),
пустой период с явным слоем (`layer` эхом), незнакомая метрика, `401`
## 4. Документация
- [x] 4.1 `docs/architecture.md`, раздел «Read API»: форма ответа точек
(`aggregation` объектом, `applicable`, `ts_end`), правило выбора слоя
формулировкой из спеки, строка маршрута с пометкой о неподдержанном `bucket`,
строка `points` в таблице компонентов
- [x] 4.2 `config.example.toml`: строка про `read_tokens` называет точки рядом с
каталогом
## 5. Верификация
- [x] 5.1 `task gate` зелёный
- [x] 5.2 Поведенческая верификация: свой `config.toml` в worktree (порт
`:18080`, база и архив в `./tmp/`), наполнение реальными пакетами
`internal/hae/testdata` через маршрут приёма, затем **К1** — метрика за год
одним запросом; рабочий `./data` не трогается
- [x] 5.3 Замер цены маршрута числом на **трёх** режимах, включая худший
(Р4, прецедент `docs/review.md` 2026-08-04 «замер снят на корпусе, где
измеряемого случая не бывает»): редкая метрика за год; плотная метрика за
сутки в `minute`; плотная метрика за неделю в `raw`. Корпус собирается
размножением **реальных** точек `testdata`, а не литералами; метод
записывается рядом с числом
- [x] 5.4 `task verify:busy` — зелёный (fold 24.08 с, replay 23.60 с).
`task verify:archive` **не прогнан**: в worktree нет `./data`, а живой архив
основного репозитория трогать запрещено; триггер прогона
(изменение правила разбора, идентичности или слияния) не сработал — маршрут
только читает витрину. Названо строкой в границах покрытия, а не замолчано