## Context

Семь находок дозапущенных проходов ревью (`tmp/triage-late.md`) по коммиту
`f8200f7`. Каждая имеет прогнанный падающий оракул; оракулы переезжают обычными
тестами пакетов, `tmp/` в `.gitignore` и жить в нём им нельзя.

Ограничения, которые задача не выбирает, а наследует:

- **Свёртка обязана быть функцией журнала.** Живая витрина и `reindex` обязаны
  сходиться отпечатком; всё, что зависит от порядка свёртки или от порядка
  элементов на проводе, — дефект по определению.
- **Тренировка приезжает повторно, пока источник её досчитывает** (26 копий,
  три различных содержимых на живом архиве), и значения между копиями
  расходятся **всегда**. Поэтому правило полноты точек к сущностям неприменимо,
  и `Covers` существует отдельно от `Relate`.
- **Маршрут — 95% веса тренировки**, и в экспорте Apple его нет вовсе.
  Затирание маршрута необратимо: `reindex` проиграет журнал и получит то же.
- **Цена канонизации измерена**: тело 40 МиБ → пик кучи 768.3 МиБ; 63 МиБ →
  блокировка удерживается 5.019 с при `busy_timeout` 5000.

## Goals / Non-Goals

**Goals:**

- Обеднённая версия сущности не замещает сохранённую и не пропадает из счётчика.
- Победитель — функция множества версий: и внутри доставки, и между доставками.
- Провенанс сущности отражает победителя по журналу, а не первую свёрнутую копию.
- Поле не той формы стоит одного поля, а не сущности; пропуск виден в базе.
- Откат бинаря поверх новой схемы отказывает, а не стартует молча.
- Каноническая форма сущности считается один раз и вне транзакции.
- Диагностика разбора не несёт значений из тела.

**Non-Goals:**

- Хранение сущности с `id` и неразобранной меткой (NULL-метка) — требует схемы
  и правил чтения витрины.
- Пределы на размер одной сущности и суммарный размер секции, потоковый расчёт
  формы и хеша — новая политика, а не правка.
- Объединение полей несравнимых версий — отвергнуто там же и по той же причине,
  что для точек.
- Поэлементная сверка **содержимого** элементов ряда (точка маршрута без
  `altitude`) — см. «Риски».

## Decisions

### 1. `Covers` получает второй разряд, запрет вырождения формы и счёт содержательных элементов

Сегодня `Covers(g)` требует от `f` лишь наличия каждого содержательного ключа
`g` и длины верхнеуровневого массива не меньше. Этого хватает, чтобы «скелет»
(`{"maxHeartRate":1,"heartRateData":[null,null]}` против настоящей тренировки)
признался равным настоящей и выиграл тай-брейк журнала.

Правило дополняется тремя проверками, все — по верхнему уровню:

1. **Второй разряд: ключи без содержания.** Каждый ключ `g` (включая пустые)
   обязан быть у `f`. Тот же стандарт записан для точек в `canon.Relate`:
   «иначе `{date, qty, Min:0, Max:0}` и `{date, qty}` неразличимы, и `Min` с
   `Max` исчезли бы из витрины по жребию тай-брейка». Отличие от `Relate`:
   там второй разряд применяется только при равенстве первого, здесь включение
   всех ключей требуется безусловно. Это эквивалентно при равном первом разряде
   и строже, когда первый разряд неравен, — а строже здесь и нужно: `Covers`
   отвечает «не потеряем ли содержания».
2. **Запрет вырождения формы.** Покрывающая версия не может подменить объект
   или массив скаляром: если `g[k]` — объект, `f[k]` обязан быть объектом; если
   массив — массивом. Обратное (скаляр у `g`, объект у `f`) разрешено: `f`
   богаче формой. Стоимость — O(ключей), первый байт литерала.
3. **Содержательные элементы массива.** Рядом с длиной считается число
   **непустых** элементов. `[null,null,null]` и `[{},{},{}]` сохраняют длину, но
   не несут ничего, а именно так выглядит затёртый маршрут.

Про третью проверку важно, что она **не добавляет обхода**: `arrayLen` уже
сегодня декодирует каждый элемент массива в выбрасываемый `json.RawMessage`,
чтобы посчитать их количество. Добавляется `isEmpty` по литералу элемента —
проверка первого байта и, для чисел, `strconv`; в дерево значений элемент не
разворачивается. Поэтому цена, названная триажем («полный обход маршрута на
каждое сравнение»), уже уплачена, и решение её не умножает.

**Отвергнуто:** сверка содержимого *внутри* элемента ряда (набор ключей у
каждой точки маршрута). Вот она обход действительно умножила бы — на 768 МиБ
пика, измеренных пунктом 4, — и остаётся названным пределом (см. «Риски»).

**Отношение остаётся частичным порядком** — конъюнкция включений множеств и
нестрогих неравенств по каждому общему ключу транзитивна. На это опирается
решение 2.

### 2. Победитель внутри доставки — минимум канонической формы среди непревзойдённых

Попарная свёртка `pickWithinDelivery` нетранзитивна: полнота — частичный
порядок, тай-брейк — тотальный, и вместе они образуют цикл. Стандарт для точек
записан в `architecture.md` («победитель — функция множества точек, а не порядка
их поступления») и реализован в `store.resolve`; для сущностей он применяется
дословно: собрать версии ключа, отбросить строго покрытые, среди оставшихся
взять минимум канонической формы.

Порядок обязан быть тотальным **до конца**. У точек кандидаты с равной
канонической формой схлопываются ещё до сравнения, поэтому минимум единственен;
у сущностей схлопывания нет, и при двух версиях, различающихся только порядком
ключей или дребезгом последнего разряда (измеренная норма HAE), «минимум формы»
неединственен — в хранилище лёг бы тот элемент, что стоял в массиве раньше.
Поэтому последним разрядом сравнения идут исходные байты, ровно тем же
движением и по той же причине, что записана в `canon.Less`.

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

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

Здесь требование постановки исполнено **по канонической форме, а не по байтам**,
и это сказано прямо, потому что постановка говорит «при равном содержании и
разных байтах обязан считать `differs=true`». Цель постановки — чтобы счётчик
перестал молчать на двух настоящих разных версиях — достигается: случай оракула
(маршрут против маршрута из `null`) считается. Побайтовый вариант отвергнут
записанным инвариантом: порядок ключей в JSON от HAE нестабилен и дребезг
последнего разряда тоже, ради чего канонизация и заведена, — счётчик по байтам
срабатывал бы на норме потока и стал бы неотличим от шума ровно тогда, когда
понадобился бы. Детерминизм при этом обеспечен не счётчиком, а тай-брейком по
байтам выше.

`pickWithinDelivery` исчезает: она была парной формой того, что теперь делает
множество.

Отбор «максимальные элементы плюс минимум по тотальному порядку» существует в
проекте для точек (`store.resolve`) и объявлен стандартом в `architecture.md`.
Второй рукописный экземпляр — ровно то, чем был `pickWithinDelivery`, и он
разошёлся со стандартом нетранзитивностью. Поэтому механизм выносится в общего
помощника, а точки и сущности становятся двумя его вызовами с разными
отношениями: `architecture.md` уже обещает смену тай-брейка точек, когда род
метрики будет измерен, то есть правка одного экземпляра при живом втором
запланирована заранее.

### 3. Провенанс обновляется при совпавшем хеше

Совпал хеш — содержимое то же, писать нечего. Но провенанс
(`delivery_id`/`delivery_received_at`) остаётся от первой свёрнутой копии, а не
от победителя журнала. Следствия два: провенанс устаревает гарантированно на
каждой из ~26 повторных присылок, и при возврате содержимого к прежнему
(A→B→A) живая витрина расходится с `reindex` — тай-брейк пункта 4 правила
сравнивает позиции, а сохранённая позиция неверна.

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

`updated_at` при этом не двигается — и это отдельное решение, а не экономия.
Тренировка приезжает до двадцати шести раз, и бамп метки на каждой сделал бы её
меткой **касания строки**, а не изменения содержимого. Потребитель запроса «что
изменилось с момента X» — естественного для коллектора и уже заказанного Read
API — получил бы двадцать шесть ложных изменений, неотличимых от настоящего
досчёта, и выяснилось бы это после того, как потребитель написан. Провенанс
несёт собственную метку (время приёма своей доставки), и для тай-брейка её
достаточно.

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

Слово «провенанс» после этого означает у сущности не то, что у часового
объекта: у объекта хранится доставка, **создавшая** его, и она не поднимается.
Асимметрия законная — у объекта нет замещения версии целиком, — но записана в
спеке явно, иначе читатель перенесёт смысл с одного на другое.

### 4. Каноническая форма считается один раз, вне транзакции

Комментарий `bucket.go` утверждает, что канонизация вынесена наружу; фактически
`analyze()` зовётся из `compareEntities` **внутри** `inTx`, а его результат
пишется в **копию** элемента среза и не переживает даже одной попытки.

Ключевое наблюдение: `canon.Hash(raw)` уже считает полную каноническую форму и
**выбрасывает** её, а `analyze()` считает ту же форму заново. То есть дорогая
часть и так платится на каждой версии, в `prepareEntities`, вне транзакции.

Решение: `canon` отдаёт форму и хеш **одним проходом** (`FormAndHash`, внутри —
запись в `io.MultiWriter(буфер, sha256)`, той же формой, какой уже написан
`HashAll`), `newEntityVersion` зовёт его один раз и держит результат вместе с
`canon.Analyze`. Ленивость, `analyze()` и флаг `parsed` уходят.

Отдельный вызов `HashForm(form []byte)` отвергнут: он вводит контракт
очерёдности («сперва `Form`, потом `HashForm`»), где передача сырых байт вместо
формы даёт правдоподобный, но неверный хеш, — а компилятор такую подмену не
ловит.

Баланс работы назван честно, потому что он не односторонний:

- на пути **разошедшегося хеша** (одна доставка из сорока четырёх) — минус одна
  полная канонизация: сегодня форма считается дважды;
- на пути **совпавшего хеша** (сорок три из сорока четырёх) — плюс один мелкий
  разбор `json.Unmarshal` в `map[string]json.RawMessage`, то есть проход по телу
  и копия каждого верхнеуровневого значения. Сегодня на этом пути `analyze()` не
  зовётся вовсе.

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

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

### 5. Страж версии схемы переезжает в `Open`

`OpenForRead` сверяет версию схемы и отказывает при расхождении; `Open`
мигрирует безусловно. Поэтому старый бинарь поверх схемы 7 стартует молча,
незнакомые секции игнорирует, а доставки за окно отката помечает разобранными —
и ничто не намекает, что для этого окна нужен `reindex`.

Асимметрия у `Open` законная: версия базы **ниже** версии бинаря — это ровно то,
ради чего миграции существуют. Отказ ставится на «версия базы **выше** версии
бинаря». Асимметрия относится только к `Open`: `OpenForRead` сохраняет строгое
равенство, как требует спека пересборки, — иначе `reindex` начал бы читать
рабочую базу схемы старее бинаря по колонкам, которых там нет. В одно место
выносится **чтение** версии, а не сравнение.

Чтение берётся у самого goose (`Provider.GetVersions` отдаёт и текущую версию
базы, и целевую), потому что имя таблицы учёта, имя колонки и правило «максимум
= текущая версия» принадлежат ему: рукописная копия его приватной схемы
разошлась бы при обновлении зависимости — и не отказом, а тем, что страж
перестал бы ловить. Если окажется, что на соединении только для чтения этот путь
требует записи или отдаёт лишнюю задержку (SQLite-диалект goose не умеет
`TableExists`, поэтому на отсутствующей таблице уходит в повторы), копия
допустима, но одной функцией и с названной причиной — и тогда «таблицы нет»
распознаётся структурно (`sqlite_master`) и `NULL` читается как `NULL`
(`sql.NullInt64`), а не по тексту ошибки драйвера: сообщения драйвера не
контракт, это уже записанное правило проекта.

**Цена отказа названа, потому что она реальна.** Страж останавливает сервис
целиком, а телефон шлёт непрерывно и молча: доставка, не попавшая в архив, в
журнал не попадает вовсе. Взвешено так: откат бинаря — действие оператора,
который в этот момент рядом и видит crash-loop сразу; дыры плотных метрик за
время простоя закроют широкий и глубокий проходы синхронизации. Не закроют
`stateOfMind` — у него доставки HAE единственный источник, — и это цена решения.
Она меньше цены молчания: молчаливый старт портит витрину за всё окно отката, а
узнать об этом неоткуда, и после ретеншена тел чинить будет нечем. Отвергнуты:
деградированный режим «принимать и архивировать, свёртку не начинать» (сохраняет
оба инварианта, но заводит режим, о существовании которого надо помнить, и
правила его видимости) и отказ только воркеру свёртки (требует доказать, что
старый бинарь корректно пишет учёт в новую схему, — доказательства нет).

### 6. Мягкий заголовок сущности

`entityHead` держит `ID`/`Name`/`Date`/`Start`/`End` типизированными строками, и
сущность теряется целиком при смене типа любого из пяти. Причина названа точно,
потому что от неё зависит выбор решения: роняет не `encoding/json`, а строка
`entity.go:51-53`, где любая ошибка разбора считается фатальной. Сам
`json.Unmarshal` «skips that field and completes the unmarshaling as best it
can» и возвращает `*UnmarshalTypeError`.

Отсюда напрашивается трёхстрочная альтернатива — `errors.As(err, &ute)` и
продолжить с уже заполненным заголовком. Она **отвергается**, и по названной
причине: та же документация тут же оговаривает — «it's not guaranteed that all
the remaining fields following the problematic one will be unmarshaled». Разбор,
построенный на дозаполнении, перестал бы быть функцией тела: одна и та же
тренировка давала бы разный заголовок в зависимости от порядка ключей на
проводе, а он у HAE нестабилен.

Решение — штатная точка расширения `encoding/json`: тип `softString` с
`UnmarshalJSON`, который на нестроковом значении ничего не пишет и возвращает
`nil`. Теги остаются декларативными, ручных извлечений нет, гарантия полная.
Заодно сохраняется различение счётчиков: элемент, который сам не объект, даёт
ошибку **верхнего** уровня и по-прежнему уходит в «не разобралось как объект», а
не в «нет `id`».

`id` при этом остаётся требованием, а не полем: без него сущность не адресуема.
Число вместо строки в `id` — это сменившаяся форма идентификатора, и
превращать `42` в `"42"` значило бы придумать идентичность за источник. Такая
сущность пропускается прежним счётчиком.

`start`, приехавший не строкой, на `date` **не** откатывается. Мягкое чтение
объявляет непонятое значение отсутствующим, а фолбэк `start → date` существует
для сущностей, у которых `start` не прислан вовсе; композиция этих двух правил
подставила бы метку другого момента времени, неотличимую от настоящей и ничем не
считаемую. Поэтому нестроковый `start` — это неразбираемая метка.

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

**Пропуски становятся видны в базе.** Миграция `00008` добавляет доставке
колонку `skipped_entities`; свёртка пишет туда сумму трёх счётчиков пропуска
сущностей. Причина не в отчётности: ретеншен решает «что потеряется, если тело
удалить», по базе, и сегодня получает ответ «терять нечего» ровно там, где
теряется тренировка с маршрутом.

Статус доставки от пропуска сущности **не** меняется: `partial` определён
списком непокрытых секций, и второй источник истины для него завёл бы ровно то
расхождение, которое спека запрещает явно.

### 7. Диагностика разбора без значений из тела

`fmt.Errorf("… встречено %v", tok)` подставляет токен целиком: тело 8 МиБ даёт
текст ошибки 8 МиБ, который уходит атрибутом `error` на уровень `WARN`.
Инвариант «тела запросов только на `DEBUG` и с обрезкой» нарушен буквально.

Ошибка называет **тип токена** и `dec.InputOffset()`. Смещение полезнее
значения: по нему место в теле находится в архиве, а значение из тела в логе не
имеет права быть в принципе.

## Три формы решения главного узла и компромисс каждой

Главный узел — глубина сравнения содержания при слиянии версий сущности.
Рассматривались три, и выбор записан не по умолчанию:

1. **Поэлементная сверка содержимого рядов** (у каждого элемента маршрута
   сравнивать набор ключей). Ловит всё, включая точку маршрута без `altitude`.
   Компромисс: полный обход маршрута с материализацией каждого элемента на
   каждое сравнение — умножение уже измеренных 768 МиБ пика; плюс пересмотр
   правила слияния целиком. Отвергнута ценой.
2. **Второй разряд + запрет вырождения формы + счёт содержательных элементов**
   (выбрана). Ловит скелет из скаляров, обнулённый ряд и исчезающий пустой ключ.
   Компромисс: строже правила точек, поэтому чаще удерживает; событие видно
   счётчиком, но контроль требует вывести счётчик в отчёт пересборки — иначе
   мера «сходимость `verify:archive`» его не увидит по построению. Стоимость —
   O(ключей) плюс `isEmpty` на элементах в уже существующем обходе.
3. **Принять предел, оставить только наблюдаемость** (счётчик по различию байт,
   предел записан в `architecture.md`). Компромисс: маршрут продолжает теряться
   необратимо при обеднённой версии, а восстановить его после ретеншена тел
   неоткуда — в экспорте Apple маршрута нет. Отвергнута последствием.

Форма (2) принята владельцем в постановке; здесь она не переоткрывается, а
уточняется недостающими определениями (пустота элемента, строгость покрытия,
тотальность порядка) и получает контроль, которого у неё не было.

## Risks / Trade-offs

- **Порча внутри элемента ряда не ловится** (точка маршрута без `altitude`:
  длина та же, элемент непуст, форма не выродилась) → предел записывается в
  `architecture.md` рядом с описанием `Covers` так же прямо, как он записан в
  комментарии кода. Закрыть его может только сверка с телом в архиве, а тело
  живёт до ретеншена.
- **Второй разряд `Covers` строже прежнего правила** и может удержать версию,
  которая раньше замещала: досчёт, потерявший ключ с пустым значением, теперь
  проигрывает. Мера контроля — **не** сходимость `verify:archive`: живой приём и
  пересборка пользуются одним правилом и одинаково сойдутся на одинаково
  замороженной версии, то есть слишком строгое правило выглядело бы идеальной
  сходимостью. Контроль — счётчик удержаний, выведенный в отчёт пересборки, и
  замер его значения на живом архиве.
- **`isEmpty` считает ноль пустотой**, и это переносится на элементы ряда: ряд
  настоящих нулей будет выглядеть опустошённым → ошибка направлена в безопасную
  сторону (удерживаем, а не затираем) и видна счётчиком; наблюдённые ряды HAE
  состоят из объектов. Записано в спеку, потому что после мерджа это часть
  правила слияния навсегда.
- **Мягкий заголовок принимает больше входов**, то есть сущности, ранее
  уходившие в `SkippedEntityMalformed`, начнут попадать в витрину → это
  изменение разбора, и по правилу «покрыли — пересверните» такие доставки надо
  пересворачивать. Замер на живом архиве сделан **до** утверждения формулировок:
  118 тел, пропусков `noID=0 noTime=0 malformed=0`, то есть пересворачивать
  нечего, и data-миграции нет.
- **Мягкий заголовок увеличивает долю тел, доходящих до канонизации**: сущность,
  раньше отсекавшаяся на разборе заголовка почти бесплатно, теперь канонизуется
  целиком, и худший случай по памяти становится достижим на входах, которые до
  него не доходили → предел на размер сущности из задачи-остатка перестаёт быть
  желательным и становится **обязательным условием**; записано в её теле.
- **Обновление провенанса при совпавшем хеше — дополнительная запись** там, где
  раньше её не было: ~26 повторных присылок на тренировку → запись касается трёх
  колонок без `payload`, то есть не трогает самое тяжёлое; счётчик записанных
  сущностей и `updated_at` не двигаются, и сравнимость замеров сохраняется.
- **Страж версии схемы отказывает при старте** — сервис не поднимется на базе
  из будущего, то есть приём останавливается, а телефон не перешлёт → цена
  взвешена выше, в решении 5, вместе с отвергнутыми альтернативами; в
  `architecture.md` уезжает эксплуатационный контракт: как это выглядит
  (crash-loop контейнера) и чем лечится (возврат бинаря).
- **Пункт 5 правила (несравнимые наборы) остаётся функцией порядка
  проигрывания**, и второй разряд `Covers` делает этот исход чаще → приёмочный
  критерий сходимости сформулирован условно (перестановка даёт один отпечаток
  при нулевом счётчике несравнимых), а сам предел записан в `architecture.md`
  как единственная точка, где витрина не является функцией множества доставок.
