Files
healthlog/openspec/changes/archive/2026-08-04-tie-break-equal-completeness/review/triage.md
T
av b278501a6e store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
2026-08-04 11:16:24 +03:00

35 KiB
Raw Blame History

Триаж: tie-break-equal-completeness

Сводка

  • Профиль: deep, режим — линейный (по слову оператора). База диффа de2001d.
  • Гейт: зелёный. Непокрытыми остались 9 изменённых строк — все ветки обработки ошибок (internal/replay/worker.go:272-275, internal/store/bucket.go:488,564-565, internal/store/delivery.go:266-267).
  • Проходы поимённо, с исходом (сверено с профилем: deep = 78 проходов по docs/review.md, здесь 7 на коде включая триггерный reimpl — расхождения с составом профиля нет):
    • gate — отработал, зелёный, 3 находки о покрытии тестами;
    • specs — отработал, 6 находок; отдельно подтвердил построчно, что переписанные тесты не подгоняют оракул;
    • code — отработал, 0 находок;
    • adversary — отработал, 4 находки + ответ на вопрос журнала (пути значения точки в лог выше DEBUG не найдено, 7 враждебных тел);
    • ops — отработал, 4 находки + ответ на вопрос журнала (результат — функция префикса журнала плюс порядка появления строк учёта);
    • reimpl — запускался по триггеру «новое правило слияния», 3 находки; поимённо назвал 6 мест, где существующее решение лучше его собственного, и снял одну чужую находку (стоящая голова очереди не нема: internal/fold/fold.go:450 пишет WARN на каждую отложенную свёртку);
    • architecture — отработал, 2 находки + 2 замечания «дешевле до мерджа»;
    • до кода — профиль design (specs, rubric, architecture): находки отработаны в спеках и коде, повторно не поднимались.
  • Вход/выход: на входе 22 именованных находки семи проходов; после дедупликации, оракулов и отсева — 3 блокирующих, 4 «исправить сейчас», 6 гипотез, 3 promote-кандидата. Выброшено как вкусовщина 3 (названы в границах покрытия).
  • Оракулы триажа (добыты на временных базах, ./tmp/oracle/, в ./data не ходил): падающий тест на разрушительное направление слияния; замер ParsedAfter на 2 000/8 000/20 000 доставках; EXPLAIN QUERY PLAN.
  • Разрешённые конфликты проходов: O1 против R3 — оба замера верны, спор был об интерпретации; разрешён собственным замером (см. пункт 3 «Стоит исправить»). A1 сверен с корпусным замером оркестратора — конструкция воспроизводится, на живом корпусе 0 вхождений.

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

1. Обеднённая доставка стирает измеренное поле сохранённой точки, и ни один счётчик этого не видит

  • Файл: internal/store/bucket.go:561-605 (resolve, pointLess), internal/canon/canon.go:267-283 (Relate)
  • Severity: critical
  • Confidence: high
  • Оракул: падающий тест триажа tmp/oracle/a1_test.go (TestA1ОбеднённаяПришедшаяСтираетПолеБезСчётчика): сохранено {date, asleep:7.5, rem:1.2, deep:0.9}, приехало {date, asleep:7.4, deep:0.9}Relate даёт equal (значения общего ключа разошлись, разряд полноты гаснет), тай-брейк отдаёт победу пришедшей, в витрине остаётся точка без rem, PointsHeld=0, Incomparable=0, Overwrites=1 (направления не различает). Подтверждает конструкцию adversary (A1).
  • Последствие: нарушение инварианта «Ничего не теряем молча» (CLAUDE.md, critical). До изменения этот исход был жребием байтового порядка; теперь он детерминированно в пользу обеднённой пришедшей, а новый счётчик PointsHeld считает только обратное, безобидное направление. Вероятность низкая: корпусный замер оркестратора — 0 вхождений на 80 129 спорных координат (2 координаты с потерей ключа — ровно 2 несравнимые пары, их считает Incomparable). Но порча с низкой вероятностью весит больше гарантированного неудобства, и молчание здесь полное.
  • Предложение — выбор владельца, оба варианта названы adversary:
    • (а) счётчик разрушительного направления: победитель incoming, а у проигравшего сохранённого есть содержательный ключ, которого нет у победителя → счётчик + координата в лог (зеркально PointsHeld, сравнение уже посчитанных fields, цена ~нулевая). Семантика слияния не меняется; на текущем корпусе счётчик будет нулевым — это и есть утверждение.
    • (б) сузить «побеждает пришедшая» до случая совпавших множеств содержательных ключей; при несовпавших — побеждает более полная по именам. Меняет правило слияния и текст дельты storage, требует прогона verify:archive заново.
    • Рекомендация триажа: (а) — восстанавливает наблюдаемость без смены семантики, соответствует уже принятому в проекте образцу («объединение полей отложено до счётчика, который заговорит»).
  • Действие: развилка
  • Найдено проходом: adversary; сверено с корпусным замером оркестратора

2. Стоящая голова очереди молчит меткой отставания — вопреки MUST дельты, а метка порядка при этом врёт и повторяется

  • Файл: internal/replay/worker.go:151-211 (Pass), worker.go:184 (warnOutOfOrder до свёртки), worker.go:290-298 (warnLag)
  • Severity: major
  • Confidence: high
  • Оракул: чтение кода — warnLag вызывается только в ветке пустой выборки (worker.go:174), а при Deferred проход возвращается из середины цикла (worker.go:206), не взводя и startupDone; значит доставка, которую занятость откладывает проход за проходом, в метку отставания не попадает никогда. Подтверждено живым замером adversary (блокировка 22.1 с: строк о задержке 0, об отложенной свёртке 1). Дельта ingest (строки 29-33) обещает дословно: «Стоящая голова очереди молчать MUST NOT… Метка отставания… покрывает этот случай». Обещание не выполнено. Та же ранняя точка выхода делает warnOutOfOrder лжецом: он пишется до свёртки, при Deferred утверждает свёртку, которой не было, и повторяется каждым проходом — против собственного комментария «одна запись на доставку».
  • Последствие: класс «молчание» — задолженность растёт при занятой базе, а единственный обещанный спекой сигнал о ней не срабатывает (частично прикрыто WARN "delivery fold deferred" из internal/fold/fold.go:450 — этим находка понижена с потенциального critical); плюс ложный WARN о свёртке вне порядка, приучающий не верить именно той строке, по которой решают о reindex.
  • Предложение (инлайн, одна зона в Pass): вызывать w.warnLag(ctx, lag) и на выходе по Deferred (метка — одна строка на проход, шквала нет; подавление первого прохода можно сохранить, взводя startupDone по завершении первого прохода независимо от исхода); warnOutOfOrder писать после свёртки и только при исходе, реально записавшем разбор (не при Deferred).
  • Действие: инлайн
  • Найдено проходами: specs (F1, F4), adversary (A2), ops (O2) — одна причина: ранний выход Pass при Deferred не согласован с обеими метками

3. Документы в шести местах продолжают утверждать «победитель — функция множества», и следующий автор откатит починку молча

  • Файл: docs/architecture.md:937, docs/database.md:130, docs/conventions/storage.md:38-40, internal/store/winner.go:16-19, internal/store/entity_test.go:821-828, internal/replay/replay_test.go:173-174
  • Severity: major
  • Confidence: high
  • Оракул: проверено чтением — docs/architecture.md:937: «Победитель — функция множества точек, а не порядка их поступления»; docs/database.md:130: «При столкновении выигрывает более полная точка, а не последняя пришедшая»; docs/conventions/storage.md:38-40 утверждает «порядок свёртки порядку журнала не равен» — ровно то, что это изменение сделало неверным; winner.go:16-19 ссылается на обещание architecture.md сменить тай-брейк «когда род будет измерен» — дизайн это отверг (Non-Goals). Риск назван самим design.md: «чтобы следующий автор не восстановил коммутативность „для чистоты“ и не откатил починку молча» — а шесть мест документации ровно к этому и приглашают.
  • Последствие: докдрейф на critical-инварианте («Ничего не теряем молча» — формулировка правила столкновения теперь в CLAUDE.md другая); строка таблицы двух правил завышает гарантию сущностей (на несравнимых версиях они тоже расходятся).
  • Предложение: привести все шесть мест к формулировке «функция множества и позиции в журнале»; заодно выполнить записанное правило CLAUDE.md («причина отказа от готового решения — промоутом в docs/adr/»): причина отказа от LWW-Register/CRDT уже написана в design.md, её осталось поднять в ADR — это часть той же работы «документы догоняют правило».
  • Действие: инлайн
  • Найдено проходом: architecture (AR1 + замечание про ADR)

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

1. Отчёт пересборки не называет удержанные точки (SHALL дельты), а одно из требований дельты невыполнимо как записано

  • Файл: cmd/healthlog/reindex_report.go:37-38; openspec/changes/tie-break-equal-completeness/specs/reindex/spec.md:30-46
  • Severity: major
  • Confidence: high
  • Оракул: чтение — отчёт печатает Partial, Incomparable, EntitiesHeld, EntitiesDiverging, но не PointsHeld, хотя дельта reindex SHALL требует «называть число точек, удержанных правилом полноты против пришедшей доставки», и число уже лежит в Report (internal/replay/replay.go:214). Второе: дельта требует «число записей „свёртка вне порядка журнала“ прогон SHALL печатать рядом с отпечатком» — эти записи существуют только в логе живого сервиса, нигде не хранятся, а пересборке та же дельта проверку прямо запрещает (ingest:55). Требование структурно невыполнимо.
  • Последствие: SHALL дельты нарушен кодом (счётчик — единственный способ увидеть, что правило удержания стало слишком строгим, сходимость отпечатка его не проверяет по построению); невыполнимое SHALL, влитое в спеку, станет вечно красным пунктом для следующего читателя.
  • Предложение: PointsHeld — добавить в строку «слияние:» отчёта (инлайн, одна строка). Невыполнимую величину — переписать в дельте на то, что измеримо (например: «число failed печатает прогон; записи „вне порядка“ наблюдаются в логе сервиса и в отчёт не входят»). Правка текста дельты — решение не оркестратора.
  • Действие: развилка (код — инлайн, но формулировка требования дельты требует решения владельца: что именно обязана печатать посылка равенства)
  • Найдено проходами: specs (F2, F5), reimpl (R1)

2. Сценарий дельты «отложенная занятостью доставка не разводит приём и пересборку» не проверен ни одним оракулом

  • Файл: openspec/changes/tie-break-equal-completeness/specs/reindex/spec.md:56-62; тесты: internal/replay/worker_internal_test.go (барьер — на подменённой свёртке), internal/replay/order_test.go (сходимость — без отложенной)
  • Severity: major
  • Confidence: high
  • Оракул: отсутствие теста подтверждено чтением обоих файлов (specs, проверено и триажем): композиция «настоящий Deferred × сходимость отпечатков» не проверяется ничем; task verify:busy (зелёный) проверяет только «занятость оставляет доставку в очереди», без сверки отпечатков.
  • Последствие: равенство «пересборка = приём» — ровно то свойство, ради которого изменение существует (инвариант «Хранилище — свёртка по журналу», critical), — в самом опасном режиме держится на рассуждении, а прецедент 2026-08-01 показал, что такие рассуждения расходятся с кодом молча.
  • Предложение — варианты specs: (а) busy-тест в internal/replay под флагом -healthlog.busy: журнал с равнополными столкновениями, свёртка под удерживаемой блокировкой, сверка отпечатков живого пути и пересборки; (б) сузить сценарий дельты и записать ограничение в границы. Рекомендация: (а) — цена одного теста против цены молчаливого расхождения витрины.
  • Действие: развилка
  • Найдено проходом: specs (F3)

3. design.md утверждает «цена не измеряется» и «тот же индекс» — оба утверждения неверны, замер триажа прилагается

  • Файл: openspec/changes/tie-break-equal-completeness/design.md:157-159; internal/store/delivery.go:256-269
  • Severity: minor (после разрешения конфликта O1/R3)
  • Confidence: high
  • Оракул: замер триажа tmp/oracle/parsedafter_test.go на временной базе: полный проход проверок по чистой задолженности — N=2000: 0.26 с; N=8000: 3.12 с; N=20000: 19.8 с (форма квадратичная, экстраполяция N=110 000 → ~10 мин, N=330 000 → ~1.5 ч). Установившийся режим — 0.0077 мс на вызов. EXPLAIN QUERY PLAN: SEARCH delivery USING INDEX delivery_received_atне тот же индекс, что у PendingDeliveries (частичный delivery_pending требует parse_status='pending' и к предикату IN ('parsed','partial') неприменим).
  • Последствие: конфликт O1 (ops) против R3 (reimpl) разрешён: оба замера верны, неверна интерпретация O1 «часами не может разобрать бэклог из-за проверок» — часы даёт сама свёртка (~0.4 с/доставку по verify:archive: при N=20 000 это ~2 ч свёртки против 20 с проверок, <2% накладных). Реалистичный потолок задолженности — архив квартала ~27 000 доставок (~300 строк/сутки) → ~35 с проверок. Оптимизация кода не нужна (правка была бы незаказанной); неверные утверждения в design.md — нужно поправить, иначе следующий читатель примет решение по ложному числу.
  • Предложение: заменить в design.md «цена не измеряется» на измеренные числа (квадратична по задолженности, ~1% от цены свёртки, 7.7 мкс в установившемся режиме) и «по тому же индексу» на delivery_received_at. Код не трогать.
  • Действие: инлайн
  • Найдено проходами: ops (O1), adversary (A3), reimpl (R3) — одна причина

4. 71 773 координаты живой витрины остаются на старом исходе до ручного reindex, и напоминания об этом нет нигде, кроме отчёта ревью

  • Файл: процедура выкладки; design.md Risks («Этим изменением не выполняется»)
  • Severity: major
  • Confidence: high
  • Оракул: корпусный замер оркестратора — исход меняется на 75 494 координатах (95% — basal_energy_burned/raw); verify:archive после изменения зелёный с новым отпечатком 03aace91…, значит рабочая витрина под старым отпечатком с новым правилом не сойдётся, пока её не пересоберут.
  • Последствие: до пересборки живая витрина расходится с тем, что даёт import + replay — «состояние, которое даёт пересборка» в этом проекте объявлено внешним поведением (docs/review.md). Подмена файла базы — необратимое действие человека (CLAUDE.md, спрашивается всегда), оркестратор выполнить его не вправе.
  • Предложение — готовый вопрос владельцу: «После мерджа витрина расходится с новым правилом на ~75 тыс. координат (95% — basal_energy_burned/raw). (а) выполнить healthlog reindex с остановкой сервиса и подменой базы сразу после выкладки; (б) отложить до планового окна, приняв расхождение витрины на этот срок; (в) не пересобирать — витрина сойдётся только по метрикам, которые переприедут доставками.» Рекомендация: (а).
  • Действие: развилка
  • Найдено проходами: ops (O4), architecture (замечание про подмену базы)

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

  • Откат бинаря молча возвращает старое правило слияния (ops O3, было major confidence medium → остаётся major-гипотезой без оракула). Миграций нет, откат стартует без слова, свёртка идёт по коммутативному правилу, и витрина расходится с пересборкой новым бинарём. Путь не построен и не воспроизведён; частично это плата, уже принятая проектом за «версия базы ниже бинаря — не отказ». Если владелец сочтёт класс важным — это та же развилка, что решалась в задаче про откат релиза.
  • failed-доставка «в витрину ничего не записала» — посылка предиката ParsedAfter держится раскладкой кода, а не проверкой (adversary). Если когда-нибудь failed начнёт писать частично, страж окна ослепнет молча. Оракула нет — гипотеза, потолок major.
  • hasIncomparablePair не видит отмены и способен продлить свёртку за дедлайн под транзакцией записи (reimpl R2, замер честный: ≈51 с при ~7000 кандидатов на координате). Понижено до гипотезы о масштабе: на живом корпусе кандидатов на координате единицы, путь к ~7000 версий одной координаты не построен; pickBest при том же масштабе сам стоит 103 с — чинить пришлось бы оба, то есть это вопрос предела на кандидатов, а не пропущенного ctx.Err().
  • Порядок свёртки под конкурентным приёмом не проверяется ни одним тестом с параллельными писателями (gate-3): -race зелёный на последовательных сценариях. Дефекта не построено; остаточное окно конкурентного приёма осознанно вынесено в journal-order-on-ingest (ops O5 туда же присоединился).
  • Ветки ошибок нового кода не покрыты тестами (gate-1, gate-2): отказ ParsedAfterwarnOutOfOrder/ERROR, отмена контекста в resolve/pickBest — 9 строк, названных оркестратором. Поведение веток прямое (залогировать и продолжить / вернуть ошибку), дефекта в них не предъявлено. Закроются заодно с правкой блокирующего пункта 2, если оркестратор добавит тест на Deferred-выход.
  • Имя метрики из чужого тела уезжает в первичный ключ витрины без предела длины (adversary A4). Подтверждено конструкцией (512 КиБ в ключе), но дефект предсуществующий, изменением не внесён — этому изменению не предъявляется. Конвенция уже записана (docs/conventions/storage.md: «любое значение из чужого JSON, попадающее в ключ… имеет названный предел длины») — значит это не promote, а невыполненное правило: заводится задачей вне этого изменения.

Promote candidates

  • Ошибки уровня store не проходят через единую границу обрезки (adversary): сегодня утечку значений в текст ошибки сдерживает дисциплина каждого call-site. Кандидат в docs/conventions/errors.md: текст ошибки хранения несёт род и координату, но не значение; проверяется тем же разобранным буфером, что и логи.
  • Агрегат счётчиков (Outcome.Add, MergeStats) суммируется руками — забытая строка молча занулит счётчик (architecture AR2): кандидат в конвенцию тестирования — рефлексивный тест «каждое числовое поле участвует в Add», или правило «новое поле счётчика приходит вместе со строкой в Add и тестом». Не находка против этого кода: все нынешние поля просуммированы.
  • Проверка «в логе нет значения точки» для новых поверхностей вывода (по мотивам A1/PointsHeldAt): координата в лог идёт через clipMetric, но правило «новая поверхность вывода получает тест на утечку» записано только для отчёта пересборки комментарием. Кандидат в docs/conventions/testing.md.

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

Запускалось: профиль deep, линейно: gate (зелёный), specs, code, adversary, ops, reimpl (по триггеру «новое правило слияния»), architecture; до кода — профиль design (specs, rubric, architecture). Оркестратор дополнительно прогнал task verify:archive (до изменения красный — унаследованный step_count, после — зелёный, отпечаток 03aace91…, воспроизведён дважды) и task verify:busy (зелёный) — то, что по docs/review.md числится «перестали проверять сознательно», в этой задаче прогнано, потому что она триггерная (правило слияния).

Не запускалось: rubric на коде — намеренно, только в design (его свойства легли приёмочными критериями задачи); других проходов профиля не пропущено.

Что запущенные проходы не могли проверить в принципе (из charter'ов): specs судит код против дельт, а не поведение под нагрузкой; code — только записанные конвенции; adversary назвал свойство без пути — постоянную остановку очереди построить не удалось (Deferred только из ErrBusy/Canceled); ops не наблюдал реальную ночную нагрузку и реальный рестарт с большой задолженностью (его замеры — синтетика, как и мои); reimpl переигрывал только правило слияния и порядок, не разбор; architecture не запускает код; триаж ничего нового не находит по определению — пропуск любого прохода был бы и моим пропуском.

Целиком на человеке — два списка, намеренно раздельных.

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

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

Документы проекта: всё, что нужно триажу, на месте — CLAUDE.md с инвариантами и severity (веса пунктам присвоены по ним, а не выведены заново), docs/review.md с разделами «Типовые ложноположительные» (использован: ни одна находка под шесть записанных классов не подпала, отсев шёл по общим критериям плюс этот раздел) и «Недоступно проверке» (перенесён выше двумя списками), docs/security.md, docs/research/apple-health.md (находка 54 — по ней adversary строил обеднённые тела). Ни один проход недостающего документа не заявил. Единственный документный пробел — не отсутствие, а протухание: docs/database.md:130 и ещё пять мест из блокирующего пункта 3.

Что не влезло в потолок и куда делось: все понижения названы в «Гипотезах» поимённо, молча не выброшено ничего. Отсеяно как вкусовщина, целиком по трём критериям (не меняет поведения, не влияет на цену следующего изменения, не нарушает записанной конвенции): атрибут waited_sec в новом WARN (specs F6 — дельта ограничивает состав «без значений точек», и это выполнено; секунды ожидания значением точки не являются); схлопывание Worker.player+fold в замыкание (architecture); переименования/перестановки из generative-выводов не поступали. Замечания reimpl «где существующее решение лучше» — не находки, а подтверждение решений; перечислены в его сыром выводе и сохранены в истории задачи.