## 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` как единственная точка, где витрина не является функцией множества доставок.