# Триаж: 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` = 7–8 проходов по `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): отказ `ParsedAfter` → `warnOutOfOrder`/`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` «где существующее решение лучше» — не находки, а подтверждение решений; перечислены в его сыром выводе и сохранены в истории задачи.