Files
av bd5d17b079 первая встреча непокрытой секции стала наблюдаемым событием
- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре
  (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат.
  Признак выводится, а не хранится — реестр был бы второй копией факта
- добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение
  только на чтение, экранированные имена и названные границы носителя
- синк документации: ADR о выводе новизны из журнала, две записи в журнал
  дефектов, два правила промоутом в конвенции, терминал оператора назван
  адресатом недоверенного входа
2026-08-04 13:39:48 +03:00

278 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.
# Триаж ревью: aktivnaya-proverka-novyh-sekcij
## Сводка
- **Профиль:** deep, режим по графу. **Гейт:** зелёный (exit 0 дважды,
покрытие диффа 96%, 2 непокрытые строки — унаследованный пробел `main.go`).
- **Сверка состава с профилем:** расхождений нет. Deep — 7–8 проходов
(`docs/review.md`, итог по конвейеру); запущено 7, восьмой (`reimpl`) —
по триггеру, и триггер не сработал (см. границы покрытия).
- **Проходы поимённо:**
- `gate` — отработал, 2 находки, зелёный;
- `specs` — отработал, 5 находок + наблюдения вне спеки;
- `code` — отработал, 3 находки;
- `adversary` — отработал, 4 находки с прогнанными оракулами + 3 свойства
без построенного пути;
- `ops` — отработал, 2 находки, обязательные вопросы отвечены;
- `architecture` — отработал, 2 находки + 1 правка «дешевле до мерджа»;
- `reimpl`**не запускался**: триггер «новое правило слияния, идентичности
или разбора» не сработал — изменение вводит наблюдение поверх существующей
логики;
- `triage` — этот отчёт.
- **Счёт:** на входе 19 именованных находок, после дедупликации по причине —
13. В отчёте: 2 блокируют, 4 сейчас, 6 в гипотезах, 2 promote, 1 подтверждённая
не влезла в потолок (названа в границах покрытия).
- Находка №1 найдена тремя проходами независимо — это подняло её приоритет;
подтверждением служит прогнанный оракул, а не согласие проходов.
- Совпадений с «Типовыми ложноположительными» из `docs/review.md` нет; отсев
шёл по проектному списку.
## Блокирует мердж
### Отказ слияния навсегда съедает событие о новой секции — владелец о ней не узнает никогда
- Файл: `internal/fold/fold.go:214-222`
- Severity: major
- Confidence: high
- Оракул: `tmp/adv/lost_event_test.go`, перепрогнан триажем — FAIL
воспроизведён: запись об отказе (`delivery fold failed`) не несёт
`uncovered_new`, при этом `fail` пишет `residue.uncovered` в колонку
(`fold.go:587`), и следующая доставка считает `ecg` виденным. Путь достижим:
`context.DeadlineExceeded` не транзиентен, тело ~63 МиБ держит блокировку
5.019 с (замер adversary).
- Последствие: единственная возможность события «поток начал приносить новую
секцию» — первая доставка с этим именем. Если её свёртка упала на слиянии,
событие теряется молча и не повторяется: имя уже записано как виденное.
Прямое нарушение требования дельта-спеки «Отказ свёртки не глотает событие»
и класс «молчание» — самый дорогой после порчи данных.
- Предложение: в ветви отказа слияния передавать `novelty` в остаток:
`s.fail(ctx, deliveryID, err, parseResidue{uncovered: parsed.Uncovered,
skipped: skippedEntities(parsed), novelty: novelty})`. Тест из
`tmp/adv/lost_event_test.go` перенести в постоянные.
- Найдено проходом: specs, code, adversary (дедуплицировано; оракул один)
- Действие: инлайн
### Вывод команды обещает «журнал не приносил», хотя носитель перечня обрезается и обнуляется — оператор поверит пустоте, которой нельзя верить
- Файл: `cmd/healthlog/uncovered.go:71-95`
- Severity: major
- Confidence: high
- Оракул: дословный пункт дельта-спеки —
`openspec/changes/aktivnaya-proverka-novyh-sekcij/specs/uncovered-sections/spec.md:198-203`:
«Система SHALL называть границы перечня в спеке **и в выводе команды**, а не
обещать „всё, что поток когда-либо приносил"». Вывод границ не называет, а
пустая ветвь печатает ровно запрещённое обещание: «Непокрытых секций журнал
не приносил.»
- Последствие: перечень производен от колонки, которую обрезает разбор
(32 имени на доставку) и обнуляет пересборка (тела, удалённые ретеншеном,
список теряют). Пустой вывод после ретеншена или вытеснения читается как
«поток ничего не приносил» — оператор примет решение по неполному ответу,
считая его полным.
- Предложение: переписать пустую ветвь («в учётных записях журнала непокрытых
имён нет») и добавить в вывод строку границ носителя (обрезка разбором,
обнуление пересборкой). Закрепить сквозным тестом `runUncovered` на непустой
базе — он же закрывает критерий приёмки К3 и находку specs о его отсутствии.
- Найдено проходом: specs
- Действие: инлайн
## Стоит исправить сейчас
### Одна битая строка `uncovered_sections` роняет и сверку (вечный WARN-дребезг), и команду перечня целиком, не называя виновника
- Файл: `internal/store/uncovered.go:37,92-98,167-186`
- Severity: major
- Confidence: high
- Оракул: тест триажа `tmp/triage/jsoneach_test.go` — воспроизведено:
`SQL logic error: malformed JSON (1)` на весь запрос; с
`AND json_valid(uncovered_sections)` битая строка пропускается, живая
считается. Достижимо не штатным путём (`json.Marshal` битого не пишет):
ручная правка, будущая миграция мимо кода, восстановление БД. Схемной защиты
`CHECK(json_valid(...))` в миграции 00005 нет.
- Последствие: `json_each` над невалидным JSON — ошибка всего запроса, а guard
`notEmptyList` проверяет только `'[]'` и `''`. Итог: сверка новизны вечно
`unknown=true` (постоянный WARN, неотличимый от занятости базы), а
`healthlog uncovered` отказывает целиком и не говорит, какая строка битая.
- Предложение: добавить `AND json_valid(uncovered_sections)` в `notEmptyList`
(действует на оба запроса) — проверено, чинит.
- Найдено проходом: ops
- Действие: инлайн
### Обоснование «индекс не нужен» стоит на замере не того случая: в заявленном сценарии скан почти полный на каждой доставке, а `LIMIT` не ограничивает работу
- Файл: `internal/store/uncovered.go:49-64,177-186`; `docs/architecture.md:383-387`
- Severity: minor (факт high, операционная угроза low)
- Confidence: high
- Оракул: `tmp/adv/seen_cost_test.go` — журнал 105 000: имя с первой встречей в
начале — 31 мкс, в хвосте — 13.9 мс, новое — 14.1 мс. Ранний выход `LIMIT 1`
окупается только для имени из **начала** журнала; в заявленном сценарии
(секция начала приезжать недавно) первая встреча — в хвосте, и цена платится
на каждой доставке, а не «один раз за жизнь имени». Плюс `EXPLAIN QUERY PLAN`
для `UncoveredSections` (ops): `SCAN d`, `USE TEMP B-TREE FOR GROUP BY`
`--limit 5` стоит столько же, сколько `--limit 200`.
- Последствие: сегодня терпимо (~14 мс × 288 доставок ≈ 4 с чтения в сутки),
но записанное рассуждение, на котором держится решение «индекса нет»,
неверно, и следующая задача обопрётся на него как на факт. Вдобавок числа
замера разошлись между двумя местами: `uncovered.go:53-58` — 28 мкс / 45 мс;
`architecture.md:383-386` — 18 мкс / 46 мс / 57 мс (находка gate и code).
- Предложение (развилка, варианты):
- (а) принять цену и переписать обоснование в `uncovered.go` и
`architecture.md` честно (худший случай — почти полный скан на доставку),
заодно свести разошедшиеся числа и оставить их в одном месте со ссылкой из
другого — ноль кода, минуты работы;
- (б) завести индекс или таблицу первых встреч имён — миграция плюс правка
`docs/database.md`; окупится только если различных имён станет много;
- (в) ничего не менять — отвергается: обоснование записано неверно.
- Найдено проходом: adversary, ops (дедуплицировано: одна причина — оценка
стоимости снята на нерепрезентативном случае); расхождение чисел — gate, code
- Действие: развилка
### Несостоявшаяся сверка не называет причину: `uncovered_seen_unknown=true` без ошибки, разбираться не по чему
- Файл: `internal/fold/fold.go:514-522`; `internal/store/uncovered.go:111`
- Severity: minor
- Confidence: high
- Оракул: положение конвенции `docs/conventions/logging.md` (проход code);
чтение кода — `novelty()` отбрасывает `err` из `SectionsSeenBefore` молча,
из-за чего `clipCoord` в тексте ошибки `sectionSeenOnce` — мёртвый
предохранитель: ошибка никуда не доезжает.
- Последствие: оператор видит «сверка не состоялась, новыми объявлены все
имена», но не видит, почему — занятость, отмена, битая строка (см. находку
про `json_valid`) неразличимы. Молчание причины при говорящем признаке.
- Предложение: при `unknown` доводить причину до записи — либо логировать в
`novelty()`, либо нести ошибку в `sectionNovelty` до атрибутов чекпоинта /
записи об отказе.
- Найдено проходом: specs, code, adversary (дедуплицировано)
- Действие: инлайн
### Сообщение чекпоинта вводит третий термин мимо словаря — операторский контракт (grep, алерты) затвердеет с неверным именем
- Файл: `internal/fold/fold.go` (msg `"delivery folded, unseen section name"`)
- Severity: minor
- Confidence: high
- Оракул: словарь изменения — `uncovered` (состояние) и `new` (событие);
«unseen» не существует ни в спеке, ни в атрибутах (атрибут называется
`uncovered_new` — виден в прогоне `tmp/adv/dupclip_test.go`).
- Последствие: после мерджа сообщение — операторский контракт; grep и алерты
завяжутся на «unseen», и переименование станет дороже с каждой неделей.
Сейчас — одна строка и один тест.
- Предложение: переименовать msg в термины словаря, например
`"delivery folded, uncovered section new"`.
- Найдено проходом: architecture
- Действие: инлайн
## Гипотезы без доказательства
- **Вторая сериализация журнального ключа** (`received_at || ' ' || id` +
`splitJournalKey`, `internal/store/uncovered.go:180-181,216-226`) —
корректность лексикографического сравнения держится на незакреплённой
фиксированной ширине `FormatTime`. Оракула (теста, ломающего ширину) не
построено; понижено до гипотезы. Дешёвая страховка — тест-шпилька на ширину
формата. (architecture, minor/medium)
- **Два SQL-пути в `SectionsSeenBefore`** (одно имя / батч,
`internal/store/uncovered.go:84-87`) — экономия быстрого пути ≈ 13 с чтения
в сутки; вопрос «что опытный человек удалил бы». Последствие — только цена
сопровождения двух запросов; ущерб не построен. Если владелец возьмёт
вариант (б) развилки про индекс — вопрос снимется сам. (architecture,
minor/low)
- **Сценарий «Отложенная доставка события не порождает» не закреплён тестом** —
держится на порядке двух блоков в `fail` (`fold.go:546-565`); перестановка
при рефакторинге даст дребезг события на каждом проходе занятой базы.
(specs, minor)
- **Ветвь новизны при переменной части в ключе навсегда занимает `msg`** — если
HAE начнёт класть в ключ переменную часть, каждая доставка будет «с новой
секцией» и чекпоинт станет постоянным WARN. Путь не построен (наблюдённые
ключи стабильны). (adversary, свойство без пути)
- **Два снимка в `UncoveredSections` (count и перечень) могут разойтись на
единицу** — два запроса без общей транзакции; окно — конкурентная запись
между ними. Команда диагностическая, ущерб — косметика остатка. (adversary,
свойство без пути)
- **Поведение вне спеки, исход не меняющее:** флаг `-limit` не заказан спекой;
`context.Background()` вместо `signal.NotifyContext` (у соседнего `reindex`
второе). Оставлено на решение оркестратора без severity. (specs)
## Promote candidates
- **`CHECK (json_valid(...))` для JSON-колонок в будущих миграциях** — схемная
защита, которой не хватило в 00005; правило для `docs/conventions/` или
чек миграций в гейте. (из находки ops про битую строку)
- **«Число замера живёт в одном месте, остальные ссылаются»** — числа одного
замера разошлись между `uncovered.go` и `architecture.md` уже до мерджа;
класс тот же, что у записи 2026-08-04 в `docs/review.md` (числа, на которых
стоит нормативный текст, читаются как факт и не перепроверяются). (gate, code)
## Границы покрытия
**Прогон:** профиль deep, режим по графу. База диффа — рабочее дерево
(HEAD == master). Гейт зелёный, exit 0 дважды; diff-coverage 96%.
**Запускалось:** gate, specs, code, adversary, ops, architecture, triage.
Оракулы adversary прогнаны в `./tmp/adv`, оракул триажа — `./tmp/triage`.
`task verify:archive` прогнан оркестратором — зелёный, 90 с.
**Не запускалось:**
- `reimpl` — триггер `docs/review.md` («новое правило слияния, идентичности или
разбора») не сработал: изменение наблюдает поверх существующей логики, правил
не двигает. Если считать сверку новизны «правилом наблюдения» — это
расширительное чтение триггера; решение не запускать принял оркестратор, и
класс «независимая реализация нашла бы другую форму» не покрыт.
- `task verify:busy` — не гонялся. Именно он проверил бы поведение новой сверки
при удерживаемой блокировке (ветвь `uncovered_seen_unknown` — ровно про
занятую базу). Изменение правил разбора/слияния не двигает, поэтому
обязательный порог CLAUDE.md формально не задет, но ветвь unknown проверена
только юнит-тестами, не прогоном.
**Не влезло в потолок (подтверждено, но ниже линии):**
- Два разных ключа, совпадающих в первых 64 байтах, дают дубль после
`clipSection`: одна доставка показана в перечне как две
(`internal/store/uncovered.go:177-186`, `count(*)` считает строки
`json_each`, а не доставки). Оракул: `tmp/adv/dupclip_test.go`, перепрогнан —
FAIL воспроизведён. Вероятность мизерная (нужны имена длиннее 64 байт с общим
префиксом), последствие — неверное число в диагностической команде. Чинится
`count(DISTINCT d.received_at || ' ' || d.id)` либо дедупликацией имён после
обрезки на разборе.
- Непокрытые строки диспетчера подкоманд `cmd/healthlog/main.go:34-35`
унаследованный пробел (весь `main()` 0% и до изменения). (gate)
**Что запущенные проходы не могли проверить по построению:**
- `gate` — только механизируемое; «в Go так не пишут» не проверяет никто (см.
ниже).
- `specs` — судит против записанной спеки; сами границы спеки: отказ слияния
как исход в дельте не описан (находка №1 стоит на общем требовании «не
глотает событие»); приоритет ветви новизны над событиями потери содержания
(`EntitiesHeld`, `PointsErased`) — выбор кода, не спеки; `Deliveries` считает
пары (доставка, элемент массива) — семантика зафиксирована кодом.
- `adversary` — три свойства названы без построенного пути (перечислены в
гипотезах); не построенный путь не означает недостижимый.
- `ops` — истории инцидентов у нового кода нет; поведение под реальным потоком
не наблюдалось. Сигналов о молчании потока нет — существующий пробел, не
этого диффа.
- `architecture` — судит форму, не поведение; шов `loggedFold`/`breakSeenQuery`
рассмотрен и находкой не признан.
- `triage` — ничего нового не находит по построению; пропуск любого прохода —
пропуск триажа тоже.
**Целиком на человеке** (`docs/review.md`, «Недоступно проверке» — два списка,
намеренно раздельных):
*Не проверит ни один проход:* реальный профиль нагрузки (телефон шлёт молча и
непрерывно); поведение HAE за пределами наблюдённого; полнота словаря переводов
после обновления iOS; секции, которых поток ещё не приносил (`symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`) — для этого изменения
это особенно прямо: команда `uncovered` написана ровно про них, и судить можно
только форму кода, не встречу с реальностью. Плюс общее: история инцидентов,
поведение внешних систем в их версиях, завязка потребителей на текущее
поведение, вопрос «а нужна ли эта функциональность вообще».
*Перестали проверять сознательно:* `verify:archive`/`verify:busy` вне гейта
(в этом прогоне archive прогнан, busy — нет, см. выше); класс «в Go так не
пишут» — не покрыт вовсе после упразднения `idiom` (запись 2026-08-02); класс
«чего нет в зрелой реализации такого узла» — вне профиля `design`.
**Документы проекта:** всё нужное было на месте — инварианты `CLAUDE.md`
(severity брались из них, не выводились), `docs/review.md` с журналом, типовыми
ложноположительными и обоими списками «Недоступно проверке». Ни один проход не
заявил недостающего документа; отдельных строк деградации нет.