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