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

24 KiB
Raw Permalink Blame History

Триаж ревью: 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 с журналом, типовыми ложноположительными и обоими списками «Недоступно проверке». Ни один проход не заявил недостающего документа; отдельных строк деградации нет.