Files
healthlog/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md
T
av 29ca8d415c httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
2026-08-04 18:46:45 +03:00

418 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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`, а не отдаёт молча пустой ряд
(найдено триажем). Осталась половина на стороне приёма — доставка с такой
меткой в теле.