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 ORMaxLeadTimeDays),threshold(минимум qty с optionalMaxLeadTimeDays-фильтром),display_transform(round_down / bucket / hide_exact),synthetic_fallback(on_request | synthetic_qty + optionalSyntheticLeadDays). - 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.SourceWarehousematching → builds[]PerWarehouseStock{Qty, LeadTimeDays}→ engine filters by lead_time → outputStockResult.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 rolestock_admin. - Snapshot polling —
atomic.Pointer[Snapshot]lock-free read, polling 10s incremental + 5min full reload safety net. - Transactional outbox: каждая Create/Update/Deprecate в single pgx.Tx с
outbox.Append. Topicstock.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 enforced —
stock-admin.yaml↔infra/http/*.goparity.
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 подMaxLeadTimeDaysfilter (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.StockByWarehouseextendedLeadTimeDays *intfield — 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