ADR-0036: PriceRule aggregate + auto-derivation
Status: accepted Date: 2026-04-25 Deciders: backend team, pricing context owner
Контекст
P1 proposal pipeline использовал PriceResolverStub — выбирал первую non-nil
PriceSet.Net из observations без применения каких-либо rule’ов. Pricing
context имел target design (PriceRule, Transformation, RuleScope, AppliesTo,
ValidityWindow), но не было реализации.
Параллельно копились observations с разными ценами для одинаковых canonical products через разные customer credentials. Эти данные содержат implicit правила pricing (наценки, скидки, gold-tier discounts), которые supplier применяет — мы видим их только пост-фактум через credential-bound observations.
Цели P2:
- Заменить P1 stub на реальный pricing engine.
- Reverse-engineer правила из observations, чтобы наполнить engine автоматически без manual entry для каждой пары (customer, canonical).
Решение
Storage: PG discriminator + typed columns
Один table price_rules с дискриминатором transform_kind и типизированными
колонками для каждого варианта (pct_value для percentage, fixed_value/precision/
currency/mode для fixed, tiers JSONB для tiers). DB CHECK constraints гарантируют
что ровно один из вариантов non-NULL. Pre-production — schema может меняться без
JSONB rigidity.
Snapshot: M2 polling
Kafka наполняет БД через outbox (write path). API server читает БД через
PollingLoader (10s incremental + 5min full reload safety net), хранит snapshot
в atomic.Pointer. Single source of truth — БД; memory всегда производное.
Нет Listen/Notify, нет invalidation messaging.
Rules combination: O1 stacked
Все matching rules применяются в canonical sequence (fixed → tiers → percentage) по убыванию priority. Stable sort tie-break: scope specificity (customer > group > supplier > tag), source (manual < derived), RuleID asc.
Overlap validation: V1 sync 409
OverlapValidator runs synchronously in CrudService.Create/Update. Conflict =
same scope + same priority + same kind + overlapping qty/customer_type/region/
window → возвращает 409 + conflicting_rule_ids. Self-update (same RuleID) —
не conflict.
Moderation BC отложен. Async approval flow (V2) не реализован.
Scopes (S3): supplier | customer_pricing_group | customer | classification_tag
Scope — текстовый дискриминатор (kind, ref). Ref для customer/group/tag — UUID, для supplier — string.
Derivation: T3 event + cron, AA3 auto-apply ≥ 0.9
- Event-driven: Kafka offers.events.v1 → DebouncedQueue (LRU 10000 / 5min window) → drain каждые 30s → Deriver.Execute(trigger=“event”).
- Weekly cron: Sunday 03:00 UTC, recompute всех (supplier, canonical) pairs.
- Detectors: D1 RatioDetector (Percentage), D2 DeltaDetector (Fixed), D3 TiersDetector (Tiers).
- Baseline (B1): system-credential observation с самым свежим observed_at.
Без system credential —
no_baselineskip. - Confidence (CF3): weighted CV + recency (half-life ≈ 14 days) + sample
bonus (samples/10). Threshold 0.9 → auto-apply (
derived_autopriority 50). Иначе draft вprice_rule_draftsдля review. - Scope inference (SI2): group-first если supporting customers покрывают всю group; иначе per-customer.
- Min credentials: ≥ 3 distinct credentials (
insufficient_observationsиначе). - Auto-deprecate: derived rules для (supplier, canonical), чьих fingerprint
больше нет в новом run и без
disabled_by_operator, marked deprecated.
Feature flags
PRICING_ENGINE_ENABLED — отдельно от derivation, контролирует replace stub.
PRICING_DERIVATION_ENABLED — Kafka consumer + cron + scheduler. Rollback
без code changes.
Последствия
Плюсы
- Auto-pricing закрывает 80%+ customer-specific rules без manual entry.
- Engine extensible: добавить новый TransformKind = новый column + Apply branch + detector.
- Invariants (privacy, snapshot atomicity, deterministic SourceHash, preview no-persist) explicit в коде + tests.
- Two-flag rollback path — instant rollback без redeploy stub.
Минусы
- Snapshot reload lag ≤10s (incremental) + до 5 min (full reload). Eventual consistency для admin updates — видны не моментально.
- Derivation false positives risk — patterns могут быть случайной correlation, не реальное rule. CF3 + draft review mitigate, но не устраняют.
- Increased complexity — 10+ packages, 4 migrations, 2 feature flags. Higher cognitive load для новых разработчиков.
- Confidence calibration (CF3) requires production tuning — текущая formula даёт conf ≈ 0.4 на 12 stable samples, что ниже 0.9 threshold. Production rollout требует наблюдения и iteration на коэффициентах.
Нейтральные
- Outbox events публикуются через LogRepo (M1 placeholder). Real Kafka bridge деferred — не блокирует engine, но draft suggestions не доходят до Kafka consumers.
- Migration 0021 добавила
qty_observedна offer_observations с default 1 для backfill совместимости.
Рассмотренные альтернативы
A. JSONB polymorphic storage
Single transformation JSONB column с shape {kind, payload}. Отвергнуто —
теряем типизацию + DB CHECK constraints; pre-production schema часто меняется,
typed columns с CHECK дают immediate constraint feedback.
B. LISTEN/NOTIFY вместо polling
PG LISTEN/NOTIFY уведомляет api-server о изменениях напрямую. Отвергнуто — single SoT через polling проще; еventual consistency 10s acceptable; NOTIFY теряет события при connection drop.
C. Full auto без threshold
AA1 — все detected patterns auto-apply без confidence gate. Отвергнуто — высокий risk false positives при noise; draft review обязателен для low-confidence patterns.
D. Async approval flow (V2)
Все mutations через Moderation BC. Отвергнуто — Moderation BC ещё не существует, sync 409 достаточен для P2; V2 — отдельный future ADR.
E. Per-customer derivation always (skip group inference)
Отвергнуто — рост table size + дубликаты equivalent rules. SI2 group-first сохраняет cardinality.
Ссылки
- Spec:
docs/superpowers/specs/2026-04-24-p2-pricing-rule-aggregate-design.md - Plan:
docs/superpowers/plans/2026-04-24-p2-pricing-rule-aggregate.md - ADR-0020: pricing context boundaries
- ADR-0025: outbox pattern
- ADR-0026: customer credential scope
- ADR-0030: runtime DI via uber/fx
- ADR-0031: microkernel submodules per BC
- ADR-0035: proposal pipeline layering
- Migrations: 0018, 0019, 0020, 0021