## Context

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

Решение владельца 2026-08-04 ломает ровно это свойство: «пришедшая побеждает» —
не полурешётка. Правило перестаёт быть коммутативным, и исход слияния
становится функцией **порядка свёртки**. Задача обязана внести плату целиком:
привести порядок свёртки к порядку журнала и доказать равенство «пересборка =
приём» оракулом, а не рассуждением.

Осложняющий факт назван в постановке: `replay` идёт по `(received_at, id)`,
живая свёртка — по факту свёртки, и доставка, отложенная занятостью базы, их
разводит.

Схему трогать нельзя: колонок провенанса на точку не заводим, формат `payload`
не меняем, миграции нет.

## Goals / Non-Goals

**Goals:**

- При равной полноте побеждает точка, пришедшая этой доставкой.
- Внутри одной доставки исход по-прежнему решает порядок канонических форм.
- Порядок свёртки равен порядку журнала везде, где это достижимо без изменения
  приёма; недостижимый остаток — наблюдаемый, а не молчащий.
- «Пришедшая проиграла сохранённой» считается отдельным счётчиком.

**Non-Goals:**

- Провенанс на точку, смена формата `payload`, миграция.
- Тай-брейк, зависящий от значения точки (вариант «б») или от измеренного рода
  метрики — род есть функция витрины, и правило слияния, читающее собственную
  выдачу, повторяет дефект наследования слоя «из будущего»
  (`docs/review.md`, 2026-08-01).
- Удержание порядка на самом приёме — это [journal-order-on-ingest](../../../docs/tasks/items/journal-order-on-ingest.md),
  у неё своё решение владельца и своя очередь.
- Пересборка витрины и подмена файла базы — необратимое действие человека.

## Decisions

### Прежде своего — prior art

**CRDT-литература отвечает на вопрос прямо.** Регистр «последняя запись
побеждает» (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)`), и задача сводится к тому, чтобы **живая свёртка
применяла его в том же порядке, что и пересборка**.

**Прецедент внутри проекта — сильнее внешнего.** Слияние сущностей
(`internal/store/entity.go`, `openspec/specs/storage/spec.md`) ту же развилку
уже прошло и выбрало **позицию доставки в журнале**, явно отвергнув «побеждает
приехавшая» такими словами: «"Побеждает приехавшая" было бы функцией порядка
свёртки, а он порядку журнала не равен». Сущность могла себе это позволить —
у неё есть колонка провенанса. У точки её нет и не будет, значит равенство
порядков обязано быть обеспечено, а не предположено.

### Решение 1: тай-брейк по происхождению кандидата, затем по канонической форме

`candidate` получает флаг `incoming` — «эта точка приехала разбираемой
доставкой, а не лежала в объекте». Тотальный порядок `pointLess` становится
лексикографическим по паре `(не incoming, каноническая форма)`: пришедшая
предшествует сохранённой, при равном происхождении решают байты канонической
формы.

Отношение полноты (`pointDominates`) **не трогается**: более полная точка
побеждает по-прежнему, и происхождение на это не влияет. Флаг участвует только
в тотальном порядке, то есть ровно там, где полнота ответа не дала.

Механизм `pickBest` остаётся общим с сущностями: он требует от `less` тотального
строгого порядка, и пара `(ранг, ключ)` его даёт — ключи различны, потому что
совпавшие канонические формы схлопываются до выбора.

**Схлопывание поднимает флаг.** Если приехавшая точка канонически совпала с
сохранённой, выживает сохранённый кандидат (его байты дословны), но флаг
`incoming` он получает. Без этого «внутри доставки решают байты» нарушалось бы
ровно в том случае, когда одна из точек доставки совпала с сохранённой:
сохранённый кандидат проигрывал бы соседу по доставке, которого сам обязан был
обойти по байтам. С поднятием флага доставка сравнивается с доставкой, и
сохранённые байты остаются в витрине — то есть хеш не двигается и объект не
переписывается.

Альтернатива — двухстадийная схема (сперва победитель внутри доставки, потом
он же против сохранённой) — отвергнута: она гасит полноту. Точка, не
превзойдённая никем, вылетала бы на внутридоставочном байтовом тай-брейке
против точки, которую сохранённая заведомо превосходит. Одна стадия над
объединением кандидатов сохраняет инвариант «выигрывает более полная».

### Решение 2: проход воркера прекращается на первой отложенной доставке

Сегодня `Worker.Pass` двигает курсор и после отложенной доставки, то есть
сворачивает её преемниц раньше неё. При коммутативном правиле это было
безразлично; теперь это молчаливое расхождение с пересборкой.

Проход прекращается на первой доставке с исходом `Deferred`. Следующий проход
(сигнал приёма или тик раз в минуту) начинает с начала очереди и берёт её же.

Опасности «очередь встала навсегда» здесь нет, и это следствие уже принятого
определения: `store.Transient` — это **только** занятость базы и отмена
снаружи; собственный дедлайн свёртки в него намеренно не входит, и доставка,
не уложившаяся в бюджет, получает `failed` и очередь освобождает. Занятость же
блокирует запись всем одинаково — проход, перешагнувший занятую доставку, всё
равно упёрся бы в ту же занятость на следующей.

Плата называется вслух: голова очереди блокирует хвост. Раньше проход при
занятости базы двигался дальше и мог свернуть хоть что-то. Это осознанный
размен «полнота витрины сейчас» на «витрина есть свёртка журнала», и он
соответствует тому, как проект уже разрешил тот же спор у сущностей.

### Решение 3: остаточное окно делается наблюдаемым

Строка учёта доставки становится видимой воркеру **после** записи тела
(измерено 184 мс на 62 МиБ), поэтому при конкурентном приёме доставка с более
ранней меткой может появиться после того, как её преемница уже свёрнута.
Барьер этого не ловит: отставшей доставки в момент прохода просто не
существует.

Закрыть окно можно только на приёме — резервированием строки учёта до записи
тела либо выдержкой перед свёрткой. И то и другое — предмет отдельной задачи
[journal-order-on-ingest](../../../docs/tasks/items/journal-order-on-ingest.md),
и переигрывать её здесь нельзя.

**Названо прямо, потому что иначе прочтётся как «закрыто»:** решение владельца
в той задаче — вариант (в), повторы при `ErrLayerUnknown`, — порядок журнала
**не восстанавливает**. Он лечит невыводимый слой, а доставка всё равно
сворачивается после своей преемницы. Значит после его реализации равенство
«пересборка = приём» останется условным. Это записано вопросом владельцу в
раздел «Вопросы» той задачи вместе с тегом `question`, четвёртым вариантом
(сериализовать выпуск ULID с записью тела и вставкой строки) и рекомендацией.

Что делается вместо: перед свёрткой воркер спрашивает журнал, есть ли доставка
**позже** этой, которая уже вышла из очереди. Есть — пишется `WARN` с
идентификатором доставки: порядок свёртки разошёлся с порядком журнала, и
витрина в этом месте не равна пересборке. Событие перестаёт быть невидимым,
а лечится оно `healthlog reindex`.

Запрос — тот же кортежный предикат `(received_at, id) > (?, ?)`, что уже
используется в `PendingDeliveries` и `LastDerivedLayer`, но **не по тому же
индексу**, и это названо, потому что первая редакция утверждала обратное.
`delivery_pending` — индекс частичный, построен для `parse_status = 'pending'`,
а предикат просит `IN ('parsed','partial')`; планировщик берёт
`delivery_received_at` (`EXPLAIN QUERY PLAN`: `SEARCH delivery USING INDEX
delivery_received_at`).

Цена измерена, а не объявлена нулевой. В установившемся режиме (очередь пуста
или почти пуста) — единицы микросекунд. На **чистой задолженности**, где
преемниц в статусе `parsed` нет вовсе, поиск доходит до конца хвоста, и
суммарная цена прохода квадратична по длине задолженности: 0,26 с при 2 000
доставок, 3,12 с при 8 000, 19,8 с при 20 000. Это меньше 2% от цены самой
свёртки (порядка 0,4 с на доставку по прогону живого архива), поэтому индекс не
заводится: миграция ради двух процентов — плохой размен, а числа названы, чтобы
следующий читатель не принимал решение по слову «дёшево».

Проверка живёт в **воркере**, а не в свёртке: пересборка зовёт `Player.Play`
напрямую и по построению идёт в порядке журнала, а её тишина здесь
содержательна.

### Решение 4: счётчик удержаний

`MergeStats.PointsHeld` — сколько пришедших точек проиграло сохранённой, плюс
`PointsHeldAt` с координатами первых (`Collision`, тот же потолок
`maxCollisionsReported`). Считается координата, где победил кандидат **без**
флага `incoming`, а кандидаты с флагом были: под новым правилом это возможно
ровно тогда, когда сохранённая строго полнее.

Отдельно от `Overwrites` — потому что `Overwrites` считает столкновение в обе
стороны (на корпусе он сработал бы ~84 000 раз) и отличить «оставили новое» от
«выбросили новое» по нему нельзя. Ровно тот же довод уже записан для
`EntitiesHeld` у сущностей, и именование берётся оттуда же.

## Risks / Trade-offs

- **Витрина в `./data` расходится с новым правилом** → отпечаток обязан
  измениться, иначе правило не сработало. Приведение в соответствие —
  `healthlog reindex` с остановкой сервиса и подменой файла базы, необратимое
  действие человека. Этим изменением не выполняется.
- **Голова очереди блокирует хвост при занятой базе** → занятость и раньше
  блокировала запись всем; проход возобновляется сигналом приёма или тиком раз
  в минуту, тела всё это время лежат в архиве и не теряются.
- **Остаточное окно конкурентного приёма** → не закрывается, но перестаёт быть
  молчащим (`WARN`). Закрытие — за [journal-order-on-ingest](../../../docs/tasks/items/journal-order-on-ingest.md),
  чьё нынешнее решение его не закрывает; вопрос владельцу записан туда же.
- **Цена изменения сосредоточена в одной метрике одного слоя** → из 75 494
  координат, где меняется исход, 71 773 (95%) — `basal_energy_burned` слоя
  `raw`. Ошибка была массовой по координатам и узкой по метрикам, и число
  переписываемых объектов растёт соответственно.
- **Правило стало функцией порядка, а значит хрупче** → это записывается в
  спеку хранения нормативно (было «функция множества», стало «функция множества
  и позиции в журнале»), чтобы следующий автор не восстановил коммутативность
  «для чистоты» и не откатил починку молча.
- **Больше записей в витрину** → повторная доставка той же координаты с другим
  значением теперь всегда переписывает объект, тогда как байтовый порядок часто
  давал `unchanged`. Хеш-детектор продолжает гасить точные повторы; рост
  ограничен долей спорных координат.
- **`WARN` о свёртке вне порядка журнала может шуметь при подборе
  задолженности** → строка пишется одна на доставку и только когда позже неё
  уже есть свёрнутая; при штатном подборе задолженности в порядке журнала такой
  доставки нет.
