## 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`, а не отдаёт молча пустой ряд
  (найдено триажем). Осталась половина на стороне приёма — доставка с такой
  меткой в теле.
