From a834d1041556ef3089e044fed4b12fd760023808 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Tue, 4 Aug 2026 16:18:05 +0300 Subject: [PATCH] =?UTF-8?q?httpapi:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=BE=D0=B4=D0=B0=20=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D1=8E=D1=89=D0=B8=D1=85=20=D0=BC=D0=B0=D1=80=D1=88=D1=80?= =?UTF-8?q?=D1=83=D1=82=D0=BE=D0=B2=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=82=D1=80=D0=B0=D0=BD=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте, тело отказа тоже получило объявленный тип — байты ответа не изменились - заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до сериализации, плюс требование json-тега на полях транспортных структур и заведомо красные случаи к обоим правилам - решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в гейте получил свой кеш — общий на машину красил прогон находками из чужого worktree --- ...4-forma-provoda-prinadlezhit-transportu.md | 126 +++++++ docs/adr/README.md | 5 + docs/architecture.md | 47 ++- docs/conventions/testing.md | 20 + docs/review.md | 38 ++ internal/catalog/catalog.go | 54 +-- internal/catalog/measure_test.go | 23 +- internal/httpapi/catalog.go | 113 +++++- internal/httpapi/catalog_test.go | 54 ++- internal/httpapi/httpapi.go | 16 +- internal/httpapi/wire_internal_test.go | 223 +++++++++++ .../.openspec.yaml | 2 + .../design.md | 356 ++++++++++++++++++ .../proposal.md | 75 ++++ .../review/triage.md | 271 +++++++++++++ .../specs/read-api/spec.md | 97 +++++ .../tasks.md | 126 +++++++ openspec/specs/read-api/spec.md | 111 ++++++ scripts/gate.py | 17 +- 19 files changed, 1721 insertions(+), 53 deletions(-) create mode 100644 docs/adr/ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md create mode 100644 internal/httpapi/wire_internal_test.go create mode 100644 openspec/changes/archive/2026-08-04-forma-provoda-chteniya/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md create mode 100644 openspec/changes/archive/2026-08-04-forma-provoda-chteniya/proposal.md create mode 100644 openspec/changes/archive/2026-08-04-forma-provoda-chteniya/review/triage.md create mode 100644 openspec/changes/archive/2026-08-04-forma-provoda-chteniya/specs/read-api/spec.md create mode 100644 openspec/changes/archive/2026-08-04-forma-provoda-chteniya/tasks.md create mode 100644 openspec/specs/read-api/spec.md diff --git a/docs/adr/ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md b/docs/adr/ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md new file mode 100644 index 0000000..3bd72be --- /dev/null +++ b/docs/adr/ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md @@ -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` и комментарии-образце. Ноль строк сейчас, + одна молчащая дыра на каждый забытый маршрут. + +**Что в проекте считается спекой — контракт системы или ещё и дисциплина его +смены.** Здесь развилка разрешена в сторону «спека нормирует наблюдаемое, +дисциплина живёт в конвенциях»: этот выбор дешевле откатить, и у второго +варианта нет предмета для сверки «спека → код». Прецедент задан на четыре +следующие задачи цели — если владелец решит иначе, переносить придётся их все. diff --git a/docs/adr/README.md b/docs/adr/README.md index a10b33a..47bde12 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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` отвергнут как вторая копия факта, с названным diff --git a/docs/architecture.md b/docs/architecture.md index bd19f37..a65e52a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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, чтобы агент подключался без промежуточного diff --git a/docs/conventions/testing.md b/docs/conventions/testing.md index 48962c2..f2bb36b 100644 --- a/docs/conventions/testing.md +++ b/docs/conventions/testing.md @@ -67,6 +67,26 @@ экспортированные функции, обходит лишь записи, достижимые из словаря: с неплоской таблицей она остаётся зелёной (воспроизведено). Такие утверждения живут во внутреннем тесте пакета и перебирают саму структуру. +- **Публичная форма ответа закрепляется байтами целого тела, и каждая различимая + форма — своим литералом.** Разбор проглатывает молча ровно то, что клиент + видит первым: `nil`-срез уезжает как `null`, отсутствующий ключ неотличим от + ключа с нулём, а разыменованный `*time.Time` даёт правдоподобную дату + `0001-01-01` вместо `null`. Тест, сличающий разобранные структуры или + подстроки, зелен в каждом из этих случаев — проверка «в ответе есть + `"first_hour"`» проходит и на нулевой дате. Различимых форм у ответа обычно + больше одной (пустая коллекция, измеренное значение, неизмеренное), и литерал + нужен каждой: одна закреплённая форма оставляет остальные без сторожа именно + там, где ручной перевод и ошибается. Литерал при этом **детектор изменения**, + а не источник истины контракта — правишь литерал, значит правишь контракт, и + рядом обязана лежать правка спеки. +- **Проверка, доказывающая ОТСУТСТВИЕ, несёт рядом заведомо красный случай.** + «Доменного типа в графе ответа нет», «значения точки в логе нет», «записи в + таблице нет» — все они зелены и будучи сломанными: протухшая константа, + пропущенная позиция обхода, перепутанное сравнение выглядят снаружи как + «искомого нет». Это обобщение двух правил ниже (отрицательный контроль для + правил порядка; утверждение о таблице-константе обходит саму таблицу): у + проверки на отсутствие обязан быть предъявленный вход, на котором она + краснеет. - **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока находится в ней сама: проверка на «5.1» краснела примерно раз на сотню diff --git a/docs/review.md b/docs/review.md index 1b1f904..43db2aa 100644 --- a/docs/review.md +++ b/docs/review.md @@ -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/...` вместо + `./...`. Не сделано намеренно — один случай не отличим от случайности, а + правило, введённое по одному случаю, потом никто не помнит зачем. diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index d90a440..80b66c8 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -63,12 +63,10 @@ func (s Style) String() string { } } -// MarshalJSON отдаёт род строкой. Нулевое значение уезжает как `unknown`, а не -// как пустая строка: клиент не должен видеть в ответе состояние, которого в -// словаре нет. -func (s Style) MarshalJSON() ([]byte, error) { - return []byte(`"` + s.String() + `"`), nil -} +// Собственной сериализации у Style нет намеренно: строку в ответ кладёт +// транспорт (`internal/httpapi`, форма провода). Домен владеет ЗНАЧЕНИЯМИ +// словаря, а не их видом на проводе; `String()` при этом нужен и логам, и +// сообщениям тестов, и проводу — второго словаря заводить незачем. // Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова, // потому что от них зависят счётчики основания в ответе. @@ -156,6 +154,16 @@ func clipMetric(metric string) string { return metric[:maxMetricInLog] + "…" } +// ФОРМЫ ПРОВОДА В ЭТОМ ПАКЕТЕ НЕТ, и это решение, а не упущение. +// +// Типы ниже — форма ответа use-case, а не форма ответа HTTP: `json`-тегов они +// не несут и до сериализации не доезжают. Публичный контракт чтения объявляет +// транспорт (`internal/httpapi`), поэтому переименование поля здесь байты +// ответа клиенту не меняет — оно ломает компиляцию перевода. Обратная цена +// названа вслух: новое поле само в ответ не попадёт, его обязан перечислить +// транспорт. Решение и цена обеих сторон — `docs/architecture.md`, раздел +// «Read API», подраздел «Форма провода». + // Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их // разности были осмысленны: `Hours − Compared` — часы, отброшенные проверкой // пригодности, `Compared − Agreeing − Conflicting` — часы, не сошедшиеся ни с @@ -165,17 +173,21 @@ func clipMetric(metric string) string { // и «часов не было вовсе» — разные события, и клиент обязан различать их без // второго запроса. type Basis struct { - Hours int `json:"hours"` - Compared int `json:"compared"` - Agreeing int `json:"agreeing"` - Conflicting int `json:"conflicting"` - FirstHour *time.Time `json:"first_hour"` - LastHour *time.Time `json:"last_hour"` + Hours int + Compared int + Agreeing int + Conflicting int + FirstHour *time.Time + LastHour *time.Time } // Aggregation — род вместе с основанием. +// +// Встраивание здесь — удобство домена, а не форма ответа: плоскость объекта +// `aggregation` на проводе объявлена транспортом поимённо и от этого +// встраивания не зависит. type Aggregation struct { - Style Style `json:"style"` + Style Style Basis } @@ -186,22 +198,22 @@ type Aggregation struct { // часовых объектов здесь нет: объект — деталь хранения, клиент про него не // знает. type LayerRange struct { - Layer string `json:"layer"` - From time.Time `json:"from"` - To time.Time `json:"to"` - Points int `json:"points"` + Layer string + From time.Time + To time.Time + Points int } // Metric — запись каталога. type Metric struct { - Metric string `json:"metric"` + Metric string // Units — множество различных единиц метрики, отсортированное. Массив, а не // строка: на живом потоке единицы не менялись ни разу, но одна форма поля // для обоих случаев честнее строки, которая при расхождении молча выберет // одно из двух. - Units []string `json:"units"` - Aggregation Aggregation `json:"aggregation"` - Layers []LayerRange `json:"layers"` + Units []string + Aggregation Aggregation + Layers []LayerRange } // Snapshot — каталог вместе с версией ответа. diff --git a/internal/catalog/measure_test.go b/internal/catalog/measure_test.go index a73b60b..ab0454d 100644 --- a/internal/catalog/measure_test.go +++ b/internal/catalog/measure_test.go @@ -271,22 +271,25 @@ func TestMeasureПротиворечиеСПеревесомМгновенной } } +// Словарь рода живёт в домене, а его вид на проводе объявляет транспорт: род +// уезжает клиенту строкой, которую кладёт `internal/httpapi`, вызывая этот же +// `String()`. Поэтому проверяется словарь, а не сериализация — второй словарь на +// проводе разошёлся бы с этим молча. +// +// Незнакомое значение даёт `unknown`, а не пустую строку: клиент не должен +// видеть состояние, которого в словаре нет. func TestStyleСловарь(t *testing.T) { t.Parallel() cases := map[catalog.Style]string{ - catalog.Cumulative: `"cumulative"`, - catalog.Instant: `"instant"`, - catalog.Unknown: `"unknown"`, - catalog.Style(42): `"unknown"`, + catalog.Cumulative: "cumulative", + catalog.Instant: "instant", + catalog.Unknown: "unknown", + catalog.Style(42): "unknown", } for style, want := range cases { - got, err := json.Marshal(style) - if err != nil { - t.Fatalf("сериализация %v: %v", style, err) - } - if string(got) != want { - t.Errorf("род %d: получили %s, ждали %s", style, got, want) + if got := style.String(); got != want { + t.Errorf("род %d: получили %q, ждали %q", style, got, want) } } } diff --git a/internal/httpapi/catalog.go b/internal/httpapi/catalog.go index 61bdac0..c0877ea 100644 --- a/internal/httpapi/catalog.go +++ b/internal/httpapi/catalog.go @@ -2,26 +2,123 @@ package httpapi import ( "net/http" + "time" "git.vakhrushev.me/av/healthlog/internal/catalog" ) +// ФОРМА ПРОВОДА КАТАЛОГА. Публичный контракт объявлен здесь и только здесь — +// доменные типы `internal/catalog` `json`-тегов не несут и до сериализации не +// доезжают. Отсюда следует то, ради чего это сделано: переименование поля в +// домене байты ответа не меняет, а смена контракта есть правка вот этих +// объявлений, то есть действие, а не побочный эффект. +// +// Обратная цена взята сознательно: новое поле домена в ответ само не попадёт — +// его обязан перечислить перевод ниже. Решение, цена обеих сторон и разбор +// чужих решений — `docs/architecture.md`, раздел «Read API», подраздел «Форма +// провода». +// +// ОБРАЗЕЦ ДЛЯ СЛЕДУЮЩИХ МАРШРУТОВ: точки, тренировки, записи и MCP объявляют +// свою форму так же — типами рядом с обработчиком, переводом-присваиванием, +// строкой в таблице `wire_internal_test.go`. Выбирать заново не нужно. + // catalogResponse — оболочка ответа каталога. // // Объект, а не голый массив: список метрик — не единственное, что каталогу // когда-нибудь придётся отдать, а массив верхнего уровня расширить нечем. type catalogResponse struct { - Metrics []catalog.Metric `json:"metrics"` + Metrics []metricWire `json:"metrics"` +} + +// metricWire — запись каталога на проводе. +type metricWire struct { + Metric string `json:"metric"` + Units []string `json:"units"` + Aggregation aggregationWire `json:"aggregation"` + Layers []layerWire `json:"layers"` +} + +// aggregationWire — род агрегации вместе с основанием, ПЛОСКО. +// +// Поля основания перечислены здесь поимённо, а не встроены структурой: в домене +// `Basis` встроен в `Aggregation`, и плоскость объекта была следствием этого +// встраивания — разъединение в домене молча дало бы клиенту вложенный объект. +// Теперь плоскость — записанное решение транспорта. +// +// Род — обычная `string`: словарь (`cumulative`/`instant`/`unknown`) клиент +// видит строкой, и кладёт её сюда транспорт. Значения словаря при этом остаются +// доменные (`catalog.Style.String()`) — свой `switch` здесь завёл бы второй +// словарь, который разошёлся бы с первым молча. +type aggregationWire struct { + Style string `json:"style"` + Hours int `json:"hours"` + Compared int `json:"compared"` + Agreeing int `json:"agreeing"` + Conflicting int `json:"conflicting"` + FirstHour *time.Time `json:"first_hour"` + LastHour *time.Time `json:"last_hour"` +} + +// layerWire — разрез метрики на проводе. +type layerWire struct { + Layer string `json:"layer"` + From time.Time `json:"from"` + To time.Time `json:"to"` + Points int `json:"points"` +} + +// catalogWire переводит записи домена в форму провода. +// +// Чистая функция от уже прочитанного значения: ни `context`, ни хранилища, ни +// часов. Версия снимка сюда не идёт — она уезжает в `ETag`, и снимок у тела и у +// метки один, иначе `304` подтверждал бы одно состояние, а `200` отдавал другое. +// +// Присваивание поле в поле, а не копирование структуры: это и есть то место, где +// смена контракта становится видимой правкой. +func catalogWire(metrics []catalog.Metric) catalogResponse { + // Непустой срез, а не nil: nil сериализуется в `null`, и пустая витрина + // отдавала бы клиенту `"metrics": null` вместо `[]`. Домен это уже + // обеспечивает, но контракт объявлен здесь — значит и держится здесь. + out := make([]metricWire, 0, len(metrics)) + for _, m := range metrics { + layers := make([]layerWire, 0, len(m.Layers)) + for _, l := range m.Layers { + layers = append(layers, layerWire{ + Layer: l.Layer, + From: l.From, + To: l.To, + Points: l.Points, + }) + } + // Так же, как слои: `make` + `append`, а не копирование среза домена. + // Ветвления «если nil» здесь нет намеренно — оно было бы веткой, которую + // сегодня не проходит ни один вход (домен nil не отдаёт), то есть + // непроверяемой страховкой. Конструкция даёт непустой срез всегда. + units := make([]string, 0, len(m.Units)) + units = append(units, m.Units...) + out = append(out, metricWire{ + Metric: m.Metric, + Units: units, + Aggregation: aggregationWire{ + Style: m.Aggregation.Style.String(), + Hours: m.Aggregation.Hours, + Compared: m.Aggregation.Compared, + Agreeing: m.Aggregation.Agreeing, + Conflicting: m.Aggregation.Conflicting, + // Указатели переносятся КАК УКАЗАТЕЛИ: разыменование дало бы + // `0001-01-01T00:00:00Z` там, где окно не измерено, а + // правдоподобная дата в ответе неотличима от настоящей. + FirstHour: m.Aggregation.FirstHour, + LastHour: m.Aggregation.LastHour, + }, + Layers: layers, + }) + } + return catalogResponse{Metrics: out} } // handleMetrics отдаёт каталог разрезов с измеренным родом агрегации. // -// Список приходит из домена уже непустым срезом: nil сериализуется в `null`, и -// пустая витрина отдавала бы клиенту `"metrics": null` вместо `[]`. Тест, -// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная -// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно: -// подстраховка поверх подстраховки прячет отказ первой. -// // Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки // снимка: в этом весь смысл — самый частый запрос потребителя есть повтор // неизменившегося, и он не должен стоить ни снимка, ни разжатия точек. @@ -62,5 +159,5 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) { // ответ уходит без метки: это ровно поведение до появления условного // запроса, то есть деградация в безопасную сторону. setReadHeaders(w, etag(scopeMetrics, snap.Version)) - writeJSON(w, http.StatusOK, catalogResponse{Metrics: snap.Metrics}) + writeJSON(w, http.StatusOK, catalogWire(snap.Metrics)) } diff --git a/internal/httpapi/catalog_test.go b/internal/httpapi/catalog_test.go index 905bbe4..7519724 100644 --- a/internal/httpapi/catalog_test.go +++ b/internal/httpapi/catalog_test.go @@ -41,7 +41,12 @@ func TestКаталогПустойВитриныОтдаётПустойСпи // Границы неизмеренного окна уезжают как `null`, а не как правдоподобная метка // `0001-01-01`: нулевое время в ответе неотличимо от данных. -func TestКаталогНеизмеренноеОкноОтдаётNull(t *testing.T) { +// +// Сравниваются БАЙТЫ ЦЕЛОГО ТЕЛА, а не подстроки. Это ветка `*time.Time`, где +// перевод домена в форму провода идёт присваиванием поле в поле: разыменуй +// указатель — и получишь `"first_hour":"0001-01-01T00:00:00Z"`, в котором +// подстрока `"first_hour"` по-прежнему есть, а обещание нарушено. +func TestКаталогНеизмеренноеОкноОтдаётБайтыСNull(t *testing.T) { h, st, _ := newAPITokens(t, nil, nil) at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC) @@ -53,11 +58,13 @@ func TestКаталогНеизмеренноеОкноОтдаётNull(t *testi t.Fatalf("слияние: %v", err) } - body := getCatalog(t, h, "").Body.String() - for _, want := range []string{`"style":"unknown"`, `"first_hour":null`, `"last_hour":null`, `"hours":0`} { - if !strings.Contains(body, want) { - t.Errorf("в ответе нет %s: %s", want, body) - } + want := `{"metrics":[{"metric":"vo2_max","units":["ml/(kg·min)"],` + + `"aggregation":{"style":"unknown","hours":0,"compared":0,"agreeing":0,` + + `"conflicting":0,"first_hour":null,"last_hour":null},` + + `"layers":[{"layer":"raw","from":"2026-06-01T10:00:00Z","to":"2026-06-01T10:00:00Z","points":1}]}]}` + + if got := strings.TrimSpace(getCatalog(t, h, "").Body.String()); got != want { + t.Errorf("форма неизмеренного окна изменилась:\n получили %s\n ждали %s", got, want) } } @@ -123,9 +130,15 @@ func TestТокенЧтенияНеОседаетВУчётеДоставки(t } // Форма ответа закреплена БАЙТАМИ на непустой витрине, а не подстроками. -// Wire-форма каталога — это доменные структуры с json-тегами, и переименование -// поля меняет публичный контракт без единого касания транспорта; страж у него -// один — этот литерал. +// +// Литерал ниже — ДЕТЕКТОР ИЗМЕНЕНИЯ ФОРМЫ, а не сам контракт. Роли разведены +// намеренно: источником истины контракта станет рукописная OpenAPI-спека +// (решение владельца 2026-08-04, задача `openapi-spec`), а сверку спеки с +// маршрутами внесёт в гейт задача `openapi-gate-check`. Детектор при этом +// краснеет РАНЬШЕ гейта — в момент правки, — и в этом весь его смысл. +// +// Правишь литерал — правишь публичный контракт. Значит рядом обязана лежать +// правка спеки, а не только «чтобы позеленело». func TestКаталогОтдаётОжидаемыеБайты(t *testing.T) { h, st, _ := newAPITokens(t, nil, nil) @@ -195,6 +208,11 @@ func TestКаталогПовторяетсяПобайтово(t *testing.T) { // Отказ хранилища переводится в 500 с человекочитаемым сообщением: текст ошибки // наружу не уходит — в нём имена колонок и форма запроса. +// +// Тело отказа сравнивается БАЙТАМИ: у читающего маршрута это такая же часть +// публичного контракта, как успешный ответ, и клиент видит его чаще. Проверка +// «внутренностей не видно» осталась рядом, но она слабее: подстрок в ответе нет +// и у тела, которое переименовало ключ `error`. func TestКаталогОтвечает500НаОтказХранилища(t *testing.T) { h, st, _ := newAPITokens(t, nil, nil) if err := st.Close(); err != nil { @@ -205,7 +223,21 @@ func TestКаталогОтвечает500НаОтказХранилища(t *te if rec.Code != http.StatusInternalServerError { t.Fatalf("статус %d, ждали 500", rec.Code) } - if body := rec.Body.String(); strings.Contains(body, "sql") || strings.Contains(body, "bucket") { - t.Errorf("наружу уехали внутренности: %s", body) + if got := strings.TrimSpace(rec.Body.String()); got != `{"error":"каталог не собрался"}` { + t.Errorf("форма тела отказа изменилась: %s", got) + } +} + +// Форма тела отказа закреплена байтами и на 401 — том коде, который потребитель +// видит первым, если ошибся токеном. +func TestКаталогОтдаётОжидаемыеБайтыОтказа(t *testing.T) { + h, _, _ := newAPITokens(t, nil, []string{"read-token"}) + + rec := getCatalog(t, h, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("статус %d, ждали 401", rec.Code) + } + if got := strings.TrimSpace(rec.Body.String()); got != `{"error":"неверный или отсутствующий токен"}` { + t.Errorf("форма тела отказа изменилась: %s", got) } } diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 0a438cc..48f5d34 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -158,6 +158,20 @@ func writeJSON(w http.ResponseWriter, status int, v any) { _ = json.NewEncoder(w).Encode(v) } +// errorWire — форма провода тела отказа, общая для всех маршрутов. +// +// Объявленный тип, а не `map[string]string`: у читающего маршрута тело отказа +// такая же часть публичного контракта, как и успешный ответ, и клиент видит его +// чаще. Карта же делает «два ответа совпадают побайтово» свойством библиотеки, а +// не решения, и переименование ключа `error` не увидел бы ни один сторож — ни +// обход графа типов (карта строк проходит как стандартный тип), ни байтовый +// литерал (тел отказа он не закреплял). +type errorWire struct { + Error string `json:"error"` +} + +// writeError отдаёт человекочитаемое сообщение, а не текст ошибки: в тексте +// имена колонок и форма запроса. func writeError(w http.ResponseWriter, status int, msg string) { - writeJSON(w, status, map[string]string{"error": msg}) + writeJSON(w, status, errorWire{Error: msg}) } diff --git a/internal/httpapi/wire_internal_test.go b/internal/httpapi/wire_internal_test.go new file mode 100644 index 0000000..a2ad981 --- /dev/null +++ b/internal/httpapi/wire_internal_test.go @@ -0,0 +1,223 @@ +package httpapi + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "git.vakhrushev.me/av/healthlog/internal/catalog" +) + +// Форма провода объявлена транспортом, и это утверждается ПРЯМО: ни один тип +// домена не достигает сериализации ответа. +// +// Отсюда и следует свойство, ради которого задача существует: переименование +// поля доменного типа байты ответа не меняет — до энкодера этот тип не доезжает +// вовсе. Прозой это уже было написано; прозу компилятор не проверяет. +// +// Прямого «переименуй и посмотри» в Go-тесте не бывает: отказ компиляции +// собственного пакета тест не наблюдает, а при неудавшейся компиляции байтов не +// существует. Поэтому утверждается эквивалентное и проверяемое — недостижимость +// домена из графа типов ответа. + +// modulePrefix — префикс путей пакетов этого модуля. +// +// Сверка идёт по `PkgPath`, а не по имени пакета: имя совпадает у чужих +// пакетов, а алиас (`type wire = catalog.Metric`) имени вообще не меняет. Если +// префикс протухнет (переезд модуля), проверка станет вечно зелёной — от этого +// и стоит рядом заведомо красный случай. +const modulePrefix = "git.vakhrushev.me/av/healthlog/" + +// selfPkg — сам транспорт: его типы и есть объявленная форма провода. +var selfPkg = reflect.TypeOf(catalogResponse{}).PkgPath() + +// foreignTypes обходит граф типов значения и собирает типы этого модуля, +// объявленные ВНЕ транспорта. +// +// Обход рекурсивный и покрывает все позиции, в которых доменный тип может +// спрятаться: поле структуры (в том числе неэкспортированное и встроенное), +// элемент среза и массива, ключ И значение карты, адресат указателя. `seen` +// защищает от самоссылающегося типа, а не оптимизирует. +// +// Исключение одно и оно задано ПО ТИПУ — `json.RawMessage`: дословно +// сохранённое содержимое уезжает клиенту сырым JSON (инвариант «точки хранятся +// дословно»). Признак «у типа есть свой `MarshalJSON`» исключением быть не мог: +// он вернул бы домен на провод ровно тем механизмом, который сняли со `Style`. +func foreignTypes(t reflect.Type) []string { + var out []string + seen := map[reflect.Type]bool{} + var walk func(reflect.Type) + walk = func(t reflect.Type) { + if t == nil || seen[t] { + return + } + seen[t] = true + if t == reflect.TypeOf(json.RawMessage(nil)) { + return + } + if p := t.PkgPath(); strings.HasPrefix(p, modulePrefix) && p != selfPkg { + out = append(out, t.String()+" ← "+p) + return + } + // Своя структура обязана объявить имя КАЖДОГО экспортированного поля + // тегом. Без этого требования в обход есть тихая калитка: определённый + // тип поверх доменной структуры (`type pointWire store.Point`) числится + // транспортным по `PkgPath`, обход через него не идёт — а ключи ответа + // оказываются именами полей домена, то есть ровно та связь, которую + // задача разрывала. Поймано проходом `specs` на профиле `deep`. + // Пустой `PkgPath` — безымянная структура: она тоже часть формы провода, + // раз доехала сюда по графу, и правило на неё распространяется. + if t.Kind() == reflect.Struct && (t.PkgPath() == selfPkg || t.PkgPath() == "") { + for i := range t.NumField() { + f := t.Field(i) + if f.PkgPath != "" || f.Anonymous { + continue // неэкспортированное `encoding/json` не пишет + } + if _, ok := f.Tag.Lookup("json"); !ok { + out = append(out, t.String()+"."+f.Name+" — поле формы провода без json-тега") + } + } + } + switch t.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Array: + walk(t.Elem()) + case reflect.Map: + walk(t.Key()) + walk(t.Elem()) + case reflect.Struct: + for i := range t.NumField() { + walk(t.Field(i).Type) + } + } + } + walk(t) + return out +} + +// Таблица образцов ответа. СЛЕДУЮЩИЙ МАРШРУТ ДОБАВЛЯЕТ СЮДА СТРОКУ — точки, +// тренировки, записи, статистика. Выбирать решение заново не нужно. +func TestФормаПроводаДоменаНеСодержит(t *testing.T) { + cases := map[string]any{ + "каталог": catalogResponse{}, + "тело отказа": errorWire{}, + "учёт приёма": ingestResponse{}, + } + + for name, sample := range cases { + t.Run(name, func(t *testing.T) { + if bad := foreignTypes(reflect.TypeOf(sample)); len(bad) > 0 { + t.Errorf("доменные типы в графе ответа: %v", bad) + } + }) + } +} + +// metricAlias — алиас доменного типа, объявленный в транспорте. Существует +// только ради отрицательного контроля ниже. +type metricAlias = catalog.Metric + +// layerDefined — ОПРЕДЕЛЁННЫЙ тип поверх доменной структуры (не алиас): пакет у +// него транспортный, поля — доменные и без тегов. Тоже только для контроля. +type layerDefined catalog.LayerRange + +// Заведомо красный случай. Проверка, доказывающая ОТСУТСТВИЕ, зелена и будучи +// сломанной: протухший `modulePrefix`, пропущенная позиция обхода, перепутанное +// сравнение — всё это выглядит как «доменных типов нет». В проекте этот класс +// уже дважды всплывал (docs/conventions/testing.md: отрицательный контроль для +// правил порядка, утверждение о таблице-константе). +func TestОбходГрафаТиповНаходитДомен(t *testing.T) { + type embedded struct{ catalog.Aggregation } + + cases := map[string]any{ + "поле": struct{ M catalog.Metric }{}, + "неэкспортированное": struct{ m catalog.Metric }{}, //nolint:unused // позиция обхода, а не поле + "срез": struct{ M []catalog.Metric }{}, + "массив": struct{ M [2]catalog.LayerRange }{}, + "указатель": struct{ M *catalog.Metric }{}, + "значение карты": struct{ M map[string]catalog.Metric }{}, + "ключ карты": struct{ M map[catalog.Style]int }{}, + "встроенное": embedded{}, + "вложенная анонимная": struct{ Inner struct{ M catalog.Metric } }{}, + // Алиас — самая тихая позиция: имя пакета у него транспортное, а + // `PkgPath` доменный. Обход, сверяющий имя пакета, пропустил бы её. + "алиас": struct{ M metricAlias }{}, + "перечисление": struct{ S catalog.Style }{}, + // Определённый тип поверх доменной структуры: по `PkgPath` он + // транспортный, обход внутрь не идёт, а ключи ответа — имена полей + // домена. Ловится требованием тега на каждом экспортированном поле. + "определённый тип поверх домена": layerDefined{}, + "поле формы провода без тега": struct{ M string }{}, + } + + for name, sample := range cases { + t.Run(name, func(t *testing.T) { + if bad := foreignTypes(reflect.TypeOf(sample)); len(bad) == 0 { + t.Error("обход не нашёл доменный тип — проверка ничего не доказывает") + } + }) + } +} + +// Перевод отдаёт пустые коллекции списком, а не отсутствием, — на входе, где +// домен отдал `nil`. +// +// Утверждение живёт здесь, а не в маршрутном тесте: домен сегодня `nil` не +// отдаёт, поэтому через HTTP этот вход недостижим, а правило принадлежит +// проводу и обязано держаться независимо от того, что домен обещает сейчас. +// Четыре следующих маршрута копируют именно `catalogWire`. +func TestПереводОтдаётПустыеКоллекцииСписком(t *testing.T) { + got, err := json.Marshal(catalogWire([]catalog.Metric{{Metric: "vo2_max"}})) + if err != nil { + t.Fatalf("сериализация: %v", err) + } + + want := `{"metrics":[{"metric":"vo2_max","units":[],` + + `"aggregation":{"style":"unknown","hours":0,"compared":0,"agreeing":0,` + + `"conflicting":0,"first_hour":null,"last_hour":null},"layers":[]}]}` + + if string(got) != want { + t.Errorf("пустые коллекции:\n получили %s\n ждали %s", got, want) + } +} + +// Пустой каталог — `[]`, а не `null`: nil-срез сериализуется в `null`, и +// клиент прочитал бы «поля нет» вместо «метрик нет». +func TestПереводПустогоКаталогаОтдаётСписок(t *testing.T) { + got, err := json.Marshal(catalogWire(nil)) + if err != nil { + t.Fatalf("сериализация: %v", err) + } + if string(got) != `{"metrics":[]}` { + t.Errorf("пустой каталог: получили %s", got) + } +} + +// Самоссылающийся тип обход не зацикливает: без `seen` это бесконечная +// рекурсия, а не медленный тест. +func TestОбходГрафаТиповНеЗацикливается(t *testing.T) { + type node struct { + Next *node `json:"next"` + At time.Time `json:"at"` + } + if bad := foreignTypes(reflect.TypeOf(node{})); len(bad) > 0 { + t.Errorf("чужие типы: %v", bad) + } +} + +// Стандартная библиотека проводу разрешена, и это надо утверждать: правило +// звучит как «домена в графе нет», а не «в графе нет ничего чужого». +// `json.RawMessage` — то самое исключение по типу, ради которого существует +// инвариант «точки хранятся дословно». +func TestОбходГрафаТиповПропускаетСтандартныеТипы(t *testing.T) { + sample := struct { + At time.Time `json:"at"` + Ptr *time.Time `json:"ptr"` + Raw json.RawMessage `json:"raw"` + Units []string `json:"units"` + }{} + if bad := foreignTypes(reflect.TypeOf(sample)); len(bad) > 0 { + t.Errorf("стандартные типы объявлены чужими: %v", bad) + } +} diff --git a/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/.openspec.yaml b/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/.openspec.yaml new file mode 100644 index 0000000..1b062d3 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-04 diff --git a/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md b/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md new file mode 100644 index 0000000..5daefba --- /dev/null +++ b/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md @@ -0,0 +1,356 @@ +## 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` назвал это развилкой; здесь она +разрешена в сторону «спека нормирует наблюдаемое, дисциплина живёт в +конвенциях», потому что этот выбор дешевле откатить (правило переносится между +двумя файлами) и потому что у второго варианта нет предмета для сверки +«спека → код». Прецедент при этом задан на четыре следующие задачи цели — если +владелец решит иначе, переносить придётся их все. diff --git a/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/proposal.md b/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/proposal.md new file mode 100644 index 0000000..c863416 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/proposal.md @@ -0,0 +1,75 @@ +## Why + +Публичный контракт единственного читающего маршрута сегодня **выведен из формы +доменных типов**: `internal/catalog` сам несёт `json`-теги, а +`internal/httpapi/catalog.go` владеет только оболочкой `{"metrics": …}`. +Переименование поля в домене, разъединение встроенного `Basis` или добавление +внутреннего поля меняют байты ответа, не касаясь транспорта; удерживает это один +литерал в тесте `TestКаталогОтдаётОжидаемыеБайты`, и о том, что он и есть +контракт, в коде не сказано нигде. + +Пока маршрут один и потребителей у него нет, цена нулевая. Со следующей задачи +цели образец копируют точки, тренировки, записи и MCP — четыре маршрута и второй +транспорт. Решение принимается **до** копирования: потом это будет не решение, а +археология. Момент выбран ещё и потому, что менять контракт каталога сейчас +бесплатно — снаружи его не читает никто. + +## What Changes + +- **Форма провода объявляется транспортом, а не выводится из домена.** Каждый + читающий маршрут `internal/httpapi` объявляет собственные типы ответа с + `json`-тегами; доменные типы `internal/catalog` тегов лишаются, перевод — + явная функция транспорта. +- **Доменные типы каталога перестают быть публичным контрактом.** `Metric`, + `LayerRange`, `Aggregation`, `Basis` остаются формой ответа **use-case**, а не + формой ответа HTTP. `Style.MarshalJSON` убирается: сериализацию рода кладёт + транспорт. Значения словаря (`cumulative`/`instant`/`unknown`) при этом + остаются в домене — провод зовёт `Style.String()`, чтобы не завести второй + словарь; граница и её цена названы в `design.md`. +- **Тело отказа тоже получает объявленный тип.** Сегодня `writeError` собирает + его из `map[string]string`; у читающего маршрута тело отказа — часть того же + контракта, и оно не должно оставаться единственным местом, куда не смотрит ни + один сторож. Байты те же. +- **Байты ответа каталога не меняются.** Правка структурная; контракт из + требования «Форма ответа каталога» остаётся тем же до символа, и это + утверждается тестом, а не намерением. +- **Заводится проверка, прямо утверждающая разделение:** ни один тип домена не + достигает сериализации ответа — обход графа типов значения ответа, с заведомо + красным случаем рядом. Отсюда и следует, что переименование поля домена до + провода не доезжает. +- **Решение и цена обеих сторон записываются** в `docs/architecture.md` + (раздел Read API); правило о **механизме** проверки — в + `docs/conventions/testing.md`, где у правил такого рода дом. +- Схема не трогается, миграции нет, данные только читаются. + +## Capabilities + +### New Capabilities + +- `read-api`: общие правила читающих маршрутов, поверх которых встают точки, + тренировки, записи и MCP. Два правила: **публичный контракт меняется только + правкой транспорта** (форма провода объявляется явно и не выводится из формы + доменных типов) и **пустая коллекция — список, а не отсутствие**. + +### Modified Capabilities + +Нет. Поведение каталога не меняется: байты ответа, заголовки и коды остаются +прежними, требование «Форма ответа каталога» из `catalog` продолжает описывать +их без единой правки. Это и есть проверяемое утверждение изменения. + +## Impact + +- `internal/catalog/catalog.go` — снятие `json`-тегов с `Basis`, `Aggregation`, + `LayerRange`, `Metric`; удаление `Style.MarshalJSON`. +- `internal/catalog/measure_test.go` — `TestStyleСловарь` переезжает с + `MarshalJSON` на `String()`. +- `internal/httpapi/catalog.go` — типы провода каталога и перевод из домена. +- `internal/httpapi/httpapi.go` — объявленный тип тела отказа вместо карты. +- `internal/httpapi/catalog_test.go` — байтовые утверждения формы (измеренное + окно, неизмеренное окно, пустая витрина). +- `internal/httpapi/wire_internal_test.go` — обход графа типов ответа и заведомо + красный случай к нему. +- `docs/architecture.md` — раздел Read API: решение с ценой обеих сторон. +- `docs/conventions/testing.md` — правило о механизме: байты на каждую + различимую форму ответа, заведомо красный случай для структурной проверки. +- Схема, витрина, архив, конфиг — не трогаются. diff --git a/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/review/triage.md b/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/review/triage.md new file mode 100644 index 0000000..1621e61 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/review/triage.md @@ -0,0 +1,271 @@ +# Триаж ревью — `forma-provoda-chteniya` + +## Сводка + +- **Профиль:** `deep`, режим `по графу`. База диффа + `bd832337df6dae5829432a9ee021b380cc6be1b1`, изменения застейджены. +- **Гейт:** зелёный, 13/13 шагов, покрытие изменённых строк 100%. +- **Проходы поимённо, с исходом** (состав сверен с профилем: `deep` = 6 кодовых + проходов + `reimpl` по триггеру + триаж; расхождения с профилем нет): + - `gate` — отработал, 2 находки (1 починена инлайн, починка проверена триажем); + - `specs` (код против спек) — отработал, 2 находки, обе починены инлайн, + починки проверены мутациями; `openspec validate --strict` — valid; + - `code` (прозаические конвенции) — отработал, 1 находка, дубль находки 1 + `specs` (найден независимо), закрыт той же починкой; + - `adversary` — отработал, 3 построенных пути (1 major, 2 minor) — **все три + унаследованы от базы, диффом не внесены** (проверено триажем по списку + файлов диффа и по диффу `internal/catalog/catalog.go`); плюс секция + «атаковано и не пробилось» и 3 свойства без построенного пути; + - `ops` — отработал, 1 находка minor, две гипотезы закрыты замерами; + - `architecture` (по коду) — отработал, 1 находка minor с готовой развилкой; + - `reimpl` — **не запускался**: триггер «новое правило слияния, идентичности + или разбора» не сработал — дифф не трогает `internal/store`, `internal/fold`, + `internal/hae` и схему (проверено списком файлов диффа); + - `triage` (этот отчёт) — ничего нового не ищет по определению; пропуск + любого прохода — его пропуск тоже. +- На профиле `design` (до кода) отдельно отработали `specs`, `rubric`, + `architecture`; их находки закрыты в предложении, рубрика легла критериями + Р1–Р12 в `tasks.md`. +- **Счёт находок:** на вход пришло 10 именованных находок и 3 свойства без + построенного пути. После дедупликации по причине — 8 уникальных + (`code` = `specs`-1; «ссылка на архив» у `architecture` = находка 2 `gate`). + Из них 3 починены инлайн и проверены триажем мутациями. Открытых осталось 6: + 3 в секции «Стоит исправить сейчас», 3 унаследованных/задокументированных — + строками в границах покрытия (в урожай). Гипотез — 2, promote — 3. + +### Проверка инлайн-починок (не по пересказу — прогонами) + +1. **Байтовые утверждения тела отказа** — есть + (`internal/httpapi/catalog_test.go:216,233`). Мутация + `json:"error"` → `json:"message"` в изолированной копии дерева → + `TestКаталогОтдаётОжидаемыеБайтыОтказа` красный. До починки эта мутация была + зелёной (оракул `specs`). **Починено.** +2. **Требование `json`-тега на полях формы провода** — есть + (`internal/httpapi/wire_internal_test.go:72-82`), в таблице заведомо красных + 13 контролей, включая «определённый тип поверх домена» и «поле без тега». + Две мутации в копии: (а) поле `aggregationWire.Hours` без тега → + `TestФормаПроводаДоменаНеСодержит` красный; (б) `type layerWire + catalog.LayerRange` с конверсией вместо перевода полем в поле → красный и + сторож (4 поля «без json-тега»), и байтовые тесты. Ровно та калитка из + находки 2 `specs` закрыта. **Починено.** +3. **`GOLANGCI_LINT_CACHE` в `scripts/gate.py`** — есть (строка 119, кеш в + `tmp/gate/golangci`), `env` корректно сливается с `os.environ` + (`run()`, строка 70). `ops` отдельно замерил: кеш не растёт без предела, + повторный прогон 0.57 с. **Починено.** + +Плюс контрольный прогон триажа: `go build ./...`, `go vet ./...`, +`go test ./internal/...` — целиком зелёные на застейдженном дереве. + +--- + +## Блокирует мердж + +Пусто. Единственный `major` прогона (путь `adversary` про метку года 10000) +воспроизведён триажем и **не принадлежит этому мерджу** — см. первый пункт +следующей секции: весь задействованный код (`internal/hae/hae.go`, +`internal/store/store.go`, `internal/store/catalog.go`) вне диффа, дифф его +поведения не меняет, а починка расширила бы scope задачи («трогается только +каталог и его транспорт») и по триггеру потребовала бы `reimpl` и +`verify:archive`/`verify:busy`. Прецедент дисциплины тот же, что в записи +2026-08-03 `docs/review.md`: краснота унаследована — заводится задачей, а не +чинится попутно. + +Формулировка «критичных проблем не обнаружено» здесь не употребляется — что +именно проверено и что не могло быть проверено, см. «Границы покрытия». + +## Стоит исправить сейчас + +### 1. Одна принятая доставка с меткой года ≥10000 навсегда убивает маршрут чтения — 500 по всем метрикам + +- Файл: `internal/hae/hae.go:812` → `internal/store/store.go:263` → + `internal/store/catalog.go:174-182` → `internal/httpapi/catalog.go` +- Severity: major (унаследовано от базы, диффом не внесено) +- Confidence: high +- Оракул: падающий тест `adversary` (прогнан) + независимое воспроизведение + триажем на уровне механизма: `time.Parse("2006-01-02 15:04:05 -0700", + "9999-12-31 23:00:00 -0700")` принимается, `FormatTime` → `"10000-01-01T06:00:00Z"`, + `ParseTime` (RFC 3339) на этой строке отказывает + (`cannot parse "0-01-01T06:00:00Z" as "-"`). Побочно подтверждено: + `"10000-…" < "2026-…"` как TEXT — защита горизонта «свидетельство из + будущего» на этом входе не срабатывает. +- Последствие: строка уже в витрине и в архиве, `import + replay` её + воспроизводит; лечится правкой кода **и** ручной правкой + `./data/healthlog.db` — необратимой операцией из списка запретов. Класс + «порча с низкой вероятностью» — выше любого гарантированного неудобства. +- Ловушка починки (названа `adversary`, сохранить в задаче): чинить только со + стороны `ParseTime` нельзя — отказ `json.Marshal` станет достижим, и + `writeJSON` отдаст 200 с пустым телом; плюс откроется дыра TEXT-горизонта. + Чинить надо на приёме области значений (разбор HAE / `FormatTime`) вместе с + горизонтом. +- Действие: **развилка.** Вопрос владельцу: (а) завести отдельную задачу в + каталоге — рекомендация триажа: правка трогает разбор, по триггеру тянет + `reimpl` + `verify:archive`/`verify:busy`, в рамки этой задачи не лезет; + (б) чинить в этой ветке с расширением scope и полным повторным циклом; + (в) принять риск без задачи — не рекомендуется: отказ молчащий до первого + чтения, а лечение требует необратимой ручной операции. + +### 2. Ссылка на архивный путь change с угаданной датой умрёт молча + +- Файл: `docs/architecture.md:1638` + (`openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md`) +- Severity: minor (внесено диффом) +- Confidence: high +- Оракул: пути не существует (проверено `ls`); ссылка инлайн-кодом, `docs.py + check` её не проверяет; дата архивации угадана — архивирование не сегодня + ломает её без сигнала. Дубль: `gate` и `architecture` нашли независимо. +- Последствие: разбор чужих решений (единственное место, где записано «почему + не как у Prometheus/Kubernetes») станет недостижим из обзора. +- Действие: **инлайн.** Убрать угаданную дату: сослаться на change по имени + (`openspec/changes/forma-provoda-chteniya/design.md`, поправить при + архивации) либо назвать документ словами без пути — на вкус оркестратора. + +### 3. Маршрут, забывший строку в таблице образцов, останется без стража молча + +- Файл: `internal/httpapi/wire_internal_test.go` (таблица `cases`, + `TestФормаПроводаДоменаНеСодержит`) +- Severity: minor (внесено диффом — сторож новый, и он opt-in) +- Confidence: high +- Оракул: прогон `architecture` — новый обработчик с `catalog.Metric` в ответе, + не добавленный в таблицу, не красит ни один тест; байтовый литерал нового + маршрута закрепил бы доменные имена как норму. +- Последствие: забытая строка неотличима от отсутствия проблемы — ровно тот + класс, против которого это же изменение завело конвенцию заведомо красного + случая. Впереди четыре маршрута и MCP, каждый копирует образец. +- Действие: **развилка.** Варианты (сформулированы `architecture`): + (а) тест полноты таблицы через `chi.Walk` — ~15 строк, забывание краснеет, + цена — сцепка теста с роутером; (б) тестовый hook в `writeJSON` — ноль мест + на новый маршрут, цена — шов в продакшн-коде; (в) оставить как есть — ноль + строк, одна молчащая дыра на каждый забытый маршрут. Триаж склоняется к (а): + сцепка с роутером дешевле шва в продакшне и дешевле молчащей дыры, но выбор + затрагивает форму, которую скопируют пять раз, — потому развилка, не инлайн. + +## Гипотезы без доказательства + +- **`catalogWire` делит `*time.Time` со снимком (нет глубокой копии).** + Понижено до nit: построенного пути нет — сегодня снимок не кешируется и + алиасинг ненаблюдаем; станет наблюдаемым с первым кешем снимка. Источник — + `adversary`, «свойства без построенного пути». Не действие, а память: при + задаче о кеше перечитать. +- **Ненайденный маршрут пишет сырой `r.URL.Path` в лог уровня INFO.** Понижено + до nit: путь до значения точки или токена через URL не построен (токены в + заголовке), последствия не названо. Источник — `adversary`. + +Ничего из `critical`/`major` понижать не пришлось: единственный `major` получил +оракул (дважды — проходом и триажем). Согласие проходов нигде не считалось +подтверждением: дубль `specs`+`code` закрыт одной починкой и проверен мутацией, +а не «подтверждён двумя проходами». + +## Promote candidates + +1. **Правило линтера: экспортированные типы вне `internal/httpapi` не несут + `json`-тегов.** Сторож графа типов ослепнет, если доменная структура + когда-нибудь получит теги обратно, — обход в неё не пойдёт как в «свою». + Механизируемо (forbidigo/ручное правило по AST); источник — `adversary`. +2. **Записать трактовку «метка времени точки — координата, а не значение».** + До WARN/ERROR доезжают `last_ts` и текст `parse time "10000-…"`; `adversary` + назвал это границей, которую проект перешёл сознательно, но трактовка нигде + не записана — следующий прогон поднимет её заново. Место — + `docs/security.md` или `docs/conventions/logging.md`. +3. **Проверка инлайн-код-ссылок на существование пути в `docs.py check`.** + Находка 2 существует ровно потому, что ссылка инлайн-кодом невидима + проверке раскладки. Механизируемо; цена — ложные срабатывания на + намеренно-будущих путях, потому promote, а не инлайн. + +## Границы покрытия + +**Прогон.** Профиль `deep`, режим «по графу». Запускались: `gate`, `specs`, +`code`, `adversary`, `ops`, `architecture`, триаж. На профиле `design` до кода — +`specs`, `rubric`, `architecture`. + +**Не запускалось и почему:** + +- `reimpl` — триггер «новое правило слияния, идентичности или разбора» не + сработал: дифф не трогает `internal/store`, `internal/fold`, `internal/hae`, + миграций нет (сверено триажем по списку файлов диффа, не по пересказу); +- `task verify:archive` и `task verify:busy` — изменение не трогает разбор, + идентичность, слияние и схему; по правилу гейта их гоняет человек или + оркестратор перед изменением этих правил. Если развилка 1 решится вариантом + (б) — оба прогона и `reimpl` становятся обязательными; +- `rubric` на коде — по составу конвейера намеренно (судить код по критерию, + под который он писался, — корреляция по построению); его свойства лежат + критериями Р1–Р12 в `tasks.md` и проверены `specs` поимённо. + +**Не влезло в потолок / унаследовано — в урожай задач, не выброшено:** + +- каталог отдаёт 8 единиц из 12 без признака урезания + (`internal/catalog/catalog.go`, `sortedKeys`, потолок 8) — minor, оракул + прогнан `adversary`, унаследовано от базы (участок диффом не тронут); +- два WARN (`future data`, `aggregation style conflict`) стоят в теле обработки + запроса — частота равна частоте чтений, условие не гаснет; проект правило + «постоянный WARN обесценивает уровень» для себя уже формулировал (`fold.go`) — + minor, оракул прогнан (10 запросов → 10 WARN), унаследовано; +- сторож графа типов слеп к полям `any`/`interface{}` — воспроизведено `ops` + собственным тестом; риск назван в `design.md` (Risks) этим же изменением, + вероятный путь эволюции (`json.RawMessage`) слепую зону обходит. Слепая зона + сторожа, а не дефект диффа. + +**Что запущенные проходы не могли проверить в принципе (из charter'ов):** + +- `gate` — только механизируемое: смысл контракта, полноту таблицы образцов и + честность спек он не видит; +- `specs` — исключение для дословного содержимого (`json.RawMessage`) проверено + только на синтетике; MCP кода не имеет; четыре из пяти читающих маршрутов не + написаны — какие байты они отдадут, проверять нечего; какие поля несёт тело + отказа, дельта не говорит; правило «отсутствие как `null`» имеет предмет + только у `*time.Time`; +- `adversary` — доказывает существование пути, не отсутствие: «атаковано и не + пробилось» (побайтовый повтор ×50, `304` 0/12, `*time.Time`, значение в теле + отказа, отказ `Marshal`) — это невыведенные атаки, а не гарантия; +- `ops` — поведение под реальным потоком телефона не наблюдал; откат бинаря + проверен рассуждением и байтовой сверкой, не прогоном на живом стенде; +- `architecture` — соответствие реализации дельте по пунктам не его работа + (названо им самим про `scripts/gate.py`, которого нет в `Impact` + предложения; `specs` это покрыл: изоляция кеша — находка `gate`, а не + самодеятельность); +- триаж — ничего нового не находит по определению; пропуск любого прохода — + его пропуск тоже. + +**Ослабленный оракул замеров.** На машине фоново живут чужой процесс +`healthlog serve` на `:18099` и рабочий контейнер на `:8080`; в замерах они не +участвовали, но общий шедулинг ОС разбавляет числа `ops` (6.16 мкс/оп на живом +размере каталога, 305 мкс на пределе, 0.57 с повторный прогон линтера) — +порядки надёжны, точные значения нет. + +**Целиком на человеке — «Недоступно проверке» из `docs/review.md`, двумя +списками, не слитыми.** + +*Не проверит ни один проход:* + +- реальный профиль нагрузки: телефон шлёт молча и непрерывно, объём и частота + меряются только по факту; +- поведение приложения HAE за пределами наблюдённого — расписание + автоматизаций пожелание, а не гарантия (разведка, находка 28); +- полнота словаря переводов после обновления iOS; +- секции, которых поток ещё не приносил: `symptoms`, `ecg`, + `heartRateNotifications`, `cycleTracking`, `medications` — разбор писался + вслепую, проход судит форму кода, не соответствие реальности. + +*Перестали проверять сознательно:* + +- прогон живого архива (`task verify:archive`) и свёртка под удерживаемой + блокировкой (`task verify:busy`) в гейт не входят (минута и ~50 секунд, + данные только на этой машине); гоняет человек перед задачей, трогающей + разбор или слияние (записи 2026-08-02…04 — три красноты этого класса подряд); +- класс «в Go так не пишут» — поимённая сверка с Effective Go, Go Code Review + Comments, стайлгайдами Uber и Google — не покрыт вовсе после упразднения + `idiom`; +- класс «чего нет в зрелой реализации такого узла» — вне профиля `design`. + +*Плюс общее, что не покрывает никакой конвейер:* история инцидентов, поведение +под реальным потоком, поведение внешних систем в их версиях, завязка +потребителей на текущее поведение (у каталога потребителей сегодня нет — задача +на этом стоит, и это проверяемо лишь отсутствием знания о чужих проектах) и +вопрос «нужна ли эта функциональность вообще». + +**Документы проекта.** Всё, что нужно триажу, на месте: `CLAUDE.md` с +инвариантами и severity, `docs/review.md` со всеми четырьмя нужными разделами +(журнал, типовые ложноположительные, вопросы к проходам, «Недоступно +проверке»), `docs/security.md`, `docs/conventions/`. Ни один проход о +недостающем документе не заявил; отсев ложноположительных шёл по заполненному +проектному списку (совпадений находок с ним не было — выбрасывать по нему +ничего не пришлось). Строк деградации в этом прогоне нет. diff --git a/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/specs/read-api/spec.md b/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/specs/read-api/spec.md new file mode 100644 index 0000000..e380268 --- /dev/null +++ b/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/specs/read-api/spec.md @@ -0,0 +1,97 @@ +## ADDED Requirements + +### Requirement: Публичный контракт читающего маршрута меняется только правкой транспорта + +Система SHALL объявлять форму ответа каждого читающего маршрута типами +транспортного слоя и MUST NOT выводить её из формы доменных типов: ни один тип +домена MUST NOT достигать сериализации ответа, а перевод домена в форму провода +MUST быть явным перечислением полей. + +**Читающий маршрут** здесь — тот, что отдаёт наружу состояние витрины: каталог, +точки, тренировки, записи, статистика. `/healthz` под правило не подпадает — +его тело есть литеральный признак живости процесса, а не данные. + +**MCP собственной формы провода не объявляет.** Адаптер переводит вызовы в те +же обработчики (`docs/architecture.md`, раздел «MCP»), поэтому объявленная форма +у контракта одна на оба транспорта. Второе объявление развело бы их молча. + +Правило распространяется и на **тело отказа**: у читающего маршрута оно часть +того же контракта, и клиент видит его чаще успешного ответа. Тело отказа MUST +собираться объявленным типом транспорта, а не картой и не свободной строкой. + +**Следствие, ради которого правило и существует:** переименование поля +доменного типа, разъединение встроенной в него структуры и появление в нём +нового поля байты ответа не меняют — до сериализации доменный тип не доезжает. +Смена публичного контракта становится правкой транспортного слоя, то есть +действием, а не побочным эффектом. + +**Цена названа и взята сознательно, потому что она обратная.** Новое поле +домена не попадает в ответ само: чтобы клиент его увидел, транспорт обязан его +перечислить. Контракт перестаёт меняться случайно в обе стороны, и это дороже +ровно на объём перевода. + +Из правила есть одно исключение, и оно ограничено **содержимым**, а не +конвертом: значение, которое хранилище держит дословно, уезжает клиенту сырым +JSON без разбора и переобъявления — переписывать его в тип провода значило бы +нарушить инвариант «точки хранятся дословно». Конверт вокруг такого значения — +метки времени, офсет, единицы, слой — объявляется типом провода и нормализуется, +как того требует инвариант «форма Apple не транслируется». + +#### Scenario: Поле доменного типа переименовано + +- **GIVEN** поле доменного типа, из которого собирается ответ, переименовано, а + транспортный слой не тронут +- **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины +- **THEN** байты ответа те же + +#### Scenario: Форма провода изменена намеренно + +- **GIVEN** транспорт переименовал поле объявленной формы +- **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины +- **THEN** байты ответа изменились, и изменение целиком лежит в правке + транспортного слоя + +#### Scenario: Читающий маршрут отвечает отказом + +- **WHEN** читающий маршрут отвечает кодом отказа +- **THEN** тело отказа собрано объявленным типом транспорта + +#### Scenario: Словарь значения виден клиенту строкой + +- **GIVEN** доменное перечисление, чьи значения клиент видит строками +- **WHEN** маршрут отвечает +- **THEN** строку в ответ кладёт транспорт, а домен собственной сериализации не + несёт + +#### Scenario: Дословное содержимое проходит насквозь + +- **WHEN** в ответ идёт значение, сохранённое хранилищем дословно +- **THEN** оно уезжает сырым JSON, а конверт вокруг него объявлен типом провода + +### Requirement: Пустая коллекция чтения — список, а не отсутствие + +Система SHALL отдавать пустую коллекцию читающего маршрута как `[]` и MUST NOT +отдавать её как `null`; отсутствующее значение MUST отдаваться как `null` и +MUST NOT подменяться нулевым значением своего типа. + +Правило общее для всех читающих маршрутов, потому что цена у него одна: +`nil`-срез сериализуется в `null`, и клиент читает «поля нет» там, где на самом +деле «элементов нет»; правдоподобная нулевая дата в ответе неотличима от +настоящей. Конкретные коллекции и поля каждого маршрута нормирует его +собственная capability — здесь только правило, чтобы следующий маршрут не +принимал его заново. + +Ручной перевод домена в форму провода делает это правило не теоретическим: +присваивание поле в поле копирует `nil` и разыменовывает указатель ровно так, +как написано, и обе ошибки молчат. + +#### Scenario: Коллекция ответа пуста + +- **WHEN** в ответ читающего маршрута идёт коллекция без единого элемента +- **THEN** она уезжает как `[]` + +#### Scenario: Значения нет + +- **WHEN** поле ответа не имеет значения +- **THEN** оно уезжает как `null`, а не как нулевая метка времени, пустая + строка или ноль diff --git a/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/tasks.md b/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/tasks.md new file mode 100644 index 0000000..cb9143b --- /dev/null +++ b/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/tasks.md @@ -0,0 +1,126 @@ +## 1. Домен перестаёт быть формой провода + +- [x] 1.1 `internal/catalog`: снять `json`-теги с `Basis`, `Aggregation`, + `LayerRange`, `Metric`; комментарии типов переписать так, чтобы они называли + их формой ответа use-case, а не формой ответа HTTP +- [x] 1.2 `internal/catalog`: удалить `Style.MarshalJSON`; `String()` оставить — + он нужен логам и сообщениям тестов, и провод зовёт его же +- [x] 1.3 `internal/catalog/measure_test.go`: `TestStyleСловарь` переезжает с + `MarshalJSON` на `String()`, значения словаря те же + +## 2. Форма провода в транспорте + +- [x] 2.1 `internal/httpapi/catalog.go`: типы `metricWire`, `layerWire`, + `aggregationWire` с `json`-тегами; поля `aggregation` перечислены **плоско**, + а не встраиванием — плоскость перестаёт быть следствием формы домена +- [x] 2.2 перевод ровно этой сигнатуры (одна на все документы изменения): + `func catalogWire(metrics []catalog.Metric) catalogResponse` — чистая функция + без `context`, без хранилища, без часов; род кладётся строкой через + `Style.String()`; пустые коллекции — `[]`, отсутствующие метки — `null` +- [x] 2.3 `handleMetrics` собирает ответ вызовом `catalogWire`; версия снимка + по-прежнему уходит только в `ETag`, в тело не попадает +- [x] 2.4 `internal/httpapi/httpapi.go`: тело отказа собирается объявленным + типом вместо `map[string]string`; байты те же — `{"error":"…"}` + +## 3. Проверки + +- [x] 3.1 `internal/httpapi/wire_internal_test.go` (внутренний тест пакета): + обход графа типов значения ответа — поля, срезы, массивы, ключи и значения + карт, указатели, встроенные и неэкспортированные поля; защита от + самоссылающегося типа; принадлежность определяется по `PkgPath` с префиксом + module path, а не по имени пакета +- [x] 3.2 обход применён **таблицей** «маршрут → образец ответа»: каталог, тело + отказа. Следующий маршрут добавляет строку +- [x] 3.3 **заведомо красный случай**: на фикстуре, содержащей `catalog.Metric`, + обход обязан быть красным. Он же ловит устаревший префикс module path +- [x] 3.4 исключение задано **по типу** `json.RawMessage`, а не по признаку + «есть свой `MarshalJSON`»; кейс с доменным типом, несущим `MarshalJSON`, — + красный +- [x] 3.5 `TestКаталогОтдаётОжидаемыеБайты` остаётся; литерал не правится ни + одним символом, комментарий называет его **детектором изменения формы**, а + источником истины — рукописную OpenAPI-спеку (задача `openapi-spec`) +- [x] 3.6 `TestКаталогНеизмеренноеОкноОтдаётNull` переписывается с подстрок на + **байты целого тела**: это ветка `*time.Time`, где ручной перевод и создаёт + риск подставить `0001-01-01` вместо `null` +- [x] 3.7 байты пустого каталога (`{"metrics":[]}`) остаются утверждением +- [x] 3.8 форма провода утверждается **ровно в одном месте** на форму ответа: + второго утверждения той же формы через разобранную структуру или подстроку в + пакете нет + +## 4. Документы + +- [x] 4.1 `docs/architecture.md`, раздел «Read API»: подраздел «Форма провода» — + решение, **цена обеих сторон**, ссылка на `design.md` изменения за prior art +- [x] 4.2 `docs/conventions/testing.md`: правило о механизме — байтовое + утверждение на **каждую различимую** форму ответа; структурная проверка, + доказывающая отсутствие, несёт заведомо красный случай +- [x] 4.3 `openspec validate --strict forma-provoda-chteniya` зелёный + +## 5. Верификация + +- [x] 5.1 `task gate` зелёный +- [x] 5.2 поведенческая: сервис поднят на своём порту и своей базе в `./tmp`. + Сравниваются **два ответа одного прогона** — снятый с базовой ревизии и + снятый с ветки против **одного и того же** файла базы при неработающем + приёме. Числа ответа (`points`, `hours`, границы) печатаются, но в + утверждении не участвуют: все они производны от корпуса. `ETag` сравним + только внутри одного часа — горизонт измерения входит в метку и едет вместе + с часами + +## Критерии приёмки задачи + +Из `docs/tasks/items/read-api-wire-format.md`, дословно: + +- [x] решение записано в `docs/architecture.md` с названной ценой обеих сторон, + а не только выбранной — оракул: глазами по разделу +- [x] переименование поля доменного типа либо не меняет байты ответа, либо + меняет их намеренно, и тест утверждает это прямо, а не проверяет непустоту — + оракул задачи назван как «тест на переименование»; **исполнимая его форма** — + обход графа типов (3.1) плюс заведомо красный случай (3.3) плюс байтовые + литералы (3.5–3.7). Прямого «переименуй и посмотри» в Go-тесте не бывает: + отказ компиляции собственного пакета тест не наблюдает, а при неудавшейся + компиляции байтов не существует вовсе. Подмена оракула названа здесь, а не + сделана молча +- [x] маршрут каталога приведён к решению, и следующий маршрут копирует + образец, а не выбирает заново — оракул: гейт зелёный плюс сверка + `httpapi/catalog.go` с записанным решением + +## Приёмочные критерии от ревью предложения (профиль `design`) + +Рубрика прохода `review-rubric`, порождённая **до** чтения кода. Порядок — +по важности. + +- [x] Р1 байты ответа не изменились, и это **измерено**: прежний байтовый + литерал не правится ни одним символом; поведенческая сверка сравнивает два + ответа **одного прогона**, а не ответ с записанным ранее эталоном +- [x] Р2 **оба сторожа доказали, что умеют краснеть**: обход графа типов красен + на заведомо доменном значении, байтовое утверждение красно при переименовании + тега в типе провода +- [x] Р3 обход полон по позициям (поле, срез, массив, ключ и значение карты, + указатель, встроенное поле, неэкспортированное поле, анонимная вложенная + структура), не зацикливается на рекурсивном типе, а «внутренний пакет + проекта» определяется по `PkgPath` с префиксом module path, а не по имени + пакета; алиас доменного типа обход **не** пропускает +- [x] Р4 пути в обход сторожа названы поимённо (`any`/интерфейс, собственный + `MarshalJSON`, тип внешней зависимости), а исключение для дословного + содержимого сужено **до типа** `json.RawMessage` +- [x] Р5 вырожденные значения закреплены байтами: `{"metrics":[]}`, пустые + коллекции как `[]`, неизмеренное окно как `null`, `"style":"unknown"` +- [x] Р6 ответ детерминирован: форма провода не содержит `map`, два вызова на + одном входе дают те же байты +- [x] Р7 перевод — **чистая функция от снимка**: без `context`, без хранилища, + без часов; тело и валидатор `ETag` выводятся из одного снимка +- [x] Р8 ни одно утверждение не пришпилено к числу, производному от размера + корпуса, и к ходу часов; метки времени фикстур — литералы +- [x] Р9 форма провода утверждается **ровно в одном месте**, и это место + названо комментарием +- [x] Р10 отказные ответы принадлежат той же объявленной форме провода +- [x] Р11 образец масштабируется: помощник применяется таблицей «маршрут → + образец ответа», следующий маршрут добавляет строку +- [x] Р12 транспорт не выносит значения точек, имена метрик и токен в лог выше + `DEBUG` и не вкладывает доменное значение в текст ошибки + +**Посылка поведенческой сверки, названная рядом с ней (Р1, Р8):** оба ответа +снимаются в одном прогоне против одного и того же файла базы при неработающем +приёме; `ETag` сравним только внутри одного часа — горизонт измерения входит в +метку и едет вместе с часами. diff --git a/openspec/specs/read-api/spec.md b/openspec/specs/read-api/spec.md new file mode 100644 index 0000000..fd097ca --- /dev/null +++ b/openspec/specs/read-api/spec.md @@ -0,0 +1,111 @@ +# read-api Specification + +## Purpose + +Общие правила читающих маршрутов — то, что у каталога, точек, тренировок, +записей, статистики и MCP одинаково и потому не должно решаться каждым заново. +Форма конкретного ответа принадлежит capability самого маршрута; здесь — правила +поверх них. + +Первое и главное: **публичный контракт объявляет транспорт.** Форма ответа не +выводится из формы доменных типов, поэтому её смена есть правка транспортного +слоя — действие, а не побочный эффект переименования поля в домене. Цена +названа и она обратная: новое поле домена в ответ само не попадёт. + +## Requirements +### Requirement: Публичный контракт читающего маршрута меняется только правкой транспорта + +Система SHALL объявлять форму ответа каждого читающего маршрута типами +транспортного слоя и MUST NOT выводить её из формы доменных типов: ни один тип +домена MUST NOT достигать сериализации ответа, а перевод домена в форму провода +MUST быть явным перечислением полей. + +**Читающий маршрут** здесь — тот, что отдаёт наружу состояние витрины: каталог, +точки, тренировки, записи, статистика. `/healthz` под правило не подпадает — +его тело есть литеральный признак живости процесса, а не данные. + +**MCP собственной формы провода не объявляет.** Адаптер переводит вызовы в те +же обработчики (`docs/architecture.md`, раздел «MCP»), поэтому объявленная форма +у контракта одна на оба транспорта. Второе объявление развело бы их молча. + +Правило распространяется и на **тело отказа**: у читающего маршрута оно часть +того же контракта, и клиент видит его чаще успешного ответа. Тело отказа MUST +собираться объявленным типом транспорта, а не картой и не свободной строкой. + +**Следствие, ради которого правило и существует:** переименование поля +доменного типа, разъединение встроенной в него структуры и появление в нём +нового поля байты ответа не меняют — до сериализации доменный тип не доезжает. +Смена публичного контракта становится правкой транспортного слоя, то есть +действием, а не побочным эффектом. + +**Цена названа и взята сознательно, потому что она обратная.** Новое поле +домена не попадает в ответ само: чтобы клиент его увидел, транспорт обязан его +перечислить. Контракт перестаёт меняться случайно в обе стороны, и это дороже +ровно на объём перевода. + +Из правила есть одно исключение, и оно ограничено **содержимым**, а не +конвертом: значение, которое хранилище держит дословно, уезжает клиенту сырым +JSON без разбора и переобъявления — переписывать его в тип провода значило бы +нарушить инвариант «точки хранятся дословно». Конверт вокруг такого значения — +метки времени, офсет, единицы, слой — объявляется типом провода и нормализуется, +как того требует инвариант «форма Apple не транслируется». + +#### Scenario: Поле доменного типа переименовано + +- **GIVEN** поле доменного типа, из которого собирается ответ, переименовано, а + транспортный слой не тронут +- **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины +- **THEN** байты ответа те же + +#### Scenario: Форма провода изменена намеренно + +- **GIVEN** транспорт переименовал поле объявленной формы +- **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины +- **THEN** байты ответа изменились, и изменение целиком лежит в правке + транспортного слоя + +#### Scenario: Читающий маршрут отвечает отказом + +- **WHEN** читающий маршрут отвечает кодом отказа +- **THEN** тело отказа собрано объявленным типом транспорта + +#### Scenario: Словарь значения виден клиенту строкой + +- **GIVEN** доменное перечисление, чьи значения клиент видит строками +- **WHEN** маршрут отвечает +- **THEN** строку в ответ кладёт транспорт, а домен собственной сериализации не + несёт + +#### Scenario: Дословное содержимое проходит насквозь + +- **WHEN** в ответ идёт значение, сохранённое хранилищем дословно +- **THEN** оно уезжает сырым JSON, а конверт вокруг него объявлен типом провода + +### Requirement: Пустая коллекция чтения — список, а не отсутствие + +Система SHALL отдавать пустую коллекцию читающего маршрута как `[]` и MUST NOT +отдавать её как `null`; отсутствующее значение MUST отдаваться как `null` и +MUST NOT подменяться нулевым значением своего типа. + +Правило общее для всех читающих маршрутов, потому что цена у него одна: +`nil`-срез сериализуется в `null`, и клиент читает «поля нет» там, где на самом +деле «элементов нет»; правдоподобная нулевая дата в ответе неотличима от +настоящей. Конкретные коллекции и поля каждого маршрута нормирует его +собственная capability — здесь только правило, чтобы следующий маршрут не +принимал его заново. + +Ручной перевод домена в форму провода делает это правило не теоретическим: +присваивание поле в поле копирует `nil` и разыменовывает указатель ровно так, +как написано, и обе ошибки молчат. + +#### Scenario: Коллекция ответа пуста + +- **WHEN** в ответ читающего маршрута идёт коллекция без единого элемента +- **THEN** она уезжает как `[]` + +#### Scenario: Значения нет + +- **WHEN** поле ответа не имеет значения +- **THEN** оно уезжает как `null`, а не как нулевая метка времени, пустая + строка или ноль + diff --git a/scripts/gate.py b/scripts/gate.py index 903aaa7..b9d4750 100755 --- a/scripts/gate.py +++ b/scripts/gate.py @@ -102,7 +102,22 @@ def main() -> int: step("build", ["go", "build", "./..."], "не собирается") step("vet", ["go", "vet", "./..."]) if shutil.which("golangci-lint"): - step("lint", ["golangci-lint", "run"]) + # Кеш линтера — СВОЙ на дерево, а не общий на машину. + # + # Общий `~/.cache/golangci-lint` отдаёт замечания, привязанные к + # путям ЧУЖОГО worktree того же модуля: конвейер задач работает в + # `tmp/wt-*`, и прогон в одном дереве красил соседнее сообщением про + # файл, которого в нём нет (воспроизведено дважды за одну задачу, + # оба раза `../…/internal/store/store.go: time.Now forbidden` — а + # исключение `^internal/(ident|store)/` на такой путь не + # распространяется). Краснота по причине, не связанной с + # изменением, приучает не читать красноту — та же цена, что у + # записи 2026-08-04 в docs/review.md. + step( + "lint", + ["golangci-lint", "run"], + env={"GOLANGCI_LINT_CACHE": str((OUT_DIR / "golangci").resolve())}, + ) else: record(SKIP, "lint", "golangci-lint не установлен (task setup)")