ADR-0039: CustomPricingHandler Plugin Registry (P3 closure)

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

Контекст

Spec parent (ADR-0035 / search-pipeline-skeleton-design) §65 ставит P3 = «Stock/Pricing/Delivery resolvers + CustomPricingHandler plugin registry». P2a/P2b/P2c шипят 3 из 4 — pricing/stock/delivery resolvers все engine-backed. Last open piece: handler plugin registry.

Business context (pricing.md) описывает CustomPricingHandler как точку расширения для бизнес-правил, которые supplier API не отдаёт — offline контракты, customer-specific volume tiers, региональные надбавки. Полноценный sandbox runtime (WASM/DSL) — большой scope (плагин loading, signature verification, network whitelist, hot reload, memory limits, etc.).

Pragmatic решение для P3 contractual closure — minimum viable plugin registry: built-in Go handlers compile-time registered + admin lifecycle для активации per-scope. Sandbox runtime откладывается до Phase 4+ когда реальный customer demand появится.

Решение

HandlerRegistration aggregate в core/pricing/:

  • Built-in Go handlers: pricing/handlers/handler.goHandler interface + Descriptor. Implementations в pricing/handlers/builtin/{volume_discount_v1, contract_markup_v1}.go. NOT runtime-loaded WASM.
  • HandlerRegistration aggregate: ties HandlerID (built-in identifier) к scope + AppliesTo + TriggerPredicate + Priority + ValidityWindow. PG table pricing_handler_registrations (migration 0025).
  • TriggerPredicate v1: JSON equality predicate. Allowed keys whitelist (supplier, customer_type, customer_id, canonical_id, observation.has_volume_discount, observation.on_request). Multiple keys = AND. Array value = IN-clause. DSL parser deferred Phase 4+.
  • Engine integration: Engine.Apply gains pre-rule hook Registry.InvokeMatching. Matching handlers invoked серийно по priority DESC, returned rules мерджатся в engine ruleSet, standard canonical chain applies.
  • 50ms timeout per handler invocation + panic recovery. Failure → handler skipped + metric (result ∈ {timeout, error, panic, unknown_handler}) + warn slog. Engine продолжает.
  • Snapshot polling: pricing_handler_registrations loaded в memory параллельно rules. Same 10s/5min cadence.
  • Transactional outbox: Create/Update/Deprecate в single pgx.Tx с outbox.Append. Topic pricing.events.v1 (existing pricing topic). Events: PricingHandlerRegistrationCreated|Changed|Deprecated.
  • Admin HTTP: GET /api/v1/admin/pricing/handlers (built-in inventory) + CRUD /api/v1/admin/pricing/handler-registrations. JWT role pricing_admin (existing — handler registry часть pricing BC).
  • Initial built-ins: volume_discount_v1 (tier-based discount), contract_markup_v1 (fixed +5% markup). Both pure functions, deterministic, < 1ms typical.

Последствия

Плюсы

  • Plugin point shipped — operator может extend pricing logic per-customer без замусоривания общей PriceRule таблицы.
  • Built-in registry compile-time — никакого runtime sandbox overhead, zero deployment risk.
  • Same admin lifecycle UX как rules — operator уже знает CRUD shape.
  • 50ms timeout + panic recovery — handlers cannot crash engine.
  • Phase 4+ extension path: if customer demand появляется, заменяем built-in inventory на WASM loader без spec breaking changes.

Минусы

  • Adding new handler требует rebuild api-server binary. Не self-service для customers.
  • TriggerPredicate v1 — equality only. Complex predicates (qty range, customer cohort) не expressible.
  • per_kg style observations не surfaced в HandlerInvocationContext в v1 (Phase 4+ extension).

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

  • New env vars PRICING_HANDLER_POLL_INTERVAL / PRICING_HANDLER_FULL_RELOAD_INTERVAL.
  • prop.PricingResult extended с HandlerTraces field — non-breaking.
  • New domain.ApplyContext fields ObservationHasVolumeDiscount / ObservationOnRequest — non-breaking (default false).
  • New metrics counters pricing_handler_invocation_total + pricing_handler_snapshot_reload_total.

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

A. WASM sandbox runtime (wazero)

WASM modules loaded at runtime, customer-uploadable. Отклонено для P3 closure: scope огромный (loader + signature verification + memory limits + I/O sandbox + hot reload + audit), reactivation criterion — actual customer demand для shipping handler binaries вне Tracium repo. Phase 4+ when concrete demand появится.

B. DSL trigger condition

Custom DSL parser (e.g. supplier=etm AND observation.qty < 100). Отклонено: JSON equality предикат покрывает known use cases; DSL parser engineering scope значительный. Phase 4+ если pattern usage показывает limitations.

C. Compile-time only (без registry table)

Each handler always-on per-scope hardcoded. Отклонено: operator не может toggle handlers per-customer без rebuild. Registry table даёт runtime control без plugin-loading complexity.

D. Lua / embedded JS / Otto

Embedded scripting language. Отклонено: каждое решение — significant runtime + security model + observability work. Same Phase 4+ deferral as WASM.

Ссылки

  • Spec: docs/superpowers/specs/2026-04-25-p3-custom-pricing-handler-design.md
  • Plan: docs/superpowers/plans/2026-04-25-p3-custom-pricing-handler.md
  • ADR-0035: Proposal Pipeline Layering
  • ADR-0036: Pricing Rule Aggregate (sister)
  • ADR-0037: Stock Rule Aggregate (sister)
  • ADR-0038: Delivery Rule Aggregate (sister)
  • Operator runbook: docs/docs/30-services/pricing/operator-runbook.md § CustomPricingHandler registry
  • OpenAPI: pricing-admin.yaml § handler-registrations endpoints
  • Migration: backend/migrations/0025_create_pricing_handler_registrations.sql