# Тай-брейк точек — порядок журнала, а не хранимая метка

- **Дата:** 2026-08-04
- **Источник:** openspec/changes/archive/2026-08-04-tie-break-equal-completeness/design.md

## Решение

При равной полноте побеждает точка, пришедшая разбираемой доставкой. Правило
слияния точек тем самым перестаёт быть функцией множества и становится **явной
функцией порядка журнала**; за это платится приведением порядка живой свёртки к
журнальному. Хранимая метка провенанса у точки — очевидный ответ на тот же
вопрос — отвергнута по цене.

## Почему

Байтовый порядок канонических форм, стоявший тай-брейком прежде, оказался не
крайним разрядом правила, а главным: перемер на живом корпусе дал 80 129 спорных
координат, из которых полнота отбрасывает кого-то лишь в 981 (1,2%), а 79 148
(98,8%) решает тай-брейк. И решает измеримо неверно — берёт меньшее значение в
1 847 случаях из 1 912, то есть системно хранит версию, которую источник уже
пересчитал. Ценой этого час `2026-08-03T07:00Z` метрики `step_count` остался
недосчитанным, сверка слоёв объявила метрику мгновенной против 23 согласных
часов, и род ушёл в `unknown`.

Готовое решение известно и рассмотрено первым. Цитата из источника:

> Регистр «последняя запись побеждает» (LWW-Register, Shapiro et al.,
> «A comprehensive study of Convergent and Commutative Replicated Data Types»,
> INRIA RR-7506) сходится **только** потому, что метка времени хранится
> **вместе со значением**: слияние сравнивает две метки, а не «кто пришёл
> вторым». Без хранимой метки то же правило вырождается в last-writer-wins по
> порядку применения — а он у реплик разный, и сходимости нет. Ровно это и
> означает «не полурешётка».
>
> Взять готовое целиком нельзя: хранимая метка — это колонка провенанса на
> точку, то есть смена формата `payload` и миграция, которые постановка
> запрещает. Отвергнуто **с названной причиной**, и причина не «нам не
> подходит», а «цена выше разрешённой рамки».

Что взято вместо метки — вывод той же литературы о плате за отказ от неё:

> Если состояние не решётка, сходимость обеспечивается **единственным
> детерминированным порядком применения операций** — это уже не CRDT, а
> конвейер репликации с журналом (state machine replication: Schneider,
> «Implementing fault-tolerant services using the state machine approach», и то
> же в Raft/Kafka log-compaction). Требование там одно и оно жёсткое: все
> потребители применяют журнал в одном порядке.

Внутренний прецедент сильнее внешнего и решён иначе: слияние сущностей ту же
развилку прошло и выбрало хранимую позицию журнала `(received_at, id)`, прямо
отвергнув «побеждает приехавшая». Разница не в намерении, а в том, что у
сущности колонка провенанса есть, а у точки нет. Критерий выбора между двумя
механизмами записан в `docs/architecture.md`, раздел «Разрешение столкновений».

Значение точки и род метрики в правило не входят намеренно: «брать бо́льшее»
неверно для мгновенных метрик, которые источник досчитывает вниз, а род есть
функция витрины — правило, читающее собственную выдачу, перестаёт быть функцией
префикса журнала (тот же дефект уже ловили на наследовании слоя «из будущего»).

## Последствия

- `+` `step_count` вернул род (`cumulative`, ноль противоречащих часов), заодно
  вернулся `headphone_audio_exposure` (`instant`); общий станок
  `task verify:archive` из красного стал зелёным.
- `+` Систематический недосчёт на 75 494 координатах прекращён (95% из них —
  `basal_energy_burned` слоя `raw`).
- `−` Правило больше не коммутативно: содержимое витрины стало функцией порядка
  свёртки. Живой порядок приведён к журнальному барьером — проход воркера
  прекращается на первой отложенной занятостью доставке, — но голова очереди
  теперь блокирует хвост.
- `−` Остаточное окно конкурентного приёма (строка учёта видна позже метки)
  закрыть без изменения приёма нельзя; оно сделано наблюдаемым (`WARN`) и
  оставлено вопросом владельца в `docs/tasks/items/journal-order-on-ingest.md`.
- `−` Появилось направление, в котором правило теряет содержание: разряд полноты
  гаснет при разошедшихся значениях общих ключей, и пришедшая точка может унести
  ключ сохранённой. Замерено — 2 координаты из 80 129 спорных; вместо запрета
  заведён счётчик и `WARN`, тем же решением и по той же причине, по какой
  отложено объединение полей.
- `−` Восстановление коммутативности «для чистоты» молча откатит починку.
  Поэтому запрет записан нормативно в спеке хранения, а формулировки во всех
  документах приведены к «функция множества **и позиции в журнале**».
- `−` Живая витрина в `./data` расходится с новым правилом до пересборки:
  подмена файла базы — необратимое действие человека и этим изменением не
  выполняется.

## Открыто, решает владелец

Записано здесь, а не в файле задачи: файл закрытой задачи удаляется, а эти два
решения переживают её.

**1. Пересобирать ли живую витрину сейчас.** Правило действует только вперёд:
уже сохранённые часы держат значение прежнего, измеримо смещённого правила, пока
витрину не пересоберут, — это 75 494 координаты (95% — `basal_energy_burned`
слоя `raw`). До пересборки сверка отпечатков с `healthlog reindex` не сойдётся и
будет выглядеть отказом.

- **(а)** `reindex` с остановкой сервиса и подменой базы сразу после выкладки.
  Цена: минута простоя приёма на нынешнем архиве плюс необратимое действие
  руками. **Рекомендация.**
- **(б)** отложить до планового окна, приняв расхождение витрины на этот срок.
- **(в)** не пересобирать: витрина сойдётся только по координатам, которые
  переприедут доставками, — смещение останется в истории навсегда.

**2. Не сузить ли тай-брейк там, где он теряет содержание.** Разряд полноты
гаснет при разошедшихся значениях общих ключей, и тогда пришедшая точка
побеждает, даже если унесёт ключ, которого сама не несёт. Замер: 2 координаты из
80 129 спорных, обе — те же, что дают несравнимые наборы.

- **(а, сделано)** оставить правило, завести счётчик `PointsErased` с `WARN`.
  Событие наблюдается, но не предотвращается; обратимо пересборкой, пока жив
  архив.
- **(б)** сузить «побеждает пришедшая» до случая совпавших множеств
  содержательных ключей, а при строгом включении имён оставлять более полную
  независимо от происхождения. Цена: правило перестаёт быть чисто структурным на
  этом разряде, дельта хранения переписывается, прогон живого архива снимается
  заново. Проверить обязательно: сохраняется ли починка `step_count` — по замеру
  его столкновения идут с одинаковыми наборами `{date, qty}`, то есть должна.

Переход к (б) остаётся дешёвым: счётчик скажет, если событие станет массовым.
