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

357 lines
31 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.
## Context
Читающий маршрут сегодня один — `GET /api/v1/metrics`. Его тело собирается
прямой сериализацией доменных типов `internal/catalog`: `Metric`, `LayerRange`,
`Aggregation`, `Basis` несут `json`-теги, `Style` несёт `MarshalJSON`, а
транспорт владеет только оболочкой `{"metrics": …}` (66 строк
`internal/httpapi/catalog.go`).
Из этого следуют три пути смены публичного контракта **без единого касания
транспорта**, и все три выглядят как внутренняя правка домена:
1. переименование поля (`Metric.Metric``Metric.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](../../../docs/tasks/items/self-description.md)).
- Правка формы ответа каталога по существу: имена и состав полей остаются.
- Разбор запроса. Речь только об **ответе**: у каталога параметров нет, а
разбор параметров точек — задача точек.
## 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`) сериализуется доменными типами напрямую.
**Домен и провод раздельны.**
- **Gitea** — `modules/structs` (алиас `api`) отделён от `models`.
- **Docker** — `api/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` плюс кодогенерируемая конверсия.
- **etcd** — `etcdserverpb` против `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](../../../docs/tasks/items/openapi-spec.md);
сверку спеки с маршрутами вносит в гейт задача
[openapi-gate-check](../../../docs/tasks/items/openapi-gate-check.md), и один
из её критериев приёмки — «переименованное поле ответа красит гейт».
- **Байтовое утверждение — детектор изменения формы, а не сам контракт.** Оно
краснеет **в момент правки**, до всякого гейта, и в этом его роль; называть
его «единственным утверждением контракта» нельзя — через две задачи спринта
это станет ложью, и автор правки обновит литерал, не тронув рукописную спеку.
- **`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.go`**401 байт**, это фикстура одной
метрики с двумя слоями, а не ответ живого корпуса; для ответа с десятками
метрик вывод «литерал читается лучше файла» не мерялся, и у точек его
придётся принять заново (см. Risks).
### Форма: типы провода в файле маршрута, перевод — явная функция
`internal/httpapi/catalog.go` объявляет `metricWire`, `layerWire`,
`aggregationWire` и функцию перевода. Сигнатура выбрана одна и повторяется
дословно во всех документах изменения:
```go
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](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` назвал это развилкой; здесь она
разрешена в сторону «спека нормирует наблюдаемое, дисциплина живёт в
конвенциях», потому что этот выбор дешевле откатить (правило переносится между
двумя файлами) и потому что у второго варианта нет предмета для сверки
«спека → код». Прецедент при этом задан на четыре следующие задачи цели — если
владелец решит иначе, переносить придётся их все.