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

11 KiB
Raw Blame History

Форма провода принадлежит транспорту, а не домену

  • Дата: 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 и комментарии-образце. Ноль строк сейчас, одна молчащая дыра на каждый забытый маршрут.

Что в проекте считается спекой — контракт системы или ещё и дисциплина его смены. Здесь развилка разрешена в сторону «спека нормирует наблюдаемое, дисциплина живёт в конвенциях»: этот выбор дешевле откатить, и у второго варианта нет предмета для сверки «спека → код». Прецедент задан на четыре следующие задачи цели — если владелец решит иначе, переносить придётся их все.