Files
av a834d10415 httpapi: форма провода читающих маршрутов объявлена транспортом
- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы
  metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте,
  тело отказа тоже получило объявленный тип — байты ответа не изменились
- заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до
  сериализации, плюс требование json-тега на полях транспортных структур и
  заведомо красные случаи к обоим правилам
- решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в
  гейте получил свой кеш — общий на машину красил прогон находками из чужого
  worktree
2026-08-04 16:18:05 +03:00

272 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Триаж ревью — `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/`. Ни один проход о
недостающем документе не заявил; отсев ложноположительных шёл по заполненному
проектному списку (совпадений находок с ним не было — выбрасывать по нему
ничего не пришлось). Строк деградации в этом прогоне нет.