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 уважают замок:
- Ингест (
offer_repoCASE) —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. - Матчер (
UpdateCanonicalID) —SELECT ... FOR UPDATEчитает флаг залочен/нет; залоченный оффер пропускается (no-op); bulk-update получает фильтрWHERE canonical_locked_at IS NULL, чтобы залоченные «соседи» тоже не двигались. - Матчер (
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; survivoractive, каждый victimactive(не уже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 по ней.
Размещение
Сервис IdentityModerationService — matching BC: он уже владеет кросс-офферной
идентичностью, пишет supplier_offers.canonical_id, имеет AdminModule. Превью — catalog
canonical BC (там деривация характеристик).
Admin API (matching BC, AdminAuthMiddleware):
POST /api/v1/admin/matching/identity/mergePOST /api/v1/admin/matching/identity/splitPOST /api/v1/admin/matching/identity/offers/{id}/lockPOST /api/v1/admin/matching/identity/offers/{id}/unlockPOST /api/v1/admin/matching/identity/canonical/{id}/unlockPOST /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_id→deprecated. - Критерий готовности: 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