Files
healthlog/docs/adr/ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md
T
av a834d10415 httpapi: форма провода читающих маршрутов объявлена транспортом
- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы
  metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте,
  тело отказа тоже получило объявленный тип — байты ответа не изменились
- заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до
  сериализации, плюс требование json-тега на полях транспортных структур и
  заведомо красные случаи к обоим правилам
- решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в
  гейте получил свой кеш — общий на машину красил прогон находками из чужого
  worktree
2026-08-04 16:18:05 +03:00

127 lines
11 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.
# Форма провода принадлежит транспорту, а не домену
- **Дата:** 2026-08-04
- **Источник:** openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md
## Решение
Публичный контракт читающих маршрутов объявляет транспорт: `internal/httpapi`
держит собственные типы с `json`-тегами и переводит в них доменное значение
присваиванием поле в поле. Доменные типы (`internal/catalog` и далее) тегов не
несут и до сериализации не доезжают. То же правило покрывает тело отказа; MCP
собственной формы не объявляет.
Противоположное решение — **доменные типы объявлены формой провода намеренно**
рассмотрено первым как живая и уважаемая практика и отвергнуто по названной
причине.
## Почему
Каталог до этого изменения жил вторым способом: `internal/catalog` сам нёс
`json`-теги и `Style.MarshalJSON`, а транспорт владел только оболочкой
`{"metrics": …}`. Отсюда три пути смены **публичного** контракта, ни один из
которых не касается транспорта и все три выглядят как внутренняя правка:
переименование поля; разъединение встроенного `Basis` (плоскость объекта
`aggregation` была следствием встраивания); появление внутреннего поля.
Удерживал контракт один литерал в тесте, и о том, что этот литерал и есть
контракт, не было сказано нигде.
Решение принималось до того, как образец скопируют четыре маршрута и MCP —
потом это была бы не развилка, а археология.
Литература расколота, и обе стороны названы в источнике поимённо: домен = провод
у Ben Johnson (`benbjohnson/wtf` — доменные типы корневого пакета несут теги
напрямую) и у Prometheus (`web/api/v1` — конверт свой, полезная нагрузка
доменная); раздельно у Gitea (`modules/structs` против `models`), Docker
(`api/types`), go-kit (service → endpoint → transport) и Kubernetes (internal
против версионированных `k8s.io/api` плюс кодогенерируемая конверсия).
Ортогональный совет Mat Ryer — объявлять типы ответа рядом с их обработчиком —
взят вместе с названной им ценой.
Развилку решил **факт проекта, а не вкус**. Цитата из источника:
> Правило «доменный тип и есть форма провода» ломается на втором же маршруте
> цели. Провод точек обещан как `{ts, tz_offset, units, values}`
> (`docs/architecture.md`, раздел «Форма ответа»), а `store.Point` несёт
> `{Start, End, OffsetSeconds, Raw}` — эти два набора не совпадают **ни одним
> именем**. Доменный тип формой провода там быть не может даже при желании.
Второй факт — внутренний прецедент, и он в ту же сторону:
> Хранилище уже применяет ровно предлагаемое решение. `store.Point` не несёт
> `json`-тегов вовсе; формат сжатого `payload` объявлен **отдельным
> неэкспортированным** типом `storedPoint`, а `encodePayload` переводит одно в
> другое **полем в поле**.
Плюс `internal/httpapi/ingest.go`, который своим типом ответа владел с самого
начала. То есть решение **устраняет** второй способ, а не заводит его: каталог
был отклонением от уже принятого в проекте образца.
Отдельная развилка того же изменения — **чем контракт сторожится**, и там тоже
есть поимённый отказ:
> `golang.org/x/exp/apidiff` и `go-apidiff` отвергнуты, и причина измерима: они
> сравнивают **Go-API** на предмет компилируемости клиентского кода. Смена
> строки тега (`json:"metric"` → `json:"name"`) при неизменном Go-имени поля для
> них — не изменение вовсе. То есть ровно тот класс, ради которого заводится
> сторож, они не видят.
Генерация OpenAPI из кода (`swaggo`) отвергнута как сторож по другой причине —
она фотографирует уже случившееся, — но не как способ **опубликовать** контракт:
владелец решил в этом же спринте, что источником истины будет рукописная
OpenAPI-спека. Байтовое утверждение поэтому названо **детектором изменения**, а
не контрактом.
## Последствия
- `+` Публичный контракт чтения перестал быть побочным эффектом имён полей
домена. Переименование поля домена ломает компиляцию перевода — разработчику
говорят в момент правки; байты ответа при этом те же (проверено: сборка
базовой ревизии и сборка ветки против одного файла базы дали побайтово
идентичные 2268 байт).
- `+` Появился машинный сторож: обход графа типов ответа утверждает, что ни один
тип домена не достигает сериализации, а требование `json`-тега на каждом
экспортированном поле транспортной структуры закрывает калитку
`type pointWire store.Point`. Рядом — заведомо красный случай на 13 позиций,
потому что проверка, доказывающая отсутствие, зелена и будучи сломанной.
- `+` Плоскость объекта `aggregation` перестала быть следствием встраивания
`Basis` в домене и стала записанным решением транспорта.
- `` **Цена обратная, и она взята сознательно:** новое поле домена в ответ само
не попадёт — его обязан перечислить перевод. Поле, не доехавшее до клиента, —
такой же дефект, как поле, уехавшее случайно, просто другой.
- `` Форма объявлена дважды: типы плюс перевод на каждый маршрут.
- `` Словарь рода остался в домене (`Style.String()`), и провод зовёт его же.
Правка `String()` ради читаемости лога изменит тело ответа клиенту. Из двух
цен взята эта: свой `switch` на проводе сторожил бы лучше, но завёл бы второй
словарь, который разошёлся бы с первым молча.
- `` Сторож остаётся **opt-in**: маршрут, забывший строку в таблице образцов,
останется без него молча. Развилка вынесена владельцу (см. ниже).
- `` Обход слеп к типам, достижимым только через `any`/интерфейс, и к типам
внешних зависимостей. Слепота названа в источнике и воспроизведена замером,
а не предположена.
## Открыто, решает владелец
Записано здесь, а не в файле задачи: файл закрытой задачи удаляется.
**Проверять ли полноту таблицы образцов машиной.** Сторож покрывает три типа,
идущие через `writeJSON` сегодня; впереди четыре маршрута и MCP — четыре шанса
забыть строку, и забытая строка не отличима от отсутствия проблемы.
- **(а)** обход роутера (`chi.Walk`) с утверждением, что число читающих
маршрутов равно числу строк таблицы. Около 15 строк, забывание краснеет; цена
— сцепка теста с роутером. **Рекомендация:** это ровно тот класс «проверка
отсутствия зелена и будучи сломанной», против которого это же изменение завело
конвенцию заведомо красного случая, — а на полноту таблицы конвенция не
распространена.
- **(б)** тестовый hook в `writeJSON`, собирающий типы реально закодированных
ответов. Ноль мест на новый маршрут, но шов в продакшн-коде.
- **(в)** оставить на спеке `read-api` и комментарии-образце. Ноль строк сейчас,
одна молчащая дыра на каждый забытый маршрут.
**Что в проекте считается спекой — контракт системы или ещё и дисциплина его
смены.** Здесь развилка разрешена в сторону «спека нормирует наблюдаемое,
дисциплина живёт в конвенциях»: этот выбор дешевле откатить, и у второго
варианта нет предмета для сверки «спека → код». Прецедент задан на четыре
следующие задачи цели — если владелец решит иначе, переносить придётся их все.