- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте, тело отказа тоже получило объявленный тип — байты ответа не изменились - заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до сериализации, плюс требование json-тега на полях транспортных структур и заведомо красные случаи к обоим правилам - решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в гейте получил свой кеш — общий на машину красил прогон находками из чужого worktree
11 KiB
Форма провода принадлежит транспорту, а не домену
- Дата: 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и комментарии-образце. Ноль строк сейчас, одна молчащая дыра на каждый забытый маршрут.
Что в проекте считается спекой — контракт системы или ещё и дисциплина его смены. Здесь развилка разрешена в сторону «спека нормирует наблюдаемое, дисциплина живёт в конвенциях»: этот выбор дешевле откатить, и у второго варианта нет предмета для сверки «спека → код». Прецедент задан на четыре следующие задачи цели — если владелец решит иначе, переносить придётся их все.