Files
healthlog/openspec/changes/archive/2026-08-04-forma-provoda-chteniya/review/triage.md
T
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

24 KiB
Raw Blame History

Триаж ревью — 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:812internal/store/store.go:263internal/store/catalog.go:174-182internal/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/. Ни один проход о недостающем документе не заявил; отсев ложноположительных шёл по заполненному проектному списку (совпадений находок с ним не было — выбрасывать по нему ничего не пришлось). Строк деградации в этом прогоне нет.