ADR-0073: Ручная модерация идентичности каноников (merge/split) + offer-level manual-lock

Status: accepted Date: 2026-07-10 Deciders: Maxim Belkanov

Контекст

Матчер и провижинер каноника иногда ошибаются в кросс-офферной идентичности: склеивают разные товары или не объединяют дубли. Классический прод-кейс — неуникальный артикул «10127»: кабель, защёлка и прожектор оказались в одном канонике (подробнее — ADR-0070, ADR-0071). Ни ручная правка supplier_offers.canonical_id, ни удаление решения матчера не выживают: следующий ре-ингест (CASE в offer_repo) и следующий тик матчера (UpdateCanonicalID) откатывают изменение обратно.

Требуется модераторский инструмент в admin UI, позволяющий слить 2+ каноника-дубля в survivor (merge) или отделить ошибочно-объединённые офферы на другой каноник (split), с гарантией, что ручное решение переживёт ре-ингест и тики матчера.

Решение

offer-level manual-lock

Замок живёт на строке оффера (supplier_offers): три колонки — canonical_locked_at, canonical_lock_by, canonical_lock_reason (миграция 0241, транзакционная). Залочен ⟺ canonical_locked_at IS NOT NULL. Замок постоянный — снятие только вручную через действие «вернуть под автоматику».

Три пути записи canonical_id уважают замок:

  1. Ингест (offer_repo CASE)INSERT ... ON CONFLICT DO UPDATE SET canonical_id = CASE WHEN canonical_locked_at IS NOT NULL THEN canonical_id ELSE excluded.canonical_id END; провижинер работает с той же строкой — его выход блокируется этим CASE.
  2. Матчер (UpdateCanonicalID)SELECT ... FOR UPDATE читает флаг залочен/нет; залоченный оффер пропускается (no-op); bulk-update получает фильтр WHERE canonical_locked_at IS NULL, чтобы залоченные «соседи» тоже не двигались.
  3. Матчер (DecisionRepo.Upsert) — CTE-гард через SELECT id FROM supplier_offers WHERE id = $1 AND canonical_locked_at IS NULL FOR UPDATE; FOR UPDATE сериализуется с manual-tx: stale-тик не вставит решение после установки замка.

Partial index WHERE canonical_locked_at IS NOT NULL (миграция 0242, CONCURRENTLY, без транзакции) покрывает admin-поиск залоченных офферов и membership-проверки в guard.

Политика stale match_decisions

RawObservationRepo.LoadObservations исключает оффер с active-решением confidence probable/weak/unmatched. Чтобы факты залоченного оффера текли в assignment и превью, любая ручная операция для каждого затронутого оффера в той же транзакции:

  • ставит canonical_locked_at;
  • удаляет его строку match_decisions.

Оффер становится self-canonical — факты текут штатным контуром. Guard в DecisionRepo.Upsert не даёт stale-тику записать решение обратно. Типизированный manual-статус в match_decisions отвергнут: потребовал бы менять CHECK-констрейнт и всех потребителей enum — больший blast radius без выгоды.

Merge

Слияние 2+ каноников-дублей в survivor. Ограничения:

  • Только в пределах одного производителя: источник истины бренда — supplier_offers.manufacturer_id (не canonical_products.manufacturer_id, который часто пуст). Собираются все непустые manufacturer_id офферов survivor и victims; ровно одно значение → ок; любое NULL/missing или >1 различных → 422 («бренд отсутствует/неоднозначен»). Кросс-бренд merge запрещён всегда — force его не обходит.
  • MPN-guard (не-force): canonicalMPNGroupsCompatible(survivor, victim) по каждому victim; несовместимо → 409. Force пропускает MPN-guard, не проверку бренда.
  • Precondition-based: SELECT ... FOR UPDATE по survivor и victims; survivor active, каждый victim active (не уже replaced/deprecated), survivor ∉ victims.
  • Офферы каждого victim перемещаются в survivor через MoveOffers-примитив (замок + чистка решений); victim получает status='replaced', survivor_id; создаётся canonical_aliases запись. Все офферы survivor (перенесённые и бывшие) лочатся.
  • Dirty обеих сторон: survivor — assignment + embedding + projector; каждый victim — projector с reconcile (ES/read-model убирает осиротевший документ).
  • Append-only строка в identity_moderation_events.

Split

Отделение группы офферов из source-каноника на новый или существующий target. Варианты target: new (свежий UUID, не детерминированный, не конфликтует с провижинером) или existing (проверяется active + тот же бренд). Precondition-based MoveOffers проверяет, что все offer_ids находятся на source_id; при дрейфе состава — 409.

Оставшиеся на source офферы тоже лочатся и очищаются от решений — иначе матчер по Tier1 exact (тот же manufacturer_id + артикул) снова затянет их в target. Source без офферов переходит в deprecated. Dirty обеих сторон. Append-only строка в identity_moderation_events.

Lock/Unlock

Отдельные операции lock/unlock на уровне оффера или группы-каноника. Lock и unlock в той же транзакции ставят enqueueAssignmentDirty + enqueueProjector по текущему canonical_id — unlock нужен для штатной пере-деривации состава/характеристик после возврата под автоматику.

Превью характеристик

Метод PreviewAssignments (catalog canonical BC, read-only) грузит факты для гипотетического состава через существующий RawObservationReader-путь и прогоняет strategy.MergePolicy(). Ничего не пишет. При отсутствии полного контекста — приблизительное превью с явной пометкой «финал уточнится после пере-деривации»; точность превью не является критерием готовности.

Аудит

Таблица identity_moderation_events append-only: operation ∈ {merge, split, lock, unlock}, reviewer, note, force, manufacturer_id, survivor_id/source_id/target_id, victim_ids[], offer_ids[], validation. Приложение не делает UPDATE/DELETE по ней.

Размещение

Сервис IdentityModerationServicematching BC: он уже владеет кросс-офферной идентичностью, пишет supplier_offers.canonical_id, имеет AdminModule. Превью — catalog canonical BC (там деривация характеристик).

Admin API (matching BC, AdminAuthMiddleware):

  • POST /api/v1/admin/matching/identity/merge
  • POST /api/v1/admin/matching/identity/split
  • POST /api/v1/admin/matching/identity/offers/{id}/lock
  • POST /api/v1/admin/matching/identity/offers/{id}/unlock
  • POST /api/v1/admin/matching/identity/canonical/{id}/unlock
  • POST /api/v1/admin/catalog/canonical-products/preview-assignments

Коды ошибок: 409 — precondition/дрейф состава; 422 — валидация бренда; 401 — auth.

Инварианты

  • Замок уважают все три пути записи canonical_id (ingest, UpdateCanonicalID, DecisionRepo.Upsert).
  • Enum match_decisions не расширяется: status ∈ {active, conflict}, match_confidence ∈ {exact, strong, probable, weak, unmatched} — остаётся без изменений.
  • Identity-логика матчера/провижинера не меняется сверх respect-замка.
  • Решения data-driven, без хардкода доменных перечней (ADR-0072).
  • Осушённый каноник без офферов и без survivor_iddeprecated.
  • Критерий готовности: acceptance-test merge+split переживают ре-ингест (InsertOrTouch) и реальный MatcherService.RunBatch.

Последствия

Плюсы

  • Ручное решение модератора необратимо сохраняется между ре-ингестами и тиками матчера.
  • Удаление stale-решений + self-canonical обеспечивает немедленный поток фактов в assignment и превью без новой инфраструктуры.
  • CTE FOR UPDATE в DecisionRepo.Upsert закрывает гонку без отдельной транзакции.
  • Append-only аудит даёт полную историю модерации.

Минусы

  • Три точки записи canonical_id обязаны поддерживать флаг — критичен контроль при добавлении новых путей.
  • Precondition-проверки (FOR UPDATE + сверка состава) добавляют RTT на горячих операциях модератора (но операции admin-только, не на пути запроса).
  • Превью приблизительное при недоступном контексте MergePolicy.

Нейтральные последствия

  • Partial index (миграция 0242) не ускоряет candidate-scan матчера (IS NULL покрывает почти все строки); он нужен только для admin-поиска и guard.
  • Add-колонки nullable, дефолт NULL → ADD COLUMN безопасен на живой таблице без переписывания строк.

Рассмотренные альтернативы

Альтернатива A — флаг в match_decisions (типизированный manual-статус)

Добавить status='manual_locked' в enum match_decisions. Отвергнуто: требует менять CHECK-констрейнт match_decisions_status_valid и обновлять всех потребителей — больший blast radius. Self-canonical (нет строки решения) уже поддержанный путь без новых констрейнтов.

Альтернатива B — отдельная таблица manual overrides

Хранить ручные правки в отдельной таблице, джойнить на каждом чтении. Отвергнуто: усложняет все три пути записи и read-path без выгоды перед колонкой на supplier_offers.

Альтернатива C — TTL замка (автоматическое истечение)

Замок истекает через N дней. Отвергнуто: создаёт риск незаметного отката ручного решения. Постоянный замок с явным unlock — явная семантика. TTL — вне объёма.

Вне объёма

  • Brand-override (правка/назначение manufacturer_id, когда бренд отсутствует/неверен).
  • Авто-истечение (TTL) замка.
  • Изменение identity-логики матчера/провижинера сверх respect-замка.
  • Запуск canonical-rekey / canonical-dedupe-repair в составе этой фичи.

Ссылки

  • ADR-0070 — идентичность матчинга = (manufacturer_id, артикул)
  • ADR-0071 — canonical identity re-key (per-offer провижинер + миграция)
  • ADR-0072 — расширяемость вместо хардкода; data-driven решения
  • Спека: docs/superpowers/specs/2026-07-10-canonical-manual-merge-split-design.md