# Форма провода принадлежит транспорту, а не домену - **Дата:** 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` и комментарии-образце. Ноль строк сейчас, одна молчащая дыра на каждый забытый маршрут. **Что в проекте считается спекой — контракт системы или ещё и дисциплина его смены.** Здесь развилка разрешена в сторону «спека нормирует наблюдаемое, дисциплина живёт в конвенциях»: этот выбор дешевле откатить, и у второго варианта нет предмета для сверки «спека → код». Прецедент задан на четыре следующие задачи цели — если владелец решит иначе, переносить придётся их все.