adr: 0028 title: "Position-aware 8-knob event propagation (team/opp × GK/DEF/MID/FWD)" status: accepted implementation-status: pending date: 2026-06-08 implemented-date: null implemented-in: - ftl-backend feat/propagation-engine deciders: [@amalkrsihna] affects-specs: [event-ingestion, amm-pricing] affects-code: - ftl-backend/internal/sportmonks/event_propagation.go - ftl-backend/internal/sportmonks/scoring.go - ftl-backend/internal/sportmonks/ticker.go - ftl-backend/migrations/000050_create_event_impact_config.up.sql - ftl-backend/migrations/000052_seed_event_impact_config.up.sql supersedes: [0019] superseded-by: null
ADR-0028: Position-aware 8-knob event propagation¶
Context¶
Current propagation behavior (code-verified)¶
When a goal or penalty event fires, applyTeamEventBump is called for instruments on
both the scoring and conceding teams. The propagation pcts are three hardcoded constants
in event_propagation.go:34–37:
const (
goalTeamFallbackPct = 1.5 // scoring-team instruments (unresolved-scorer fallback)
goalConcedePct = 2.0 // each conceding outfield instrument
goalConcedeGKPct = 3.0 // conceding goalkeeper
)
isGoalEvent() triggers propagation only for "goal" and "penalty".
Gap G-03 (code-verified): applyTeamEventBump calls bumpInstrumentEvent, which
writes the full delta to both event_score AND micro_bump at equal magnitude
(event_propagation.go:191–192). With persistencePct=100, the scorer's primary bump
writes to event_score only (via SplitEventBump(bump, 100)). Propagation's
double-write is behaviorally inconsistent: the conceding GK gets a −3% event_score
drop (durable) AND a −3% micro_bump (transient, decays in ~60s), producing an
immediate −6% visible price impact that decays to the intended −3% within roughly 140
seconds. This is the G-03 "double-write" bug, quantified in the pre-implementation
validation report.
Gap G-13 (code-verified): The three hardcoded constants cannot be configured per-event-type or per-position. Admin cannot make red cards propagate to the scoring team (opponents get stronger) or vary goal propagation by position. Every event type uses identical propagation semantics despite very different gameplay significance.
Minimum position resolution needed for WC26¶
The Sportmonks lineup detail types include explicit position information for each
instrument (player). The instruments table uses a position string field with values
confirmed at: GK, DEF, MID, FWD (with legacy variant "Goalkeeper" handled
by canonicalPosition()). This is sufficient for position-aware propagation without
any schema change to the instruments table.
Decision¶
Replace the three hardcoded constants with eight admin-configurable propagation knobs
per event type, stored as columns in event_impact_config:
| Column | Description | Seed (goal/penalty) | Seed (other types) |
|---|---|---|---|
team_gk_pct |
Scoring-team GK receives this % of the anchor bump | 1.5 | 0.0 |
team_def_pct |
Scoring-team DEF instruments | 1.5 | 0.0 |
team_mid_pct |
Scoring-team MID instruments | 1.5 | 0.0 |
team_fwd_pct |
Scoring-team FWD instruments (scorer excluded if resolved) | 1.5 | 0.0 |
opp_gk_pct |
Conceding GK | 3.0 | 0.0 |
opp_def_pct |
Conceding DEF outfield | 2.0 | 0.0 |
opp_mid_pct |
Conceding MID outfield | 2.0 | 0.0 |
opp_fwd_pct |
Conceding FWD outfield | 2.0 | 0.0 |
The seed values preserve the behavior of the three current hardcoded constants:
- goalTeamFallbackPct=1.5 → all four team_*_pct columns seeded at 1.5
- goalConcedePct=2.0 → opp_def_pct, opp_mid_pct, opp_fwd_pct seeded at 2.0
- goalConcedeGKPct=3.0 → opp_gk_pct seeded at 3.0
Note: seed values of 1.5/2.0/3.0 are behavior-preserving from the current hardcoded model — they are not the illustrative example values used in some design documents (which used 2/5/10/20%, representing a 3–13× gameplay shock). Admin tunes live via the Event Registry panel after observing real WC26 data.
Code changes¶
PropKnobs type in scoring.go:
type PropKnobs struct {
TeamGKPct, TeamDEFPct, TeamMIDPct, TeamFWDPct float64
OppGKPct, OppDEFPct, OppMIDPct, OppFWDPct float64
}
EventPropagationKnobs(normalizedName string) PropKnobs — reads from eventImpactConfigMap.
HasTeamPropagation(k PropKnobs) bool — returns k.TeamGKPct != 0 || k.TeamDEFPct != 0 || ...
HasOpponentPropagation(k PropKnobs) bool — symmetric.
canonicalPosition(pos string) string handles:
- "GK" / "Goalkeeper" → "GK"
- "DEF" / "Defender" → "DEF"
- "MID" / "Midfielder" → "MID"
- "FWD" / "Attacker" / "Forward" → "FWD"
- anything else → "MID" (safe default; unknown positions do not zero out)
isGoalEvent() replacement in processEvents (ticker.go):
knobs := EventPropagationKnobs(normalizedName)
if HasTeamPropagation(knobs) || HasOpponentPropagation(knobs) {
applyTeamEventBump(ctx, evt, knobs, ...)
}
This makes propagation fully config-driven. Removing isGoalEvent() means any event
type can have propagation enabled by admin — red cards, assists, yellow cards — without
a code deploy.
applyTeamEventBump rewrite — replaces goalTeamFallbackPct/goalConcedePct/goalConcedeGKPct
with knob lookups. Per instrument, canonicalPosition(instrument.Position) selects the
appropriate knob. Scorer instrument is excluded from team propagation when scorer is
resolved (scorer already received the primary bump).
bumpInstrumentEventSplit replaces bumpInstrumentEvent — fixes G-03 by routing
the propagation bump through SplitEventBump(propBump, 100.0):
transient, durable := SplitEventBump(propBump, 100.0)
// transient=0, durable=propBump (with persistencePct=100)
// write durable to event_score only — NOT to micro_bump
The old bumpInstrumentEvent function (which wrote both) is deleted. All call sites
that previously called bumpInstrumentEvent are updated to call
bumpInstrumentEventSplit.
Always-on team propagation replaces the scorer-fallback logic. Previously,
goalTeamFallbackPct was only applied when the scorer's player_id was not in the
instrument universe (unresolved scorer). With 5,664 instruments covering the WC26 squad
universe, scorer resolution is nearly universal. The team propagation knobs now fire
always (for the rest of the team, excluding the resolved scorer), not only as a fallback.
This is an intentional behavioral change: every goal produces both a primary bump for
the scorer AND team propagation for teammates. The seed value 1.5 keeps the magnitude
consistent with the historical fallback magnitude.
Actor exclusion — the instrument receiving the primary bump (the scorer or card-recipient) is excluded from the team propagation loop to prevent double-bumping:
for _, inst := range teamInstruments {
if inst.PlayerID == evt.PlayerID {
continue // scorer already received primary bump
}
applyKnobBump(ctx, inst, knobs, direction, ...)
}
G-03 fix — SplitEventBump in propagation path¶
Migration 000052 seeds persistence_pct=100 for all types. Once the seed is applied,
SplitEventBump(propBump, 100) always returns (transient=0, durable=propBump).
The G-03 double-write (both event_score and micro_bump) is eliminated because
bumpInstrumentEventSplit only updates event_score.
Before fix (G-03, with persistencePct=100 hypothetically):
- Conceding GK: −3% to event_score + −3% to micro_bump = −6% visible immediately,
decaying to −3% over ~140 seconds.
After fix:
- Conceding GK: −3% to event_score only = −3% immediately and persistently.
Practical pre-launch note: G-03 has been present since the propagation feature was
written. Under persistencePct=0 (current live state), 100% of the propagation went to
micro_bump (transient) — so the double-write manifested as 0+−3%=−3% transient,
which is correct for the old model. G-03 only matters when persistencePct > 0. The fix
and the persistencePct=100 seed must land in the same deployment to produce correct
behavior.
event_bump_log journal for propagation rows¶
Each propagation bump inserts a row using the exactly-once gate:
INSERT INTO event_bump_log
(fixture_id, provider_event_id, instrument_id, normalized_name,
impact_type, actor_player_id, affected_player_id,
price_before, price_after, applied_transient, applied_durable,
config_version, replay_id)
VALUES ($1, $2, $3, $4, 'TEAM_PROPAGATION'|'OPPONENT_PROPAGATION',
$actor, $affected, $before, $after, 0, $durable, $ver, $replayID)
ON CONFLICT (fixture_id, provider_event_id, instrument_id) DO NOTHING
impact_type distinguishes primary bumps (PRIMARY) from team and opponent propagation
rows. The actor_player_id is the event originator (scorer, card-recipient); the
affected_player_id is the instrument receiving the propagation bump. The uniqueness
constraint (fixture_id, provider_event_id, instrument_id) prevents double-propagation
across Redis restarts, replicas, and replay runs.
INSERT RETURNING id pattern: the gate INSERT returns the new row ID, then a
subsequent UPDATE sets price_before/price_after and split columns on that row. This
avoids the sketchy "UPDATE latest row" pattern and ensures the audit columns are always
paired with the correct gate row.
Alternatives considered¶
3-knob model (team_propagation_pct, opponent_propagation_pct, opponent_gk_pct from the registry design §5.3). Rejected in favor of 8-knob. The 3-knob model treats all outfield opponents identically (DEF/MID/FWD get the same pct) and treats all scoring-team players identically. The 8-knob model allows admin to express: "a goal benefits scoring-team FWDs more than GKs" or "a goal hurts conceding DEFs more than MIDs." The database migration writes 8 columns natively (not as an addendum), so the schema cost is identical. The 3-knob model would require a follow-up migration to extend to 8 knobs if admin finds it too coarse after day 1.
Position-agnostic flat pct (single team_propagation_pct and opponent_propagation_pct). Same problem as 3-knob. Coarser than 8-knob with no schema cost advantage.
Shard propagation by sub-event type (only propagate for sub-type penalty_scored,
not for penalty award). Considered as part of the N-PENALTY-MISS-PROP finding. Deferred
to WEEK-1 (T-N2 fix) because regular in-match penalties have sub_type_id=null in all
recorded fixtures; routing propagation from sub-events requires T09 (SubType parsing).
The default behavior (propagate on award unconditionally) is the current production
behavior and is acceptable for launch.
Consequences¶
- Goal propagation is behavior-preserving: every WC26 goal produces the same price movement pattern as pre-launch (scorer +6%, teammates +1.5%, outfield opponents −2%, conceding GK −3%). Admin can tune these values live without a code deploy.
- New event types (red cards, assists) can have propagation enabled by admin without any code change. This was not possible before.
- The G-03 double-write is fixed. Opponent prices no longer show an overshoot that
decays back. The fix is correct under
persistencePct=100and transparent underpersistencePct=0(legacy mode). - 8 new columns in
event_impact_config(allNUMERIC(8,4) NOT NULL DEFAULT 0.0). Seeded with behavior-preserving values for goal and penalty. All other types seed 0/0. Schema is additive; existing rows unaffected. - Position lookup from
instruments.positionis required at propagation time. The instruments table is already in memory (instrument cache loaded on startup). No new query per-propagation. isGoalEvent()is deleted. The only callers areprocessEvents(replaced byEventPropagationKnobscheck) andapplyTeamEventBump(rewritten). Confirm no other callers in dead-code review pass.