Question definitions: league event and store_state contracts¶
This document describes the incoming event payload shape (under MatchListenerEvent.data) and the store_state keys persisted on each QuestionIssuance for leagues that have active question definitions in PostgreSQL.
Question copy and conditions refer to extracted values as {{$.store_state.<key>}} (for example {{$.store_state.batter_name}}). The <key> column below matches that name exactly.
The match listener passes event.data directly into issuance and resolution evaluation. JSONPath expressions on definition rows are written against that dictionary (typically including a payload object from SportRadar). Effective configuration is the database; manage it through /admin/question-definitions.
Legend: always vs sometimes¶
| Term | Meaning |
|---|---|
| Always | Must be present (non-null) for every event the listener forwards while the match is eligible for questions, or whenever that field is read and the question logic expects it to be meaningful. If it is missing, issuance or resolution for affected definitions may silently fail or evaluate false. |
| Sometimes | Only required on specific event types or game situations. Other events may omit the field or leave it null; definitions are written so those events are not used to resolve a given prop incorrectly. |
store_state (persisted) |
After a question is issued, these keys are stored on the issuance record. They are always present as keys for that row (values may be null if extraction failed); templates use {{$.store_state.<key>}}. |
NHL¶
Active NHL definitions in PostgreSQL.
Incoming payload (event.data): payload subtree¶
Paths are shown relative to the evaluation root (the same dict as event.data), e.g. payload.event.strength means $.payload.event.strength in JSONPath.
| Path | Description | Always / sometimes | Where it matters |
|---|---|---|---|
payload.event.strength |
Situation strength (e.g. even, powerplay). |
Sometimes | Penalty-kill / power-play issuance and resolution. |
payload.event.event_type |
Normalized event type string (e.g. goal, penalty, faceoff). |
Sometimes | Resolution for goal, penalty, faceoff, and timed props. |
payload.event.attribution.id |
Team (or entity) id for the event subject. | Sometimes | Team-scoped issuance and resolution. |
payload.event.attribution.name |
Display name aligned with attribution.id. |
Sometimes | Source for team-name store_state keys. |
payload.event.clock |
Game clock string (e.g. mm:ss for to_sec()). |
Sometimes | Timed-window issuance and resolution. |
payload.event.period.number |
Period number. | Sometimes | Stored as start_period for timed questions (issued_in_period also uses period extraction). |
payload.game.home |
Home team object (structure must include at least id and name used by rand()). |
Always (while definitions use random team) | Random-team questions at issuance. |
payload.game.away |
Away team object (same as home). | Always (while definitions use random team) | Random-team questions at issuance. |
SportRadar feeds vary by event type: many fields exist only on “statistics” or play events, not on heartbeats or minimal payloads. Treat rows marked sometimes as required only when implementing handlers for the event types those questions depend on.
Implementation note: Some NHL issuance conditions in JSON reference $.store_state (for example, time-window checks using start_time_seconds). Today, QuestionDefinition.is_issuable evaluates triggers against the incoming event dict only and does not inject store_state. If you rely on those questions, either extend issuance evaluation to merge the appropriate store_state into the condition context or change the trigger expressions to use only payload fields.
Extracted store_state (from issuance_state_extract)¶
Each row is one key in QuestionIssuance.store_state. The Source column matches the issuance_state_extract value on the corresponding NHL definition. Effective keys are whatever active rows store — manage them in /admin/question-definitions.
Penalty / power-play issuances¶
store_state key |
Source (issuance_state_extract value) |
Always / sometimes (value) |
|---|---|---|
penalized_team_id |
$.payload.event.attribution.id |
Always for these issuances: present on the triggering event. |
penalized_team_name |
$.payload.event.attribution.name |
Always for these issuances. |
team_name |
$.payload.event.attribution.name |
Always for these issuances (duplicate of penalized_team_name for templates). |
Used in question_text / outcome copy as {{$.store_state.team_name}}, etc. Resolution correct_condition strings use {{$.store_state.penalized_team_id}} for goal vs penalty-kill logic.
Timed window issuances¶
store_state key |
Source (issuance_state_extract value) |
Always / sometimes (value) |
|---|---|---|
random_team |
rand({{$.payload.game.home}}, {{$.payload.game.away}}) |
Always for these issuances: whole team object chosen at random. |
random_team_id |
$.store_state.random_team.id |
Always (depends on random_team having id). |
random_team_name |
$.store_state.random_team.name |
Always (depends on random_team having name). |
start_time |
$.payload.event.clock |
Always for these issuances: clock at issue time. |
start_time_seconds |
to_sec({{$.payload.event.clock}}) |
Always (derived from start_time). |
start_period |
$.payload.event.period.number |
Always for these issuances. |
Used in templates as {{$.store_state.random_team_name}}, {{$.store_state.start_time_seconds}}, etc. Resolution conditions also reference random_team_id and start_time_seconds.
Faceoff issuances¶
store_state key |
Source (issuance_state_extract value) |
Always / sometimes (value) |
|---|---|---|
random_team |
rand({{$.payload.game.home}}, {{$.payload.game.away}}) |
Always for this issuance. |
random_team_id |
$.store_state.random_team.id |
Always. |
random_team_name |
$.store_state.random_team.name |
Always. |
Listener match_state (NHL)¶
No NHL keys are published yet. The listener uses a no-op updater; Field browser lists none until an NHL module is added under lib.match_event_state.
MLB¶
Active MLB definitions in PostgreSQL.
Concurrency groups: assign each definition to an existing
QuestionGroup so at-bat and
half-inning questions can be open together (e.g. atbat vs half_inning).
Sport scopes below (at_bat_id, inning / inning_half) are still for
resolution identity via store_state — they are not the concurrency key.
These definitions target raw SportRadar MLB push event shapes (as in listener recordings / the packaged MLB sample feed), not a separate normalized schema.
Incoming payload: payload subtree¶
| Path | Description | Always / sometimes | Where it matters |
|---|---|---|---|
payload.game.away / payload.game.home |
Team objects (name, market, abbr, id, runs, hits, errors). |
Sometimes | On half_start (and most play events): batting-team names via if_eq on inning_half (top = away, bottom = home). hits is the game line-score total (snapshot at half start for two-plus-hit props). runs is the game total (tied late-inning pivotal props compare home vs away). |
payload.event.type |
Event discriminator string. | Sometimes | Issuance: atbat, half_start, situation-gated pitch (e.g. bases loaded). Resolution: pitch, next atbat, half_start, event_over. |
payload.event.id |
Event id; on atbat this is the plate-appearance id. |
Sometimes | Stored as at_bat_id at issuance; compared on the next atbat for “no” paths. |
payload.event.atbat_id |
Plate-appearance id on pitch events. |
Sometimes | At-bat-scoped resolution on pitches. |
payload.event.hitter.full_name |
Batter display name on atbat / pitch. |
Sometimes | Required on atbat (issuance) for at-bat props that template the batter name. |
payload.event.pitcher.full_name |
Pitcher display name on atbat. |
Sometimes | Stored as pitcher_name when a definition templates the pitcher. |
payload.event.pitcher.pitch_type |
Pitch classification on pitch (e.g. FA, SL, SI, CU). |
Sometimes | First-pitch / pitch-type resolution. |
payload.event.count.pitch_count |
Pitches actually thrown in the current AB (0 on a no-pitch aIBB). |
Sometimes | Pitch-count buckets; >= 5 may resolve immediately. Automatic IBB with 0 pitches matches no pitch-count choice (all lose). |
payload.event.count.balls |
Current ball count. | Sometimes | Ball-count buckets on AB over. A no-pitch aIBB still reports balls == 4; treat pitch_count == 0 as zero balls thrown. |
payload.event.count.strikes |
Current strike count. | Sometimes | Strike-count thresholds on pitch. |
payload.event.count.outs |
Outs in the current half (after the play on a terminal pitch). | Sometimes | Two-out / fewer-than-two-out pivotal gates; leftover-runner Yes/No when outs == 3. |
payload.event.flags.is_ab_over |
Whether the plate appearance has ended. | Sometimes | Terminal pitch for AB-outcome props. |
payload.event.flags.is_hit |
AB ended with a hit. | Sometimes | Hit / reach-base / out-style resolution. |
payload.event.flags.is_double_play |
AB ended in a double play. | Sometimes | Double-or-triple-play resolution on the terminal pitch (with is_triple_play). |
payload.event.flags.is_triple_play |
AB ended in a triple play. | Sometimes | Same Yes path as double play when present; SportRadar push flag. |
payload.event.status |
Play review status on pitch / steal / atbat (official, under review, overturned, upheld). |
Sometimes | Listener resolution hold: under review / under_review pauses the correction window for this event id (and the at-bat unit). Not the boolean official field (ingest vs LDE). |
payload.event.outcome_id |
SportRadar pitch/AB outcome code (e.g. bB, aIBB, aHBP, aS, aD, aHR, kF, kFT, kKS). |
Sometimes | Walk (include aIBB; HBP is not a walk), extra-base / HR, foul (kF / foul tip kFT), strikeout, and similar AB outcomes. |
payload.event.runners |
Runner list on some pitch / steal events. |
Sometimes | Everyone involved in the play, including a runner who is out. List length is not occupancy — runners[1] existing does not mean two on base (CLE / Jays 2026-09-01 force out). Occupied bags are ending_base in {1,2,3} on indexes 0–3. Steal codes (SB2/SB3/SBH); scoring (ending_base == 4 or outcome_id == "ERN"). |
payload.event.inning / inning_half |
Half-inning identity (T/B). |
Sometimes | Half-inning question issuance (half_start) and resolution scoping. |
Extracted store_state (from issuance_state_extract)¶
Each row is one key in QuestionIssuance.store_state. The Source column matches issuance_state_extract on the corresponding MLB definition. Effective keys are whatever active rows store — manage them in /admin/question-definitions.
At-bat issuances¶
store_state key |
Source (issuance_state_extract value) |
Always / sometimes (value) |
|---|---|---|
at_bat_id |
$.payload.event.id (on atbat) or $.payload.event.atbat_id (on situation-gated pitch) |
Always for at-bat-scoped issuances. |
prior_at_bat_id |
$.payload.event.atbat_id on the PA that just ended |
Sometimes — “next batter” props (e.g. grand slam): identity of the completed PA so resolution targets the following atbat_id. |
batter_id |
$.payload.event.hitter.id |
Always when the definition extracts it — Sportradar player id for match-wide subject uniqueness (issuance_subject_extract often $.payload.event.hitter.id or $.store_state.batter_id; occupancy is not per group). Present on atbat and on pitch events that carry hitter. |
batter_name |
$.payload.event.hitter.full_name |
Always when the definition extracts it. |
batter_last_name |
$.payload.event.hitter.last_name |
Sometimes — surname for punchier copy; templates use {{$.store_state.batter_last_name ~ Player}}. |
pitcher_name |
$.payload.event.pitcher.full_name |
Sometimes — only when the definition extracts it. |
Templates use {{$.store_state.at_bat_id}}, {{$.store_state.batter_name}} / batter_last_name, and optionally {{$.store_state.pitcher_name}}. Yes and same-PA opposite No compare {{$.payload.event.atbat_id}} on a pitch to {{$.store_state.at_bat_id}}. Complementary No for “this PA is over” is the next atbat start (type == atbat and {{$.payload.event.id}} != store) or event_over (walk-off) — not any later pitch whose atbat_id differs. The same ban applies to an explicit resolution_trigger_template close. The feed often re-sends the previous PA’s last pitch after the new atbat has issued; with resolution_start_on_corrective that rewrite can open resolve on the wrong eid (TOR @ TB, 2026-08-20; CLE / Jays 2026-09-01 MLB At Bat Number of Balls locked with no winning choice — issuance 35665478-884f-4d4d-a748-a1646f8e5aac). Leave the flag on; tighten the close. See authoring — Correctives vs a later play.
Bases-loaded / next-batter grand slam: atbat events do not carry runners. Issue on a terminal pitch (is_ab_over, not rPABO, count.outs != 3) when runners[].ending_base covers 1/2/3. Use issuance_correction_window_seconds plus issuance_start_on_corrective so incomplete first deliveries can wait for (or start from) the corrective that finishes the runner list — and so a corrective that unloads the bases can drop the pending before publish. Store prior_at_bat_id + half identity; Yes = next PA (atbat_id != prior) terminal aHR with bases still loaded; stay open through rPABO and use resolution_start_on_corrective.
Half-inning issuances¶
store_state key |
Source (issuance_state_extract value) |
Always / sometimes (value) |
|---|---|---|
inning |
$.payload.event.inning |
Always for half-inning issuances (from half_start or later pitches in the half). |
inning_half |
$.payload.event.inning_half |
Always for these issuances (T or B). |
batting_team_name |
if_eq({{$.payload.event.inning_half}}, 'T', {{$.payload.game.away.name}}, {{$.payload.game.home.name}}) ~ batting team |
Always when the definition extracts it: nickname of the batting side (T → away, B → home); falls back to batting team if extract fails. |
start_hits |
if_eq({{$.payload.event.inning_half}}, 'T', {{$.payload.game.away.hits}}, {{$.payload.game.home.hits}}) ~ 0 |
Always for two-plus-hit half-inning props: batting team’s game hit total at half_start. Yes when current game.*.hits - start_hits >= 2 (totals usually increment on the same terminal pitch as flags.is_hit; confirm on the corrective when the first delivery lags). |
runner_id |
Nested if_eq over runners[0]…runners[3] selecting .id when starting_base == 3 (or == 2 for runner-on-second props), or ending_base == 2 for next-PA “advance from second” props |
Always for named-runner props issued on a pitch with that runner on the target base. |
runner_last_name |
Same nested if_eq, selecting .last_name |
Always for those props (player-facing surname in copy). |
Templates use {{$.store_state.batting_team_name}} or {{$.store_state.runner_last_name}}. Half-scoped “yes” conditions typically require the same inning/half plus an occupied-base or named-runner check (optionally keyed to runner_id) — not “runners[1] exists.” Two-or-more on base is at least two distinct ending_base values in {1,2,3}; “no” is usually the next half_start for a different half or event_over. Named-runner props (on second or third) also resolve No in the same half when an event includes a runners list and the stored runner_id is absent (out / no longer on base)—gated on runners being present so atbat and similar events without that list do not false-trigger.
Leave a runner on: Yes on the 3rd-out pitch in the issued half when any runners[].ending_base is 1/2/3; No when that 3rd out has a runners list and none remain, or on event_over (walk-off without three outs).
Late-inning tied score: go-ahead / winning-run props compare payload.game.home.runs == payload.game.away.runs with inning >= 9 and inning_half T vs B. There is no team strikeout total on game.home / game.away (only runs / hits / errors). Use $.match_state.half_strikeouts for a half-inning strikeout count.
Listener match_state (MLB)¶
The match listener keeps a compact per-match scoreboard, updated on every non-heartbeat / non-synthetic event before issue/resolve. Definitions read {{$.match_state.<key>}}; they do not configure which keys exist. Keys are a code contract (lib.match_event_state.mlb). Private bookkeeping (per-eid strikeout corrections, first-occurrence latch) is not published.
On listener restart, the local event recording is replayed through the same updater. If event recording is off, half-scoped values start at zero until the next half reset.
match_state key |
Meaning | Always / sometimes |
|---|---|---|
half_key |
Current half-inning (1:T / 1:B), from extract_mlb_inning. |
Sometimes — set once an event carries inning / half. |
half_strikeouts |
AB-ending strikeouts this half (outcome_id in kKS / kKL / kFT and flags.is_ab_over). Resets when half_key changes. Same-eid correctives adjust the count. |
Always as a key after the updater has run (value 0 until a K). |
isolated_r1_is_first_this_half |
true only on the first event this half that is isolated runner-on-first (exactly one occupied base, first) with count.outs < 2. Later matches in the same half are false. |
Always as a key (false until that first event). |
NFL¶
No in-game question_definitions rows yet. Paths below are from staging S3 weekend archives (Sportradar NFL Official push), not the legacy Drools RuleRequest shape. Historical catalogs live under legacy_rules/. Do not author against drive.tracked.*, drive_start / drive_end event types, or yards_to_opponent_goal — those are not on the live feed.
Incoming payload (event.data): payload subtree¶
Archive envelopes are {gid, ts, eid, data, corrective} with the wire body at data. Evaluation uses event.data (typically {payload: {game, event}}).
payload.event.type is play or event. There are no drive_start / drive_end / period_start types. Drive boundaries are nested on plays (drive.id, drive.end_reason, drive.start_reason). type=event uses event_type (timeout, tv_timeout, period_end, two_minute_warning, game_over).
| Path | Description | Always / sometimes | Where it matters |
|---|---|---|---|
payload.event.type |
play or event. |
Always on forwarded sport events | Issuance / resolution gates; Match-question lock already includes both. |
payload.event.play_type |
pass, rush, punt, kickoff, field_goal, extra_point, penalty, conversion. |
Sometimes (play) |
Play-kind filters. |
payload.event.event_type |
period_end, timeout, tv_timeout, two_minute_warning, game_over. |
Sometimes (type=event) |
Period / clock-only rows. |
payload.event.id |
Play / event id (same as archive eid on plays). |
Sometimes | Review unit; same-eid correctives. |
payload.event.official |
false while the play is under review; true once official. Description often includes “Play is under review.” |
Sometimes (play) |
Review hold (is_nfl_play_under_review). |
payload.event.period.sequence / number |
Quarter. Prefer sequence (1–4; overtime is sequence: 5 with number: 1). |
Always on plays and type=event |
issued_in_period; even-split keys 1–4. OT stays uncapped. |
payload.event.clock |
Clock at the snap (mm:ss). |
Sometimes | issued_clock (also payload.game.clock and situation clocks). |
payload.event.home_points / away_points |
Running score after the play. | Sometimes (play) |
issued_score fallback. |
payload.event.drive.id |
Current drive id. | Sometimes (play) |
Drive identity; match_state.drive_key. |
payload.event.drive.first_downs |
First downs on this drive (feed already totals this). | Sometimes (play) |
First-down thresholds — read the feed, not match_state. |
payload.event.drive.net_yards / gain / play_count |
Drive aggregates the feed already publishes. | Sometimes | Yardage context; not rush/pass splits. |
payload.event.drive.end_reason |
UNKNOWN while the drive is live; then Punt, Touchdown, Field Goal, Interception, Fumble, End of Half, Missed FG. |
Sometimes | Score-on-drive / punt resolution. |
payload.event.drive.start_reason |
How this drive began (Kickoff, Punt, Interception, Fumble). |
Sometimes | “Previous drive ended punt” without a previous_drive object. |
payload.event.start_situation / end_situation |
clock, down, yfd, possession (team), location (team + yardline). No yards_to_opponent_goal. |
Sometimes (play) |
issued_situation; yard-line props (own side: 100 - yardline to goal; opponent side: yardline to goal). |
payload.event.statistics[] |
Per-play rows: stat_type pass / rush / receive / first_down / punt / field_goal / …. Pass rows have complete, yards, sack; rush rows have yards. |
Sometimes (play) |
Source for listener drive/home completion totals. |
payload.game.clock / payload.game.quarter |
Scoreboard clock and quarter integer. | Sometimes | Clock fallback; period still prefers event.period.sequence. |
payload.game.summary.home / away |
id, alias, name, points, timeouts, challenges. No passing totals. |
Sometimes | Live score (issued_score); home team id for home_pass_completions. |
Official Match-question boxscore uses the same summary.home.points / summary.away.points shape (no Game Summary feed).
Listener match_state (NFL)¶
Published by NflMatchStateUpdater. Compact scalars only — do not invent keys in issuance_state_extract.
| Key | Meaning | Always / sometimes |
|---|---|---|
drive_key |
Current payload.event.drive.id. Resets drive counters when it changes. |
Sometimes — set once a play carries a drive id. |
drive_rush_yards |
Sum of non-nullified statistics[].yards where stat_type=rush this drive. Same-eid correctives adjust. |
Always as a key after the updater has run (value 0 until a rush). |
drive_pass_yards |
Sum of non-nullified, non-sack pass yards this drive. |
Always as a key (0 until a pass). |
drive_pass_completions |
Count of non-nullified, non-sack pass rows with complete this drive. |
Always as a key (0 until a completion). |
home_pass_completions |
Game-scoped completions whose statistics[].team.id matches payload.game.summary.home.id. Does not reset on drive change. |
Always as a key (0 until a home completion). |
Not published (already on the event): drive.first_downs, drive.end_reason / start_reason, drive.net_yards. Period labels stay on extract_period_from_event ("1"…"4").
Extracted store_state (from issuance_state_extract)¶
Each row is one key in QuestionIssuance.store_state. The Source column matches issuance_state_extract on the corresponding NFL definition. Effective keys are whatever active rows store — manage them in /admin/question-definitions.
Drive / downs rows snapshot the unit at the first live snap. Complementary No is the same unit’s terminal drive.end_reason (or series convert / game_over) — not drive.id != store_state (a previous-play corrective would start resolve on the wrong eid). Pivotal rows snapshot the previous play and resolve on the next type=play whose id differs from issued_play_id.
store_state key |
Source (issuance_state_extract value) |
Always / sometimes (value) |
|---|---|---|
drive_id |
$.payload.event.drive.id |
Always on drive / downs / pivotal rows when the triggering play carries a drive. |
offense_team_name |
$.payload.event.start_situation.possession.name (drive / downs) or $.payload.event.end_situation.possession.name (pivotal) |
Always when possession is present — copy fallback the offense. |
offense_team_id |
$.payload.event.start_situation.possession.id or end_situation.possession.id |
Always when possession is present — same-possession yard-line / red-zone checks. |
series_first_downs |
$.payload.event.drive.first_downs |
Always on downs (and pivotal first-down) rows — series identity with drive_id. |
issued_ytg |
if_eq({{possession.id}}, {{location.id}}, 100 - {{yardline}}, {{yardline}}) from start_situation |
Always on gain-ten-yards rows when location is present (own 22 → 78; opponent 15 → 15). |
issued_play_id |
$.payload.event.id |
Always on pivotal next-play rows — resolve when a later play’s id differs. |
trail_side |
if_eq({{end_situation.possession.id}}, {{summary.home.id}}, 'home', 'away') |
Sometimes — take-lead pivotal only. |
Templates use {{$.store_state.offense_team_name ~ the offense}}. Do not write complementary No as “any later play whose drive id differs.”
Staging question groups¶
Lookup the league with leagues.name::text = 'NFL'. Unique on (league_id, slug).
INSERT INTO question_groups (
id, league_id, slug, name, description, is_active,
issuance_min_interval, issuance_min_interval_after_groups,
period_distribution, max_per_match
)
SELECT
gen_random_uuid(),
l.id,
v.slug,
v.name,
v.description,
true,
0,
'{}'::json,
v.period_distribution::json,
v.max_per_match
FROM leagues l
CROSS JOIN (
VALUES
(
'drive',
'Drive',
'Lane for drive-scoped in-game questions.',
'{"dist": "even"}',
NULL
),
(
'game',
'Game',
'Lane for game-scoped in-game questions.',
'{}',
1
)
) AS v(slug, name, description, period_distribution, max_per_match)
WHERE l.name::text = 'NFL'
ON CONFLICT (league_id, slug) DO NOTHING;
UPDATE leagues
SET default_question_period_distribution = '[]'::json
WHERE name::text = 'NFL'
AND (
default_question_period_distribution IS NULL
OR default_question_period_distribution::text IN ('{}', 'null')
);
downs / pivotal groups and the authored in-game + Match question rows live in the operator script sql/nfl_question_definitions.staging.sql (re-runnable; do not apply from CI).
Other leagues¶
- None without active
question_definitionsrows for that league.
Related code¶
- Listener passes
event.dataplus livematch_stateinto question logic:src/listener/listen.py(_execute_question_logic). store_stateis filled fromissuance_state_extractinQuestionDefinition.extract_issuance_state:src/lib/db/models/question_definition.py.- Resolution and issuance conditions use
jsonpath_condition_evaluatewith a context of{**event, "store_state": ..., "match_state": ...}:src/lib/db/models/question_issuance.py. - Per-league scoreboard modules:
src/lib/match_event_state/(get_updater,mlb.py,nfl.py).