# Дозакрыть находки ревью по слиянию сущностей **Приоритет:** высокий Задача «Тренировки и секции с собственными id» (`f8200f7`) прошла ревью не полностью: проходы `adversary`, `ops` и архитектурный на коде не запускались. Дозапуск принёс девять причин, триаж оставил семь. Оба заявленных `critical` понижены до `major` с названной причиной — вход недостижим из штатного потока HAE (корпус в 118 доставок такого не производил), — но остались в работе: потеря маршрута необратима, а `reindex` проигрывает то же поражение. Отчёт триажа с прогнанными оракулами — `tmp/triage-late.md`, оракулы — `tmp/adv/*.go`. Каждый пункт ниже имеет падающий тест; правка считается сделанной, когда соответствующий оракул зеленеет **и** переезжает из `tmp/` в обычные тесты пакета. ## Что делать **1. Скелет не затирает маршрут.** `canon.Fields.Covers` проверяет только наличие ключа и длину верхнеуровневого массива, поэтому версия, где каждый массив заменён массивом той же длины из `null`, а каждое не-массивное значение — скаляром, признаётся равной настоящей и по тай-брейку журнала замещает её. Оракул: `-run 'Скелет|Маршрут|ДвеВерсии'`. Решение принято, новое не проектируем: применяем к сущностям **тот же стандарт, что записан для точек** — второй разряд сравнения, как у `canon.Relate` («иначе ключ с пустым значением исчезает по жребию»). Плюс запрет вырождения формы: покрывающая версия не может подменить объект или массив скаляром — проверка по верхнему уровню, стоимость O(ключей). Поэлементная содержательность массивов **отвергнута ценой**: пункт 4 измерил 768 МиБ пика на канонизации, полный обход маршрута на каждое сравнение эту цену умножит. Остающийся предел — порча *внутри* элемента ряда (точка маршрута без `altitude`) — не закрывается ничем, кроме сверки с телом в архиве, и должен быть записан в `architecture.md` рядом с описанием `Covers` так же прямо, как он записан в комментарии кода. Отдельно: `pickWithinDelivery` при равном содержании и **разных байтах** обязан считать `differs=true` — сейчас две версии одного `id` в одном теле дают `удержано=0` и молчащий счётчик. **2. Одно поле не той формы не уносит сущность.** `entityHead` держит `ID`/`Name`/`Date`/`Start`/`End` типизированными строками, поэтому смена типа любого из пяти роняет `json.Unmarshal` целиком, а доставка при этом получает `parsed` с пустым списком непокрытого. Достижимо из реального потока: дрейф формата дат у HAE задокументирован. Оракул: `-run ОдноПоле`. Читать пять полей через `json.RawMessage` и извлекать мягко — это буквально принцип, уже записанный в коде для `Duration` («нечисловое значение — это пропуск ОДНОГО поля, а не сломанная сущность»). Плюс пропуски обязаны быть видны в **учётной записи** доставки, а не только в логе: ретеншен решает по базе, и сегодня он получит ответ «терять нечего». Хранение сущности с неразобранной меткой (NULL) в эту задачу не входит — см. остаток ниже. **3. Откат бинаря не проходит молча.** `store.Open` мигрирует безусловно и не сверяет версию схемы, поэтому старый бинарь успешно стартует поверх схемы 7, молча игнорирует незнакомые секции и помечает доставки разобранными. Оракул прогнан живьём: `-run СтарыйБинарь`. Перенести в `Open` страж из `OpenForRead` — прецедент записан там же: «расхождение версий — отказ, а не повод мигрировать». **4. Канонизация — за транзакцию, по-настоящему.** Комментарий `bucket.go:143-146` утверждает, что канонизация вынесена наружу; фактически `analyze()` вызывается из `compareEntities` **внутри** `inTx`, который открывает `immediate` и повторяет до пяти раз, а кеш `analyze()` пишется в **копию** элемента среза и не переживает даже одной попытки. Измерено: тело 40 МиБ → пик 768.3 МиБ; 63 МиБ → блокировка удерживается 5.019 с при `busy_timeout` 5000, то есть конкурентный `CreateDelivery` исчерпывает повторы и приём отвечает 500 по доставке, тело которой уже на диске. В этой задаче: вынести `analyze()` наружу по-настоящему, кешировать в срезе, а не в копии, и различать в логе `delivery failed` занятость базы (`store.ErrBusy` уже выделен доменной ошибкой) от прочих причин. Пределы на размер сущности и потоковый расчёт хеша — остатком. **5. Значения из тела не попадают в текст ошибки.** `fmt.Errorf("… встречено %v", tok)` подставляет токен целиком: тело 8 МиБ даёт текст ошибки 8 МиБ, который уходит атрибутом `error` на уровень `WARN`. Инвариант «тела запросов только на `DEBUG` и с обрезкой» нарушен буквально. Называть тип токена и `dec.InputOffset()`. Оракул: `-run Тело`. Дефект в базе диффа, не внесён разбором сущностей. **6. Победитель внутри доставки — функция множества, а не порядка.** Попарная свёртка частичного порядка с тотальным тай-брейком нетранзитивна: `[A,B,C]` даёт `C`, `[B,C,A]` даёт `A`. Стандарт «победитель — функция множества точек, а не порядка» записан в `architecture.md` для точек и для сущностей молча не применён. Собрать версии ключа, отбросить строго покрытые, среди оставшихся взять минимум канонической формы. Оракул: `-run ПорядокВнутри`. **7. Провенанс обновляется при равных хешах.** Совпал хеш — запись пропускается вместе с провенансом, и в `delivery_id`/`delivery_received_at` остаётся первая свёрнутая копия, а не победитель по журналу. Провенанс устаревает на каждой из ~26 повторных присылок гарантированно; расхождение живой витрины с `reindex` латентно (требует возврата содержимого к прежнему — корпус такого не производил), но нарушает записанный инвариант детерминизма. Сравнивать позиции в журнале и обновлять провенанс. Оракул: `-run Порядок`. ## Что уходит остатком - хранение сущности с `id`, но неразобранной меткой (NULL-метка): требует схемы и правил чтения, а после пункта 2 случай становится редким; - пределы на размер одной сущности и суммарный размер секции, потоковый расчёт канонической формы и хеша — заводится задачей вместе с условием из пункта 4; - принцип «data-миграции не отбирают строки по спискам, которые где-то обрезаются» (миграция `00007` отбирает по обрезаемому на 32 `uncovered_sections`; для неё дефект пустой — HAE шлёт одну секцию за доставку, — но следующая покрытая секция унаследует слепую зону); - длина очереди `pending` в `/stats` **и без WARN**: после миграции, переводящей доставки в `pending`, отставание по конструкции не WARN-ится (`startupDone`), и бэклог идёт молча при зелёном `/healthz` — строка уходит в [наблюдаемость](stats-nablyudaemost.md). ## Кандидаты в конвенции - текст ошибки разбора не содержит значений из тела — только тип токена и смещение; - тест перестановок правила слияния обязан включать версию с содержимым, равным одной из уже присланных: тест трёх версий с разными хешами ветку равенства не посещает ни разу. Готово, когда все семь оракулов зелены, живут обычными тестами пакетов, а `task verify:archive` сходится. Связано: `internal/canon`, `internal/store/entity.go`, `internal/hae/entity.go`, `docs/review-journal.md` (пропуск проходов на чекпоинте — отклонение процесса, ему там место).