Skip to content

adr: 0027 title: "persistencePct=100 / cooldownSecs=0 locked pricing model (every occurrence counts, no decay)" status: accepted implementation-status: pending date: 2026-06-08 implemented-date: null implemented-in: - ftl-backend feat/event-registry - ftl-backend feat/propagation-engine - ftl-backend feat/two-layer-pricing deciders: [@amalkrsihna] affects-specs: [event-ingestion, amm-pricing] affects-code: - 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/event_propagation.go - ftl-backend/internal/adminsettings/service.go supersedes: [0025] superseded-by: null


ADR-0027: persistencePct=100 / cooldownSecs=0 locked pricing model

Context

Two behavioral parameters control how long a football event's price impact lasts:

PersistencePct governs the split between the transient accumulator (micro_bump, which decays at ×0.85 per 10-second poll, roughly a 60-second half-life) and the durable accumulator (event_score, which holds for the full match duration and is reset to zero only at full-time). SplitEventBump(bump, persistencePct) computes:

transient = bump × (1 − persistencePct/100)    → written to micro_bump
durable   = bump × (persistencePct/100)         → written to event_score

With persistencePct=0 (the current empty-JSONB default): every event bump is 100% transient. It appears in live_base_price immediately but decays to near-zero within 60 seconds regardless of how significant the event was. A goal scored in minute 10 would have faded almost entirely by minute 20.

With persistencePct=100: every event bump is 100% durable. It enters event_score, accumulates additively across events, and remains at full magnitude for the rest of the match. micro_bump is unchanged by discrete events; it continues to receive stat-delta contributions (tackles, saves, etc.) that are intentionally shorter-lived.

CooldownSecs gates duplicate event processing: if the same event type fires for the same instrument within cooldownSecs seconds, subsequent occurrences are suppressed. The gate uses a Redis SetNX with TTL equal to cooldownSecs.

The product decision for WC26 is: every occurrence of every event counts, there is no decay, and there is no cooldown. This is a business rule, not a technical constraint.

The G-12 / G-13 gap (critical pre-launch bug)

Migration 000044 seeds admin_settings.event_config = '{}'::jsonb — an empty object. EventPersistencePct(name) returns 0.0 when the key is absent. The result: even with persistencePct semantically locked at 100, the code silently applies 0 until an explicit seed is written. This is gap G-12 in the pre-implementation validation report (2026-06-08) and is confirmed live on both staging and production branches as of that date.

Without the fix, the SplitEventBump call in processEvents and in applyTeamEventBump routes 100% of every event's impact to micro_bump (transient), leaving event_score at zero. The locked model is violated from kickoff.

Decision

Lock persistencePct=100 and cooldownSecs=0 for all event types. The locking mechanism is three-layered:

Layer 1 — Schema defaults (migration 000050)

event_impact_config.persistence_pct NUMERIC(8,4) NOT NULL DEFAULT 100.0000 event_impact_config.cooldown_secs INTEGER NOT NULL DEFAULT 0

Every row inserted by the seed migration and every row auto-registered for unknown types begins with these values. Admin can read them but the column semantics are documented as prohibited from non-default values during WC26.

Layer 2 — JSONB seed (migration 000052 Step 3, CRITICAL)

UPDATE admin_settings
SET value = jsonb_build_object(
    'goal',         '{"persistencePct":100,"cooldownSecs":0}'::jsonb,
    'penalty',      '{"persistencePct":100,"cooldownSecs":0}'::jsonb,
    -- ... all ~27 active types ...
    'yellow_red_card', '{"persistencePct":100,"cooldownSecs":0}'::jsonb
)
WHERE key = 'event_config';

This seeds the backward-compat JSONB path used by the existing EventPersistencePct and EventCooldownSecs lookups until loadEventImpactConfig() (from ADR-0026) fully supersedes them. It fixes G-12 / G-13 unconditionally.

Verification gate: after applying migration 000052 on both staging and production, run:

SELECT value->'goal'->'persistencePct' FROM admin_settings WHERE key='event_config';

Expected result: 100. If this returns null, 0, or any non-100 value, STOP the deployment — the locked model cannot be active and every discrete event bump will be transient. This check is a hard gate in the Wave 4 integration → release/staging PR.

Layer 4 — MicroBumpCap (scoring.go)

MicroBumpCapPercentOfBase = 25.0 (raised from 10.0 per T07 in the pre-implementation validation report, 2026-06-08). This constant caps the effectiveCap used in:

effectiveCap = MicroBumpCapPercentOfBase / 100.0 * base_price
new_event_score = clampAbs(old_event_score + durable_bump, effectiveCap)

Raising the cap from 10% to 25% ensures that all realistic single-match WC26 accumulation scenarios — including a hat-trick (+18%) plus an assist (+3%) — remain below cap. At 10%, a two-goal scorer would be partially capped after the second goal, producing a visible non-linearity in pricing that is difficult to explain to users. At 25%, the cap functions purely as a circuit-breaker against extreme edge cases (five-goal outings, data anomalies) rather than a routine pricing constraint.

The cap applies equally to event_score (discrete event bumps) and micro_bump (stat-delta contributions). The stat-delta path is unaffected by the persistencePct lock and continues to use micro_bump; its cap is now also 25% of base price.

Layer 3 — Code comment (ticker.go:454)

// cooldownSecs is seeded at 0 for all event types (locked business-model decision
// 2026-06-08: every occurrence counts, no cooldowns). The gate is preserved as a
// future knob but its use is prohibited during WC26. Do not enable without an explicit
// user decision and migration.

The SetNX(TTL=0) call is effectively a no-op; the gate never fires. The code is retained so that a future decision to enable per-type cooldowns requires only a config change, not a code change.

Pricing model behavior under persistencePct=100

During a match (every 10-second poll)

live_base_price = ComputeLiveBasePrice(base_price, match_score)
                + micro_bump       ← stat-delta contributions only; decays ×0.85/poll
                + event_score      ← discrete event contributions; NO decay; accumulates additively

processPlayerStats reads event_score each poll and adds it to live_base_price as a constant offset. No function in the codebase modifies event_score between events — ApplyMicroBumpDecay operates only on the micro_bump argument passed to it, and the flusher's decayPermanentImpact operates only on the permanent_impact field (trade-footprint from the AMM bonding curve, see ADR-0017). Both are verified not to touch event_score.

Multiple events in the same match

Each event's bump is added to event_score and clamped to effectiveCap:

new_event_score = clampAbs(old_event_score + durable_bump, effectiveCap)

A goal scored in minute 10 and another in minute 55 both contribute their full +6% bump to event_score. With MicroBumpCapPercentOfBase=25.0 (raised from 10.0 as part of this release), effectiveCap = 0.25 × base_price. For a ₹200 player, the cap is ₹50. Hat-trick + assist reaches ₹42, safely below the cap. A fifth goal (uncommon) would be partially absorbed by the cap — documented as an accepted circuit-breaker behavior, not a bug.

At full-time (FT)

postmatch.go HDel: event_score, micro_bump, live_base_price, noise_pattern, permanent_impact

event_score is reset to zero. Its within-match price effect is not explicitly carried forward; instead, the player's final statistical performance (which reflects the consequences of goals, cards, etc.) is folded into the EWMA-updated base_price via ComputeMatchScore(final_stats)UpdateEWMA. This is by design: within-match excitement (the event_score contribution) is distinct from long-term reputation (the base_price EWMA). The double contribution — event_score visible during the match, EWMA visible after — is intentional.

Stat-delta path (accepted limitation, Phase 2)

Stat-delta events (tackles, saves, shots) currently write to micro_bump only, even with persistencePct=100. The persistence split in SplitEventBump is not yet applied to the stat-delta code path (detectStatDeltas). The permanent stat contribution is captured via ComputeMatchScore → EWMA at FT. Routing stat-deltas through SplitEventBump is a Phase 2 (post-launch) change and is documented in the design as an accepted pre-launch limitation.

Alternatives considered

persistencePct=50 (blend of transient and durable). Rejected. A blended model creates unpredictable gameplay: a goal scored in the first minute has ≈ 50% of its impact fade by half-time, while a goal in minute 89 retains most of its impact at FT. The asymmetry rewards late-game trading over early positions in a way that is not intuitive to users and difficult to explain in product copy.

persistencePct=0 (all-transient, current behavior). Already active as a consequence of the empty JSONB. Rejected explicitly: the experience shows a goal disappearing from a player's price within two minutes, which is disconcerting to users expecting lasting market impact from significant events.

Per-type persistence (some events durable, others transient). Retained as a future possibility via the persistence_pct column and admin panel. Prohibited during WC26 to maintain a simple, auditable, explainable product experience. Admin can unlock per-type variation post-launch if desired.

Non-zero cooldownSecs for high-frequency stat events. Rejected. Stat-delta events (tackles, saves) already use delta arithmetic — only the increment since the last poll is priced. A cooldown would create a race between the cooldown TTL and the polling interval, potentially suppressing legitimate incremental stats. The stat-delta path's delta <= 0 guard already handles spurious API corrections.

Consequences

  • Every goal, card, substitution, and statistical increment has immediate and persistent price impact for the rest of the match. This is the intended user-facing behavior: the market responds clearly and lastingly to football events.
  • event_score accumulates additively for the full match. After a hat-trick, the scorer's price is elevated by the sum of all three goal bumps for the remaining match duration, subject to the cap circuit-breaker.
  • Stat-delta contributions (micro_bump) remain transient and decay. This creates a visible within-poll spike for active players that decays between events, distinguishing "just scored" from "generally performing well" — both visible in price, at different time scales.
  • The cap at ±25% of base_price limits runaway accumulation. With the cap raised from 10% to 25%, all realistic single-match WC26 scenarios (including a hat-trick + assist) remain below cap. The cap is documented to users as a circuit-breaker, not a pricing model component.
  • G-12 fix (migration 000052 Step 3) must be verified before kickoff. If the check fails in production, all event bumps are transient for the entire tournament — a fundamental product failure. The check SQL and stop-gate are mandatory.