## 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` о свёртке вне порядка журнала может шуметь при подборе задолженности** → строка пишется одна на доставку и только когда позже неё уже есть свёрнутая; при штатном подборе задолженности в порядке журнала такой доставки нет.