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,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` сравним только внутри одного часа — горизонт измерения входит в
метку и едет вместе с часами.