ADR-0035: Proposal pipeline layering

Status: accepted Date: 2026-04-24 Deciders: Platform, Search, Max (product owner) Supersedes: none Superseded-by: none Relates-to: ADR-0024 (supplier connector contract), ADR-0025 (price/stock observation extensions), ADR-0009 (multi-tenant supplier credentials)

Контекст

Phase 1-a закрыла ingestion: ETM, IEK и Systeme Electric наполняют supplier_offer_observations с credential_scope, customer_ref и validity_window на ценах. Следующий шаг — построить read-path поверх накопленных наблюдений: discovery поставщиков → credential routing → обогащение (остатки / цены / доставка) → конверсия валют → формирование предложений для конкретного клиента.

Без явного архитектурного каркаса каждый новый резолвер рискует нарушить customer isolation (утечка observation клиента A к клиенту B), не соблюдать fixed-порядок слоёв или создать прямые зависимости между leaf BC. Для фиксации «foundational law» для сессий P2–P6 принято решение закрепить все инварианты pipeline в этом ADR.

Подробный design описан в docs/superpowers/specs/2026-04-23-search-pipeline-skeleton-design.md.

Решение

BC search/proposalединственный оркестратор read-path pipeline коммерческих предложений. Каркас слоёв зафиксирован: supplier_indexer → credential_routing → discovery → stock → pricing → delivery → conversion → adjustments → ranking.

Для admin-трассировки и customer-response введён раздельный split raw vs redacted trace (§8.2 spec). События pipeline публикуются в отдельный топик search.proposal.events.v1 (partition key: customer_ref); топик search.events.v1 каталог-поиска не затрагивается.

Архитектурные инварианты (21)

  1. Единственный оркестратор proposal-flow. Пакет core/search/proposal — единственный владелец proposal-pipeline read-path. Leaves (pricing/stock/delivery/exchange/credentials/offers) не знают друг о друге, не знают о search/proposal. Существующий core/search/candidates (catalog-search) этот инвариант не затрагивает — у него свой контракт.
  2. Fixed layer order (proposal). 1.1 supplier-indexer → 1.2 credential-routing → 1.3 discovery → 2a stock → 2b pricing → 2c delivery → 3a conversion → 3b adjustments → 3c ranking. Изменение порядка = ADR + все тесты.
  3. CustomerContext immutable + второй аргумент. Любое port method имеет сигнатуру func(ctx context.Context, c CustomerContext, pol EffectivePolicy, ...). Нарушение = compile/lint fail. CustomerContext — value object без mutable полей (claims — неизменяемый claimsSnapshot, не map).
  4. Policy layering + третий аргумент. EffectivePolicy = RequestPolicy ∩ CustomerFixedPolicy, «только ужесточать». Merger — единая точка, reused во всех слоях. Leaves принимают EffectivePolicy третьим аргументом, не ищут политику в context/glob.
  5. Currency invariant. Pricing работает ТОЛЬКО в supplier base currency. Конверсия — эксклюзивно в Layer 3a. Leaves pricing/stock/delivery не зависят от exchange BC.
  6. Rule shape. Все aggregate правил следуют общему pattern: Scope + OwnerRef + Priority + ValidFrom/To + AppliesTo + Transform. Новый тип правил = новый aggregate по этой форме, не custom shape.
  7. Period validity nullable. ValidFrom/ValidTo опциональны. nil = «открытая граница». Rule может быть ретро-действующим и бессрочным.
  8. Per-customer rules mandatory isolation. Правило с scope=customer/customer_group доступно только по precedence filter (§4.3 SQL). System-wide — общий пул. Смешивание невозможно через type system.
  9. Batch contracts with per-port cardinality. Все port methods принимают и возвращают коллекции. Same-length только для обогащающих портов (CredentialRouter 1:1 по briefing, Stock/Price/Delivery/ExchangeRate 1:1 по candidate). Fan-out для SupplierIndexer (1 item → 0..N briefings) и OfferDiscoverer (1 routing-result → 0..M candidates) — часть контракта. Per-item sync loops внутри leaf/orchestrator запрещены (N+1 assert).
  10. Outcome status не скрывается. Каждый Outcome несёт Status + Reason. Orchestrator не «теряет» failure по дороге — всё идёт в trace.
  11. Gradual rejection — в orchestrator, не в leaf. Leaves возвращают Outcome{Status}, не делают reject. Orchestrator применяет policy и решает.
  12. Trace always emitted. Каждый proposal search выбрасывает ProposalPipelineExecuted в outbox (topic search.proposal.events.v1). Inline-trace — опционален по политике и редактирован согласно §8.2 spec.
  13. No BC-to-BC direct calls. Leaf BC не импортирует другой leaf BC. Если нужен shared domain type (например money.Amount) — он в shared backend/internal/shared/.
  14. Runtime customer leakage check. HMAC-fingerprint проверка в orchestrator (§9 spec) — обязательна. Mismatch = request-level hard fail (HTTP 500, slog ERROR, prometheus counter), НЕ panic, НЕ crash процесса.
  15. No business logic in stubs. P1 stubs = пасс-тру или простое агрегирование из observation. Всё что выглядит как «правило» — в P2+.
  16. Mandatory structured emission. Каждый слой emits ровно одну layer-aggregate запись (is_aggregate=true) + по одной per-item/per-briefing/per-candidate/per-proposal записи. Item-level failures на pre-discovery слоях эмитятся именно на своём слое, не ждут появления OfferCandidate. Любой новый слой должен заявить свою строку в matrix §8.4 spec до merge.
  17. Outbox always emitted. ProposalPipelineExecuted в outbox (topic search.proposal.events.v1) — на каждый proposal search без исключений. Event — source of truth (raw, no redaction) для E2E и аналитики. Redacted view — только inline в response.
  18. Trace redaction invariant. Customer JWT не получает visibility_blocked | product_discontinued | supplier_blocklist_by_policy | seller_blocklist | visibility_transform_applied в inline trace. TraceRedactor — единственная точка преобразования raw→summary.
  19. Credential routing invariant. CredentialRouter.Select — единственное место, где разрешается precedence customer → system (ADR-0009). Routing product-aware: CredentialConstraints применяются по briefing (§4.2.1 spec). После Discovery каждое чтение observations идёт по точному credential_ref, без fallback. Stock/pricing/delivery/outbox фильтры используют именно выбранный credential.
  20. Ingestion supports per-credential refresh (soft invariant). Архитектура ingestion поддерживает per-credential ingest jobs и on-demand refresh. E2E pipeline обязана прогонять над данными, собранными per-credential. Это не обязывает scheduler периодически обходить все customer credentials — стратегия обхода задаётся per-supplier/per-job (см. ingestion.md).
  21. Visibility deny-only в P1. P1 поддерживает только visibility deny (Discovery reject). Visibility transforms (price_to_range, hide_warehouse, response-shaping) — P5 (отдельная стадия Layer 3d: post-ranking response transform). До P5 leaf resolvers не модифицируют цену/остаток/склад на основании visibility.

Archlint enforcement

Правило archlint-gen (backend/cmd/archlint-gen/) проверяет инварианты §13 для пакетов core/search/proposal/**. Запускается в CI обязательно для этого BC. Нарушение = build fail.

Последствия

Плюсы

  • Compile-time gate против утечек клиент-данных: тип CustomerContext + EffectivePolicy как обязательные аргументы не позволяют «забыть» изоляцию.
  • Один оркестратор = единственная точка добавления слоёв; leaf BC остаются независимыми и легко тестируемыми.
  • Тестируемость за счёт слоёв: каждый runner (Discovery/Enrichment/Proposal) покрывается unit-тестами с in-memory fakes без compose-окружения.

Минусы

  • Дополнительный слой промежуточных типов: SupplierBriefing → CredentialRoutingResult → OfferCandidate → EnrichedOffer → Proposal. При добавлении новых фич нужно пронести изменение через все типы.
  • При новых use-case под search BC нужно помнить про два независимых под-пакета (search/candidates и search/proposal): общие доменные типы (CustomerContext, ProductRef) живут в shared, остальные — разделены.

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

  • Архитектурный lint (archlint-gen) обязателен в CI для core/search/proposal/** начиная с P1. При нарушении инвариантов — build fail, не warning.
  • Топик search.proposal.events.v1 — отдельный; search.events.v1 (каталог) не затрагивается.

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

Альтернатива A — моно-handler вместо BC

Единый handler, который читает offers/prices/stock напрямую из БД без порт-абстракций. Отвергли: слишком жёстко связывает правила и обогащение; каждое новое правило меняет handler; невозможно покрыть unit-тестами без compose-окружения.

Альтернатива B — переиспользование observation_repo precedence helper

Использовать существующий helper observation_repo (customer_ref → credential_group_ref → credential_ref → system) как точку precedence для proposal pipeline. Отвергли: нарушает инвариант §13.19 — после Discovery все чтения должны идти по точному credential_ref, без fallback. Старый helper остаётся для catalog-search/admin-API.

Альтернатива C — Smaller BC split (routing/enrichment/proposal как отдельные BC)

Разбить на три самостоятельных BC: search/routing, search/enrichment, search/proposal. Отвергли на уровне P1: слишком фрагментарно, межBC-интерфейсы добавляют накладные расходы; может ревизоваться в P5+ при росте команды.

Ссылки

  • ADR-0009 multi-tenant supplier credentials
  • ADR-0024 supplier connector contract
  • ADR-0025 price and stock observation extensions
  • ADR-0031 microkernel sub-modules per BC
  • Design spec docs/superpowers/specs/2026-04-23-search-pipeline-skeleton-design.md
  • Plan docs/superpowers/plans/2026-04-23-p1-proposal-pipeline-skeleton.md
  • Context doc docs/docs/10-business/contexts/search-proposal.md

Live-refresh mode полностью реализован (2026-04-29)

  • Live-A foundation (2026-04-27, docs/superpowers/specs/2026-04-27-live-a-foundation-design.md) — SourceMode, RefreshMode, tier-chain.
  • Live-B Price (2026-04-27, docs/superpowers/specs/2026-04-27-live-b-price-design.md) — PriceResolver tier-0 live + cache warm + idempotency + rate-limit.
  • Live-C Stock + Delivery (2026-04-28, docs/superpowers/specs/2026-04-28-live-c-stock-delivery-design.md) — параллельные resolver’ы + concrete connector adapters etm/iek/systeme + RefreshMode top-level migration + cross-axis coalesced fetch.
  • Live-D Polish (2026-04-29, docs/superpowers/specs/2026-04-28-live-d-polish-design.md) — observability (LiveMetrics через slog-mirror), AmountRange-через-3b adjustments (post-monotonic), refactor cleanup (LiveScopeChecker location, IdempotencyRunner removal), edge-case partial-state cleanup, runbook expansion.

Все три resolver’а имеют tier-0 live + cache warm + idempotency + rate-limit. AmountRange при cached_estimate проходит через Layer 3b adjustments (post-monotonic; non-monotonic conditional rules — Phase 7 concern). 21 invariant ADR-0035 сохраняется без изменений.

True HTTP-уровень coalescing (Connector.Fetch({Kinds: [...]}) вместо трёх sub-calls в Bridge.FetchAll) и /metrics HTTP endpoint + Prometheus exporter — отложены до follow-up циклов после soft-launch signal.