httpapi: форма провода читающих маршрутов объявлена транспортом

- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы
  metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте,
  тело отказа тоже получило объявленный тип — байты ответа не изменились
- заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до
  сериализации, плюс требование json-тега на полях транспортных структур и
  заведомо красные случаи к обоим правилам
- решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в
  гейте получил свой кеш — общий на машину красил прогон находками из чужого
  worktree
This commit is contained in:
av
2026-08-04 16:18:05 +03:00
parent bd832337df
commit a834d10415
19 changed files with 1721 additions and 53 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-04
@@ -0,0 +1,356 @@
## Context
Читающий маршрут сегодня один — `GET /api/v1/metrics`. Его тело собирается
прямой сериализацией доменных типов `internal/catalog`: `Metric`, `LayerRange`,
`Aggregation`, `Basis` несут `json`-теги, `Style` несёт `MarshalJSON`, а
транспорт владеет только оболочкой `{"metrics": …}` (66 строк
`internal/httpapi/catalog.go`).
Из этого следуют три пути смены публичного контракта **без единого касания
транспорта**, и все три выглядят как внутренняя правка домена:
1. переименование поля (`Metric.Metric``Metric.Name`);
2. **разъединение встроенной структуры**`Aggregation` встраивает `Basis`, и
плоскость объекта `aggregation` на проводе есть следствие этого встраивания;
вынеси `Basis` полем — и клиент получит вложенный объект;
3. появление внутреннего поля в доменном типе — оно уезжает клиенту само.
Удерживает контракт один литерал в `TestКаталогОтдаётОжидаемыеБайты`, и нигде не
сказано, что этот литерал и есть контракт.
Со следующей задачи цели образец копируют точки, тренировки, записи и MCP. Два
факта проекта делают выбор не вкусовым:
- **`internal/httpapi/ingest.go` уже владеет своим типом ответа**
(`ingestResponse` с `json`-тегами). То есть образец «форма провода принадлежит
транспорту» в проекте **уже есть**, и каталог от него отклоняется, а не
наоборот.
- **Хранилище уже применяет ровно предлагаемое решение.** `store.Point`
(`internal/store/bucket.go:24-29`) не несёт `json`-тегов вовсе; формат
сжатого `payload` объявлен **отдельным неэкспортированным** типом
`storedPoint` (`json:"s"/"e"/"o"/"p"`, там же, `:774-782`), а `encodePayload`
переводит одно в другое **полем в поле**. То есть образец «своя форма —
своим типом с явным переводом» в проекте есть и в записи тоже; каталог
отклоняется от него, а не наоборот.
- **Правило «доменный тип и есть форма провода» ломается на втором же маршруте
цели.** Провод точек обещан как `{ts, tz_offset, units, values}`
(`docs/architecture.md`, раздел «Форма ответа»), а `store.Point` несёт
`{Start, End, OffsetSeconds, Raw}` — эти два набора не совпадают **ни одним
именем**. Доменный тип формой провода там быть не может даже при желании.
(Первая редакция этого абзаца утверждала, что `store.Point` «уже потратил
свои `json`-теги» на формат хранения. Утверждение неверно, поймано проходом
`specs` на профиле `design` и заменено на проверяемое; вывод от этого стал
сильнее, а не слабее.)
Схема не трогается, миграции нет, витрина только читается.
## Goals / Non-Goals
**Goals:**
- Публичный контракт чтения перестаёт быть следствием формы доменных типов.
- Правило одинаково применимо к четырём оставшимся маршрутам цели и к MCP.
- Правило выражено **проверкой**, а не прозой: забыть его нельзя молча.
- Байты ответа каталога не меняются — правка структурная, и это утверждается.
- Решение и цена **обеих** сторон записаны там, где их найдёт следующая задача.
**Non-Goals:**
- Версионирование контракта (`/api/v2`, conversion-слой). Потребителей нет,
ломать нечего.
- Машиночитаемая схема контракта — это отдельная цель
([self-description](../../../docs/tasks/items/self-description.md)).
- Правка формы ответа каталога по существу: имена и состав полей остаются.
- Разбор запроса. Речь только об **ответе**: у каталога параметров нет, а
разбор параметров точек — задача точек.
## Decisions
### Прежде своего — prior art
Вопрос «домен или DTO на проводе» решался десятки раз, и литература по нему
**расколота**. Обе стороны названы поимённо, потому что обе живые.
**Домен = провод.**
- **Ben Johnson, «Standard Package Layout» и `benbjohnson/wtf`** — доменные типы
корневого пакета несут `json`-теги напрямую, и HTTP-слой кодирует их без
промежуточного типа; собственную структуру (`findDialsResponse`) транспорт
заводит только там, где домена не хватает на **конверт**. Это ровно наше
сегодняшнее состояние, включая оболочку `catalogResponse`.
- **Prometheus, `web/api/v1`** — смешанный вид: конверт (`Response`,
`QueryData`) объявлен API-слоем, а полезная нагрузка внутри (`parser.Value`,
`stats.QueryStats`) сериализуется доменными типами напрямую.
**Домен и провод раздельны.**
- **Gitea** — `modules/structs` (алиас `api`) отделён от `models`.
- **Docker** — `api/types`, разбитый по областям, отдельно от движка.
- **go-kit (Peter Bourgon)** — service → endpoint → transport, «business logic
should have no knowledge of endpoint- or transport-domain concepts».
- **Kubernetes** — самая жёсткая форма: internal-типы против версионированных
`k8s.io/api` плюс кодогенерируемая конверсия.
- **etcd** — `etcdserverpb` против `mvccpb`; разделение достаётся побочным
эффектом protobuf, а не отдельным решением.
- **Mat Ryer, «How I write HTTP services in Go after 13 years»** — ортогональный
совет: типы запроса и ответа, нужные одному обработчику, объявляются **рядом с
ним**, чтобы никто снаружи не начал опираться на форму, которую автор не
считает устойчивой. Названа и цена: трение, когда те же типы понадобились
тесту.
**Что из этого взято и что отвергнуто.**
- **Взят вид Gitea/Docker/Prometheus-конверта в редакции Райера**: типы провода
объявляются транспортом, живут рядом со своим обработчиком и неэкспортированы.
Это же продолжает собственный образец проекта — `ingestResponse`.
- **Отвергнут `wtf`-вид (домен = провод)** — не «нам не нравится», а по факту:
он неприменим ко второму маршруту цели, потому что поля `store.Point` не
совпадают с обещанным проводом точек ни одним именем. Правило, которое
ломается на первом же копировании, правилом не является.
- **Отвергнут go-kit** — слой `endpoint` покупает мультитранспортность и
middleware-набор; у нас транспорта полтора (HTTP и MCP тем же процессом), а
middleware даёт `chi`. Цена — три слоя и типы запроса/ответа на каждый метод.
- **Отвергнут вид Kubernetes** — версионирование плюс конверсия — это
подсистема, а не приём; она окупается там, где контракт обязан жить в
нескольких версиях одновременно. У нас ноль потребителей и один владелец.
- **Отвергнут etcd/protobuf-first** — транспорт задан снаружи: HAE шлёт JSON,
агент читает JSON, MCP говорит JSON. Разделение через protobuf куплено бы
зависимостью, которая никому здесь не нужна.
### Чем это сторожится: байтовый детектор сегодня, рукописная спека потом
Разделение само по себе контракта не сторожит — оно лишь делает его смену
**явной правкой транспорта**. Нужен ещё детектор. И роли здесь надо развести
поимённо, потому что в этом же спринте владелец уже решил вторую половину
вопроса.
- **Источник истины контракта — рукописная OpenAPI-спека.** Решение владельца
2026-08-04, задача [openapi-spec](../../../docs/tasks/items/openapi-spec.md);
сверку спеки с маршрутами вносит в гейт задача
[openapi-gate-check](../../../docs/tasks/items/openapi-gate-check.md), и один
из её критериев приёмки — «переименованное поле ответа красит гейт».
- **Байтовое утверждение — детектор изменения формы, а не сам контракт.** Оно
краснеет **в момент правки**, до всякого гейта, и в этом его роль; называть
его «единственным утверждением контракта» нельзя — через две задачи спринта
это станет ложью, и автор правки обновит литерал, не тронув рукописную спеку.
- **`golang.org/x/exp/apidiff` и `go-apidiff` отвергнуты, и причина
измерима:** они сравнивают **Go-API** на предмет компилируемости клиентского
кода. Смена строки тега (`json:"metric"``json:"name"`) при неизменном
Go-имени поля для них — не изменение вовсе. То есть ровно тот класс, ради
которого заводится сторож, они не видят.
- **`swaggo`/генерация OpenAPI из кода отвергнута** — и это отказ от
*генерации*, а не от OpenAPI: генератор фотографирует то, что уже изменилось,
то есть опечатка в имени поля становится частью спеки. Проект пошёл ровно
обратным путём (спека первична коду, как и в OpenSpec).
- **Взят golden-подход** (`goldie`, `nao1215/golden` и их обычай `-update`):
побайтовое сравнение целого ответа. Библиотека при этом не берётся — литерал
в тесте читается в диффе лучше, чем бинарный `testdata`-файл, а `-update` у
литерала есть по построению (его правит рука). Метод числа: сегодняшний
литерал `internal/httpapi/catalog_test.go`**401 байт**, это фикстура одной
метрики с двумя слоями, а не ответ живого корпуса; для ответа с десятками
метрик вывод «литерал читается лучше файла» не мерялся, и у точек его
придётся принять заново (см. Risks).
### Форма: типы провода в файле маршрута, перевод — явная функция
`internal/httpapi/catalog.go` объявляет `metricWire`, `layerWire`,
`aggregationWire` и функцию перевода. Сигнатура выбрана одна и повторяется
дословно во всех документах изменения:
```go
func catalogWire(metrics []catalog.Metric) catalogResponse
```
Чистая функция от уже прочитанного значения: без `context`, без хранилища, без
часов. Версия ответа в тело не идёт — она уезжает в `ETag`, и снимок у обоих
один (`catalog.Snapshot`), так что `304` и `200` описывают одно состояние.
Поля перечисляются **плоско и поимённо**: плоскость объекта `aggregation`
перестаёт быть следствием встраивания `Basis` в домене и становится записанным
решением транспорта.
Тело отказа тоже получает объявленный тип: `writeError` сегодня собирает его из
`map[string]string` (`internal/httpapi/httpapi.go:161-163`). Карта на проводе
делает «два ответа совпадают побайтово» свойством библиотеки, а не решения, и
переименование ключа `error` не видит ни один сторож. Байты при этом те же —
`{"error":"…"}`.
Типы неэкспортированы и объявлены на уровне пакета, а не внутри обработчика
(Райер советует внутри). Причина названа им же — трение с тестом: утверждение
«домен на провод не попадает» обязано пройти по графу типов ответа, и
функционально-локальный тип этому графу недоступен. Плата за отступление
маленькая: пакет `httpapi` внутренний, «снаружи» здесь на один слой ближе.
Перевод — **явное присваивание поле в поле**. Отсюда симметрия, ради которой всё
и делается: контракт не меняется случайно **ни в одну сторону** — ни
переименование в домене не доезжает до клиента, ни новое доменное поле не
уезжает клиенту само.
### `Style` теряет `MarshalJSON`, но сохраняет `String`
`aggregationWire.Style` — обычная `string`, и кладёт её транспорт. Значения
словаря (`cumulative`/`instant`/`unknown`) при этом остаются в домене:
`Style.String()` нужен логам и сообщениям тестов, и провод зовёт **его же**
второго словаря не заводится.
Граница названа вслух, потому что она нарочно неполна: домен владеет
**значениями** словаря, но не их сериализацией. Правка `String()` ради
читаемости лога изменит тело ответа клиенту — этот путь остаётся, и сторожит
его байтовое утверждение. Альтернатива (свой `switch` на проводе) сторожит
лучше, но заводит второй словарь, который разъедется с первым молча; из двух
цен взята эта, и она здесь записана, а не умолчана.
### Проверка, выражающая правило: домен не достигает энкодера
Утверждение пишется **прямо** и механически: пройти по графу типов значения
ответа и убедиться, что ни один тип в нём не принадлежит внутреннему пакету
проекта, кроме самого `httpapi`. Тогда «переименование поля доменного типа не
меняет байты ответа» — не обещание, а следствие: доменный тип до сериализации не
доезжает вовсе.
Обход рекурсивен (структуры, срезы, массивы, ключи и значения карт, указатели,
встроенные и неэкспортированные поля), защищён от самоссылающихся типов и живёт
во внутреннем тесте пакета — снаружи `catalogResponse` не виден. Принадлежность
пакету определяется по `PkgPath` с префиксом module path, а не по имени пакета:
алиас (`type wire = catalog.Metric`) обход тогда не пропускает. Помощник
пишется общим по значению ответа и применяется **таблицей** «маршрут → образец
ответа»: следующие четыре маршрута добавляют строку, а не выбирают заново.
Стандартная библиотека проходит: `time.Time` сериализуется своим устойчивым
контрактом (RFC 3339), `json.RawMessage` — дословный проброс, ради которого
существует инвариант «точки хранятся дословно». Исключение задаётся **по типу**
(`json.RawMessage`), а не по признаку «есть свой `MarshalJSON`»: второй признак
вернул бы домен на провод ровно тем механизмом, который только что сняли со
`Style`.
**Заведомо красный случай обязателен.** Проверка, доказывающая **отсутствие**
чего-либо, зелена и будучи сломанной; у проекта это уже дважды всплывало
(`docs/conventions/testing.md`: отрицательный контроль для правил порядка,
утверждение о таблице-константе). Поэтому рядом стоит кейс с фикстурой,
содержащей `catalog.Metric`, и ожиданием отказа — он же ловит устаревший
префикс module path.
### Куда пишется решение
Два адреса, и они разные по назначению.
- `docs/architecture.md`, раздел «Read API», подраздел «Форма провода»: сам
выбор, **цена обеих сторон** и ссылка сюда за prior art. Обоснование в
`architecture.md` не переезжает — он обзор (CLAUDE.md).
- `docs/conventions/testing.md`: правило о **механизме** проверки — байтовое
утверждение на каждую различимую форму ответа и заведомо красный случай для
структурной проверки. Дом у такого правила там, а не в спеке: спека нормирует
наблюдаемое поведение системы, а не устройство репозитория. Оба прохода
профиля `design` (`specs` и `architecture`) назвали это независимо, и дельта
переписана — в ней остались только требования, у которых есть предмет для
сверки «спека → код».
## Risks / Trade-offs
- **Новое доменное поле не доезжает до клиента молча** → это цена, взятая
сознательно, и она обратная сегодняшней. Смягчение: перевод — одна функция в
одном файле, а байтовое утверждение показывает **текущий** контракт целиком,
так что «поле не доехало» видно на первом же прогоне, который его ждёт.
- **Двойное объявление формы (домен + провод)** → цена в строках: у каталога
это четыре типа и одна функция перевода; числом её здесь не называем, потому
что мерить пока нечего — счёт станет известен по факту правки. Обратная цена
(контракт как побочный эффект имён полей домена) уже измерена в постановке
как причина задачи.
- **Райер советует локальные типы, взяты пакетные** → чуть шире область
видимости внутри `httpapi`. Отступление названо, причина — доступность графу
типов в тесте.
- **Проверка «домен не достигает энкодера» обходит `reflect.Type`, а не
значения** → она слепа к типам, достижимым только через `any`/интерфейс, и к
типам **внешних зависимостей**: чужой тип с чужими тегами обход пройдёт. У
читающих маршрутов таких полей нет и не планируется; появятся — правило
придётся усилить, и это названо здесь, а не обнаружится потом.
- **Байтовое утверждение растёт вместе с ответом** → у точек тело может быть
крупным, и вывод «литерал читается лучше файла», снятый на 401 байте
каталога, там придётся принять заново. Смягчение: утверждать байты **малого**
ответа (несколько точек), а объём проверять отдельно.
- **Словарь рода остаётся в домене** → правка `Style.String()` меняет тело
ответа. Смягчение и его предел названы в решении выше; сторожит байтовое
утверждение.
## Migration Plan
Миграции нет. Схема, витрина и архив не трогаются, конфиг не меняется. Откат —
обратный коммит: наружу не уезжает ничего, кроме тех же байтов ответа.
## Open Questions
Три вопроса, каждый — решение не этой задачи. Первые два принесло ревью кода
(профиль `deep`), их оракулы прогнаны и лежат в
[review/triage.md](review/triage.md).
### 1. Метка года 10000, принятая приёмом, навсегда убивает маршрут чтения
**Что решить:** чинить ли парой `FormatTime`/`ParseTime` замкнутость, отбором на
границе разбора HAE, или обоими.
**Путь (прогнан проходом `adversary`, оракул — падающий тест):** тело доставки с
`"date": "9999-12-31 23:00:00 -0700"` принимается (200), в UTC это **год
10000**, `store.FormatTime` молча печатает `"10000-01-01T06:00:00Z"` в
`hour_utc`/`first_ts`/`last_ts`, а `store.ParseTime` (RFC 3339) на чтении
отказывает — `GET /api/v1/metrics` отвечает 500 **по всем метрикам**. Состояние
переживает пересборку: строка в витрине, тело в архиве, `import + replay`
воспроизводит её.
**Цена вариантов.** Чинить **только** `ParseTime` нельзя: отказ `json.Marshal`
станет достижим (`writeJSON` при ошибке кодирования отдаёт **200 с пустым
телом** — статус и `ETag` уже отправлены), а сравнение горизонта идёт как TEXT
(`hour_utc <= ?`), и `"10000-…"` лексикографически **меньше** `"2026-…"` — то
есть защита «свидетельство из будущего свидетельством не является» на этом входе
не срабатывает вовсе.
**Почему не здесь:** весь задействованный код (`internal/hae`, `internal/store`)
лежит вне рамок задачи («трогается только каталог и его транспорт»), а правка
правила разбора тянет за собой триггер прохода `reimpl` и обязательные прогоны
`verify:archive` / `verify:busy`.
**Рекомендация:** отдельная задача, секция ядро, до выкладки наружу — сегодня
контур чтения открыт одной принятой доставкой.
### 2. Таблица образцов сторожа — opt-in, и забытая строка молчит
**Что решить:** проверять ли полноту таблицы «маршрут → образец ответа» в
`wire_internal_test.go` машиной.
**Цена вариантов.** (а) обход роутера (`chi.Walk`) с утверждением, что число
читающих маршрутов равно числу строк таблицы — около 15 строк, забывание
краснеет, цена — сцепка теста с роутером; (б) тестовый hook в `writeJSON`,
собирающий типы реально закодированных ответов — ноль мест на новый маршрут, но
шов в продакшн-коде; (в) оставить на спеке `read-api` и комментарии-образце —
ноль строк сейчас, одна молчащая дыра на каждый забытый маршрут.
**Что стоит, пока решения нет:** сторож покрывает три типа, идущие через
`writeJSON` сегодня (каталог, тело отказа, учёт приёма). Впереди четыре маршрута
и MCP — четыре шанса забыть.
**Рекомендация:** (а). Это ровно тот класс «проверка отсутствия зелена и будучи
сломанной», против которого это же изменение завело конвенцию заведомо красного
случая; на полноту таблицы конвенция не распространена.
### 3. Что в проекте считается спекой — контракт системы или ещё и дисциплина его смены
Вопрос процесса, адресован владельцу. Проход `architecture` назвал это развилкой
на профиле `design`; здесь она разрешена в сторону «спека нормирует
наблюдаемое, дисциплина живёт в конвенциях», потому что этот выбор дешевле
откатить (правило переносится между двумя файлами) и потому что у второго
варианта нет предмета для сверки «спека → код». Прецедент задан на четыре
следующие задачи цели — если владелец решит иначе, переносить придётся их все.
---
Развилка «домен или транспорт» решена выше **фактом** (поля `store.Point` не
совпадают с обещанным проводом точек ни одним именем), а не предпочтением.
Открытым остаётся один вопрос **процесса**, и он адресован владельцу, а не этой
задаче: **что в проекте считается спекой — контракт системы или ещё и
дисциплина его смены.** Проход `architecture` назвал это развилкой; здесь она
разрешена в сторону «спека нормирует наблюдаемое, дисциплина живёт в
конвенциях», потому что этот выбор дешевле откатить (правило переносится между
двумя файлами) и потому что у второго варианта нет предмета для сверки
«спека → код». Прецедент при этом задан на четыре следующие задачи цели — если
владелец решит иначе, переносить придётся их все.
@@ -0,0 +1,75 @@
## Why
Публичный контракт единственного читающего маршрута сегодня **выведен из формы
доменных типов**: `internal/catalog` сам несёт `json`-теги, а
`internal/httpapi/catalog.go` владеет только оболочкой `{"metrics": …}`.
Переименование поля в домене, разъединение встроенного `Basis` или добавление
внутреннего поля меняют байты ответа, не касаясь транспорта; удерживает это один
литерал в тесте `TestКаталогОтдаётОжидаемыеБайты`, и о том, что он и есть
контракт, в коде не сказано нигде.
Пока маршрут один и потребителей у него нет, цена нулевая. Со следующей задачи
цели образец копируют точки, тренировки, записи и MCP — четыре маршрута и второй
транспорт. Решение принимается **до** копирования: потом это будет не решение, а
археология. Момент выбран ещё и потому, что менять контракт каталога сейчас
бесплатно — снаружи его не читает никто.
## What Changes
- **Форма провода объявляется транспортом, а не выводится из домена.** Каждый
читающий маршрут `internal/httpapi` объявляет собственные типы ответа с
`json`-тегами; доменные типы `internal/catalog` тегов лишаются, перевод —
явная функция транспорта.
- **Доменные типы каталога перестают быть публичным контрактом.** `Metric`,
`LayerRange`, `Aggregation`, `Basis` остаются формой ответа **use-case**, а не
формой ответа HTTP. `Style.MarshalJSON` убирается: сериализацию рода кладёт
транспорт. Значения словаря (`cumulative`/`instant`/`unknown`) при этом
остаются в домене — провод зовёт `Style.String()`, чтобы не завести второй
словарь; граница и её цена названы в `design.md`.
- **Тело отказа тоже получает объявленный тип.** Сегодня `writeError` собирает
его из `map[string]string`; у читающего маршрута тело отказа — часть того же
контракта, и оно не должно оставаться единственным местом, куда не смотрит ни
один сторож. Байты те же.
- **Байты ответа каталога не меняются.** Правка структурная; контракт из
требования «Форма ответа каталога» остаётся тем же до символа, и это
утверждается тестом, а не намерением.
- **Заводится проверка, прямо утверждающая разделение:** ни один тип домена не
достигает сериализации ответа — обход графа типов значения ответа, с заведомо
красным случаем рядом. Отсюда и следует, что переименование поля домена до
провода не доезжает.
- **Решение и цена обеих сторон записываются** в `docs/architecture.md`
(раздел Read API); правило о **механизме** проверки — в
`docs/conventions/testing.md`, где у правил такого рода дом.
- Схема не трогается, миграции нет, данные только читаются.
## Capabilities
### New Capabilities
- `read-api`: общие правила читающих маршрутов, поверх которых встают точки,
тренировки, записи и MCP. Два правила: **публичный контракт меняется только
правкой транспорта** (форма провода объявляется явно и не выводится из формы
доменных типов) и **пустая коллекция — список, а не отсутствие**.
### Modified Capabilities
Нет. Поведение каталога не меняется: байты ответа, заголовки и коды остаются
прежними, требование «Форма ответа каталога» из `catalog` продолжает описывать
их без единой правки. Это и есть проверяемое утверждение изменения.
## Impact
- `internal/catalog/catalog.go` — снятие `json`-тегов с `Basis`, `Aggregation`,
`LayerRange`, `Metric`; удаление `Style.MarshalJSON`.
- `internal/catalog/measure_test.go``TestStyleСловарь` переезжает с
`MarshalJSON` на `String()`.
- `internal/httpapi/catalog.go` — типы провода каталога и перевод из домена.
- `internal/httpapi/httpapi.go` — объявленный тип тела отказа вместо карты.
- `internal/httpapi/catalog_test.go` — байтовые утверждения формы (измеренное
окно, неизмеренное окно, пустая витрина).
- `internal/httpapi/wire_internal_test.go` — обход графа типов ответа и заведомо
красный случай к нему.
- `docs/architecture.md` — раздел Read API: решение с ценой обеих сторон.
- `docs/conventions/testing.md` — правило о механизме: байты на каждую
различимую форму ответа, заведомо красный случай для структурной проверки.
- Схема, витрина, архив, конфиг — не трогаются.
@@ -0,0 +1,271 @@
# Триаж ревью — `forma-provoda-chteniya`
## Сводка
- **Профиль:** `deep`, режим `по графу`. База диффа
`bd832337df6dae5829432a9ee021b380cc6be1b1`, изменения застейджены.
- **Гейт:** зелёный, 13/13 шагов, покрытие изменённых строк 100%.
- **Проходы поимённо, с исходом** (состав сверен с профилем: `deep` = 6 кодовых
проходов + `reimpl` по триггеру + триаж; расхождения с профилем нет):
- `gate` — отработал, 2 находки (1 починена инлайн, починка проверена триажем);
- `specs` (код против спек) — отработал, 2 находки, обе починены инлайн,
починки проверены мутациями; `openspec validate --strict` — valid;
- `code` (прозаические конвенции) — отработал, 1 находка, дубль находки 1
`specs` (найден независимо), закрыт той же починкой;
- `adversary` — отработал, 3 построенных пути (1 major, 2 minor) — **все три
унаследованы от базы, диффом не внесены** (проверено триажем по списку
файлов диффа и по диффу `internal/catalog/catalog.go`); плюс секция
«атаковано и не пробилось» и 3 свойства без построенного пути;
- `ops` — отработал, 1 находка minor, две гипотезы закрыты замерами;
- `architecture` (по коду) — отработал, 1 находка minor с готовой развилкой;
- `reimpl`**не запускался**: триггер «новое правило слияния, идентичности
или разбора» не сработал — дифф не трогает `internal/store`, `internal/fold`,
`internal/hae` и схему (проверено списком файлов диффа);
- `triage` (этот отчёт) — ничего нового не ищет по определению; пропуск
любого прохода — его пропуск тоже.
- На профиле `design` (до кода) отдельно отработали `specs`, `rubric`,
`architecture`; их находки закрыты в предложении, рубрика легла критериями
Р1–Р12 в `tasks.md`.
- **Счёт находок:** на вход пришло 10 именованных находок и 3 свойства без
построенного пути. После дедупликации по причине — 8 уникальных
(`code` = `specs`-1; «ссылка на архив» у `architecture` = находка 2 `gate`).
Из них 3 починены инлайн и проверены триажем мутациями. Открытых осталось 6:
3 в секции «Стоит исправить сейчас», 3 унаследованных/задокументированных —
строками в границах покрытия (в урожай). Гипотез — 2, promote — 3.
### Проверка инлайн-починок (не по пересказу — прогонами)
1. **Байтовые утверждения тела отказа** — есть
(`internal/httpapi/catalog_test.go:216,233`). Мутация
`json:"error"``json:"message"` в изолированной копии дерева →
`TestКаталогОтдаётОжидаемыеБайтыОтказа` красный. До починки эта мутация была
зелёной (оракул `specs`). **Починено.**
2. **Требование `json`-тега на полях формы провода** — есть
(`internal/httpapi/wire_internal_test.go:72-82`), в таблице заведомо красных
13 контролей, включая «определённый тип поверх домена» и «поле без тега».
Две мутации в копии: (а) поле `aggregationWire.Hours` без тега →
`TestФормаПроводаДоменаНеСодержит` красный; (б) `type layerWire
catalog.LayerRange` с конверсией вместо перевода полем в поле → красный и
сторож (4 поля «без json-тега»), и байтовые тесты. Ровно та калитка из
находки 2 `specs` закрыта. **Починено.**
3. **`GOLANGCI_LINT_CACHE` в `scripts/gate.py`** — есть (строка 119, кеш в
`tmp/gate/golangci`), `env` корректно сливается с `os.environ`
(`run()`, строка 70). `ops` отдельно замерил: кеш не растёт без предела,
повторный прогон 0.57 с. **Починено.**
Плюс контрольный прогон триажа: `go build ./...`, `go vet ./...`,
`go test ./internal/...` — целиком зелёные на застейдженном дереве.
---
## Блокирует мердж
Пусто. Единственный `major` прогона (путь `adversary` про метку года 10000)
воспроизведён триажем и **не принадлежит этому мерджу** — см. первый пункт
следующей секции: весь задействованный код (`internal/hae/hae.go`,
`internal/store/store.go`, `internal/store/catalog.go`) вне диффа, дифф его
поведения не меняет, а починка расширила бы scope задачи («трогается только
каталог и его транспорт») и по триггеру потребовала бы `reimpl` и
`verify:archive`/`verify:busy`. Прецедент дисциплины тот же, что в записи
2026-08-03 `docs/review.md`: краснота унаследована — заводится задачей, а не
чинится попутно.
Формулировка «критичных проблем не обнаружено» здесь не употребляется — что
именно проверено и что не могло быть проверено, см. «Границы покрытия».
## Стоит исправить сейчас
### 1. Одна принятая доставка с меткой года ≥10000 навсегда убивает маршрут чтения — 500 по всем метрикам
- Файл: `internal/hae/hae.go:812``internal/store/store.go:263`
`internal/store/catalog.go:174-182``internal/httpapi/catalog.go`
- Severity: major (унаследовано от базы, диффом не внесено)
- Confidence: high
- Оракул: падающий тест `adversary` (прогнан) + независимое воспроизведение
триажем на уровне механизма: `time.Parse("2006-01-02 15:04:05 -0700",
"9999-12-31 23:00:00 -0700")` принимается, `FormatTime``"10000-01-01T06:00:00Z"`,
`ParseTime` (RFC 3339) на этой строке отказывает
(`cannot parse "0-01-01T06:00:00Z" as "-"`). Побочно подтверждено:
`"10000-…" < "2026-…"` как TEXT — защита горизонта «свидетельство из
будущего» на этом входе не срабатывает.
- Последствие: строка уже в витрине и в архиве, `import + replay` её
воспроизводит; лечится правкой кода **и** ручной правкой
`./data/healthlog.db` — необратимой операцией из списка запретов. Класс
«порча с низкой вероятностью» — выше любого гарантированного неудобства.
- Ловушка починки (названа `adversary`, сохранить в задаче): чинить только со
стороны `ParseTime` нельзя — отказ `json.Marshal` станет достижим, и
`writeJSON` отдаст 200 с пустым телом; плюс откроется дыра TEXT-горизонта.
Чинить надо на приёме области значений (разбор HAE / `FormatTime`) вместе с
горизонтом.
- Действие: **развилка.** Вопрос владельцу: (а) завести отдельную задачу в
каталоге — рекомендация триажа: правка трогает разбор, по триггеру тянет
`reimpl` + `verify:archive`/`verify:busy`, в рамки этой задачи не лезет;
(б) чинить в этой ветке с расширением scope и полным повторным циклом;
(в) принять риск без задачи — не рекомендуется: отказ молчащий до первого
чтения, а лечение требует необратимой ручной операции.
### 2. Ссылка на архивный путь change с угаданной датой умрёт молча
- Файл: `docs/architecture.md:1638`
(`openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md`)
- Severity: minor (внесено диффом)
- Confidence: high
- Оракул: пути не существует (проверено `ls`); ссылка инлайн-кодом, `docs.py
check` её не проверяет; дата архивации угадана — архивирование не сегодня
ломает её без сигнала. Дубль: `gate` и `architecture` нашли независимо.
- Последствие: разбор чужих решений (единственное место, где записано «почему
не как у Prometheus/Kubernetes») станет недостижим из обзора.
- Действие: **инлайн.** Убрать угаданную дату: сослаться на change по имени
(`openspec/changes/forma-provoda-chteniya/design.md`, поправить при
архивации) либо назвать документ словами без пути — на вкус оркестратора.
### 3. Маршрут, забывший строку в таблице образцов, останется без стража молча
- Файл: `internal/httpapi/wire_internal_test.go` (таблица `cases`,
`TestФормаПроводаДоменаНеСодержит`)
- Severity: minor (внесено диффом — сторож новый, и он opt-in)
- Confidence: high
- Оракул: прогон `architecture` — новый обработчик с `catalog.Metric` в ответе,
не добавленный в таблицу, не красит ни один тест; байтовый литерал нового
маршрута закрепил бы доменные имена как норму.
- Последствие: забытая строка неотличима от отсутствия проблемы — ровно тот
класс, против которого это же изменение завело конвенцию заведомо красного
случая. Впереди четыре маршрута и MCP, каждый копирует образец.
- Действие: **развилка.** Варианты (сформулированы `architecture`):
(а) тест полноты таблицы через `chi.Walk` — ~15 строк, забывание краснеет,
цена — сцепка теста с роутером; (б) тестовый hook в `writeJSON` — ноль мест
на новый маршрут, цена — шов в продакшн-коде; (в) оставить как есть — ноль
строк, одна молчащая дыра на каждый забытый маршрут. Триаж склоняется к (а):
сцепка с роутером дешевле шва в продакшне и дешевле молчащей дыры, но выбор
затрагивает форму, которую скопируют пять раз, — потому развилка, не инлайн.
## Гипотезы без доказательства
- **`catalogWire` делит `*time.Time` со снимком (нет глубокой копии).**
Понижено до nit: построенного пути нет — сегодня снимок не кешируется и
алиасинг ненаблюдаем; станет наблюдаемым с первым кешем снимка. Источник —
`adversary`, «свойства без построенного пути». Не действие, а память: при
задаче о кеше перечитать.
- **Ненайденный маршрут пишет сырой `r.URL.Path` в лог уровня INFO.** Понижено
до nit: путь до значения точки или токена через URL не построен (токены в
заголовке), последствия не названо. Источник — `adversary`.
Ничего из `critical`/`major` понижать не пришлось: единственный `major` получил
оракул (дважды — проходом и триажем). Согласие проходов нигде не считалось
подтверждением: дубль `specs`+`code` закрыт одной починкой и проверен мутацией,
а не «подтверждён двумя проходами».
## Promote candidates
1. **Правило линтера: экспортированные типы вне `internal/httpapi` не несут
`json`-тегов.** Сторож графа типов ослепнет, если доменная структура
когда-нибудь получит теги обратно, — обход в неё не пойдёт как в «свою».
Механизируемо (forbidigo/ручное правило по AST); источник — `adversary`.
2. **Записать трактовку «метка времени точки — координата, а не значение».**
До WARN/ERROR доезжают `last_ts` и текст `parse time "10000-…"`; `adversary`
назвал это границей, которую проект перешёл сознательно, но трактовка нигде
не записана — следующий прогон поднимет её заново. Место —
`docs/security.md` или `docs/conventions/logging.md`.
3. **Проверка инлайн-код-ссылок на существование пути в `docs.py check`.**
Находка 2 существует ровно потому, что ссылка инлайн-кодом невидима
проверке раскладки. Механизируемо; цена — ложные срабатывания на
намеренно-будущих путях, потому promote, а не инлайн.
## Границы покрытия
**Прогон.** Профиль `deep`, режим «по графу». Запускались: `gate`, `specs`,
`code`, `adversary`, `ops`, `architecture`, триаж. На профиле `design` до кода —
`specs`, `rubric`, `architecture`.
**Не запускалось и почему:**
- `reimpl` — триггер «новое правило слияния, идентичности или разбора» не
сработал: дифф не трогает `internal/store`, `internal/fold`, `internal/hae`,
миграций нет (сверено триажем по списку файлов диффа, не по пересказу);
- `task verify:archive` и `task verify:busy` — изменение не трогает разбор,
идентичность, слияние и схему; по правилу гейта их гоняет человек или
оркестратор перед изменением этих правил. Если развилка 1 решится вариантом
(б) — оба прогона и `reimpl` становятся обязательными;
- `rubric` на коде — по составу конвейера намеренно (судить код по критерию,
под который он писался, — корреляция по построению); его свойства лежат
критериями Р1–Р12 в `tasks.md` и проверены `specs` поимённо.
**Не влезло в потолок / унаследовано — в урожай задач, не выброшено:**
- каталог отдаёт 8 единиц из 12 без признака урезания
(`internal/catalog/catalog.go`, `sortedKeys`, потолок 8) — minor, оракул
прогнан `adversary`, унаследовано от базы (участок диффом не тронут);
- два WARN (`future data`, `aggregation style conflict`) стоят в теле обработки
запроса — частота равна частоте чтений, условие не гаснет; проект правило
«постоянный WARN обесценивает уровень» для себя уже формулировал (`fold.go`) —
minor, оракул прогнан (10 запросов → 10 WARN), унаследовано;
- сторож графа типов слеп к полям `any`/`interface{}` — воспроизведено `ops`
собственным тестом; риск назван в `design.md` (Risks) этим же изменением,
вероятный путь эволюции (`json.RawMessage`) слепую зону обходит. Слепая зона
сторожа, а не дефект диффа.
**Что запущенные проходы не могли проверить в принципе (из charter'ов):**
- `gate` — только механизируемое: смысл контракта, полноту таблицы образцов и
честность спек он не видит;
- `specs` — исключение для дословного содержимого (`json.RawMessage`) проверено
только на синтетике; MCP кода не имеет; четыре из пяти читающих маршрутов не
написаны — какие байты они отдадут, проверять нечего; какие поля несёт тело
отказа, дельта не говорит; правило «отсутствие как `null`» имеет предмет
только у `*time.Time`;
- `adversary` — доказывает существование пути, не отсутствие: «атаковано и не
пробилось» (побайтовый повтор ×50, `304` 0/12, `*time.Time`, значение в теле
отказа, отказ `Marshal`) — это невыведенные атаки, а не гарантия;
- `ops` — поведение под реальным потоком телефона не наблюдал; откат бинаря
проверен рассуждением и байтовой сверкой, не прогоном на живом стенде;
- `architecture` — соответствие реализации дельте по пунктам не его работа
(названо им самим про `scripts/gate.py`, которого нет в `Impact`
предложения; `specs` это покрыл: изоляция кеша — находка `gate`, а не
самодеятельность);
- триаж — ничего нового не находит по определению; пропуск любого прохода —
его пропуск тоже.
**Ослабленный оракул замеров.** На машине фоново живут чужой процесс
`healthlog serve` на `:18099` и рабочий контейнер на `:8080`; в замерах они не
участвовали, но общий шедулинг ОС разбавляет числа `ops` (6.16 мкс/оп на живом
размере каталога, 305 мкс на пределе, 0.57 с повторный прогон линтера) —
порядки надёжны, точные значения нет.
**Целиком на человеке — «Недоступно проверке» из `docs/review.md`, двумя
списками, не слитыми.**
*Не проверит ни один проход:*
- реальный профиль нагрузки: телефон шлёт молча и непрерывно, объём и частота
меряются только по факту;
- поведение приложения HAE за пределами наблюдённого — расписание
автоматизаций пожелание, а не гарантия (разведка, находка 28);
- полнота словаря переводов после обновления iOS;
- секции, которых поток ещё не приносил: `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications` — разбор писался
вслепую, проход судит форму кода, не соответствие реальности.
*Перестали проверять сознательно:*
- прогон живого архива (`task verify:archive`) и свёртка под удерживаемой
блокировкой (`task verify:busy`) в гейт не входят (минута и ~50 секунд,
данные только на этой машине); гоняет человек перед задачей, трогающей
разбор или слияние (записи 2026-08-02…04 — три красноты этого класса подряд);
- класс «в Go так не пишут» — поимённая сверка с Effective Go, Go Code Review
Comments, стайлгайдами Uber и Google — не покрыт вовсе после упразднения
`idiom`;
- класс «чего нет в зрелой реализации такого узла» — вне профиля `design`.
*Плюс общее, что не покрывает никакой конвейер:* история инцидентов, поведение
под реальным потоком, поведение внешних систем в их версиях, завязка
потребителей на текущее поведение (у каталога потребителей сегодня нет — задача
на этом стоит, и это проверяемо лишь отсутствием знания о чужих проектах) и
вопрос «нужна ли эта функциональность вообще».
**Документы проекта.** Всё, что нужно триажу, на месте: `CLAUDE.md` с
инвариантами и severity, `docs/review.md` со всеми четырьмя нужными разделами
(журнал, типовые ложноположительные, вопросы к проходам, «Недоступно
проверке»), `docs/security.md`, `docs/conventions/`. Ни один проход о
недостающем документе не заявил; отсев ложноположительных шёл по заполненному
проектному списку (совпадений находок с ним не было — выбрасывать по нему
ничего не пришлось). Строк деградации в этом прогоне нет.
@@ -0,0 +1,97 @@
## ADDED Requirements
### Requirement: Публичный контракт читающего маршрута меняется только правкой транспорта
Система SHALL объявлять форму ответа каждого читающего маршрута типами
транспортного слоя и MUST NOT выводить её из формы доменных типов: ни один тип
домена MUST NOT достигать сериализации ответа, а перевод домена в форму провода
MUST быть явным перечислением полей.
**Читающий маршрут** здесь — тот, что отдаёт наружу состояние витрины: каталог,
точки, тренировки, записи, статистика. `/healthz` под правило не подпадает —
его тело есть литеральный признак живости процесса, а не данные.
**MCP собственной формы провода не объявляет.** Адаптер переводит вызовы в те
же обработчики (`docs/architecture.md`, раздел «MCP»), поэтому объявленная форма
у контракта одна на оба транспорта. Второе объявление развело бы их молча.
Правило распространяется и на **тело отказа**: у читающего маршрута оно часть
того же контракта, и клиент видит его чаще успешного ответа. Тело отказа MUST
собираться объявленным типом транспорта, а не картой и не свободной строкой.
**Следствие, ради которого правило и существует:** переименование поля
доменного типа, разъединение встроенной в него структуры и появление в нём
нового поля байты ответа не меняют — до сериализации доменный тип не доезжает.
Смена публичного контракта становится правкой транспортного слоя, то есть
действием, а не побочным эффектом.
**Цена названа и взята сознательно, потому что она обратная.** Новое поле
домена не попадает в ответ само: чтобы клиент его увидел, транспорт обязан его
перечислить. Контракт перестаёт меняться случайно в обе стороны, и это дороже
ровно на объём перевода.
Из правила есть одно исключение, и оно ограничено **содержимым**, а не
конвертом: значение, которое хранилище держит дословно, уезжает клиенту сырым
JSON без разбора и переобъявления — переписывать его в тип провода значило бы
нарушить инвариант «точки хранятся дословно». Конверт вокруг такого значения —
метки времени, офсет, единицы, слой — объявляется типом провода и нормализуется,
как того требует инвариант «форма Apple не транслируется».
#### Scenario: Поле доменного типа переименовано
- **GIVEN** поле доменного типа, из которого собирается ответ, переименовано, а
транспортный слой не тронут
- **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины
- **THEN** байты ответа те же
#### Scenario: Форма провода изменена намеренно
- **GIVEN** транспорт переименовал поле объявленной формы
- **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины
- **THEN** байты ответа изменились, и изменение целиком лежит в правке
транспортного слоя
#### Scenario: Читающий маршрут отвечает отказом
- **WHEN** читающий маршрут отвечает кодом отказа
- **THEN** тело отказа собрано объявленным типом транспорта
#### Scenario: Словарь значения виден клиенту строкой
- **GIVEN** доменное перечисление, чьи значения клиент видит строками
- **WHEN** маршрут отвечает
- **THEN** строку в ответ кладёт транспорт, а домен собственной сериализации не
несёт
#### Scenario: Дословное содержимое проходит насквозь
- **WHEN** в ответ идёт значение, сохранённое хранилищем дословно
- **THEN** оно уезжает сырым JSON, а конверт вокруг него объявлен типом провода
### Requirement: Пустая коллекция чтения — список, а не отсутствие
Система SHALL отдавать пустую коллекцию читающего маршрута как `[]` и MUST NOT
отдавать её как `null`; отсутствующее значение MUST отдаваться как `null` и
MUST NOT подменяться нулевым значением своего типа.
Правило общее для всех читающих маршрутов, потому что цена у него одна:
`nil`-срез сериализуется в `null`, и клиент читает «поля нет» там, где на самом
деле «элементов нет»; правдоподобная нулевая дата в ответе неотличима от
настоящей. Конкретные коллекции и поля каждого маршрута нормирует его
собственная capability — здесь только правило, чтобы следующий маршрут не
принимал его заново.
Ручной перевод домена в форму провода делает это правило не теоретическим:
присваивание поле в поле копирует `nil` и разыменовывает указатель ровно так,
как написано, и обе ошибки молчат.
#### Scenario: Коллекция ответа пуста
- **WHEN** в ответ читающего маршрута идёт коллекция без единого элемента
- **THEN** она уезжает как `[]`
#### Scenario: Значения нет
- **WHEN** поле ответа не имеет значения
- **THEN** оно уезжает как `null`, а не как нулевая метка времени, пустая
строка или ноль
@@ -0,0 +1,126 @@
## 1. Домен перестаёт быть формой провода
- [x] 1.1 `internal/catalog`: снять `json`-теги с `Basis`, `Aggregation`,
`LayerRange`, `Metric`; комментарии типов переписать так, чтобы они называли
их формой ответа use-case, а не формой ответа HTTP
- [x] 1.2 `internal/catalog`: удалить `Style.MarshalJSON`; `String()` оставить —
он нужен логам и сообщениям тестов, и провод зовёт его же
- [x] 1.3 `internal/catalog/measure_test.go`: `TestStyleСловарь` переезжает с
`MarshalJSON` на `String()`, значения словаря те же
## 2. Форма провода в транспорте
- [x] 2.1 `internal/httpapi/catalog.go`: типы `metricWire`, `layerWire`,
`aggregationWire` с `json`-тегами; поля `aggregation` перечислены **плоско**,
а не встраиванием — плоскость перестаёт быть следствием формы домена
- [x] 2.2 перевод ровно этой сигнатуры (одна на все документы изменения):
`func catalogWire(metrics []catalog.Metric) catalogResponse` — чистая функция
без `context`, без хранилища, без часов; род кладётся строкой через
`Style.String()`; пустые коллекции — `[]`, отсутствующие метки — `null`
- [x] 2.3 `handleMetrics` собирает ответ вызовом `catalogWire`; версия снимка
по-прежнему уходит только в `ETag`, в тело не попадает
- [x] 2.4 `internal/httpapi/httpapi.go`: тело отказа собирается объявленным
типом вместо `map[string]string`; байты те же — `{"error":"…"}`
## 3. Проверки
- [x] 3.1 `internal/httpapi/wire_internal_test.go` (внутренний тест пакета):
обход графа типов значения ответа — поля, срезы, массивы, ключи и значения
карт, указатели, встроенные и неэкспортированные поля; защита от
самоссылающегося типа; принадлежность определяется по `PkgPath` с префиксом
module path, а не по имени пакета
- [x] 3.2 обход применён **таблицей** «маршрут → образец ответа»: каталог, тело
отказа. Следующий маршрут добавляет строку
- [x] 3.3 **заведомо красный случай**: на фикстуре, содержащей `catalog.Metric`,
обход обязан быть красным. Он же ловит устаревший префикс module path
- [x] 3.4 исключение задано **по типу** `json.RawMessage`, а не по признаку
«есть свой `MarshalJSON`»; кейс с доменным типом, несущим `MarshalJSON`, —
красный
- [x] 3.5 `TestКаталогОтдаётОжидаемыеБайты` остаётся; литерал не правится ни
одним символом, комментарий называет его **детектором изменения формы**, а
источником истины — рукописную OpenAPI-спеку (задача `openapi-spec`)
- [x] 3.6 `TestКаталогНеизмеренноеОкноОтдаётNull` переписывается с подстрок на
**байты целого тела**: это ветка `*time.Time`, где ручной перевод и создаёт
риск подставить `0001-01-01` вместо `null`
- [x] 3.7 байты пустого каталога (`{"metrics":[]}`) остаются утверждением
- [x] 3.8 форма провода утверждается **ровно в одном месте** на форму ответа:
второго утверждения той же формы через разобранную структуру или подстроку в
пакете нет
## 4. Документы
- [x] 4.1 `docs/architecture.md`, раздел «Read API»: подраздел «Форма провода» —
решение, **цена обеих сторон**, ссылка на `design.md` изменения за prior art
- [x] 4.2 `docs/conventions/testing.md`: правило о механизме — байтовое
утверждение на **каждую различимую** форму ответа; структурная проверка,
доказывающая отсутствие, несёт заведомо красный случай
- [x] 4.3 `openspec validate --strict forma-provoda-chteniya` зелёный
## 5. Верификация
- [x] 5.1 `task gate` зелёный
- [x] 5.2 поведенческая: сервис поднят на своём порту и своей базе в `./tmp`.
Сравниваются **два ответа одного прогона** — снятый с базовой ревизии и
снятый с ветки против **одного и того же** файла базы при неработающем
приёме. Числа ответа (`points`, `hours`, границы) печатаются, но в
утверждении не участвуют: все они производны от корпуса. `ETag` сравним
только внутри одного часа — горизонт измерения входит в метку и едет вместе
с часами
## Критерии приёмки задачи
Из `docs/tasks/items/read-api-wire-format.md`, дословно:
- [x] решение записано в `docs/architecture.md` с названной ценой обеих сторон,
а не только выбранной — оракул: глазами по разделу
- [x] переименование поля доменного типа либо не меняет байты ответа, либо
меняет их намеренно, и тест утверждает это прямо, а не проверяет непустоту —
оракул задачи назван как «тест на переименование»; **исполнимая его форма**
обход графа типов (3.1) плюс заведомо красный случай (3.3) плюс байтовые
литералы (3.5–3.7). Прямого «переименуй и посмотри» в Go-тесте не бывает:
отказ компиляции собственного пакета тест не наблюдает, а при неудавшейся
компиляции байтов не существует вовсе. Подмена оракула названа здесь, а не
сделана молча
- [x] маршрут каталога приведён к решению, и следующий маршрут копирует
образец, а не выбирает заново — оракул: гейт зелёный плюс сверка
`httpapi/catalog.go` с записанным решением
## Приёмочные критерии от ревью предложения (профиль `design`)
Рубрика прохода `review-rubric`, порождённая **до** чтения кода. Порядок —
по важности.
- [x] Р1 байты ответа не изменились, и это **измерено**: прежний байтовый
литерал не правится ни одним символом; поведенческая сверка сравнивает два
ответа **одного прогона**, а не ответ с записанным ранее эталоном
- [x] Р2 **оба сторожа доказали, что умеют краснеть**: обход графа типов красен
на заведомо доменном значении, байтовое утверждение красно при переименовании
тега в типе провода
- [x] Р3 обход полон по позициям (поле, срез, массив, ключ и значение карты,
указатель, встроенное поле, неэкспортированное поле, анонимная вложенная
структура), не зацикливается на рекурсивном типе, а «внутренний пакет
проекта» определяется по `PkgPath` с префиксом module path, а не по имени
пакета; алиас доменного типа обход **не** пропускает
- [x] Р4 пути в обход сторожа названы поимённо (`any`/интерфейс, собственный
`MarshalJSON`, тип внешней зависимости), а исключение для дословного
содержимого сужено **до типа** `json.RawMessage`
- [x] Р5 вырожденные значения закреплены байтами: `{"metrics":[]}`, пустые
коллекции как `[]`, неизмеренное окно как `null`, `"style":"unknown"`
- [x] Р6 ответ детерминирован: форма провода не содержит `map`, два вызова на
одном входе дают те же байты
- [x] Р7 перевод — **чистая функция от снимка**: без `context`, без хранилища,
без часов; тело и валидатор `ETag` выводятся из одного снимка
- [x] Р8 ни одно утверждение не пришпилено к числу, производному от размера
корпуса, и к ходу часов; метки времени фикстур — литералы
- [x] Р9 форма провода утверждается **ровно в одном месте**, и это место
названо комментарием
- [x] Р10 отказные ответы принадлежат той же объявленной форме провода
- [x] Р11 образец масштабируется: помощник применяется таблицей «маршрут →
образец ответа», следующий маршрут добавляет строку
- [x] Р12 транспорт не выносит значения точек, имена метрик и токен в лог выше
`DEBUG` и не вкладывает доменное значение в текст ошибки
**Посылка поведенческой сверки, названная рядом с ней (Р1, Р8):** оба ответа
снимаются в одном прогоне против одного и того же файла базы при неработающем
приёме; `ETag` сравним только внутри одного часа — горизонт измерения входит в
метку и едет вместе с часами.