ADR-0037: Stock Rule Aggregate (P2b)

Status: accepted Date: 2026-04-25 Deciders: Maxim Belkanov (architect), agent-claude (implementer)

Контекст

Layer 2a proposal pipeline (ADR-0035) поднялся с P1-стабом StockResolverStub — adapter читает stock_current per warehouse + delivery_term из offer_observations, applies staleness + AllowMissing policy, без бизнес-правил. Bull-shit no:

  • Operators не могут скрыть склад для конкретного клиента (warehouse blacklist).
  • Нет SLA-фильтра — клиент видит склады с lead_time 14 дней при срочной поставке.
  • Нет минимального порога — partial stock 1 шт показывается как “в наличии”.
  • Нет synthetic fallback — поставщик с backorder support не может объявить «под заказ».

Pricing P2a (ADR-0036) уже шипит full rule aggregate — нужен зеркальный StockRule с adapter лежащий на тех же платформенных примитивах (transactional outbox, atomic snapshot, polling reload).

Решение

StockRule aggregate в core/stock/{domain,app,infra}/:

  • 4 transform kinds в discriminated union: visibility_filter (drops/excludes warehouses по identity OR MaxLeadTimeDays), threshold (минимум qty с optional MaxLeadTimeDays-фильтром), display_transform (round_down / bucket / hide_exact), synthetic_fallback (on_request | synthetic_qty + optional SyntheticLeadDays).
  • RuleScope identical к pricing (supplier | customer_pricing_group | customer | classification_tag); AppliesTo (qty_min, customer_type, region) identical.
  • Engine.Apply — canonical порядок filter → threshold → synthetic → display независимо от operator priority. Predictable result; sort within phase: scope class DESC (customer > group > supplier > tag), priority DESC, created_at ASC.
  • Per-warehouse lead_time end-to-end: adapter reads delivery_term.SourceWarehouse matching → builds []PerWarehouseStock{Qty, LeadTimeDays} → engine filters by lead_time → output StockResult.Warehouses пропагирует filtered slice в prop.StockByWarehouse{LeadTimeDays} для downstream Delivery layer (P2c).
  • Engine always-on с дня один — no rollout flag (учли урок pricing P1 stub retire).
  • Admin HTTP на canonical layout /api/v1/admin/stock/stock-rules/* (per-route Go 1.22 patterns, r.PathValue("id")). JWT role stock_admin.
  • Snapshot pollingatomic.Pointer[Snapshot] lock-free read, polling 10s incremental + 5min full reload safety net.
  • Transactional outbox: каждая Create/Update/Deprecate в single pgx.Tx с outbox.Append. Topic stock.events.v1. Events: StockRuleCreated|Changed|Deprecated|ConflictDetected.
  • Overlap validator — sync 409 на CRUD; fingerprint = sha256(scope+priority+kind+applies_to+window).
  • Health.Checker stock.snapshot — fails until Bootstrap completes.
  • Metrics interface + AtomicMetrics + slog mirror (counters: snapshot_reload, outbox_emit, overlap_conflicts, rule_mutations).
  • openapilint enforcedstock-admin.yamlinfra/http/*.go parity.

StockResolverStub deleted (d6130fb); StockResolverEngine adapter wraps engine + reads observations.

Последствия

Плюсы

  • Per-warehouse lead_time доступен downstream без повторного fetch observations — Delivery layer (P2c) consumes готовый StockResult.Warehouses.
  • Operator может задавать SLA-aware фильтры («show only same-day warehouses for customer X») и threshold без deploy.
  • Engine always-on — никакой rollout drift между окружениями; сервер ведёт себя одинаково с empty rules table (pass-through) и с заполненной.
  • Mirror pricing pattern — однообразный mental model для operators + тестовая инфраструктура переиспользуется (pgtest, dockertest, fxtest).
  • openapilint catches API drift автоматически.

Минусы

  • Canonical phase ordering surprises operators ожидающих numeric priority как глобальный порядок — документировано в operator runbook.
  • Per-warehouse lead_time depends на supplier выдавая delivery_term.SourceWarehouse корректно; partial data → unknown lead_time → drops под MaxLeadTimeDays filter (treats nil как ∞). Operator runbook flags это как «data quality».
  • Stock auto-derivation отсутствует — operator manually maintains rules. Не критично для P2b (Stock patterns плохо derivable из observations); может быть reconsidered в Phase 6+.

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

  • New JWT role stock_admin — отдельная permission boundary от pricing_admin (intentional — разные ownership domains).
  • prop.StockByWarehouse extended LeadTimeDays *int field — non-breaking domain change (proposal pipeline).
  • New TraceReason("stock_synthetic") enum value для synthetic fallback outcomes.

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

A. Generic Rule[T] type parameter abstraction over pricing/stock

Один shared engine across BCs. Отклонено: премaturная абстракция до third aggregate (Delivery P2c). Cost-benefit fail — abstraction cost (testing, type parameter complexity) > duplication cost при 2 BCs.

B. Dedicated lead_time_filter 5th transform kind

Отдельный transform для SLA-фильтрации. Отклонено в пользу extension к visibility_filter с MaxLeadTimeDays. Aргумент: visibility_filter уже dropping warehouses по identity — lead_time это просто другой attribute warehouse. Single kind keeps taxonomy простой; threshold gets свой MaxLeadTimeDays поскольку semantics разные (фильтр vs measurement boundary).

C. Stock auto-derivation (ratio/threshold patterns из observations)

Mirror pricing P2a derivation. Отклонено: Stock observations дают qty + warehouse + (sometimes) lead_time — нет сигнала по которому можно derive «hide warehouse X for customer Y» (это business rule, не pattern в данных). Возможные derivable patterns (e.g. «warehouses с lead_time > 14 дней consistently appear as low-volume для customer cohort») — research deferred to Phase 6+.

D. Multiple DeliveryTerm per observation (per-warehouse explicit)

Schema change: offer_observations.delivery_term jsonb → array of (warehouse_ref, delivery_term). Отклонено: out of scope P2b (требует connector changes для всех существующих suppliers). Текущий single DeliveryTerm.SourceWarehouse matching достаточен; broadcast fallback (SourceWarehouse=nil → apply ко всем warehouses) покрывает edge case.

Ссылки

  • Spec: docs/superpowers/specs/2026-04-25-p2b-stock-rule-aggregate-design.md
  • Plan: docs/superpowers/plans/2026-04-25-p2b-stock-rule-aggregate.md
  • ADR-0035: Proposal Pipeline Layering (parent)
  • ADR-0036: Pricing Rule Aggregate (sister BC)
  • Operator runbook: docs/docs/30-services/stock/operator-runbook.md
  • OpenAPI: docs/docs/20-architecture/schemas/api/stock-admin.yaml
  • Migration: backend/migrations/0023_create_stock_rules.sql