Skip to content

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.0opp_def_pct, opp_mid_pct, opp_fwd_pct seeded at 2.0 - goalConcedeGKPct=3.0opp_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=100 and transparent under persistencePct=0 (legacy mode).
  • 8 new columns in event_impact_config (all NUMERIC(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.position is 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 are processEvents (replaced by EventPropagationKnobs check) and applyTeamEventBump (rewritten). Confirm no other callers in dead-code review pass.