ADR-0046: canonical_assignments source field & manual-over-auto policy

Status: accepted Date: 2026-04-29 Deciders: agent-claude

Контекст

CanonicalProduct.Assignments в проде пуст: запись характеристик в агрегат никогда не производилась автоматически. Исторически CanonicalProductRepo.Save() переписывал assignments полностью через DELETE+INSERT — при любом вызове ручные правки стирались без следа. Механизм защиты manual-правок отсутствовал.

ADR-0044 ввёл charnorm pipeline: char_name_mappings содержит нормализованные имена характеристик, offer_characteristic_raw накапливает сырые значения от поставщиков. ADR-0045 дал Tier 3 LLM matcher — подтверждённые offer↔canonical links с confidence. Оба pipeline готовы; недостающее звено — worker, который переносит нормализованные данные в canonical_assignments с правильным source tracking.

Требования к циклу 1: (а) заполнять assignments автоматически на основе накопленных observations; (б) защищать ручные правки от перезаписи на уровне БД и домена; (в) не блокировать поток данных из-за неизвестных characteristic names.

Решение

  1. Поле source в canonical_assignments: тип text с CHECK constraint, допустимые форматы auto:<strategy> или manual. Цикл 1 — стратегии auto:latest и manual. Циклы 2-3 расширят список (auto:mode, auto:union, auto:trust_weighted, auto:llm). CHECK выбран вместо ENUM: расширение обычной миграцией, без ALTER TYPE ADD VALUE с его ограничениями на откат.

  2. SQL-защита manual-строк: worker выполняет UPSERT с условием WHERE canonical_assignments.source LIKE 'auto:%'. Строки с source='manual' SQL игнорирует — защита работает на уровне БД без race-условий между процессами.

  3. Доменная политика: метод CanonicalProduct.AssignCharacteristic(source=auto) при existing.Source == "manual" возвращает (false, nil) — первая линия защиты в памяти, ещё до обращения к репозиторию.

  4. Auto-provision характеристик: новое поле characteristics.status с CHECK ('pending', 'active'). Worker встречает неизвестное characteristic name — создаёт запись со статусом pending, assignment пишется немедленно. Pending-характеристики видны в proposal’ах. Модератор переводит в active через PATCH /admin/characteristics/{id}.

  5. Worker — отдельный процесс cmd/canonical-assignment-worker по шаблону charnorm-worker. Singleton через Postgres advisory lock (LeaseGuard.Acquire("canonical_assignment")). Loopback HTTP :9093. Изолирован от matcher-worker: упал — не уронил остальных; масштабируется независимо; собственный rate limit на ресурсы.

  6. CanonicalProductRepo.Save() больше не пишет assignments. Запись — через отдельный порт CanonicalAssignmentWriter.BatchUpsert. Read path расширен полями source, decided_by, decided_at. Разделение ответственности устраняет риск случайного стирания через основной Save.

  7. Admin HTTP API в catalog/canonical/api/http: GET /admin/canonical/{id}/assignments, PUT /admin/canonical/{id}/assignments/{char_id}, DELETE /admin/canonical/{id}/assignments/{char_id}, GET /admin/characteristics?status=pending, PATCH /admin/characteristics/{id}. Bearer-middleware через ENV CANONICAL_ASSIGNMENT_ADMIN_BEARER; пустой ключ → 503. Паттерн аналогичен ADR-0045 Admin API.

  8. loopbackhttp.Server.Extra []func(*http.ServeMux) — backward-compatible extension hook. Worker регистрирует admin handlers через fx.Invoke до Start. Основной сервер не требует изменений.

  9. Стратегия слияния цикла 1 — latest-by-observed_at: для каждой пары (canonical_id, char_name) берётся observation с максимальным observed_at, tie-break по supplier_code ASC. Стратегии mode, union, trust_weighted, llm — циклы 2 и 3.

Последствия

Плюсы:

  • Ручные правки невозможно затереть автоматом: двойная защита SQL+domain.
  • Незнакомые characteristic names не блокируют автоматику: pending-flow позволяет писать assignment немедленно.
  • Worker изолирован: сбой не затрагивает matcher-worker или ingestion.
  • CHECK constraint расширяется обычной миграцией — ALTER TYPE ADD VALUE не нужен.
  • Разделение Save/BatchUpsert устраняет класс ошибок silent-overwrite.

Минусы / accepted trade-offs:

  • Pending-характеристики могут накапливаться, если модератор не работает с очередью. Runbook описывает массовую очистку через SQL.
  • latest-by-observed_at игнорирует расхождения между поставщиками: supplier с поздней датой observation перезапишет supplier с лучшим качеством данных. Цикл 2 вводит trust_weighted.
  • Ещё один контейнер в проде. Acceptable для cycle 1.

Альтернативы, отклонённые

  • ENUM вместо CHECK для source. Отклонено: расширение ENUM в Postgres усложняет откат миграций в future cycles; ALTER TYPE ADD VALUE не транзакционен.
  • Очередь на ревью вместо auto-provision неизвестных chars. Отклонено: блокирует поток данных на старте; большинство pending-записей будут корректны.
  • Worker внутри matcher-worker. Отклонено: один тяжёлый цикл может задержать другой; смешиваются метрики и логи; сбой одного убивает второго.
  • Отдельная таблица canonical_characteristic_values с deprecation старой. Отклонено: создаёт migration window с tech debt; модель агрегата не меняется.
  • /metrics-snapshot JSON endpoint на worker. Отклонено: не согласуется с prevailing pattern (другие workers — slog-mirror only); метрики читаются через slog.

Реализация

Затронутые компоненты:

backend/internal/core/catalog/canonical/:

  • domain/assignment.go — поле Source, метод AssignCharacteristic с manual guard
  • domain/characteristic.go — поле Status (pending/active)
  • domain/metrics.go (AtomicAssignmentMetrics)
  • app/assignment_service.go (BatchAssign, стратегия latest-by-observed_at)
  • app/characteristic_service.go (auto-provision, promote)
  • ports/assignment_writer.go (CanonicalAssignmentWriter interface)
  • infra/postgres/assignment_repo.go (BatchUpsert с WHERE source LIKE ‘auto:%’)
  • infra/postgres/characteristic_repo.go (pending upsert)
  • api/http/assignment_handler.go, characteristic_handler.go, admin_auth.go
  • di.go

backend/cmd/canonical-assignment-worker/main.go

backend/internal/platform/loopbackhttp/server.go — поле Extra []func(*http.ServeMux)

backend/migrations/:

  • 0040_canonical_assignments_source.sql
  • 0041_characteristics_status.sql

Deferred items

  1. Стратегии auto:mode, auto:union, auto:trust_weighted — цикл 2.
  2. auto:llm — цикл 3 (после накопления обучающей выборки).
  3. Общий cross-source Moderation BC (аналогично ADR-0045 п.4 — после второго consumer).
  4. UI для pending-характеристик и assignments review.
  5. Auto-cleanup stale pending-характеристик (TTL-based).
  6. Multi-reviewer claim для ручного редактирования.
  7. Per-supplier trust score (prerequisite для trust_weighted).

Ссылки

  • Spec: docs/superpowers/specs/2026-04-29-canonical-assignments-foundation-design.md
  • Plan: docs/superpowers/plans/2026-04-29-canonical-assignments-foundation.md
  • Roadmap: docs/superpowers/specs/2026-04-29-canonical-assignments-roadmap.md
  • ADR-0044 — LLM gateway + charnorm pipeline (char_name_mappings, offer_characteristic_raw)
  • ADR-0045 — Tier 3 LLM matcher (паттерн admin Bearer-middleware)
  • ADR-0030 — Backend DI rule (main.go = lifecycle only)
  • ADR-0043 — Ingestion orchestration (advisory lock pattern)