Skip to main content

Market-status handling — design (PR #878 follow-up)

Date: 2026-07-03 Base branch: fix/live-market-continuity (PR #878, → dev); this work lands on feat/market-status-inactive as a stacked PR that merges after #878. Author: Hannibal Architect (design), driven by Bhargav's 4 directives Status: DESIGN — approved fork-by-fork; pending spec review before writing-plans


1. Problem

PR #878 fixes prod cricket (Bifrost/Betfair) markets that vanished mid-match or never loaded. Four follow-up directives refine status handling:

  1. Add inactive as a first-class canonical MarketStatus; hide inactive markets in player view, reveal them when they open (continuity).
  2. Explain the Betfair statusInferred ("SUSPENDED inferred, no market-def") mechanism.
  3. No silent status fallbacks — log the raw provider status, hide genuinely-unknown statuses.
  4. A suspended market shows the BALL_RUNNING-style frozen overlay but with "SUSPENDED" text, updated live over WebSocket.

Scope: display + routing-visibility only. No money path changes. Placement and settlement are untouched (they gate on the raw provider status / statusId, not the canonical display status). Money-flow risk: 0.


2. Answer to directive #2 — what statusInferred is

Betfair's Exchange Stream ships the full marketDefinition (which carries status) only when the status changes. Ordinary price ticks (rc runner-changes inside an mcm) arrive with no marketDefinition. When BetfairCache builds/updates a book from a change that has no marketDefinition.status, the current market state is genuinely unknown, so:

  • BetfairCache.ts:388statusInferred: !status — "this status is a guess, not Betfair-confirmed."
  • BetfairCache.ts:455book.statusInferred = false — cleared the moment a later change does carry marketDefinition.status.
  • BetfairMapper.ts:490 → also flags it when book.status is undefined at map time.

The display gate drops statusInferred === true markets (fail-closed: never show a market whose status we're only guessing), while a Betfair-confirmed SUSPENDED shows with last-known odds. In other words statusInferred is already the Betfair streaming analog of directive #3's "unknown → hide". Decision: keep it unchanged.


3. Design decisions (all forks resolved)

3.1 Canonical model — add inactive

backend/src/exchanges/core/models/canonical.ts:118

export type MarketStatus = 'open' | 'inactive' | 'suspended' | 'closed' | 'settled';

inactive semantics: non-visible, non-bettable, non-terminal. Distinct from closed (which is terminal) — an inactive market will open. Adding it lets both mappers map the provider's real state honestly instead of lying (INACTIVE → 'closed' / 'suspended').

3.2 Visibility rule (single, explicit)

A market reaches player view iff:

status === 'open'  ||  (status === 'suspended' && inPlay)

Everything else — inactive, non-in-play suspended, closed, settled, statusInferred===true, unknown — is hidden.

  • Backend gate (tournamentMarketViewService): keep inactive out of PLAYER_VISIBLE_MARKET_STATUSES (stays {'open','suspended'}); the existing statusInferred drop is unchanged.
  • FE fixture-page filter (fixture/[id]/page.tsx:72-73) already enforces the in-play clause for suspended; add an explicit inactive drop. This is required, not defensive: a market that is open at page-load then transitions to INACTIVE live arrives through the WS in-place merge (which stamps status:'inactive' onto the existing card — the backend gate won't re-send it), so the FE filter is the only layer that then hides it.

3.3 Mapper changes — no fallbacks, log raw, hide unknowns (directives #1 + #3)

ProviderINACTIVE mappingUnknown / unmapped status
Betfair (BetfairMapper.ts mapMarketStatus)INACTIVE → 'inactive' (was 'suspended')remove default → 'suspended'; on an unrecognized value → logger.warn('DOMAIN_INTEGRITY: unknown Betfair market status', { marketId, raw }) and hide (mapper signals drop). Keep the statusInferred no-market-def path.
Bifrost (bifrost/types.ts BIFROST_MARKET_STATUS_MAP + BifrostMapper.ts)INACTIVE → 'inactive' (was 'closed')remove ?? 'closed'; on an unmapped value → logger.warn('DOMAIN_INTEGRITY: unknown Bifrost market status', { marketId, raw }) and hide.
  • "Hide" = the mapper returns no canonical market (or a drop signal its caller honors), per Fork B (log raw + drop). Safe because placement is independently gated on raw-status === OPEN.
  • Dedup the warn (per-marketId Set, mirroring the existing loggedNotOfferedMarketIds pattern) so a repeating unknown status doesn't flood logs.
  • Model to copy: BifrostMapper.mapEventStatus (:125-153) already does the DOMAIN_INTEGRITY-warn-on-unknown pattern for event status.
  • BALL_RUNNING stays 'suspended' in the Bifrost map (a genuine between-delivery freeze, non-bettable) — the FE re-labels it (§3.4). Not remapped to inactive.

3.4 FE suspended/ball-running label (directive #4) — Fork A1

getMarketVisualState in both SportsbookGrid.tsx and ExchangeGrid.tsx:

if (market.status === 'inactive' || market.status === 'closed' || market.status === 'settled')
return 'closed'; // inactive should never reach here; defensive
if (market.status === 'suspended') {
// 'Ball Running' is a Bifrost cricket concept only. A Betfair in-play SUSPENDED is a real
// suspension, not ball-running — label it 'Suspended' regardless of inPlay.
return (market.source === MARKET_SOURCE.BIFROST && market.inPlay) ? 'ball_running' : 'suspended';
}
return 'open';
  • Fixes the current bug where a Betfair in-play suspension mislabels as "Ball Running".
  • market.source is present on the FE Market (betting.ts:133) and survives the WS merge (useWebSocket.ts:341 is a spread { ...market, status, inPlay }) — verified.
  • Bifrost inPlay cleanly separates its own states (BALL_RUNNING → inPlay=true, SUSPENDED → inPlay=false), so this is exact, not heuristic, for Bifrost.
  • The two grids duplicate this logic; keep the change symmetric. (Extracting a shared helper is optional cleanup, not required — noted, not in scope unless trivial.)

3.5 Live status updates (directive #4) — already works, no change

Both adapters publish odds:updated inside the stream/queue callback on any market change, price or status (throttled 100ms in-play / 500ms pre-play), and the payload carries status: canonical.status + inPlay: canonical.inPlay (BetfairAdapter.ts:363-364; Bifrost buildMarketDeltaForEvent). The FE merges both into the query cache (useWebSocket.ts:346-347) and getMarketVisualState recomputes. A Betfair OPEN→SUSPENDED (a marketDefinition change) therefore flips the overlay live with no refetch. Nothing to build.

3.6 Appear-on-open (directive #1 continuity) — Fork C2 plus per-viewer reconciliation

Pre-fix constraint: useFixtureDetails disabled HTTP polling while the WebSocket was connected, and the WS merge only updates markets already in the list (old.markets.map) — it cannot add a market that wasn't in the initial payload (the delta has no outcome names / selectionIds to build a card). Omitting inactive markets from the initial load therefore meant a market that flipped INACTIVE→OPEN could remain absent while WS stayed connected. The per-viewer reconciliation below removes the indefinite-polling part of this constraint.

Backend transition signal:

The trigger predicate is backend player-visible SET membership, NOT the render rule:

inSentSet = status ∈ {'open','suspended'} && statusInferred !== true

This is deliberately the backend set {open, suspended} (§3.2), not the narrower FE render rule. The backend sends all suspended markets (in-play and pre-play); the FE hides the pre-play ones. So a suspended market is already in the FE's data list — a later suspended-pre-play → suspended-in-play flip is handled entirely by the in-place WS merge and must NOT trigger a refetch. A refetch is only needed when a market first enters what the backend sends, i.e. crosses inSentSet from false → true.

  1. Each adapter tracks a per-market lastInSentSet: boolean (a Map<marketId, boolean>), computed at publish time from inSentSet above.
  2. When a market crosses false → true (e.g. INACTIVE→OPEN, INACTIVE→SUSPENDED, re-open from CLOSED, or a Betfair market whose statusInferred just cleared to a confirmed open/suspended), the adapter publishes an odds:updated payload for that fixture with markets omitted.
  3. The FE handler's else branch (useWebSocket.ts:387-389) fires queryClient.invalidateQueries({ queryKey: fixtureKeys.detailPrefix(fixtureId) }) → forced refetch (invalidate overrides staleTime) → the backend gate now includes the market → it appears with full metadata. If it arrives as pre-play suspended it lands hidden but present, so its later in-play flip reveals instantly via the in-place merge.
  4. false → true only. Every other transition needs no invalidate: OPEN→INACTIVE / OPEN→suspended-not-inplay (still in or leaving the set — the in-place merge updates the card and the FE filter hides it), and all transitions within the set (open↔suspended, suspended-pre-play→suspended-in-play) which the in-place merge already handles.
  • Both adapters already collapse an empty delta to markets: undefined (Betfair :385, Bifrost :507), so the empty-markets payload is idiomatic.
  • The existing per-fixture throttle debounces bunched opens (e.g. innings start) into ~one refetch per throttle window.
  • Cost: one targeted refetch per open-transition burst per fixture. No steady-state payload bloat (inactive markets are never sent).

Per-viewer reconciliation (2026-07-29 incident follow-up):

lastInSentSet is adapter-process state keyed by market, not delivery state keyed by socket. The first viewer/tick that establishes true consumes the false → true edge. A later viewer can therefore receive only steady-state deltas; if that browser's HTTP cache is missing the market, old.markets.map cannot add it and the process-wide edge cannot prove that viewer ever received a membership invalidation.

The frontend closes that gap without synthesizing a partial market from a price-only delta:

  1. useFixtureWebSocket invalidates the targeted fixture-detail query after every initial subscription attempt and reconnect. With cancelRefetch: false, an authoritative HTTP request already in flight is not cancelled/restarted; the bounded poll remains the recovery path if that request fails.
  2. useFixtureDetails retains a 30-second HTTP membership reconciliation while a live fixture WebSocket is connected. The disconnected cadence remains 5 seconds, and the existing bounded error-retry window remains unchanged for data-less/dead fixtures. A previously loaded live fixture resumes its normal cadence after that window so a background outage cannot stop reconciliation permanently.
  3. A pushed market ID absent from the cached HTTP view is not used as an immediate refetch or integrity signal. Dictionary-publishable markets can still be intentionally excluded from one HTTP view by tournament-tier or per-player visibility gates, so absence alone is ambiguous and event-driven refetching would create false alerts/request loops.
  4. Each adapter reports whether its own membership read was authoritative. Betfair marks catalogue truncation, missing description enrichment, fallback/cooldown reads, and failed book batches incomplete. Bifrost always remains incomplete because catalogue messages are incremental and no event-level snapshot-complete signal exists. The coordinator composes these facts into complete/incomplete provider scopes. Returned markets can always add/heal cards, but an absent cached market is retained only for an incomplete provider that can later converge to a complete snapshot. Bifrost absence follows the current HTTP payload until it gains an authoritative snapshot/tombstone contract, avoiding an indefinitely stale open card after a missed terminal update. A newer Bifrost OPEN/SUSPENDED WebSocket update received after the partial HTTP request started remains the bounded exception and wins that race. A healthy Betfair scope still heals exchange membership during a Bifrost outage.
  5. Each detail request snapshots an out-of-band live-update revision before HTTP starts. WebSocket score/market/outcome updates and the delta-odds path record per-field touched scope. Direct WS owns status/in-play, supplied ladder, depth, odds, and dynamic caps; delta-odds owns odds and the named bookmaker entry. When HTTP completes, only fields touched after that revision are preserved. Value/reference comparison is deliberately insufficient because it misses idempotent and ABA updates such as OPEN → SUSPENDED → OPEN.
  6. Player visibility authority stays separate from provider completeness. A successful detail response includes the blocked market tiers (empty for unrestricted/non-player views). If any tier is blocked, the exact HTTP membership wins because cached marketTier is admin-mutable. A policy-specific 404 suppresses the previous TanStack fixture immediately. A transient provider or display 404 with active restrictions suppresses stale content behind the normal retry skeleton. Missing visibility metadata from an old backend also selects exact HTTP membership during rollout.

The complete per-viewer market set therefore remains HTTP-authoritative; WebSocket deltas remain authoritative for price/status changes on markets already in that set.

3.7 Placement / settlement — unchanged

  • Placement gates read the raw provider status and reject anything ≠ OPEN (BifrostAdapter.ts:1060, BetfairAdapter.ts:1811), so inactive, suspended, and unknown are non-bettable by construction. orderService MARKET_SUSPENDED / MARKET_NOT_OPEN handling is untouched.
  • Settlement gates on statusId === 2 and settlement events, not the canonical display status. No change.

4. Blast radius (adding inactive to MarketStatus)

Every exhaustive switch / comparison on canonical MarketStatus. Placement (raw status) and settlement (statusId/events) are NOT affected. Known consumers to touch / verify:

  • Backend: canonical.ts (type def), BetfairMapper.ts, bifrost/types.ts + BifrostMapper.ts, tournamentMarketViewService.ts (gate), BetfairAdapter.ts + BifrostAdapter.ts (publish + transition tracking).
  • Frontend: SportsbookGrid.tsx, ExchangeGrid.tsx, fixture/[id]/page.tsx filter. betting.ts Market.status is typed string — no type change needed, but the visual helpers must handle 'inactive'.
  • Full file-by-file enumeration (incl. any persistence / netting switch on canonical status) is produced in the implementation plan via graphify affected before edits, per .claude/rules/impact-analysis.md.

5. Error handling & financial integrity

  • Unknown status → DOMAIN_INTEGRITY warn (deduped) + hide + non-bettable. No silent fallback, no fabricated status. Fail-closed (a hidden market beats a wrong/bettable one).
  • Display-only change; no ledger, settlement, commission, or balance path touched. No Decimal / currency surface. financial-security.md money-path rules are not engaged beyond the fail-closed principle already honored here.

6. Testing

  • Mapper unit: Betfair & Bifrost INACTIVE → 'inactive'; unknown/unmapped status → market dropped + a single dedup'd DOMAIN_INTEGRITY warn; statusInferred no-market-def path unchanged; BALL_RUNNING still maps 'suspended'.
  • FE visual-state unit/component: Betfair suspended + inPlay'suspended' ("Suspended"); Bifrost BALL_RUNNING (suspended + inPlay + source 3) → 'ball_running' ("Ball Running"); inactive → hidden/closed, non-bettable.
  • Visibility gate integration: inactive excluded; open and in-play suspended included.
  • Adapter transition: hidden→visible flip emits an empty-markets invalidate payload; no invalidate on →hidden or steady-state; throttle collapses a burst.
  • FE membership reconciliation: subscription/reconnect invalidates the targeted detail query; connected live fixtures poll every 30 seconds, disconnected live fixtures every 5 seconds, and non-live fixtures do not poll. Provider-completeness, interleaving, and ABA coverage pin HTTP membership + metadata ownership while preserving concurrent WS price/status fields. Viewer-policy removals remain authoritative even when a provider read is incomplete.
  • Manual on dev (cricket fixture): inactive not shown; an opening market appears within a tick (invalidate → refetch); suspended shows the frozen overlay with the correct per-provider label; live WS status flip with no refetch for already-visible markets.

7. Open items / non-goals

  • Extracting the duplicated getMarketVisualState / MarketOverlay into a shared module is optional cleanup, not required for correctness.
  • Instant (sub-tick) reveal of opening markets via prehydration (Fork C1) is rejected in favor of C2's targeted refetch — no steady-state payload bloat.
  • No change to statusInferred, placement gates, settlement, or the WS payload shape.