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:
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:
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)¶
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_scoreaccumulates 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.