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:
- Add
inactiveas a first-class canonicalMarketStatus; hide inactive markets in player view, reveal them when they open (continuity). - Explain the Betfair
statusInferred("SUSPENDED inferred, no market-def") mechanism. - No silent status fallbacks — log the raw provider status, hide genuinely-unknown statuses.
- 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:388→statusInferred: !status— "this status is a guess, not Betfair-confirmed."BetfairCache.ts:455→book.statusInferred = false— cleared the moment a later change does carrymarketDefinition.status.BetfairMapper.ts:490→ also flags it whenbook.statusis 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): keepinactiveout ofPLAYER_VISIBLE_MARKET_STATUSES(stays{'open','suspended'}); the existingstatusInferreddrop is unchanged. - FE fixture-page filter (
fixture/[id]/page.tsx:72-73) already enforces the in-play clause for suspended; add an explicitinactivedrop. This is required, not defensive: a market that isopenat page-load then transitions toINACTIVElive arrives through the WS in-place merge (which stampsstatus:'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)
| Provider | INACTIVE mapping | Unknown / 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-
marketIdSet, mirroring the existingloggedNotOfferedMarketIdspattern) 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 toinactive.
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.sourceis present on the FEMarket(betting.ts:133) and survives the WS merge (useWebSocket.ts:341is a spread{ ...market, status, inPlay }) — verified.- Bifrost
inPlaycleanly 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.
- Each adapter tracks a per-market
lastInSentSet: boolean(aMap<marketId, boolean>), computed at publish time frominSentSetabove. - When a market crosses
false → true(e.g.INACTIVE→OPEN,INACTIVE→SUSPENDED, re-open fromCLOSED, or a Betfair market whosestatusInferredjust cleared to a confirmed open/suspended), the adapter publishes anodds:updatedpayload for that fixture withmarketsomitted. - The FE handler's
elsebranch (useWebSocket.ts:387-389) firesqueryClient.invalidateQueries({ queryKey: fixtureKeys.detailPrefix(fixtureId) })→ forced refetch (invalidate overridesstaleTime) → the backend gate now includes the market → it appears with full metadata. If it arrives as pre-playsuspendedit lands hidden but present, so its later in-play flip reveals instantly via the in-place merge. false → trueonly. 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:
useFixtureWebSocketinvalidates the targeted fixture-detail query after every initial subscription attempt and reconnect. WithcancelRefetch: false, an authoritative HTTP request already in flight is not cancelled/restarted; the bounded poll remains the recovery path if that request fails.useFixtureDetailsretains 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.- 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.
- 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.
- 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. - 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
marketTieris 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), soinactive,suspended, and unknown are non-bettable by construction.orderServiceMARKET_SUSPENDED / MARKET_NOT_OPEN handling is untouched. - Settlement gates on
statusId === 2and 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.tsxfilter.betting.tsMarket.statusis typedstring— 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 affectedbefore edits, per.claude/rules/impact-analysis.md.
5. Error handling & financial integrity
- Unknown status →
DOMAIN_INTEGRITYwarn (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.mdmoney-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;statusInferredno-market-def path unchanged; BALL_RUNNING still maps'suspended'. - FE visual-state unit/component: Betfair
suspended + inPlay→'suspended'("Suspended"); BifrostBALL_RUNNING(suspended + inPlay + source 3) →'ball_running'("Ball Running");inactive→ hidden/closed, non-bettable. - Visibility gate integration:
inactiveexcluded;openand in-playsuspendedincluded. - 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/MarketOverlayinto 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.