httpapi: форма провода читающих маршрутов объявлена транспортом

- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы
  metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте,
  тело отказа тоже получило объявленный тип — байты ответа не изменились
- заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до
  сериализации, плюс требование json-тега на полях транспортных структур и
  заведомо красные случаи к обоим правилам
- решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в
  гейте получил свой кеш — общий на машину красил прогон находками из чужого
  worktree
This commit is contained in:
av
2026-08-04 16:18:05 +03:00
parent bd832337df
commit a834d10415
19 changed files with 1721 additions and 53 deletions
@@ -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/`. Ни один проход о
недостающем документе не заявил; отсев ложноположительных шёл по заполненному
проектному списку (совпадений находок с ним не было — выбрасывать по нему
ничего не пришлось). Строк деградации в этом прогоне нет.