Files
healthlog/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/review/triage.md
T
av 29ca8d415c httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
2026-08-04 18:46:45 +03:00

24 KiB
Raw Blame History

Триаж ревью: точки метрики за период

Сводка

  • Профиль: deep, режим прогона — по графу. Гейт зелёный (383 изменённые исполняемые строки, 30 не покрыто, 92% — но см. находку 3: шаг покрытия не красит по построению).
  • Расхождения состава прогона с профилем нет (сверено с docs/review.md: deep = 6 обязательных проходов + reimpl по триггеру + rubric только в design).

Проходы поимённо с исходом:

Фаза design (до кода):

  • review-specs (режим «дизайн до кода») — отработал, находки ушли правкой спек;
  • review-rubric (фаза 1) — отработал, свойства легли критериями в tasks.md;
  • review-architecture (на предложении) — отработал, под его находки код писался.

Фаза deep (по коду):

  • review-gate — зелёный; отдельно нашёл дефект самого гейта (находка 3);
  • review-specs — отработал (декодирование имени, доли секунды, пустой ?layer=, границы в тексте ошибки — исправлено инлайн; развилка про интервальную точку — находка 2);
  • review-code — отработал (имя метрики в ETag — исправлено инлайн);
  • review-adversary — отработал (обрыв тела, двойное декодирование — исправлено; размер ответа — находка 1; nosniff — гипотеза);
  • review-ops — отработал (предикат по слою: 13.9 мс → 0.026 мс; WARN вместо DEBUG; ReadOnly не запрещает запись — исправлено/названо; транзакция без бюджета — находка 1);
  • review-architecture — отработал (единый словарь слоёв, Scope в транспорте, Spans убран — исправлено инлайн);
  • review-reimplне запускался: проектный триггер (docs/review.md, «новое правило слияния, идентичности или разбора») не сработал — изменение только читает витрину; независимый взгляд на предложение дал профиль design.

Счёт: на вход триажа пришло 8 отложенных находок (плюс 13 позиций, заявленных как исправленные инлайн). Все 13 инлайн-позиций проверены по коду и подтверждены (internal/httpapi/points.go, internal/points/points.go, internal/store/series.go, internal/hae/hae.go, internal/catalog/catalog.go, internal/httpapi/httpapi.go; тесты пяти затронутых пакетов зелёные, включая TestТочкиНеПерехватываютКаталог и TestСловарьСлоёвОдин). Из 8 отложенных: 4 в основном списке (одна — слияние двух причин), 2 понижены в гипотезы, 2 правила ушли в promote. Ничего не выброшено молча — судьба каждой названа.

Дедупликация: «размер ответа» найден adversary и ops независимо — это один источник, высказавшийся дважды; приоритет поднят, Confidence — нет.

Блокирует мердж

Пусто. Кандидат был один — ресурсный бюджет маршрута (находка 1), но обе его половины уже разложены владельцем в задачи текущего спринта (read-api-response-limit, shutdown-and-migration-traces), а риск материализуется только на деплое, который в этом проекте спрашивается всегда. Блокировать мердж значило бы отменить декомпозицию спринта решением триажа.

Стоит исправить сейчас

1. Один широкий читатель способен уронить процесс вместе с приёмом — и доставки этого окна потеряны навсегда

  • Файл: internal/store/series.go:134-190, internal/httpapi/points.go:188, docker-compose.yml
  • Severity: major
  • Confidence: high
  • Оракул: замеры проходов adversary+ops: неделя raw — 604 800 точек, 1.75 с сборки, 1375 МиБ суммарных выделений; под HTTP — 2.89 с, тело 279.7 МиБ, 1335 МиБ живой кучи; 4 одновременных запроса — 4322 МиБ. docker-compose.yml прочитан: mem_limit нет (есть restart: unless-stopped, что ограничивает окно простоя, но не отменяет его). WriteTimeout контекст запроса не отменяет — измерено проходом ops. Прецедент WAL: 51 МБ при лимите 8 МиБ.
  • Последствие: авторизованный читатель (свой же агент, опрашивающий по расписанию) четырьмя широкими запросами доводит процесс до OOM. Падает не маршрут — падает весь бинарь, включая приём; телефон шлёт молча и не перешлёт: доставки окна простоя теряются необратимо (CLAUDE.md: «Поток не останавливается»). Побочно: перекрывающиеся долгие читатели не дают продвинуться пассивному чекпойнту WAL. Вероятность низкая (клиенты — свои), ущерб — высший класс: необратимая потеря данных.
  • Предложение: работу не дублировать — обе половины уже в спринте (read-api-response-limit — предел размера, там же форма умолчания слоя; shutdown-and-migration-traces — дедлайн маршрута). Решить надо порядок: (а) мерджить сейчас, деплой — только после read-api-response-limit того же спринта (ноль работы, дисциплина деплоя и так требует вопроса к человеку); (б) то же плюс mem_limit в docker-compose.yml одной строкой уже сейчас — дешёвый стопор, превращающий OOM хоста в перезапуск контейнера; (в) сцепить мердж этой ветки с веткой предела в один батч. Сюда же слита развилка rubric: правило «при равном охвате — самый мелкий слой» максимизирует размер ответа; инверсия меняет правило, записанное в docs/architecture.md до этой задачи, и решается вместе с формой предела в read-api-response-limit, а не здесь.
  • Найдено проходом: adversary + ops (один источник дважды; оракулы — замеры)
  • Действие: развилка

2. Запрос «сон за ночь» молча не увидит эпизод, начавшийся до полуночи

  • Файл: internal/store/series.go:233-256; openspec/changes/tochki-metriki-za-period/specs/points/spec.md:257-260
  • Severity: major
  • Confidence: high
  • Оракул: положение кода (series.go:253: p.Start.Before(w.From) → пропуск) и дословный пункт спеки: «точка-интервал, начавшаяся раньше from, в ответ не входит… запрос "сон за ночь с полуночи" не увидит эпизод, начавшийся до неё».
  • Последствие: потребитель получает ряд, выглядящий полным, — эпизод сна с 23:40 отсутствует без единого признака усечения. Класс «молчание»: сон — профильный сценарий проекта, и решение потребителя принимается по неполным данным. Спека цену честно называет, поэтому это не сломанное требование, а открытая развилка дизайна.
  • Предложение: вопрос владельцу, варианты: (а) оставить как есть — правило симметрично адресации объектов, потребитель расширяет from сам (цена: каждый потребитель обязан знать про максимальную длину эпизода); (б) при выборке захватывать N часов до from и отбирать по пересечению интервала с периодом (цена: магическое число глубины N); (в) индекс по концу координаты (цена: миграция схемы и её сопровождение). Вариант (а) стоит нуля кода, но тогда правило обязано попасть в контракт read-api так же явно, как в спеку points.
  • Найдено проходом: specs
  • Действие: развилка

3. Гейт обещает красить непокрытую изменённую строку — и не красит никогда

  • Файл: scripts/diff-coverage.py:88-96; scripts/gate.py:167-177; CLAUDE.md («Что красит безусловно»)
  • Severity: major
  • Confidence: high
  • Оракул: положение кода: diff-coverage.py main() возвращает 0 и при uncovered > 0 (строка 96), gate.py:172-173 пишет OK по нулевому коду возврата. Текущий прогон — живая демонстрация: 30 непокрытых строк, шаг OK, гейт зелёный.
  • Последствие: пункт CLAUDE.md «что красит безусловно: …непокрытая изменённая строка» ложен. Класс — молчание конвейера, тот самый, что в журнале ревью трижды за три дня («у проверки, которую гейт не гоняет, краснота никому не видна» — здесь хуже: проверка гоняется и молчит). Непокрытые строки будут копиться, а все читатели CLAUDE.md — включая проходы ревью — считают их невозможными.
  • Предложение: вопрос, потому что починка меняет состояние текущего прогона: (а) return 1 при uncovered > 0 — гейт этой задачи немедленно красный, и надо покрывать 30 строк веток ошибок драйвера (правки, которых никто не заказывал) либо гнать их через //nolint-аналог для покрытия; (б) привести CLAUDE.md к реальности: покрытие диффа — информационный шаг, печатает и не красит; (в) красить только строки вне уже признанных классов (ветки ошибок драйвера) — потребует механики исключений, которой нет. Дешевле всего (б) + отдельная задача на (а); выбирать не триажу — обещание записано владельцем.
  • Найдено проходом: gate
  • Действие: развилка

4. Граница, переваленная офсетом за 9999 год, молча опустошает ряд

  • Файл: internal/httpapi/points.go:254-265; internal/store/store.go:263
  • Severity: minor
  • Confidence: high
  • Оракул: прогнан тест (временный, удалён): 9999-12-31T23:00:00-08:00 → UTC год 10000 → FormatTime даёт 10000-01-01T07:00:00Z; ParseTime его не разбирает (cannot parse "0-01-01…"); лексикографически '10000-…' < '2026-…' — истина, значит hour_utc BETWEEN '2020-…' AND '10000-…' пуст всегда.
  • Последствие: уточнено против входной формулировки «роняет маршрут» — маршрут не падает: запрос «с 2020 до конца времён» с to=9999-12-31T23:00:00-08:00 отвечает 200 и пустым рядом при непустых данных. Класс «молчание», но вход экзотический (нужен офсет, переваливающий год), клиенты — свои. Унаследовано от store.FormatTime, однако этот маршрут — первый, где клиент задаёт временные границы, то есть первый внешний путь к дефекту.
  • Предложение: в parseBound отвергать границу, чьё UTC-представление выходит за год 9999 (и симметрично — раньше года 1), с 400 и текстом без значений из запроса. Локально, однозначно, right-size.
  • Найдено проходом: наследие, названо оркестратором; оракул добыт триажем
  • Действие: инлайн

Гипотезы без доказательства

  • X-Content-Type-Options: nosniff на читающих маршрутах (adversary, minor, понижено). Сам проход назвал это «свойством без пути»: API отдаёт JSON под Bearer-токеном, браузерного потребителя нет, путь эксплуатации не построен. Одна строка в setReadHeaders (internal/httpapi/conditional.go:145) — но без пути это гигиена, а не находка; пусть едет с первой задачей, трогающей conditional.go.
  • Машинно-различимый код причины отказа (rubric, minor, понижено). Ни одного потребителя, различающего причины программно, ещё нет, а правка трогает общий errorWire — то есть контракт приёма, на который уже завязан телефон. Цена сейчас выше пользы; момент — появление первого программного потребителя ошибок (адаптер MCP). Правило — в promote.

Promote candidates

  • «Обещание гейта подкрепляется кодом возврата шага»: шаг, чей исход не влияет на код возврата gate.py, называется в CLAUDE.md информационным, а не «красящим безусловно». Кандидат в правило для scripts/gate.py и раздел «Гейт» CLAUDE.md (следствие находки 3 — расхождение обещания и кода прожило молча неизвестно сколько задач).
  • «Тексты отказов — не контракт»: клиенты не парсят человекочитаемые сообщения; при первом программном потребителе причин вводится машинный код единым решением для всех маршрутов (следствие пониженной находки rubric).

Границы покрытия

Профиль и режим: deep, по графу; фаза design прогнана до кода. Гейт зелёный (с оговоркой находки 3 про шаг покрытия).

Не запускалось и почему:

  • review-reimpl — триггер docs/review.md («новое правило слияния, идентичности или разбора») не сработал: изменение читает витрину, правила хранения не тронуты; независимый взгляд на замысел дал профиль design. Если промахнёмся классом «второй способ прочитать то же самое» — искать здесь.
  • task verify:archive — не прогнан: в worktree нет ./data, живой архив основного репозитория запрещён оркестратором; триггер CLAUDE.md («перед изменением правила разбора, идентичности или слияния») не сработал — изменение сходимость журнала не трогает. Журнал ревью при этом трижды фиксирует красноту этого прогона от роста корпуса — очередной запуск остаётся за человеком на основной машине.
  • task verify:busyпрогнан вручную, зелёный (fold 24.08 с, replay 23.60 с): долгий читатель этой задачи не развёл живую витрину с пересборкой.

Что запущенные проходы не могли проверить в принципе (charter):

  • specs — соответствие спеки реальности формата за пределами testdata;
  • code — поведение под конкуренцией и на реальном потоке;
  • adversary — реальный профиль нагрузки (замеры сняты на синтетике по мотивам живых плотностей);
  • ops — поведение на боевом железе и на горизонте недель (WAL-чекпоинт под непрерывным опросом агента — экстраполяция из прецедента, не замер);
  • architecture — исполнение против замысла (судит форму, не поведение);
  • триаж — ничего нового не находит по определению; пропуск любого прохода — и его пропуск тоже.

Непокрытые изменённые строки (30 из 383): ветки распространения ошибок драйвера в internal/store/series.go (тот же класс, что уже непокрыт в internal/store/catalog.go) и ветка «ответ не подписался» (тот же класс, что в internal/catalog/catalog.go). Связка с находкой 3: гейт эти строки не красил бы в любом случае — класс признан, но признание нигде не записано, кроме этой секции.

Подмена оракула критерия К1: живой архив заменён реальными пакетами internal/hae/testdata на изолированном сервисе :18080. Пакеты реальные, но корпус — не архив: плотности и возраст данных другие, и оценка «на живых данных» остаётся на человеке.

Не проверит ни один проход (из docs/review.md): реальный профиль нагрузки — телефон шлёт молча и непрерывно, объём и частота меряются только по факту; поведение приложения HAE за пределами наблюдённого (расписание автоматизаций — пожелание, не гарантия); полнота словаря переводов после обновления iOS; секции, которых поток ещё не приносил (symptoms, ecg, heartRateNotifications, cycleTracking, medications) — разбор писался вслепую. Плюс общее: история инцидентов, поведение внешних систем в их версиях, завязка будущих потребителей (MCP-агентов) на текущую форму ответа и вопрос «нужна ли эта функциональность вообще».

Перестали проверять сознательно (отдельным списком — при следующем промахе первый вопрос «не тот ли это класс»):

  • task verify:archive / task verify:busy вне гейта (в этой задаче: первый не прогнан с причиной выше, второй прогнан вручную);
  • класс «в Go так не пишут» — поимённая сверка со стайлгайдами не покрыта вовсе после упразднения idiom; в этой задаче никто не спросил «идиоматичен ли колбэк pick внутри транзакции» — судили форму и поведение, не идиому;
  • класс «чего нет в зрелой реализации такого узла» — вне профиля design; здесь design-фаза была, так что класс покрыт частично, на замысле, не на коде.

Документы проекта: нехватки нет — все входы триажа были на месте и непустые: инварианты с severity в CLAUDE.md (по ним ранжировано), «Типовые ложноположительные» в docs/review.md (ни одна из 8 отложенных находок под них не подпала — проверено поимённо), оба подраздела «Недоступно проверке» (разнесены выше), docs/security.md (периметр использован находкой про ETag), docs/research/apple-health.md (находки 34 и 36 использованы кодом).

Потолок: не потребовался — в основной список вошло 4 пункта из 4 кандидатов; слияние «умолчание самого мелкого слоя» в находку 1 и понижение двух minor в гипотезы названы поимённо выше.