httpapi: форма провода читающих маршрутов объявлена транспортом
- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте, тело отказа тоже получило объявленный тип — байты ответа не изменились - заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до сериализации, плюс требование json-тега на полях транспортных структур и заведомо красные случаи к обоим правилам - решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в гейте получил свой кеш — общий на машину красил прогон находками из чужого worktree
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
# Форма провода принадлежит транспорту, а не домену
|
||||
|
||||
- **Дата:** 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` и комментарии-образце. Ноль строк сейчас,
|
||||
одна молчащая дыра на каждый забытый маршрут.
|
||||
|
||||
**Что в проекте считается спекой — контракт системы или ещё и дисциплина его
|
||||
смены.** Здесь развилка разрешена в сторону «спека нормирует наблюдаемое,
|
||||
дисциплина живёт в конвенциях»: этот выбор дешевле откатить, и у второго
|
||||
варианта нет предмета для сверки «спека → код». Прецедент задан на четыре
|
||||
следующие задачи цели — если владелец решит иначе, переносить придётся их все.
|
||||
@@ -33,6 +33,11 @@
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
|
||||
- [ADR-2026-08-04-forma-provoda-prinadlezhit-transportu](ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md)
|
||||
— публичный контракт чтения объявляет транспорт, а не домен; «доменные типы и
|
||||
есть форма провода» (`wtf`, Prometheus) отвергнуто фактом — поля `store.Point`
|
||||
не совпадают с обещанным проводом точек ни одним именем; `apidiff` как сторож
|
||||
отвергнут: смены `json`-тега он не видит вовсе.
|
||||
- [ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala](ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala.md)
|
||||
— признак «секция встречена впервые» выводится запросом к журналу; реестр по
|
||||
образцу `category_value` отвергнут как вторая копия факта, с названным
|
||||
|
||||
+46
-1
@@ -230,7 +230,7 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
||||
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
|
||||
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
|
||||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) |
|
||||
| `httpapi` | приём и read API | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md) |
|
||||
| `httpapi` | приём, read API и **форма провода** ответов чтения | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md), [`read-api`](../openspec/specs/read-api/spec.md) |
|
||||
|
||||
## Приём
|
||||
|
||||
@@ -1594,6 +1594,51 @@ GET /healthz
|
||||
семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
|
||||
что каждая новая метрика требует правки коллектора, а незнакомая теряется.
|
||||
|
||||
### Форма провода
|
||||
|
||||
**Форму ответа объявляет транспорт, а не домен.** Каждый читающий маршрут
|
||||
`internal/httpapi` держит собственные типы с `json`-тегами и переводит в них
|
||||
доменное значение присваиванием поле в поле; доменные типы (`internal/catalog`
|
||||
и далее) `json`-тегов не несут и до сериализации не доезжают. То же правило
|
||||
покрывает тело отказа. MCP собственной формы не объявляет — адаптер переводит
|
||||
вызовы в те же обработчики.
|
||||
|
||||
Цена названа с обеих сторон, потому что она обратная, а не односторонняя.
|
||||
|
||||
- **Домен = провод** (как было у каталога): формы объявлены один раз, перевода
|
||||
нет, ноль строк на маршрут. Платим тем, что публичный контракт меняется
|
||||
правкой домена **молча** — переименованием поля, разъединением встроенной
|
||||
структуры (плоскость `aggregation` была следствием встраивания `Basis`),
|
||||
появлением внутреннего поля. Ни одна из трёх правок транспорт не трогает.
|
||||
- **Раздельно** (взято): контракт меняется только правкой транспорта, то есть
|
||||
действием. Платим двумя вещами. Форма объявлена дважды — типы и перевод на
|
||||
каждый маршрут. И цена **обратная**: новое поле домена в ответ само не
|
||||
попадёт, его обязан перечислить перевод; поле, не доехавшее до клиента, —
|
||||
такой же дефект, как поле, уехавшее случайно, просто другой.
|
||||
|
||||
Развилку решил факт, а не вкус: провод точек обещан как
|
||||
`{ts, tz_offset, units, values}`, а `store.Point` несёт
|
||||
`{Start, End, OffsetSeconds, Raw}` — эти наборы не совпадают ни одним именем,
|
||||
и доменный тип формой провода там быть не может. Хранилище, кстати, уже живёт
|
||||
по этому правилу: формат `payload` объявлен отдельным неэкспортированным
|
||||
`storedPoint`, а `encodePayload` переводит в него полем в поле.
|
||||
|
||||
Сторожей два, и роли у них разные. **Обход графа типов ответа** (внутренний
|
||||
тест `httpapi`) утверждает, что домен до энкодера не доезжает — отсюда и
|
||||
следует, что переименование поля домена байт не меняет; рядом стоит заведомо
|
||||
красный случай, потому что проверка, доказывающая отсутствие, зелена и будучи
|
||||
сломанной. **Байтовый литерал** на каждую различимую форму ответа — детектор
|
||||
изменения формы: он краснеет в момент правки. Источником истины контракта он
|
||||
не является — им станет рукописная OpenAPI-спека, и сверку с маршрутами внесёт
|
||||
в гейт отдельная задача.
|
||||
|
||||
Разбор чужих решений (домен = провод у `wtf` и Prometheus; раздельно у Gitea,
|
||||
Docker и go-kit; версионирование с конверсией у Kubernetes; отвергнутый
|
||||
`apidiff`, который смены `json`-тега не видит вовсе) —
|
||||
[design.md изменения](../openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md).
|
||||
Ссылка markdown-ссылкой намеренно: инлайн-код `docs.py check` не проверяет, а
|
||||
путь угадывался до архивации.
|
||||
|
||||
### MCP
|
||||
|
||||
Поверх Read API — адаптер MCP, чтобы агент подключался без промежуточного
|
||||
|
||||
@@ -67,6 +67,26 @@
|
||||
экспортированные функции, обходит лишь записи, достижимые из словаря: с
|
||||
неплоской таблицей она остаётся зелёной (воспроизведено). Такие утверждения
|
||||
живут во внутреннем тесте пакета и перебирают саму структуру.
|
||||
- **Публичная форма ответа закрепляется байтами целого тела, и каждая различимая
|
||||
форма — своим литералом.** Разбор проглатывает молча ровно то, что клиент
|
||||
видит первым: `nil`-срез уезжает как `null`, отсутствующий ключ неотличим от
|
||||
ключа с нулём, а разыменованный `*time.Time` даёт правдоподобную дату
|
||||
`0001-01-01` вместо `null`. Тест, сличающий разобранные структуры или
|
||||
подстроки, зелен в каждом из этих случаев — проверка «в ответе есть
|
||||
`"first_hour"`» проходит и на нулевой дате. Различимых форм у ответа обычно
|
||||
больше одной (пустая коллекция, измеренное значение, неизмеренное), и литерал
|
||||
нужен каждой: одна закреплённая форма оставляет остальные без сторожа именно
|
||||
там, где ручной перевод и ошибается. Литерал при этом **детектор изменения**,
|
||||
а не источник истины контракта — правишь литерал, значит правишь контракт, и
|
||||
рядом обязана лежать правка спеки.
|
||||
- **Проверка, доказывающая ОТСУТСТВИЕ, несёт рядом заведомо красный случай.**
|
||||
«Доменного типа в графе ответа нет», «значения точки в логе нет», «записи в
|
||||
таблице нет» — все они зелены и будучи сломанными: протухшая константа,
|
||||
пропущенная позиция обхода, перепутанное сравнение выглядят снаружи как
|
||||
«искомого нет». Это обобщение двух правил ниже (отрицательный контроль для
|
||||
правил порядка; утверждение о таблице-константе обходит саму таблицу): у
|
||||
проверки на отсутствие обязан быть предъявленный вход, на котором она
|
||||
краснеет.
|
||||
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
|
||||
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
|
||||
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
|
||||
|
||||
@@ -552,3 +552,41 @@
|
||||
описывать тот случай, ради которого снято» действует только для тестов
|
||||
(`conventions/testing.md`). На `design.md` оно теперь распространено записью
|
||||
ниже, но механизировать его нечем.
|
||||
|
||||
## 2026-08-04 — гейт дважды покраснел от чужого мусора: кеш линтера и черновик в `./tmp` [пойман]
|
||||
|
||||
- **Где:** конвейер, а не код — `scripts/gate.py`, шаги `lint` и `test`
|
||||
- **Симптом:** в задаче про форму провода `task gate` дал `FAIL lint` с
|
||||
сообщением `../../internal/store/store.go:260: use of time.Now forbidden` —
|
||||
путь ведёт в **главный репозиторий**, а прогон шёл в worktree задачи. Позже, в
|
||||
том же прогоне задачи, `FAIL test` на
|
||||
`TestОднаМеткаИзТелаУбиваетМаршрутКаталога` — тесте, которого в задаче нет
|
||||
вовсе.
|
||||
- **Причина:** два разных механизма, один класс — в гейт затекает то, что к
|
||||
изменению отношения не имеет.
|
||||
- `golangci-lint` ходит в **общий на машину** `~/.cache/golangci-lint`, а
|
||||
конвейер задач работает в нескольких worktree одного модуля (`tmp/wt-*`).
|
||||
Кеш отдаёт замечания, привязанные к путям чужого дерева, и правило-исключение
|
||||
`^internal/(ident|store)/` на путь вида `../…` не распространяется.
|
||||
- `go test ./...` не знает про `./tmp`: `.golangci.yml` каталог исключает
|
||||
(коммит `9f77e56`), а `go test` — нет. Проход `adversary` оставил там свой
|
||||
падающий тест-оракул, и он стал частью набора.
|
||||
- **Чем воспроизведён:** первое — независимо проходом `review-gate`: временный
|
||||
worktree базовой ревизии, `golangci-lint run ./...` без очистки кеша даёт
|
||||
замечание с путём **другого** дерева; после `golangci-lint cache clean` на той
|
||||
же ревизии — `0 issues`. Второе — `tmp/gate/test.log`: `FAIL` в пакете
|
||||
`git.vakhrushev.me/av/healthlog/tmp/adv/oracle`.
|
||||
- **Чем пойман:** обоими случаями — самим гейтом, но **ценой разбора**: краснота
|
||||
выглядела как дефект изменения, и каждый раз пришлось доказывать, что это не
|
||||
он. Ровно та цена, что названа записью 2026-08-04 выше: «краснота по причине,
|
||||
не связанной с изменением, приучает не читать красноту».
|
||||
- **Что изменено:** шаг `lint` получил свой кеш —
|
||||
`GOLANGCI_LINT_CACHE=tmp/gate/golangci`, — то есть прогон стал герметичным по
|
||||
дереву. Цена названа и замерена проходом `ops`: N деревьев × 10–15 МиБ вместо
|
||||
одного общего кеша, штатный трим go-build-формата у него есть.
|
||||
- **Что осталось незакрытым:** `go test ./...` по-прежнему видит черновые
|
||||
go-пакеты в `./tmp`. Убирать за собой обязан тот, кто их создал (в этот раз —
|
||||
проход ревью), и механизма против забывчивости нет. Дешёвый кандидат, если
|
||||
класс повторится: `go test` по явному списку `./cmd/... ./internal/...` вместо
|
||||
`./...`. Не сделано намеренно — один случай не отличим от случайности, а
|
||||
правило, введённое по одному случаю, потом никто не помнит зачем.
|
||||
|
||||
Reference in New Issue
Block a user