Skip to content

Question definitions

This document describes the QuestionDefinition row (template for in-game questions), how league defaults and match overrides layer for issuance settings and points, and how QuestionAnswer rows reflect resolution. For JSONPath contracts on live payloads and store_state keys, see question_definitions_league_event_state.md. For end-to-end issuance and resolution flow, see question_issuance_resolution_flow.md.

Admin UI (database configuration)

Day-to-day editing uses the admin workspace at /admin/question-definitions (v1: MLB only). See Admin: question definition editor for the operator workflow, API endpoints, sample catalog, and troubleshooting.

  • Saves write only to PostgreSQL (question_definitions). The match listener reads those rows; there is no JSON seed import for definitions.
  • The page is labeled Database configuration. Use the sidebar to select a definition; edit via guided sections (Overview, Issuance, Answer Choices, Resolution) or the Raw JSON tab.
  • Structured controls and Raw JSON stay in sync: edit either side, then Save. id, league_id, created_at, and updated_at are shown for reference and are not overwritten by Save.
  • Field browser builds a compact path catalog from the packaged MLB sample feed (src/lib/question_definitions/fixtures/mlb_sample.json) so you can copy JSONPaths to the clipboard for use in conditions, issuance_state_extract, or copy templates. It also lists read-only $.match_state.* keys the listener publishes for the selected league (code catalog). It does not modify the definition. The Issuance tab’s copy preview renders placeholders against a sample event of the selected type.
  • Clone creates a new row (name (copy)) with Include in league defaults off. Archive sets is_archived=true (hidden from the default tree; use Show archived to reactivate). Include in league defaults (is_active) is separate — matches that inherit the league allowlist only issue definitions with that flag on. Hard delete is allowed only when no issuances or sponsorship slots reference the definition.

Purpose and lifecycle

A question definition is a reusable template per league: issuance triggers, text templates, optional answer-choice templates, resolution triggers (or derived conditions), and optional point overrides.

When the match listener (or API) decides a match is eligible for a new question, it selects one active definition whose issuance_trigger_condition_list matches the event (weighted random via select_by_league over that issuable pool, using base issuance_weight optionally decayed by session issuance_dynamic_weighting / issuance_weight_decay), then applies that definition’s issuance_probability gate (optional pity timer via issuance_probability_max_false_streak). On a true outcome it creates a QuestionIssuance for that match, copies store_state from issuance_state_extract, and stores generated answer_choices when a template exists. Later, when is_resolvable is true for an event, the listener resolves the issuance (resolve_with_event, persisted with resolution_event / resolved_at and per-choice correctness on answer_choices), unless resolution_correction_window_seconds is greater than zero—in that case resolution is deferred in listener memory for that many seconds (allowing same-eid correctives to replace the candidate). How those seconds are measured is controlled by LISTENER_TIMING_SOURCE (see below). Answer evaluation is a separate step: the listener commits that state, then enqueues one or more Celery evaluate_question_answers_batch tasks (chunked by ANSWER_EVALUATION_BATCH_SIZE in src/listener/lib/stages/answer_evaluation.py) that load each QuestionAnswer and set is_correct, points_awarded, and resolved_at.

QuestionDefinition fields

Field Role
id, league_id Primary key and owning league.
name Human-readable label (operations / admin UI).
is_active Include in league defaults — when true, matches that inherit the league allowlist may issue this definition (still subject to triggers, weight, and caps). New/cloned rows default off. Turning on requires at least one issuance trigger. Does not archive the row.
is_archived Hidden from the default admin tree and never issued. Archive / Reactivate flip this only.
question_text Copy with {{...}} JSONPath-style placeholders; evaluated on the issuance using issuance event + store_state (see QuestionIssuance.get_formatted_question_text()). Right/wrong phrases are league default_resolve_*_copies, stamped as resolve_copy at score.
question_group_id Required FK to a league QuestionGroup. Preferred write field.
issuance_group Concurrency lane slug synced from the group (default default). Same group cannot have two unresolved questions; different groups can be open together. Still at most one new issue per event among free groups. Examples: atbat, half_inning. Copied onto QuestionIssuance.issuance_group at issue time. Write alias that must resolve to an existing same-league group (do not invent slugs).
issuance_min_interval Seconds after this question resolves before another in the same group may issue (default 300). Snapshotted onto QuestionIssuance.issuance_min_interval. The next wait is max(match.question_min_interval, live group.issuance_min_interval, last_issuance.issuance_min_interval). 0 uses the group/match floors only.
issuance_weight Relative weight among issuable active definitions for the current event whose group is free; higher weight ⇒ more likely. May be greater than 1; 0 excludes the definition. Base for dynamic weighting when that flag is on.
issuance_dynamic_weighting When true (default), after each successful issuance in a listener session the effective selection weight becomes issuance_weight * (issuance_weight_decay ** times_issued). Counts are in-memory per listener process and reset on restart.
issuance_weight_decay Multiplier in [0, 1] (default 0.5) applied when dynamic weighting is on. 0 excludes the definition after its first issue in the session; 1 leaves the weight unchanged. Ignored when dynamic weighting is off.
issuance_probability Percent (0–100) chance to issue after this definition is selected (final gate). Default 100 (always issue). Rolled once for the winner, not once per eligible definition. A false skip means no issuance on that event (no re-pick) and does not decay weight.
issuance_probability_max_false_streak After this many consecutive false rolls for this definition, the next selection is forced true and the streak resets. Default 0 = pity timer disabled. Streak is in-memory per listener session.
issuance_trigger_condition_list List of conditions OR’d together; each evaluated with jsonpath_condition_evaluate on the incoming issuance event (root = that dict, including live match_state when the listener merged it). Condition strings must be non-blank and syntactically evaluable (placeholders substituted for a parse check on save); blank or broken expressions are rejected.
issuance_state_extract Map of store_state keys to JSONPath (or helper) expressions run at issuance; later keys can read earlier keys via $.store_state.<key>. Each value must be a non-empty string.
issuance_subject_extract Optional JSONPath for the player / subject id this in-game question is about. Evaluated after store_state (so $.store_state.runner_id is valid). NULL / empty = no subject (team or situation props skip uniqueness). At most one non-pivotal in-game question may publish per subject per period / phase (issued_in_period). Match-wide — occupancy is not scoped to issuance_group; a routine question about a player blocks later routine questions about that id in every group. Group mutex, interval, and group caps stay separate. A pending (not yet published) candidate occupies the same way. Pivotal moments skip this gate and do not occupy the slot. Retracted rows do not occupy. The path is per definition (league payload shapes differ); the gate itself is league-agnostic.
answer_choices_template Optional; drives multiple-choice rows on the issuance (generate_answer_choices). When set, must include at least one choice; choice identifier/text must be non-blank; correct_condition (if set) must be evaluable like issuance triggers. Each choice may set optional override_points (absolute or *N of the question-effective points) and optional outcome_text (on-field copy when that choice wins; same placeholders as text; stamped at issue). The winning line is sent on play REST, question.closed, history, and opt-in resolve push {{outcome_text}}.
resolution_trigger_template Optional. If empty (default), a later event may close the question when any answer correct_condition matches (those same conditions also mark the winner). If set, only these OR’d conditions may close the question; answer correct_conditions still decide is_correct on that same event and do not open the close. Context is the resolution event merged with store_state and live match_state.
override_question_points Optional absolute whole points (integer / number string) or multiplier (*2, spaces allowed; product is floored); if set, overrides match and league defaults (see below). Fractional absolutes are rejected. Per-choice override_points may further adjust that value.
resolution_correction_window_seconds Optional int (default 0). Seconds to wait for a same-eid corrective re-issue before calculating resolution. 0 resolves immediately unless the play is under review. While deferred, matching corrective payloads replace the candidate. The window does not expire while the play is under review; after the feed marks it final, or after a short hold cap, the configured seconds run as call-on-the-field. Measured per LISTENER_TIMING_SOURCE: realtime (default) uses real-time asyncio timers; ts expires when a later stream event’s ts advances by the window (simulator / compressed replay).
resolution_start_on_corrective Optional bool (default true for new definitions). On: a same-eid correction may start waiting/resolving when the updated event first matches this definition’s resolve rules (the first delivery can still start if it already matches). Off: a correction may only update a resolve already waiting for that event; if nothing was waiting, it does not start resolve. Existing rows keep their stored value until saved. Introduced after TOR @ WSH (2026-07-29): a half-inning “Will a run score?” stayed pending while runs showed only on feed corrections, then incorrectly resolved No at half end. Counter-example TOR @ TB (2026-08-20): with this flag on, a late rewrite of the previous at-bat matched a loose “any other-unit pitch → No” rule and locked the new batter’s hit / extra-base-hit questions incorrectly. CLE / Jays (2026-09-01) is the same fallacy on an explicit resolution_trigger_template (not a No choice): MLB At Bat Number of Balls locked with no winning choice because the previous plate appearance’s rewrite started the lock while scoring still required this batter’s last pitch (issuance 35665478-884f-4d4d-a748-a1646f8e5aac). Do not treat “unit id ≠ store_state” on an arbitrary later event as close / No — use the next unit’s start event or a same-unit opposite. Do not turn this flag off to paper over that. See authoring — Correctives vs a later play.
issuance_correction_window_seconds Optional int (default 0). Seconds to wait for a same-eid corrective before creating (publishing) an issuance. 0 creates immediately when selected. While deferred, the candidate is process-local (not in the DB); matching correctives update the payload or drop the candidate if triggers no longer match. The definition’s issuance_group is held so another question in that group cannot issue. A pending subject also occupies that player match-wide (other groups cannot issue a routine question about the same person first). Max/period caps are re-checked at finalize. Same timing source as resolution.
issuance_start_on_corrective Optional bool (default false). Use when issuance details usually arrive on a feed correction, not the first delivery. Off: correctives never start a new issue (they may only refine an already-deferred candidate). On: a correction may also start a deferred (or immediate, if the issuance window is 0) issue when the updated event first matches this definition’s issuance triggers.
issuance_retract_on_corrective Optional bool (default false). After publish, if a same-eid corrective no longer matches issuance triggers, close the open issuance as retracted. Fans see a withdrawn notice; answers keep 0 points; the row does not count toward period/max caps. Prefer issuance_correction_window_seconds when you can wait before publish; use retract for early-publish / pivotal cases. See play client — retracted questions.
issuance_max_count Optional int. Hard cap on how many times this definition may publish in one match. NULL (default) = unlimited. 0 = never issue. Retracted rows do not count. Independent of match question_max_count. Typical MLB standard types use 2; leave unset on pivotal.

Points hierarchy (correct answers)

Question-effective points are a non-negative integer from await QuestionDefinition.get_question_points(db, match) (async: loads match.league via the session). There are no partial points.

  1. override_question_points on the definition, if not NULL
  2. Else Match.override_question_points, if not NULL
  3. Else League.default_question_points (default 5)

Each answer choice may then set optional override_points (same absolute / *N syntax). At issuance, that resolves to a stamped points integer on the choice (shown on open/in-play APIs). Scoring awards that stamped value when the selected choice is correct. Legacy fractional stamps are floored when read for scoring.

Layer Field Applies to
League / match / definition default_question_points / override_question_points Question-effective base
Per choice (optional) AnswerChoice.override_points That choice only (Yes/No or any multi-choice option)

Each override may be:

Form Example Meaning
Absolute 10 Use that many whole points (fractions like 10.5 are rejected)
Multiplier *2, *1.5, * 2 Multiply the next-lower value, then floor to a whole number (choice × question-effective; definition × match/league; match × league default)

Whitespace around * and the factor is ignored; stored/canonical form is compact (*2). Blank / unset choice override_points inherits the question-effective value.

League defaults and match overrides (issuance + points)

Issuance pacing and caps follow the same match overrides league pattern as points (except only points also have a definition-level override):

League default_* Match override_* Effect
default_question_max_count override_question_max_count Max issuances per match (match-wide).
default_question_pivotal_max_count override_question_pivotal_max_count Max pivotal-moment issuances per match (match-wide, league default 4). When full, only is_pivotal_moment defs are blocked. Independent of max count and of per-period pivotal caps (both must have headroom). Pivotal skip of regular match max / period does not bypass this.
default_question_min_interval override_question_min_interval Match-wide floor (seconds) after a group resolution. Combined via max(match floor, live group interval, last issuance snapshot). League default is 0 so definitions (and groups) control pacing unless raised. Measured per LISTENER_TIMING_SOURCE: realtime compares wall now to prior resolved_at; ts compares stream ts of the resolution event.
default_question_period_distribution override_question_period_distribution Per-period normal and pivotal caps for the current event's period only (match-wide). {} / unset means Issue ASAP (no period caps). [] / {"dist": "even"} opts registered leagues into start-time even auto-fill with default pivotal 1. Explicit maps (no dist) use bare int ⇒ pivotal 0, or [n, p]. unlisted: allow (default) leaves omitted keys uncapped; unlisted: block treats omitted keys as 0. NHL/NFL keys are period numbers. MLB keys may be half-innings ("1:T", "1:B") or coarse inning numbers ("1""9"); eligibility falls back from a half label to a parent inning key. Labels come from lib.event_period.extract_period_from_event. Independent of max count — see below.
default_question_points override_question_points Question-effective points for a correct answer; definition override_question_points wins if set. Absolute or *N multiplier (see above). Per-choice override_points may further adjust the award.
default_question_definition_ids override_question_definition_ids Optional allowlist of definition ids. League NULL = all non-archived definitions included in league defaults (is_active); [] = none; non-empty = only those ids (still filtered by is_active when inheriting, weight, triggers, and group gates). Match NULL inherits the league list (later league edits apply). A non-null match list is a pinned snapshot (may issue ids not included in league defaults). Admin trees toggle folders; storage stays a flat UUID list. folder_path on each definition is organization only and is not part of this allowlist.

Match fields are NULL when the league default should apply (for points, after considering the definition).

Operators edit league baselines on Manage → League Defaults (PATCH /api/leagues/{id}). Match overrides remain on the Matches board.

Issuance concurrency groups

Each in-game definition belongs to a league QuestionGroup (question_group_id). The group slug is synced onto QuestionDefinition.issuance_group and snapshotted on QuestionIssuance.

  • An unresolved issuance blocks only other definitions in the same group.
  • After a resolution in the group, the next wait is max(match.question_min_interval, live group.issuance_min_interval, last.issuance_min_interval). The group interval is live; the definition interval is the last issuance snapshot. A definition value can only lengthen the wait.
  • issuance_min_interval_after_groups is a live map of other group slugs to seconds. This group cannot issue until each listed source group's last issuance (issue time, not resolution) is at least that old. Open questions count; retracted rows do not. Independent of the resolution min-interval. Pivotal skip of match max/period does not skip this wait.
  • Subject uniqueness is match-wide, not a group rule: a non-pivotal issuance about a player occupies that id for the period across every group. Group mutex, interval, and group caps stay separate.
  • Group period_distribution (ints only) and optional max_per_match apply in addition to match-wide caps. Omitted explicit keys are uncapped unless unlisted: block. [] / {dist: even} is 1 issuance of this group per registered league key when max_per_match is unset; when that ceiling is set, the integer is even-split across those keys (leftover slots pinned in registered order; live eval, not shuffled). {1: 0} never issues that group in that period.
  • Each definition may also set issuance_period_distribution (same int-only shapes as the group map). Counts that definition’s non-retracted issuances only.
  • is_pivotal_moment skips only the match normal period cap (and match max) and subject uniqueness (does not occupy the subject slot). It does not bypass the group map or issuance_period_distribution.
  • Inactive groups cannot receive new issuances. Retracted rows do not count toward group caps.
  • The listener still issues at most one new question per event (weighted pick among definitions whose groups pass), then the selected definition’s probability gate may skip that event entirely. When any pivotal definition is eligible, selection is restricted to that subset (priority over non-pivotal definitions).

All definitions default to group default and issuance_min_interval 300. Group interval defaults to 0 so existing definition 300s keep current pacing until operators move the lane wait onto the group.

Pivotal moment vs regular match caps

is_pivotal_moment (default false) is the single switch for must-fire moments. There are no per-definition ignore-max / ignore-period flags.

Flag Effect
issuance_period_distribution Per-definition period/half caps (ints only). {} = none. unlisted: block treats omitted keys as 0. Pivotal skip of the match map does not skip this.
issuance_max_count Per-definition match cap (NULL = unlimited, 0 = never). Retracted rows do not count. Pivotal skip of match max does not skip this.
issuance_subject_extract Optional subject JSONPath. When set, at most one non-pivotal in-game question about that id per period, match-wide (every group). Empty = skip. Pivotal skip of match max / period also skips this gate and does not occupy the subject slot.
is_pivotal_moment Skips and does not consume match question_max_count or the match normal period cap (including start-time even auto-fill remaining). Skips subject uniqueness and does not occupy the subject slot. Wins selection priority over non-pivotal defs on the same event (still one issue per event). Still consumes and is blocked by match max pivotal and the period pivotal half. Still AND with group period_distribution / max_per_match and this definition’s maps. Admin QD workspace and /play decorate these questions.

Typical use: overtime sudden death or game-winning field goal — mark pivotal and size the pivotal budgets. Put a moment that must not share an atbat mutex in its own group.

Issuance probability gate

After weighted selection picks a definition, decide_issuance_probability rolls against issuance_probability (percent) once for that winner. Other eligible definitions on the same event are not rolled. Ten eligible definitions with the selected one at 50% is still about a 50% chance to issue — not ten independent 50% rolls. Defaults 100 / 0 keep prior always-on behavior.

  • False: increment a per-definition consecutive-false counter (listener memory only) and do not issue; nothing is re-selected for that event.
  • True / force: reset the counter and proceed to create the issuance.
  • When issuance_probability_max_false_streak is N > 0 and the streak already equals N, the next selection is forced true (pity timer).

Typical use: lower probability on MLB atbat definitions so the first batter of a half is not always paired with a simultaneous half_inning question.

Period distribution vs max count

question_max_count and question_period_distribution are independent gates (Match.question_max_count_gate_open / period_distribution_gate_open; is_eligible_for_question_issuance is both open) — not a shared budget. When a gate is closed, prepare_question_issuance still considers is_pivotal_moment definitions.

Period distribution values are [normal, pivotal] pairs after normalize / materialize. Bare ints mean pivotal 0. A separate Match.pivotal_period_gate_open blocks only is_pivotal_moment definitions when the pivotal cap for the current period is full (pivotal skip of the regular period map does not bypass pivotal caps).

Config Meaning
{} Issue ASAP — no normal period caps, no pivotal period caps
[] Sugar for {"dist": "even"} — even regulars + default pivotal 1 per registered key
{"dist": "even"} Same as []
{"dist": "even", "5": [5, 1], "8": 2} Even-fill all registered keys (pivotal 1), then overrides ("8" → 2/0)
{"1": 3} Explicit map, no dist — 3 regular, 0 pivotal
{"1": [3, 1], "2": 2} Explicit — period 1 is 3/1; period 2 is 2/0
{"unlisted": "block", "1": [4, 1]} Only period 1; omitted keys treated as 0
{"dist": "even", "unlisted": "block"} Even-fill registered keys; extras blocked after materialize

dist is a strategy discriminator — today only "even"; other strategies may be added later. Unknown dist values are rejected on save (HTTP 400). Reserved unlisted is allow (default) or block. {unlisted: block} with no period keys and no dist: even is rejected. [] cannot carry unlisted.

  • The normal distribution values do not need to sum to max count. There is no validation that they match.
  • Unused slots in one period are not redistributed to another.
  • Caps apply only when the candidate event’s period label is a key in the map and unlisted is allow (default). Periods (or innings) missing from an explicit (no-dist) map have no period cap unless unlisted: block. The same skip (under allow) applies when the event has no extractable period.
  • A normal cap of 0 for a period is valid and means never issue non-pivotal defs while the event is in that period. That is different from omitting the key (uncapped).
  • If every configured period is full and play stays in those periods, issuance stops even when total issuances are still below max.

Empty/null/[] (before materialize) effective distribution skips the period gate entirely (max count and per-group min interval still apply). {} is Issue ASAP; [] / unresolved {"dist": "even"} is the even-fill sentinel (no caps until start materializes pairs).

Examples

[]
{}
{"1": 3}
{"1": [3, 1], "2": 2}
{"1:T": [1, 1], "1:B": 1, "5:T": [2, 0]}
{"dist": "even"}
{"dist": "even", "5": [5, 1], "8": 2}
{"dist": "even", "5:T": [3, 1]}

Assume max count 20, distribution {"1": 2, "2": 3} (normalized to pivotal 0), and other gates pass (group free, min interval elapsed for that group).

When What happens
Event in period 1, already issued 2 in period 1 Blocked by period cap (even if only 2 of 20 total).
Event in period 2, already issued 3 in period 2 Blocked by period cap.
Event in period 3 (not in the map), 5 issued so far Period gate skipped → can issue; continues until total reaches 20.
Event has no extractable period, 5 issued so far Period gate skipped → same as uncapped.
Play never leaves periods 12 after both caps are full Stops at 5 total; the unused 15 of max never issue.
Distribution {"1": 0, "2": 3} , event in period 1 Blocked immediately — zero means no questions in that period.
Same {"1": 0, "2": 3}, event in period 2 Can issue up to 3 in period 2 (period 1 being zero does not move slots).
Effective distribution {} / null No period caps (Issue ASAP); issues until 20 (other gates still apply).
Effective distribution [] / {"dist": "even"} Same as Issue ASAP until listener start; then even-filled [n, 1] for registered leagues.
Pivotal cap full for current period Non-pivotal defs still eligible; is_pivotal_moment defs dropped from the pool.

MLB uses the same rules with half-inning keys when the event has inning + inning_half (e.g. "1:T", "1:B"). Operator maps may still use coarse inning keys ("1""9"): a half label falls back to the parent inning cap and counts both halves toward that cap. Example: max 20 and {"1": 2, "2": 2, "3": 1} — innings 13 are capped across both halves; inning 4 and later are uncapped and can fill the remaining match budget up to 20.

Auto even period distribution at listener start

When the effective distribution (match override, else league default) is the even-fill sentinel [] or {"dist": "even", …} and the league is listed in AUTO_PERIOD_DISTRIBUTION_KEYS (src/lib/period_distribution.py), listener start writes an even split of remaining questions (question_max_count − already issued) onto override_question_period_distribution as [n, 1] pairs (default pivotal 1). Every registered period gets remaining // N; only leftover remaining % N slots are assigned at random (+1 each). Period-key overrides from a dist: even object replace keys after the even map (bare int ⇒ pivotal 0). This is an even split with a random remainder — not a randomized distribution.

  • MLB is registered first: regulation half-innings 1:T9:B (18 keys).
  • {} (empty object) means Issue ASAP — no period caps and no auto-fill.
  • Unregistered leagues (NHL/NFL today) leave even sentinels as no period caps (no keys to fill).
  • An explicit map (no dist) still wins (no auto-fill); unspecified pivotal is 0.
  • Restart does not reshuffle once the override is written as a pair map.
  • Invalid stored shapes at listener runtime warn and fall back to [] / even (do not crash the listener). Admin/API save still rejects bad JSON with HTTP 400.
  • To add another league: append keys to AUTO_PERIOD_DISTRIBUTION_KEYS (and a period extractor if the payload shape differs).

Listener timing source

The match listener env var LISTENER_TIMING_SOURCE (listener_timing_source) controls how timed gates are measured:

Value Use Correction window Issuance min-interval
realtime (default) Live SportRadar Real-time asyncio timer; does not wait for the next stream event Now vs prior resolved_at
ts Simulator / compressed replay Expire when a later event’s stream ts advances by N seconds Stream event ts vs prior resolution event ts

Set LISTENER_TIMING_SOURCE=ts when driving the listener from the simulator or any replay that compresses or skips real time. Stream ts is always stored on issuance/resolution payloads for logging regardless of mode.

Post-issuance pause (realtime)

LISTENER_POST_ISSUANCE_PAUSE_SECONDS (listener_post_issuance_pause_seconds, default 0) sleeps that many real-time seconds after each successful question issuance before the listener reads the next stream event. It is independent of LISTENER_TIMING_SOURCE: even with ts timing and high playback_speed, the pause is always real-time so a human can answer. It does not change answer_window_seconds. Use on Clone Past Match / listener start settings for fast simulator runs; leave 0 for live.

QuestionAnswer resolution state

State is_correct points_awarded resolved_at
Not yet scored NULL / None NULL NULL
Scored wrong false 0.0 Set when the row was scored
Scored right true Selected choice’s stamped points (question-effective ± choice override_points) Set when the row was scored
Stale credit NULL / None Selected choice’s stamped points Set when the row was scored

Use resolved_at IS NULL (or equivalent) as the primary filter for “answer not yet scored.” Award-selected settlement credits points without marking correct/incorrect. resolution_reason is stale when the game ended (complete / closed) but this card never got feed truth, or postponed / cancelled / unnecessary when the match will not continue on this id.

resolved_at is the time this answer row was finalized. It is not required to equal QuestionIssuance.resolved_at (issuance may resolve first; answers may be updated later, e.g. in parallel workers).

Award-selected issuance settlement

When a match is terminal and an issuance never received a feed resolve:

  • Postpone / cancel / unnecessary — leftover in-game cards whose answer window has ended settle on the status change (apply_match_question_side_effects) with resolution_reason matching Match.status. Cards whose window is still open wait until it ends.
  • Complete / closed — leftover cards stay on Celery Beat sweep_stale_issuances (every 5 minutes) so the listener can still feed-resolve. After the answer window ends: resolution_reason="stale".

Both paths award each answerer their selected choice’s stamped points, leave is_correct null, and publish question.closed with that reason. Beat is also the backup for any miss.

Example (shape only)

Issuance trigger (simplified):

{{$.payload.event.type}} == "faceoff"

issuance_state_extract (illustrative):

{
  "winner_id": "$.payload.event.attribution.id",
  "period_no": "$.payload.period.number"
}

Answer choices (two options) each carry a correct_condition evaluated at resolution against the resolution event plus store_state. Resolution template may either list explicit conditions or stay empty so conditions are derived from those correct_condition strings.

Exact paths depend on your feed; align with question_definitions_league_event_state.md for the payload and store_state contracts used by active database definitions.