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.go—Handlerinterface +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 tablepricing_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.Applygains pre-rule hookRegistry.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_registrationsloaded в 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 rolepricing_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_kgstyle observations не surfaced в HandlerInvocationContext в v1 (Phase 4+ extension).
Нейтральные последствия
- New env vars
PRICING_HANDLER_POLL_INTERVAL/PRICING_HANDLER_FULL_RELOAD_INTERVAL. prop.PricingResultextended сHandlerTracesfield — non-breaking.- New
domain.ApplyContextfieldsObservationHasVolumeDiscount/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