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)
- Единственный оркестратор proposal-flow. Пакет
core/search/proposal— единственный владелец proposal-pipeline read-path. Leaves (pricing/stock/delivery/exchange/credentials/offers) не знают друг о друге, не знают оsearch/proposal. Существующийcore/search/candidates(catalog-search) этот инвариант не затрагивает — у него свой контракт. - 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 + все тесты.
- CustomerContext immutable + второй аргумент. Любое port method имеет сигнатуру
func(ctx context.Context, c CustomerContext, pol EffectivePolicy, ...). Нарушение = compile/lint fail. CustomerContext — value object без mutable полей (claims — неизменяемыйclaimsSnapshot, неmap). - Policy layering + третий аргумент.
EffectivePolicy = RequestPolicy ∩ CustomerFixedPolicy, «только ужесточать». Merger — единая точка, reused во всех слоях. Leaves принимаютEffectivePolicyтретьим аргументом, не ищут политику в context/glob. - Currency invariant. Pricing работает ТОЛЬКО в supplier base currency. Конверсия — эксклюзивно в Layer 3a. Leaves pricing/stock/delivery не зависят от
exchangeBC. - Rule shape. Все aggregate правил следуют общему pattern:
Scope + OwnerRef + Priority + ValidFrom/To + AppliesTo + Transform. Новый тип правил = новый aggregate по этой форме, не custom shape. - Period validity nullable.
ValidFrom/ValidToопциональны.nil= «открытая граница». Rule может быть ретро-действующим и бессрочным. - Per-customer rules mandatory isolation. Правило с
scope=customer/customer_groupдоступно только по precedence filter (§4.3 SQL). System-wide — общий пул. Смешивание невозможно через type system. - 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).
- Outcome status не скрывается. Каждый
OutcomeнесётStatus + Reason. Orchestrator не «теряет» failure по дороге — всё идёт в trace. - Gradual rejection — в orchestrator, не в leaf. Leaves возвращают
Outcome{Status}, не делают reject. Orchestrator применяет policy и решает. - Trace always emitted. Каждый proposal search выбрасывает
ProposalPipelineExecutedв outbox (topicsearch.proposal.events.v1). Inline-trace — опционален по политике и редактирован согласно §8.2 spec. - No BC-to-BC direct calls. Leaf BC не импортирует другой leaf BC. Если нужен shared domain type (например
money.Amount) — он в sharedbackend/internal/shared/. - Runtime customer leakage check. HMAC-fingerprint проверка в orchestrator (§9 spec) — обязательна. Mismatch = request-level hard fail (HTTP 500, slog ERROR, prometheus counter), НЕ panic, НЕ crash процесса.
- No business logic in stubs. P1 stubs = пасс-тру или простое агрегирование из observation. Всё что выглядит как «правило» — в P2+.
- 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. - Outbox always emitted.
ProposalPipelineExecutedв outbox (topicsearch.proposal.events.v1) — на каждый proposal search без исключений. Event — source of truth (raw, no redaction) для E2E и аналитики. Redacted view — только inline в response. - Trace redaction invariant. Customer JWT не получает
visibility_blocked | product_discontinued | supplier_blocklist_by_policy | seller_blocklist | visibility_transform_appliedв inline trace.TraceRedactor— единственная точка преобразования raw→summary. - Credential routing invariant.
CredentialRouter.Select— единственное место, где разрешается precedencecustomer → system(ADR-0009). Routing product-aware:CredentialConstraintsприменяются по briefing (§4.2.1 spec). После Discovery каждое чтение observations идёт по точномуcredential_ref, без fallback. Stock/pricing/delivery/outbox фильтры используют именно выбранный credential. - 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). - 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 под
searchBC нужно помнить про два независимых под-пакета (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
Footer annotations
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.