ADR-0055: Categories namespace + ETIM-7 backbone

Status: accepted Date: 2026-05-13 Deciders: belkanov, agent-claude

Контекст

К концу подцикла 1 «Catalog Categories Backbone» (HEAD 9b48f6fe) supplier_offers.category_id заполняется resolver-ом на каждом тике для четырёх поставщиков (ETM/IEK/Systeme/Russvet). Каждая поставщическая иерархия пишется в свою подветку таблицы categories. DKC параллельно заполняет taxonomy_classes (ETIM-7), но связь с categories не устанавливается — это два изолированных дерева.

Бизнес-постановка: оператор каталога видит единое каноническое дерево категорий в /canonical. Поставщические деревья — внутренняя машинерия ingestion-pipeline, оператор их не настраивает руками. Каноническое дерево должен быть:

  • Свободно для ручных правок: rename, новые узлы, переустройство, объединение или split. Никакой жёсткой привязки к структуре внешнего стандарта.
  • С быстрым стартом: на 5k+ узлов руками не нарисовать; нужен bootstrap из существующего стандарта.

Обсуждались три подхода:

  1. Отдельная таблица canonical_categories, параллельная categories. Минус — двукратное дублирование схемы (parent_id, slug, alias_slugs, locale, indexes), двойной admin handler, двойной Resolver code-path.
  2. Использовать taxonomy_classes напрямую как каноническое дерево. Минус — нельзя ручные правки: переименование класса нарушает контракт federated taxonomy (ADR-0050), таблица read-only от importer’а.
  3. Один categories с namespace-колонкой. Re-use инфраструктуры, но требует discipline на запросах (filter by tree_type) и двухтабличного mapping через category_external_links.

Решение

Идём по подходу 3. Конкретно:

Namespace в categories

  • Добавляем categories.tree_type TEXT NOT NULL DEFAULT 'supplier_native' с CHECK в {supplier_native, canonical}. Существующие строки (из подцикла 1) автоматически попадают в supplier_native.
  • Resolver из подцикла 1 продолжает писать в supplier_native без изменений (DEFAULT срабатывает).
  • Bootstrap-job создаёт строки в canonical namespace.

Новая таблица зеркалит canonical_external_identities, но для категорий, а не продуктов:

category_external_links (
    id, category_id, standard_code, external_code,
    source CHECK (source IN ('manual', 'auto_supplier', 'auto_llm', 'auto_inference')),
    decision_meta JSONB,
    created_by, created_at, updated_at,
    UNIQUE (category_id, standard_code),
    UNIQUE (standard_code, external_code)
)

Двойной UNIQUE: один canonical-узел не имеет двух связок с одним стандартом; один внешний код не привязан к двум canonical-узлам одновременно. Это сильное ограничение, но реалистичное: bootstrap-инвариант — ровно одна (standard, external_code) → canonical_category.

Защита ручных правок

Двухуровневая, симметричная подходу ADR-0050:

  1. Уровень категории: categories.origin = 'manual' означает «строка создана/правлена админом». Bootstrap не перезаписывает name и parent_id для таких строк; только обновляет updated_at.
  2. Уровень связи: category_external_links.source = 'manual' означает «админ привязал ETIM-класс к ДРУГОЙ canonical-категории вручную». Bootstrap не трогает такую связь и не создаёт дубликат.

Эти два уровня независимы: можно иметь origin=etim-7-auto + link.source=manual (категория из bootstrap, но связь переустановлена админом) и origin=manual + link.source=auto_supplier (категория переименована админом, связь автоматическая, обновляется на следующем bootstrap).

Bootstrap-flow

backend/internal/core/catalog/categories/app/etim_bootstrapper.go:

  1. Создать (если нет) корневой canonical-узел slug=etim-7, tree_type=canonical, origin=etim-7-auto, parent_id=NULL.
  2. Загрузить все классы taxonomy_classes стандарта ETIM-7.
  3. Топологически отсортировать по parent_external (root first).
  4. Для каждого класса:
    • Найти существующую связь (standard=ETIM-7, external_code). Если source='manual' — skip.
    • Найти существующую категорию по slug etim7-<external_code>. Если origin='manual' — bump только updated_at; иначе — full upsert name + parent_id (parent_id уже резолвится из вставленного выше узла).
    • Upsert связь с source='auto_supplier'.
  5. Эмитировать метрики и report.

Когда запускается bootstrap

В подцикле 2 — только через CLI cmd/category-bootstrap. UI-инициируемый bootstrap откладывается на подцикл 3 (вместе с tree management UI). Bootstrap идемпотентен, безопасен для повторного запуска (annual ETIM-update use case).

Альтернативы (отвергнуто)

  • Отдельная таблица: см. контекст #1. Удваивает support cost.
  • Triggers вместо bootstrap-job: на каждый INSERT в taxonomy_classes создавать row в categories. Минус — теряем топологический порядок, теряем контроль над --dry-run, лишний side-effect в чистый ingestion. Bootstrap-job явный и видимый.

Последствия

Положительные

  • Resolver из подцикла 1 не меняется (DEFAULT срабатывает).
  • Admin handler GET /api/v1/admin/catalog/categories получает опциональный tree_type фильтр; backward-compatible.
  • В будущем легко добавить ещё один namespace (например, customer_private для клиент-специфичных деревьев).

Отрицательные

  • Запросы по дереву должны явно фильтровать tree_type — иначе смешивают supplier-native и canonical. Расширение ListQuery обязательно.
  • Расхождение конвенций: canonical_external_identities использует auto_supplier для DKC, а category_external_links — для самого bootstrap-job’а. Семантика в этом контексте одинаковая («автоматически из источника»), conflate приемлем.
  • Двойной UNIQUE на category_external_links строгий: если в будущем потребуется аналог-маппинг (один ETIM-класс на две canonical category), constraint нужно ослабить. На текущем horizon не предвидится.

Open items

  • Подцикл 2b (отдельный план): Resolver делает lookup ETIM-class → canonical category при обработке DKC offer-а и пишет supplier_offers.category_id сразу в canonical-узел. Сейчас supplier_offers.category_id продолжает указывать на supplier_native — /canonical фильтр по canonical-категории не найдёт DKC-офферы. 2b закрывает этот gap.
  • Multi-language: ETIM публикует имена на многих языках; categories локали = ru по умолчанию. Bootstrap копирует name как есть, multi-language отнесён в будущий подцикл.
  • ETIM deactivation: класс может стать is_active=false. Bootstrap пока не отражает это в categories (is_active колонки нет). Если понадобится — отдельная миграция.