Контекст: поиск — предложения (proposal pipeline)

NOTE

Статус: Skeleton (P1) реализован в backend/internal/core/search/proposal/. Резолверы stub’овые. Полная реализация правил — P2+. Stack: fx, HTTP handler POST /api/v1/search/proposals.

Назначение

Формирование коммерческих предложений (Proposal[]) per customer: из product_ref + qty → кандидатов → их обогащения остатками/ценами/доставкой → ранжирования. Все шаги разведены по слоям; каждый слой — port. Pipeline полностью read-path (ingestion и normalization не затрагиваются).

Разграничение с контекстом “поиск” (catalog)

Существующий search.md описывает каталожный поиск (BM25, аналоги, фасеты). Выход — SearchResult / SearchCandidatesReturned. Новый proposal pipeline работает поверх уже выбранного product_ref — выход Proposal[] с customer-specific price/stock/delivery.

Use-caseEndpointEventTopic
Catalog searchPOST /v1/searchSearchExecutedsearch.events.v1
Proposal pipelinePOST /v1/search/proposalsProposalPipelineExecutedsearch.proposal.events.v1

Estimate BC вызывает оба: сначала catalog search (если нет явного product_ref), затем proposal pipeline для выбранного product_ref.

Агрегаты / сущности / value objects

Запрос и контекст:

ИмяТипНазначение
ProposalQueryVOЗапрос: items[], mode, request_policy, target_currency, region.
QueryItemVOОдна позиция: id, product_ref, qty_requested.
QueryItemIDVOСтабильный идентификатор item’а в рамках запроса. Сквозной через все слои pipeline.
ProductRefVOПринимает canonical_uuid или viewable_id (TR-XXXXXXX).
SearchModeVO enumlive / reference / sourcing / preview.
CustomerContextVOImmutable снимок из JWT: customer_id, tenant_id, credential_group_id. Второй аргумент каждого port method.

Промежуточные типы между слоями:

ИмяТипНазначение
SupplierBriefingVOВыход Layer 1.1: поставщик + QueryItemID + manufacturer_ref + classification_tags + observed_warehouses.
CredentialRoutingResultVOВыход Layer 1.2: status=selected|skipped, Selection (если selected), Reason.
CredentialSelectionVOВыбранный credential: credential_ref, scope=customer|system, resolved_at, selection_reason.
OfferCandidateVOВыход Layer 1.3: QueryItemID, offer_ref, canonical_ref, credential_selection, lifecycle_status, matched_observations[].
EnrichedOfferVOВыход Layer 2: candidate + stock_outcome + price_outcome (supplier ccy) + delivery_outcome.
ProposalAggregateФинальный выход Layer 3: proposal_id, query_item_id, supplier_ref, price (в target ccy), stock, delivery, score, adjustments[].
ProposalPriceVOamount, currency, pricing_mode, supplier_base_amount, supplier_base_currency.
PricingModeVO enumfixed / on_request / unavailable. Non-fixed позиции не суммируются в estimate total.

Политики:

ИмяТипНазначение
SearchPolicyVOКонтейнер всех sub-policy: Discovery / Stock / Pricing / Delivery / Currency / Proposal / Trace.
CustomerFixedPolicyVOПолитика из БД per customer.
RequestPolicyVOПолитика из тела запроса.
EffectivePolicyVORequestPolicy ∩ CustomerFixedPolicy (только ужесточать). Третий аргумент port methods.

Трассировка:

ИмяТипНазначение
LayerTraceVOОдна запись per layer per item: layer, status, reason, is_aggregate.
ProposalTraceVOАгрегат трассировки всего search: entries[], inline_level.
TraceStatusVO enumok / skipped / rejected / partial.
TraceReasonVO enumno_supplier / no_credential / constraint_mismatch / product_discontinued / visibility_blocked / …
TraceInlineLevelVO enumnone / summary / full (определяет что попадает в HTTP response).

Порты

ПортСлойКардинальностьНазначение
SupplierIndexer1.1fan-out: 1 item → 0..N SupplierBriefingСписок поставщиков для product_ref
CredentialRouter1.21:1 по briefingВыбор credential per briefing (precedence customer → system)
OfferDiscoverer1.3fan-out: 1 routing-result → 0..M OfferCandidateOffers по exact credential_ref
StockResolver2a1:1 по candidateОстатки в supplier BC
PriceResolver2b1:1 по candidateЦены в supplier base currency
DeliveryResolver2c1:1 по candidateУсловия доставки
ExchangeRateResolver3a1:1 по паре валютКонверсия в target currency

Все порты принимают (ctx context.Context, c CustomerContext, pol EffectivePolicy, ...) — сигнатура зафиксирована инвариантом ADR-0035 §13.3.

Слои

Девять стадий в fixed порядке (изменение = ADR + все тесты):

Layer 1 — Discovery
  1.1  SupplierIndexer.ListSuppliers   — fan-out per QueryItem
  1.2  CredentialRouter.Select         — 1:1 per briefing, precedence customer → system
  1.3  OfferDiscoverer.Discover        — fan-out per selected routing-result

Layer 2 — Enrichment (fixed order, 1:1 per candidate)
  2a   StockResolver.Resolve           — остатки (exact credential_ref)
  2b   PriceResolver.Resolve           — цены в supplier base currency
  2c   DeliveryResolver.Resolve        — доставка (missing → status=partial)

Layer 3 — Proposal
  3a   ExchangeRateResolver            — конверсия supplier ccy → target ccy
  3b   Adjustments                     — VAT, markup из CustomerFixedPolicy
  3c   Ranking                         — P1: stable input order; P5: scoring

Diagram → §3.2 design spec.

Инварианты

21 архитектурный инвариант зафиксирован в ADR-0035 docs/docs/20-architecture/adr/0035-proposal-pipeline-layering.md. Ключевые:

  • Единственный оркестраторcore/search/proposal монополизирует read-path; leaf BC не знают друг о друге (§13.1).
  • Fixed layer order — нарушение = отдельный ADR (§13.2).
  • CustomerContext immutable — второй аргумент каждого port method; no mutable map (§13.3).
  • EffectivePolicy — только ужесточение RequestPolicy ∩ CustomerFixedPolicy (§13.4).
  • Currency invariant — pricing работает только в supplier base currency; конверсия — эксклюзивно Layer 3a (§13.5).
  • No BC-to-BC direct calls — shared types через backend/internal/shared/ (§13.13).
  • Runtime HMAC leakage check — обязателен; mismatch = HTTP 500 + slog ERROR + counter (§13.14).
  • Credential routingCredentialRouter.Select единственное место precedence; после Discovery все чтения — exact credential_ref (§13.19).
  • Visibility deny-only в P1 — transforms (price_to_range, hide_warehouse) — P5 (§13.21).

События

СобытиеТопикPartition keyКогда
ProposalPipelineExecutedsearch.proposal.events.v1customer_refКаждый proposal search без исключений (§13.12, §13.17)

Payload события — всегда RAW (без редактирования). Inline trace в HTTP response — редактирован согласно TraceInlineLevel и правилам TraceRedactor (§8.2 spec).

Связи в context map

BCПаттернНазначение
OffersOHS (offers экспортирует порты)SupplierIndexer (Layer 1.1) + OfferDiscoverer (Layer 1.3)
CredentialsOHS (credentials экспортирует порт)CredentialRouter (Layer 1.2)
Stock / Pricing / DeliveryOHS (leaf BC экспортируют порты)Resolvers Layer 2a-c
ExchangeOHS (exchange экспортирует порт)ExchangeRateResolver (Layer 3a)
EstimateOHS (proposal → Estimate)Estimate вызывает POST /v1/search/proposals как downstream
Search (catalog)Независимый sub-packagesearch/candidates и search/proposal — два use-case, разные пакеты, разные топики

Ссылки

  • ADR-0035 docs/docs/20-architecture/adr/0035-proposal-pipeline-layering.md — 21 инвариант
  • 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
  • search.md — каталожный поиск (смежный use-case)
  • credentials.md, offers.md, pricing.md, ingestion.md, estimate.md, visibility.md