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