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