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

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