Files
healthlog/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.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

31 KiB
Raw Blame History

Context

Читающий маршрут сегодня один — GET /api/v1/metrics. Его тело собирается прямой сериализацией доменных типов internal/catalog: Metric, LayerRange, Aggregation, Basis несут json-теги, Style несёт MarshalJSON, а транспорт владеет только оболочкой {"metrics": …} (66 строк internal/httpapi/catalog.go).

Из этого следуют три пути смены публичного контракта без единого касания транспорта, и все три выглядят как внутренняя правка домена:

  1. переименование поля (Metric.MetricMetric.Name);
  2. разъединение встроенной структурыAggregation встраивает Basis, и плоскость объекта aggregation на проводе есть следствие этого встраивания; вынеси Basis полем — и клиент получит вложенный объект;
  3. появление внутреннего поля в доменном типе — оно уезжает клиенту само.

Удерживает контракт один литерал в TestКаталогОтдаётОжидаемыеБайты, и нигде не сказано, что этот литерал и есть контракт.

Со следующей задачи цели образец копируют точки, тренировки, записи и MCP. Два факта проекта делают выбор не вкусовым:

  • internal/httpapi/ingest.go уже владеет своим типом ответа (ingestResponse с json-тегами). То есть образец «форма провода принадлежит транспорту» в проекте уже есть, и каталог от него отклоняется, а не наоборот.

  • Хранилище уже применяет ровно предлагаемое решение. store.Point (internal/store/bucket.go:24-29) не несёт json-тегов вовсе; формат сжатого payload объявлен отдельным неэкспортированным типом storedPoint (json:"s"/"e"/"o"/"p", там же, :774-782), а encodePayload переводит одно в другое полем в поле. То есть образец «своя форма — своим типом с явным переводом» в проекте есть и в записи тоже; каталог отклоняется от него, а не наоборот.

  • Правило «доменный тип и есть форма провода» ломается на втором же маршруте цели. Провод точек обещан как {ts, tz_offset, units, values} (docs/architecture.md, раздел «Форма ответа»), а store.Point несёт {Start, End, OffsetSeconds, Raw} — эти два набора не совпадают ни одним именем. Доменный тип формой провода там быть не может даже при желании.

    (Первая редакция этого абзаца утверждала, что store.Point «уже потратил свои json-теги» на формат хранения. Утверждение неверно, поймано проходом specs на профиле design и заменено на проверяемое; вывод от этого стал сильнее, а не слабее.)

Схема не трогается, миграции нет, витрина только читается.

Goals / Non-Goals

Goals:

  • Публичный контракт чтения перестаёт быть следствием формы доменных типов.
  • Правило одинаково применимо к четырём оставшимся маршрутам цели и к MCP.
  • Правило выражено проверкой, а не прозой: забыть его нельзя молча.
  • Байты ответа каталога не меняются — правка структурная, и это утверждается.
  • Решение и цена обеих сторон записаны там, где их найдёт следующая задача.

Non-Goals:

  • Версионирование контракта (/api/v2, conversion-слой). Потребителей нет, ломать нечего.
  • Машиночитаемая схема контракта — это отдельная цель (self-description).
  • Правка формы ответа каталога по существу: имена и состав полей остаются.
  • Разбор запроса. Речь только об ответе: у каталога параметров нет, а разбор параметров точек — задача точек.

Decisions

Прежде своего — prior art

Вопрос «домен или DTO на проводе» решался десятки раз, и литература по нему расколота. Обе стороны названы поимённо, потому что обе живые.

Домен = провод.

  • Ben Johnson, «Standard Package Layout» и benbjohnson/wtf — доменные типы корневого пакета несут json-теги напрямую, и HTTP-слой кодирует их без промежуточного типа; собственную структуру (findDialsResponse) транспорт заводит только там, где домена не хватает на конверт. Это ровно наше сегодняшнее состояние, включая оболочку catalogResponse.
  • Prometheus, web/api/v1 — смешанный вид: конверт (Response, QueryData) объявлен API-слоем, а полезная нагрузка внутри (parser.Value, stats.QueryStats) сериализуется доменными типами напрямую.

Домен и провод раздельны.

  • Giteamodules/structs (алиас api) отделён от models.
  • Dockerapi/types, разбитый по областям, отдельно от движка.
  • go-kit (Peter Bourgon) — service → endpoint → transport, «business logic should have no knowledge of endpoint- or transport-domain concepts».
  • Kubernetes — самая жёсткая форма: internal-типы против версионированных k8s.io/api плюс кодогенерируемая конверсия.
  • etcdetcdserverpb против mvccpb; разделение достаётся побочным эффектом protobuf, а не отдельным решением.
  • Mat Ryer, «How I write HTTP services in Go after 13 years» — ортогональный совет: типы запроса и ответа, нужные одному обработчику, объявляются рядом с ним, чтобы никто снаружи не начал опираться на форму, которую автор не считает устойчивой. Названа и цена: трение, когда те же типы понадобились тесту.

Что из этого взято и что отвергнуто.

  • Взят вид Gitea/Docker/Prometheus-конверта в редакции Райера: типы провода объявляются транспортом, живут рядом со своим обработчиком и неэкспортированы. Это же продолжает собственный образец проекта — ingestResponse.
  • Отвергнут wtf-вид (домен = провод) — не «нам не нравится», а по факту: он неприменим ко второму маршруту цели, потому что поля store.Point не совпадают с обещанным проводом точек ни одним именем. Правило, которое ломается на первом же копировании, правилом не является.
  • Отвергнут go-kit — слой endpoint покупает мультитранспортность и middleware-набор; у нас транспорта полтора (HTTP и MCP тем же процессом), а middleware даёт chi. Цена — три слоя и типы запроса/ответа на каждый метод.
  • Отвергнут вид Kubernetes — версионирование плюс конверсия — это подсистема, а не приём; она окупается там, где контракт обязан жить в нескольких версиях одновременно. У нас ноль потребителей и один владелец.
  • Отвергнут etcd/protobuf-first — транспорт задан снаружи: HAE шлёт JSON, агент читает JSON, MCP говорит JSON. Разделение через protobuf куплено бы зависимостью, которая никому здесь не нужна.

Чем это сторожится: байтовый детектор сегодня, рукописная спека потом

Разделение само по себе контракта не сторожит — оно лишь делает его смену явной правкой транспорта. Нужен ещё детектор. И роли здесь надо развести поимённо, потому что в этом же спринте владелец уже решил вторую половину вопроса.

  • Источник истины контракта — рукописная OpenAPI-спека. Решение владельца 2026-08-04, задача openapi-spec; сверку спеки с маршрутами вносит в гейт задача openapi-gate-check, и один из её критериев приёмки — «переименованное поле ответа красит гейт».
  • Байтовое утверждение — детектор изменения формы, а не сам контракт. Оно краснеет в момент правки, до всякого гейта, и в этом его роль; называть его «единственным утверждением контракта» нельзя — через две задачи спринта это станет ложью, и автор правки обновит литерал, не тронув рукописную спеку.
  • golang.org/x/exp/apidiff и go-apidiff отвергнуты, и причина измерима: они сравнивают Go-API на предмет компилируемости клиентского кода. Смена строки тега (json:"metric"json:"name") при неизменном Go-имени поля для них — не изменение вовсе. То есть ровно тот класс, ради которого заводится сторож, они не видят.
  • swaggo/генерация OpenAPI из кода отвергнута — и это отказ от генерации, а не от OpenAPI: генератор фотографирует то, что уже изменилось, то есть опечатка в имени поля становится частью спеки. Проект пошёл ровно обратным путём (спека первична коду, как и в OpenSpec).
  • Взят golden-подход (goldie, nao1215/golden и их обычай -update): побайтовое сравнение целого ответа. Библиотека при этом не берётся — литерал в тесте читается в диффе лучше, чем бинарный testdata-файл, а -update у литерала есть по построению (его правит рука). Метод числа: сегодняшний литерал internal/httpapi/catalog_test.go401 байт, это фикстура одной метрики с двумя слоями, а не ответ живого корпуса; для ответа с десятками метрик вывод «литерал читается лучше файла» не мерялся, и у точек его придётся принять заново (см. Risks).

Форма: типы провода в файле маршрута, перевод — явная функция

internal/httpapi/catalog.go объявляет metricWire, layerWire, aggregationWire и функцию перевода. Сигнатура выбрана одна и повторяется дословно во всех документах изменения:

func catalogWire(metrics []catalog.Metric) catalogResponse

Чистая функция от уже прочитанного значения: без context, без хранилища, без часов. Версия ответа в тело не идёт — она уезжает в ETag, и снимок у обоих один (catalog.Snapshot), так что 304 и 200 описывают одно состояние.

Поля перечисляются плоско и поимённо: плоскость объекта aggregation перестаёт быть следствием встраивания Basis в домене и становится записанным решением транспорта.

Тело отказа тоже получает объявленный тип: writeError сегодня собирает его из map[string]string (internal/httpapi/httpapi.go:161-163). Карта на проводе делает «два ответа совпадают побайтово» свойством библиотеки, а не решения, и переименование ключа error не видит ни один сторож. Байты при этом те же — {"error":"…"}.

Типы неэкспортированы и объявлены на уровне пакета, а не внутри обработчика (Райер советует внутри). Причина названа им же — трение с тестом: утверждение «домен на провод не попадает» обязано пройти по графу типов ответа, и функционально-локальный тип этому графу недоступен. Плата за отступление маленькая: пакет httpapi внутренний, «снаружи» здесь на один слой ближе.

Перевод — явное присваивание поле в поле. Отсюда симметрия, ради которой всё и делается: контракт не меняется случайно ни в одну сторону — ни переименование в домене не доезжает до клиента, ни новое доменное поле не уезжает клиенту само.

Style теряет MarshalJSON, но сохраняет String

aggregationWire.Style — обычная string, и кладёт её транспорт. Значения словаря (cumulative/instant/unknown) при этом остаются в домене: Style.String() нужен логам и сообщениям тестов, и провод зовёт его же — второго словаря не заводится.

Граница названа вслух, потому что она нарочно неполна: домен владеет значениями словаря, но не их сериализацией. Правка String() ради читаемости лога изменит тело ответа клиенту — этот путь остаётся, и сторожит его байтовое утверждение. Альтернатива (свой switch на проводе) сторожит лучше, но заводит второй словарь, который разъедется с первым молча; из двух цен взята эта, и она здесь записана, а не умолчана.

Проверка, выражающая правило: домен не достигает энкодера

Утверждение пишется прямо и механически: пройти по графу типов значения ответа и убедиться, что ни один тип в нём не принадлежит внутреннему пакету проекта, кроме самого httpapi. Тогда «переименование поля доменного типа не меняет байты ответа» — не обещание, а следствие: доменный тип до сериализации не доезжает вовсе.

Обход рекурсивен (структуры, срезы, массивы, ключи и значения карт, указатели, встроенные и неэкспортированные поля), защищён от самоссылающихся типов и живёт во внутреннем тесте пакета — снаружи catalogResponse не виден. Принадлежность пакету определяется по PkgPath с префиксом module path, а не по имени пакета: алиас (type wire = catalog.Metric) обход тогда не пропускает. Помощник пишется общим по значению ответа и применяется таблицей «маршрут → образец ответа»: следующие четыре маршрута добавляют строку, а не выбирают заново.

Стандартная библиотека проходит: time.Time сериализуется своим устойчивым контрактом (RFC 3339), json.RawMessage — дословный проброс, ради которого существует инвариант «точки хранятся дословно». Исключение задаётся по типу (json.RawMessage), а не по признаку «есть свой MarshalJSON»: второй признак вернул бы домен на провод ровно тем механизмом, который только что сняли со Style.

Заведомо красный случай обязателен. Проверка, доказывающая отсутствие чего-либо, зелена и будучи сломанной; у проекта это уже дважды всплывало (docs/conventions/testing.md: отрицательный контроль для правил порядка, утверждение о таблице-константе). Поэтому рядом стоит кейс с фикстурой, содержащей catalog.Metric, и ожиданием отказа — он же ловит устаревший префикс module path.

Куда пишется решение

Два адреса, и они разные по назначению.

  • docs/architecture.md, раздел «Read API», подраздел «Форма провода»: сам выбор, цена обеих сторон и ссылка сюда за prior art. Обоснование в architecture.md не переезжает — он обзор (CLAUDE.md).
  • docs/conventions/testing.md: правило о механизме проверки — байтовое утверждение на каждую различимую форму ответа и заведомо красный случай для структурной проверки. Дом у такого правила там, а не в спеке: спека нормирует наблюдаемое поведение системы, а не устройство репозитория. Оба прохода профиля design (specs и architecture) назвали это независимо, и дельта переписана — в ней остались только требования, у которых есть предмет для сверки «спека → код».

Risks / Trade-offs

  • Новое доменное поле не доезжает до клиента молча → это цена, взятая сознательно, и она обратная сегодняшней. Смягчение: перевод — одна функция в одном файле, а байтовое утверждение показывает текущий контракт целиком, так что «поле не доехало» видно на первом же прогоне, который его ждёт.
  • Двойное объявление формы (домен + провод) → цена в строках: у каталога это четыре типа и одна функция перевода; числом её здесь не называем, потому что мерить пока нечего — счёт станет известен по факту правки. Обратная цена (контракт как побочный эффект имён полей домена) уже измерена в постановке как причина задачи.
  • Райер советует локальные типы, взяты пакетные → чуть шире область видимости внутри httpapi. Отступление названо, причина — доступность графу типов в тесте.
  • Проверка «домен не достигает энкодера» обходит reflect.Type, а не значения → она слепа к типам, достижимым только через any/интерфейс, и к типам внешних зависимостей: чужой тип с чужими тегами обход пройдёт. У читающих маршрутов таких полей нет и не планируется; появятся — правило придётся усилить, и это названо здесь, а не обнаружится потом.
  • Байтовое утверждение растёт вместе с ответом → у точек тело может быть крупным, и вывод «литерал читается лучше файла», снятый на 401 байте каталога, там придётся принять заново. Смягчение: утверждать байты малого ответа (несколько точек), а объём проверять отдельно.
  • Словарь рода остаётся в домене → правка Style.String() меняет тело ответа. Смягчение и его предел названы в решении выше; сторожит байтовое утверждение.

Migration Plan

Миграции нет. Схема, витрина и архив не трогаются, конфиг не меняется. Откат — обратный коммит: наружу не уезжает ничего, кроме тех же байтов ответа.

Open Questions

Три вопроса, каждый — решение не этой задачи. Первые два принесло ревью кода (профиль deep), их оракулы прогнаны и лежат в review/triage.md.

1. Метка года 10000, принятая приёмом, навсегда убивает маршрут чтения

Что решить: чинить ли парой FormatTime/ParseTime замкнутость, отбором на границе разбора HAE, или обоими.

Путь (прогнан проходом adversary, оракул — падающий тест): тело доставки с "date": "9999-12-31 23:00:00 -0700" принимается (200), в UTC это год 10000, store.FormatTime молча печатает "10000-01-01T06:00:00Z" в hour_utc/first_ts/last_ts, а store.ParseTime (RFC 3339) на чтении отказывает — GET /api/v1/metrics отвечает 500 по всем метрикам. Состояние переживает пересборку: строка в витрине, тело в архиве, import + replay воспроизводит её.

Цена вариантов. Чинить только ParseTime нельзя: отказ json.Marshal станет достижим (writeJSON при ошибке кодирования отдаёт 200 с пустым телом — статус и ETag уже отправлены), а сравнение горизонта идёт как TEXT (hour_utc <= ?), и "10000-…" лексикографически меньше "2026-…" — то есть защита «свидетельство из будущего свидетельством не является» на этом входе не срабатывает вовсе.

Почему не здесь: весь задействованный код (internal/hae, internal/store) лежит вне рамок задачи («трогается только каталог и его транспорт»), а правка правила разбора тянет за собой триггер прохода reimpl и обязательные прогоны verify:archive / verify:busy.

Рекомендация: отдельная задача, секция ядро, до выкладки наружу — сегодня контур чтения открыт одной принятой доставкой.

2. Таблица образцов сторожа — opt-in, и забытая строка молчит

Что решить: проверять ли полноту таблицы «маршрут → образец ответа» в wire_internal_test.go машиной.

Цена вариантов. (а) обход роутера (chi.Walk) с утверждением, что число читающих маршрутов равно числу строк таблицы — около 15 строк, забывание краснеет, цена — сцепка теста с роутером; (б) тестовый hook в writeJSON, собирающий типы реально закодированных ответов — ноль мест на новый маршрут, но шов в продакшн-коде; (в) оставить на спеке read-api и комментарии-образце — ноль строк сейчас, одна молчащая дыра на каждый забытый маршрут.

Что стоит, пока решения нет: сторож покрывает три типа, идущие через writeJSON сегодня (каталог, тело отказа, учёт приёма). Впереди четыре маршрута и MCP — четыре шанса забыть.

Рекомендация: (а). Это ровно тот класс «проверка отсутствия зелена и будучи сломанной», против которого это же изменение завело конвенцию заведомо красного случая; на полноту таблицы конвенция не распространена.

3. Что в проекте считается спекой — контракт системы или ещё и дисциплина его смены

Вопрос процесса, адресован владельцу. Проход architecture назвал это развилкой на профиле design; здесь она разрешена в сторону «спека нормирует наблюдаемое, дисциплина живёт в конвенциях», потому что этот выбор дешевле откатить (правило переносится между двумя файлами) и потому что у второго варианта нет предмета для сверки «спека → код». Прецедент задан на четыре следующие задачи цели — если владелец решит иначе, переносить придётся их все.


Развилка «домен или транспорт» решена выше фактом (поля store.Point не совпадают с обещанным проводом точек ни одним именем), а не предпочтением.

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