Skip to content

adr: 0026 title: "Event Type Registry + Impact Config (unified, registry-driven, hot-reload)" status: accepted implementation-status: pending date: 2026-06-08 implemented-date: null implemented-in: - ftl-backend feat/event-registry deciders: [@amalkrsihna] affects-specs: [event-ingestion, amm-pricing] affects-code: - ftl-backend/migrations/000046_extend_event_bump_log.up.sql - ftl-backend/migrations/000047_price_ticks_source_index.up.sql - ftl-backend/migrations/000048_create_fixture_stat_snapshots.up.sql - ftl-backend/migrations/000049_create_event_type_registry.up.sql - ftl-backend/migrations/000050_create_event_impact_config.up.sql - ftl-backend/migrations/000051_create_event_impact_config_versions.up.sql - ftl-backend/migrations/000052_seed_event_impact_config.up.sql - ftl-backend/internal/sportmonks/scoring.go - ftl-backend/internal/sportmonks/ticker.go - ftl-backend/internal/sportmonks/events_feed.go - ftl-backend/internal/handler/admin.go - ftl-backend/cmd/api-server/main.go - ftl-backend/cmd/ws-server/main.go - ftl-backend/cmd/flusher/main.go supersedes: [0024, 0025] superseded-by: null


ADR-0026: Event Type Registry + Impact Config

Context

Three separate systems currently control what price impact a football event has:

  1. defaultEventBumps — a compile-time map[string]float64 in scoring.go:583–610 with 20 entries keyed by normalized string name. Gaps: missing penalty shootout types; yellow_red_card (Sportmonks type_id=21) normalizes to a key that has no entry → zero impact. Admin cannot see or change these values at runtime.

  2. statDeltaTypes — a compile-time map in ticker.go:687–721 with 30 entries keyed by Sportmonks integer type_id. Impact lookup chains back to defaultEventBumps; stat types unknown to defaultEventBumps silently produce zero impact. No admin visibility.

  3. event_config JSONB — five fields per event type (Enabled, MaxMovePct, Category, CooldownSecs, PersistencePct) stored in admin_settings and editable via the admin panel. Keyed by normalized name. The JSONB is seeded empty ('{}'::jsonb) by migration 000044, so every lookup falls back to zero-value defaults — including PersistencePct=0, which silently violates the locked pricing model.

Additionally, event_config does not store propagation percentages (the three hardcoded constants goalTeamFallbackPct, goalConcedePct, goalConcedeGKPct), which cannot be configured per event type or per position. When a Sportmonks API sends an event type not seen before, there is no record of it and no admin notification — the event scores zero and the team has no way to know it fired.

ADR-0024 (per-event admin config) and ADR-0025 (per-event persistence split) each address parts of this problem. This ADR supersedes both, implementing the full unified registry as designed in reports/wc26-final-lock-2026-06-08/07-registry-design.md.

Decision

Replace all three systems with a single, database-backed, registry-driven configuration layer composed of two tables and an in-memory reload mechanism:

Schema

event_type_registry (migration 000049): master catalog of every known and auto-discovered Sportmonks event type. Columns: provider_type_id (Sportmonks integer type ID), provider_name (raw name string), developer_name (SCREAMING_SNAKE), normalized_name (output of normalizeEventType() — the lookup key used throughout the codebase), family (event|sub_event|stat_counter|stat_ratio|timeline), classification, is_known (pre-seeded vs auto-discovered), first_seen_at, fixture_id_first, description. Partial unique constraint on provider_type_id where provider_type_id > 0 (synthetic types use ≤ 0).

event_impact_config (migration 000050): per-type pricing parameters. FK to registry. Columns: enabled, impact_pct NUMERIC(8,4), team_gk_pct, team_def_pct, team_mid_pct, team_fwd_pct, opp_gk_pct, opp_def_pct, opp_mid_pct, opp_fwd_pct (all NUMERIC(8,4) NOT NULL DEFAULT 0.0 — 8-knob position-aware propagation per ADR-0028), max_move_pct, persistence_pct DEFAULT 100.0 (locked model), cooldown_secs DEFAULT 0 (locked model), value_mode (per_event|per_delta_unit|snapshot), category, visibility (visible|hidden|triage), config_version (FK to versions table), updated_by, updated_at.

event_impact_config_versions (migration 000051): append-only audit log. Each admin save snapshots the full event_impact_config state before overwriting.

Lookup precedence (during rollout)

1. event_impact_config (in-memory, hot-reloaded)   [primary after seed applied]
   ↓ if not in cache
2. eventBumps[normalizedName]                       [in-memory from SetEventBumps]
   ↓ if not found
3. defaultEventBumps[normalizedName]                [compile-time fallback]
   ↓ if not found
4. 0.0                                              [unknown type, auto-register fires]

Post-rollout: step 1 always resolves. The old maps remain populated as safety nets and are deprecated post-launch.

Seeding (migration 000052)

All ~128 known football-relevant provider types seeded with behavior-preserving defaults: - 20 discrete headline events from defaultEventBumps (values unchanged) - 30 stat-counter types from statDeltaTypes - Sub-event types (penalty sub-outcomes: 1507/1508/1509, pen shootout 22/23, VAR sub-events 9699/9702/1512, goal disallowed 1512) — previously missing, now routed - ~50 additional catalog types (enabled=false, impact_pct=0, visibility=triage)

Key seed corrections vs current behavior:

Type Provider ID Normalized name Seeded impact_pct Change vs current
Yellow/Red card 21 yellow_red_card −4.0 FIX: was 0 (normalization miss — G-01)
Penalty scored sub-event 1507 penalty_scored +6.0 NEW (sub-event not previously routed)
Penalty off-target 1508 penalty_shot_off_target −5.0 NEW
Penalty saved 1509 penalty_saved_by_gk −5.0 NEW
Pen shootout goal 23 pen_shootout_goal +6.0 NEW
Pen shootout miss 22 pen_shootout_miss −5.0 NEW
Goal disallowed (VAR) 1512 goal_disallowed −6.0 NEW

Scored penalty seeded at +6.0 per occurrence. Pre-implementation measurement observed two Sportmonks delivery patterns for a scored penalty: Pattern 1 (two separate event_id rows — type_id=16 award and type_id=14 goal — each passing the seen_events dedup gate, producing a combined +12.0%) and Pattern 2 (single mutating event_id, dedup fires once, net +6.0%). Seeding at +6.0 is safe in both patterns; admin can retune after observing day-1 WC26 data via the Event Registry panel without any code deploy.

Propagation defaults are behavior-preserving (see ADR-0028 for propagation design): goal and penalty seeded with all four team_*_pct=1.5, opp_def_pct=opp_mid_pct=opp_fwd_pct=2.0, opp_gk_pct=3.0; all other types seeded with all eight knobs at 0.0.

Migration 000052 Step 3 (CRITICAL): UPDATE admin_settings SET value = {all types with persistencePct:100} WHERE key='event_config' — seeds the backward-compat JSONB path used until the in-memory eventImpactConfigMap is fully loaded. Without this step, the locked model is violated from kickoff (G-12 / G-13 gap: empty event_config → persistencePct=0 → all bumps transient). This UPDATE is a STOP gate during the Wave 4 staging migration job. Verification SQL: SELECT value->'goal'->'persistencePct' FROM admin_settings WHERE key='event_config' must return 100.

Hot-reload via admin_settings:changed

All three services (api-server, ws-server, flusher) already subscribe to the admin_settings:changed Redis pub/sub channel and call loadAdminSettings() on message. Extend the handler to also call loadEventImpactConfig(), which reads the full event_impact_config table into eventImpactConfigMap (sync.Map or RWMutex-protected map keyed by normalized_name). Cross-replica propagation in < 1 second. A 5-minute fallback re-poll ensures recovery if a pub/sub message is missed.

The existing admin_settings:changed mechanism is also published by the auto-register path, allowing the admin triage panel to reflect newly discovered types within one second of the first event occurrence, with no operator action.

Auto-registration of unknown types

When processEvents or detectStatDeltas encounters a Sportmonks type not present in the in-memory registry cache:

  1. Best-effort goroutine: INSERT INTO event_type_registry ... ON CONFLICT DO NOTHING
  2. Insert a zero-impact, disabled, visibility=triage event_impact_config row for the new registry entry.
  3. Publish admin_settings:changed "registry_updated".
  4. A sync.Map deduplicates concurrent auto-register calls for the same type_id.

The goroutine is non-blocking — a failure does not affect pricing. The event still scores zero impact (same as current unrecognized behavior) but is now visible to the operator in the admin triage panel.

Admin API

Endpoint Description
GET /api/admin/event-types All registry rows joined with impact config
GET /api/admin/event-types/triage Auto-discovered unknown types, sorted by occurrence count
GET /api/admin/event-types/:id Single row
PUT /api/admin/event-types/:id/config Upsert config; snapshot to versions table; publish reload
POST /api/admin/event-types/promote/:id Promote triage type to is_known=true
GET /api/admin/event-types/versions Audit trail (existing revert routes work)
POST /api/admin/event-types/refresh Manual cache flush (debug)

Journal extension (migration 000046)

event_bump_log gains 14 nullable columns for the M3 audit trail: impact_type CHECK (impact_type IN ('PRIMARY','TEAM_PROPAGATION','OPPONENT_PROPAGATION')), actor_player_id, affected_player_id, price_before, price_after, applied_transient, applied_durable, config_version, event_family, delta_units, replay_id, provider_type, and index on (instrument_id, created_at DESC).

These columns are nullable — existing INSERT paths continue to work; new paths populate them. The impact_type column allows the admin history modal to distinguish a scorer's primary bump from its team/opponent propagation rows in the same event.

Alternatives considered

Extend event_config JSONB only (no new tables). Rejected: JSONB has no referential integrity, no index for fast lookup by integer type_id, no first-class support for the triage/auto-register workflow, and no foreign-key link to propagation config. The five existing fields in event_config cannot grow cleanly to 11+ fields without making the admin UI logic unmaintainable.

Compile-time registration (Go map, no DB). Rejected: requires a code deploy to add support for any Sportmonks type seen for the first time during a live match. With 1,307 measured Sportmonks types and only ~128 seeded, new types will appear. Auto-register eliminates this operational risk entirely.

Separate hot-reload channel. Rejected: the existing admin_settings:changed mechanism already reaches all three services within < 1 second and has a tested 5-minute fallback. Adding a second channel doubles the subscription surface for identical semantics.

Consequences

  • Admin operators can inspect and configure every Sportmonks event type — known and newly discovered — without a code deploy.
  • Unknown types auto-surface in the admin triage panel within one second of first occurrence.
  • Config changes propagate across all replicas in < 1 second via Redis pub/sub.
  • The audit trail (event_impact_config_versions) provides a complete history of every config change with revert capability via existing admin history routes.
  • Backward-compat fallback (eventBumps + defaultEventBumps) ensures zero behavior change during the rollout window before the seed migration is applied.
  • Breaking the compile-time maps creates a hard dependency on the DB being healthy at startup. Mitigation: startup code treats loadEventImpactConfig() failure as a warning (falls back to in-memory maps) rather than a fatal error.
  • ~128 seed rows + config rows are small enough to fit entirely in memory; no pagination or lazy loading required.

Supersession notes

This ADR supersedes ADR-0024 (per-event admin config) and ADR-0025 (per-event decay via persistence split). ADR-0024's five EventConfig fields are incorporated as columns in event_impact_config. ADR-0025's persistencePct field is column persistence_pct (DEFAULT 100.0, locked model). Both prior ADRs are set status: superseded and superseded-by: 0026 in the same PR as this ADR.