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:
-
defaultEventBumps— a compile-timemap[string]float64inscoring.go:583–610with 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. -
statDeltaTypes— a compile-time map inticker.go:687–721with 30 entries keyed by Sportmonks integertype_id. Impact lookup chains back todefaultEventBumps; stat types unknown todefaultEventBumpssilently produce zero impact. No admin visibility. -
event_configJSONB — five fields per event type (Enabled,MaxMovePct,Category,CooldownSecs,PersistencePct) stored inadmin_settingsand 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 — includingPersistencePct=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:
- Best-effort goroutine:
INSERT INTO event_type_registry ... ON CONFLICT DO NOTHING - Insert a zero-impact, disabled, visibility=triage
event_impact_configrow for the new registry entry. - Publish
admin_settings:changed "registry_updated". - A
sync.Mapdeduplicates 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.