Skip to content

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 03. 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 (14; overtime is sequence: 5 with number: 1). Always on plays and type=event issued_in_period; even-split keys 14. 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_definitions rows for that league.
  • Listener passes event.data plus live match_state into question logic: src/listener/listen.py (_execute_question_logic).
  • store_state is filled from issuance_state_extract in QuestionDefinition.extract_issuance_state: src/lib/db/models/question_definition.py.
  • Resolution and issuance conditions use jsonpath_condition_evaluate with 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).