store: при равной полноте точек побеждает пришедшая доставка

- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
This commit is contained in:
av
2026-08-04 11:16:24 +03:00
parent ae607f1ceb
commit b278501a6e
44 changed files with 3780 additions and 172 deletions
@@ -0,0 +1,368 @@
# Триаж: 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` «где существующее решение лучше» — не находки, а
подтверждение решений; перечислены в его сыром выводе и сохранены в истории
задачи.