Skip to main content

Data-Plane Integration Architecture — TheSports, and the retirement of Roanuz

Companion to provider-integration.md, not a replacement for it. That document maps the money path: Betfair and Bifrost, commands down, provider events up, meeting at the database. This one maps the data path: scores, stats and timelines, which never touch money and are structurally barred from doing so. The two meet at exactly one object — the catalogue match — and §7 is that seam.

  • Status: current as of 2026-08-27. Every number below is dated and sourced.
  • Scope: data plane + fixture identity/linking. Odds and markets appear only where they compose against the same fixture identity.
  • Explicitly out of scope: capacity, ingest sizing, Redis keyspace budgeting, socket fan-out cost. See §11.
  • Entitlement basis: what the account holds today. Nothing here is designed against a purchase. See §2.

1. The spine: three gates in series, and they are not equally binding​

A score reaches a punter's screen only if a fixture passes all three of these, in this order. Each is a different subsystem, owned by different code, failing for different reasons. Confusing them is why earlier sessions in this tree went the wrong way.

  ┌─ GATE 1 ─────────────┐   ┌─ GATE 2 ──────────────┐   ┌─ GATE 3 ─────────────┐
│ CLASSIFICATION │ │ FIXTURE IDENTITY │ │ FEED COVERAGE │
│ │ │ │ │ │
│ catalogue_display_ │──▶│ thesports_fixture_ │──▶│ TheSports actually │
│ configs.tier │ │ links (approved) │ │ publishes the field │
│ IS NOT NULL │ │ │ │ │
│ │ │ │ │ │
│ admin-owned │ │ matcher-owned │ │ provider-owned │
│ curation │ │ composite key │ │ coverage.* flags │
└──────────────────────┘ └───────────────────────┘ └──────────────────────┘
▲ ▲ ▲
│ │ │
fails → the fixture fails → the fixture fails → the fixture
is not rendered AT ALL renders with NO score renders with a
(no card, no score) PARTIAL score

Gate 1 — classification. The most binding constraint, and it is not a feed problem.​

selectUnlinkedFixtures (backend/src/services/thesports/fixtureLinkService.ts:1016) offers a fixture to the matcher only if its tournament carries a non-null tier on catalogue_display_configs. That is this codebase's single definition of "classified", and an unclassified tournament is not shown to a player at all.

Measured on dev, 2026-08-27 ~19:00 UTC, current diary window (−3d … +2d):

sportcatalogue matchesclassifiedlinkedlink rate on classified
basketball (1)4600—
(sport 3)9000—
tennis (6)55500—
soccer (10)5,4041,59471244.7%
cricket (27)3691366950.7%

Tennis and basketball render no score today, and the reason is neither the feed nor the join. Tennis is 31/31 entitled with odds, per-set serve/return statistics and point-by-point rallies — the second-richest feed we own — and zero of its 555 fixtures in the current window sit under a classified tournament. Basketball the same at 46.

The same gate produced three separate incidents already recorded in this tree:

  • Ind v Sri Lanka, 2026-08-26. An admin write set is_visible=false, tier=NULL at 13:17:40 UTC and the whole Test series left the player view. Restored 2026-08-27 05:33:50. Root cause is tournamentConfigUpdateSchema (backend/src/routes/admin.ts:4869-4878), which makes tier a required key with a nullable value while isVisible is optional — so a visibility-only edit silently declassifies the tournament in the same write.
  • ~36% curation withholding. Prod, 9h, 59 events: largest sample 763 fixtures → 118 unclassified + 161 admin-hidden → 484 rendered, with passedVisibilityGate == renderedFixtures on all 59.
  • Basketball's permanently wasted matcher work, recorded in the code's own comment.

Gate 2 — fixture identity. Working, and better on cricket than on soccer.​

thesports_fixture_links, dev, 2026-08-27 ~19:00 UTC:

sportapproved linksresolutionswapped
soccer (10)3,070100% composite_match12
tennis (6)318100% composite_match0
cricket (27)262100% composite_match3
basketball (1)0——
by providerbetfair-ex 2,136 · bifrost 1,514

Prod, measured 2026-08-27 06:11 UTC in the Ind-v-SL lane: 2,768 soccer / 1,222 tennis / 299 cricket approved.

Correction to the standing narrative. The "182 cricket vs 2,296 soccer" reading is from 2026-08-22 and the ratio was never the measurement that mattered — soccer carries ~15× the fixtures. On the rate that does matter, cricket links 50.7% of its classified fixtures against soccer's 44.7%. The cricket join is not empty and is not the weak link.

thesports_team_links holds 0 rows on dev. Every crest served today comes from the name-keyed logo index fallback, not from an approved team link.

Gate 3 — feed coverage. The provider's own flags are the contract.​

Nothing at TheSports is broken: 107 of 169 documented endpoints answer, and all 62 refusals map 1:1 onto proposal line items we did not buy. What is thin is coverage — endpoints that answer code:0 and return little or nothing. §5 is the per-sport table.

The coverage flags predict this and must be read on the read path, not discovered by a failed call:

flagpredictsmeasured reliability
coverage.lineupmatch/lineup/detail returns a lineupcricket: 1→28/29, 0→0/4. Football: exactly the 2/12 that had it
season/list.has_tableseason/*/table/detail returns standingsbasketball: exactly the 4 of 8 seasons with has_table=1
coverage.mlivelive animation/data existsdoes NOT predict match/live/history — see §4

2. Entitlement, as it stands — design against this and nothing else​

sporttier heldendpointsoddsrenews
CricketALL DATA20/20✅ odds/live + odds/history2026-10-15 16:00 UTC
TennisALL DATA31/31✅ odds/live + history + update2026-10-15 16:00 UTC
FootballBASIC INFO + BASIC DATA31/68❌ all three refused2026-10-15 16:00 UTC
BasketballBASIC INFO + BASIC DATA25/50❌ all three refused2026-10-15 16:00 UTC

All six line items lapse together on 2026-10-15 16:00 UTC. That is a date to diary, not a design assumption — nothing in this document depends on a renewal or a purchase.

Therefore, and this is binding on every surface below: no squads, no injuries, no transfers, no historical seasons, no brackets for football/basketball, no football or basketball odds, no season-aggregate statistics, no FIFA/FIBA rankings, no highlight GIFs, no localized names. A design that needs one of those is a design for a different contract.

Operational constraint. The IP allowlist is 3 of 3, quota 0: 159.65.94.143 (strykrdev), 46.101.30.145 (strykrstaging), 68.183.44.107 (strykrprod). agentdev1 (174.138.46.58) was removed 2026-08-16. Adding a fourth host requires deleting one; the provider's own test documentation rules out IP pools, so raising the cap is the only route.


3. Provider authority — who owns what, and where the line is​

concernauthoritynever
Fixture existence, tournament, start time, orientationBetfair / Bifrost via catalogue_matches + catalogue_match_sourcesTheSports never creates a fixture
Markets, prices, back/lay, exposure, settlementBetfair / BifrostTheSports is structurally barred (§7)
Live score, phase, per-period splits, timeline, statisticsTheSportsnever a settlement input
Team crests, competition logosTheSports team catalogue + logo indexonly cricket + tennis today (§6)
Cricket ball-by-ballTheSports (timeline[].overs / .wickets)Roanuz — retiring, §8
Match odds shown on a product surfaceBetfair / Bifrostnever TheSports' H2H odds strings (§9)

The firewall is a type, not a convention. TheSportsDataAdapter implements IMultiSportDataProvider, which has no odds, order or settlement surface, and is structurally unable to join the exchange ProviderRegistry (typed to IExchangeAdapter). Nothing reachable from the data plane may read or write order routing, settlement, balance, exposure or placement odds (financial-security.md §3/§5).


4. The transport contract — endpoint, cadence, storage, in one table​

Base https://api.thesports.com/v1/<slug>/<resource>, user + secret as query params on every request. HTTP 200 does not mean success: three distinct failure shapes arrive as 200 and every one of them throws rather than degrading to an empty result.

4a. REST — what is pulled, when, and where it lands​

purposeresourcecadencewindowlands in
Fixture list, forward<slug>/match/diary?tsp=1 htoday … +2 dthesports:match:{sportId}:{id} — TTL 48 h active / 24 h finished
Fixture list, recent — the only source of a terminal status<slug>/match/diary?tsp=5 min−1 d … today; cricket −6 dsame, and reconciles deletions
Live snapshot / baseline<slug>/match/detail_liveevent-triggered only — startup, MQTT (re)connect, detected gap, held fragment, stalenessin-playthesports:live:{sportId}:{id} — TTL 4 × staleness, min 30 s
Team catalogue<slug>/team/list12 hwhole registerthesports:teamlogo:{sportId} hash, TTL 7 d
Entitlement re-probe after a refusal—1 h—quarantine release

Successful diary responses also refresh their embedded per-ID team and competition records under thesports:team:* and thesports:competition:*, both with a sliding seven-day TTL. The whole-register team catalogue does not write those per-ID keys; it only rebuilds the bounded logo hash.

There is no live poller. The unconditional 15 s match/detail_live timer was removed (#1187); it was our sampling rate, not the provider's — the endpoint documents "suggest the request frequency: 2 seconds/time", so 15 s under-sampled it ~7×.

Four constraints on this table, each one measured and each one load-bearing:

  1. match/diary has a hard ±30-day window. Bisected exactly: ±30 d answers, ±31 d returns 405 Beyond the scope of account permissions. Param-less it silently defaults to today, so it looks healthy until it isn't. For anything older than 30 days use match/season or match/list, which have no such limit.
  2. Cricket's recent lookback is 6 days, not 1. A Test is scheduled for five days and TheSports files it once, at day one, while the betting side re-dates the fixture as each day's play begins. Measured on dev1 2026-08-15: Australia v Bangladesh sat on diary date 20260813 while our fixture read 2026-08-16 — outside every window we pulled, so it was never in the store, never offered to the matcher, and rendered no score for days while the provider carried ft [359,426]. Six = five scheduled days + one day of slack for the provider's UTC+8 filing calendar.
  3. The diary is reconciled, not merely merged. Both cycles delete stored scheduled fixtures the provider no longer lists. Without it a deleted fixture is never contradicted and lives out its 48 h TTL still reading "upcoming".
  4. The live feed has no "match ended" message. Absence is the signal. That is why a live record carries a short TTL and why the recent diary cycle exists — the TTL stops a stale live record shadowing the match record, the diary pull makes the record it falls back to correct. Neither half is sufficient alone.

4b. MQTT — the live path​

MQTT-over-WebSocket at wss://mq.thesports.com:443 path /mqtt (measured: on / the broker returns no CONNACK, no error and no close for the full connect timeout — a silent timeout is indistinguishable from a quiet feed). MQTT 3.1.1, QoS 0.

sporttopicverified
cricketthesports/cricket/match/v1Granted QoS 0, 2026-08-07
tennisthesports/tennis/match/v1Granted QoS 0
footballthesports/football/match/v1Granted QoS 0
basketballthesports/basketball/match/v1Granted QoS 0

The proposal and the provider's test documentation both say WebSocket. The delivered mechanism is MQTT. Same service, renamed. Nothing needs to change; it needs one line of written confirmation from the provider so the discrepancy stops re-surfacing.

Five properties of the delta stream that the design depends on:

  • Deltas are PARTIAL. {id, stats}, {id, score}, {id, incidents}, {id, timeline}. Applying one by replacing stored state would erase a score because a possession stat ticked. Merge at the raw provider-entry level, shallow: a present field replaces wholly, an absent field is left alone. Merging pre-normalization is the trick — a merged entry is byte-identical in shape to what normalizeLive() already consumes, so every sport's positional decoder works on a streamed state with no second code path.
  • A delta cannot establish a baseline. A fragment merged into nothing normalizes to a live state with unknown phase and no score — junk indistinguishable from real. Baselines come from the feed store, seeded at exactly four moments, none of them a timer: startup, (re)connect, detected gap, held fragment. The fourth is not optional: a match that goes live after the last baseline event never had an entry, so its deltas arrive as fragments and, without it, nothing could ever promote them. Measured decay on dev1 2026-08-07: stored froze at 25,367 for four hours with held == buffered exactly.
  • No sequence numbers and no publish timestamps. 68 messages inspected. Out-of-order delivery is undetectable from the payload; the merge is arrival-order last-write-wins per field. Under QoS 0 on one TCP connection the broker preserves per-topic order, and a reconnect re-establishes truth from REST anyway. Duplicates are idempotent.
  • Per-topic failure is never global. A 0x80 SUBACK for one sport is an entitlement fact about that sport, recorded and left behind while the others carry on — and retried on every later reconnect, because entitlement is granted out-of-band and a process-lifetime lockout would need a container recreate to notice.
  • A quiet topic is not an empty provider. 10 of 15 live cricket matches pushed nothing for 5+ minutes because nothing was happening in them. Do not re-derive "cricket pushes nothing" from a 45-second window; the topic averages a message every ~7 s.

4c. The last hop — built, and now mounted​

  MQTT delta ─▶ DeltaMergeBuffer ─▶ Redis thesports:live:* ─▶ liveScorePublisher
│
resolve OUR fixture id (approved link only)
▼
Redis PUB 'thesports:score' ─▶ clientWs
│
room = fixture:<id>, per fixture
▼
browser ── useTheSportsFixtureScores ── ✓ MOUNTED

useTheSportsFixtureScores is now mounted, on app/home/page.tsx, app/fixture/[id]/page.tsx and components/sports/TournamentFixtures.tsx. Rooms are joined, and a score reaches the screen by PUSH; the REST poll below is no longer the delivery path but the recovery path — it is what corrects a card when a push is missed, dropped by the per-socket cap, or lost across a restart (ADR-0038).

The polls themselves are unchanged and still the fallback: the fixture list polls 10 s (useFixtures.ts:46; upcoming lists 60 s), and the fixture DETAIL page polls 30 s when the socket is connected and 5 s when it is not (fixtureDetailReconciliation.ts:3-4), through buildFixtureScoreOverlay.

History, kept because the numbers are still the argument for push. Until 2026-09-04 this section read "zero mounted consumers" — every backend hop written, tested and running, with nothing joining the rooms. In that state one goal traced end to end took 49.2 s to reach the DOM, of which 48.1 s was waiting for a refetch and 1.1 s was fetch plus render; provider to backend was ~10 s. The browser was the bottleneck, not the feed — which is precisely what mounting the hook removes. (An earlier revision also claimed a "20-second REST poll"; no 20-second timer has ever existed in the frontend. Re-measured at deployed frontend ref de620007d.)

Identity is resolved on the publish side, not at fan-out: an unlinked match is dropped before it costs a Redis PUBLISH, and the message on the wire is already addressed by our fixture id, so clientWs stays a pure fan-out with no database dependency in the path that also carries balance, settlement and odds.

The publish happens after the store write and only on success. Publish-before-write would let a failed write show 2–1 on screen against a store holding 2–0, and the next re-read would roll the score backwards — the one direction a scores feed must never move.


5. Per sport: what renders, and what cannot​

Four sports need four score models, not variants of one. Football is a minute clock with minute-stamped incident pips; cricket is overs and balls with no clock and no match statistics at all; tennis is sets/games/points with "40"/"AD" strings; basketball is quarters with a countdown clock. There is no shared scoreboard.

The rule applied throughout: a field populated 2 of 20 times is not a surface. It is an enhancement that must degrade to absence, never to a zero and never to a placeholder.

5a. Cricket (sportId 27) — richest feed we own, 20/20 entitled​

surfacesourcecadenceverdict
Headline score 170/4 (20)detail_live score[4].innings (live) / scores.ft (diary)MQTT + 5 min diaryRENDERS
Per-innings splits, Test 3rd/4th inningsscore[3].p1..pN + score[4].inningssameRENDERS
Break states (lunch/tea/water/innings/super over)status_id 532–545sameRENDERS — needs its own banners
Ball-by-ball striptimeline[].overs = [over, ball, runs, extra runs, extra code], .wickets = [over, ball]MQTT timeline deltasDECODED, NOT RENDERED — §8
Full batting + bowling card, strike rates, economydetail_live.players[] (live 4/8) · live/history.players[] (finished 23/23)MQTT + on-demandRENDERS where present
Lineupsmatch/lineup/detail — 28/33 (85%); coverage.lineup predicts exactlyon demandRENDERS, flag-gated
Standingsseason/table/detail — 13/33 seasons (39%)on demandPARTIAL — league tables real; bilateral series are position-only stubs with every counter 0; no net run rate anywhere
Oddsodds/live / odds/history — 11/41 matches (27%), BET365 only, eu only—DO NOT BUILD A CRICKET ODDS SURFACE. 27% coverage, one book, moneyline only. asia/bs never returned in 41 matches
Bracketsbracket/season—CANNOT — {} on 33/33 seasons incl. current knockouts
Match format badge (TEST/ODI/T20)tournament/list.type—CANNOT — set on 283/1000 (28%). Unknown for ~72% of stages
Toss, DLS target, partnerships, required run rate——DO NOT EXIST. Derive from timeline or omit
Commentary——DOES NOT EXIST for cricket

Two cricket-specific traps. (1) Cricket renders nothing without detail — headline scores alone produce a blank card, and un-bailing prints a literal 0 for a side that has not batted. Blank is correct behaviour, not the bug. (2) timeline[] position 2 is "number of rounds", not overs — The Hundred uses 5-ball rounds. Everything must go through decodeBallsPerRound().

Sparse fields to plan around: non-striker id 379/2,571 balls (15%) — the batting pair cannot be reconstructed per ball; dismissal fielder id 113/220 (51%) — "c Smith b Jones" is fully renderable half the time; team logo 772/1000 (77%); player photo 593/1000 (59%); match/diary.venue_id 10/145 (7%) — venue is on match/list (946/1000) and stripped from the diary, so join, don't read it off the diary.

5b. Tennis (sportId 6) — 31/31 entitled, and rendering nothing at all today​

surfacesourceverdict
Sets / games / current point "40"/"AD"score[3] p*/x*/pt/ftRENDERS once gate 1 opens
Serving sidescore[2] (1 home / 2 away / 0 none)RENDERS
16 serve/return statistics, scoped per setstats[[period,[[code,home,away]]]], codes 301–317RENDERS — 11/16 live, 9/12 finished
Point-by-point ralliestimeline[].rounds[].pointsRENDERS — 12/16 live, 12/12 finished
Draw / bracketbracket/season — 8/8 seasons populatedRENDERS — the only sport where brackets work
ATP/WTA rankings, career totals, Davis Cupranking/team, */player/career/*, davis_cup/rankingAVAILABLE, unused
Odds — eu and asia and bsodds/live, odds/history 12/12AVAILABLE (more than the proposal sells)
Standingsseason/table/detailSTRUCTURALLY EMPTY for single-elimination — 0/8 tour seasons. Only round-robin/group formats populate. Not a bug
Localized nameslanguage/listCANNOT — total: 0 on all four types

The in-progress game deliberately omits its score object. Present on completed rounds, absent on the live one. A renderer must treat that as by-design, not as a gap.

Tennis is the largest unrealised asset in this integration. Fully entitled, fully decoded, fully typed on the frontend — and 0 of 555 fixtures classified, so none of it has ever been on a screen.

5c. Football / soccer (sportId 10) — BASIC only, and the coverage cliff is steep​

surfacesourceverdict
Score, half-time score, red cards, yellow cards, cornershome_scores/away_scores positional arrayRENDERS — cards and corners are free counters inside the score array
Ticking match clockstatus_id + score[4] kickoff timestampRENDERS
Incident pips — 38 types, with scorer, assist, running score, second-precisiondetail_live.incidents[], 25/33 liveDATA LIVE ON THE WIRE, DECODED, NOT RENDERED — §8
Live team statisticsdetail_live.stats, 24/33RENDERS
Attack-momentum trendmatch/trend/live 15 rows · trend/detail 10/12AVAILABLE — per-minute values in [−100,100]; semantics undocumented, §12
H2H, recent form, future fixturesmatch/analysis 12/12RENDERS — but see §9 on its odds block
Current-season standingsseason/recent/table/detail, table/liveRENDERS
Lineups + pitch coordinates + live ratingsmatch/lineup/detail — 2/12 live; coverage.lineup 117/379 = 31% provider-wideENHANCEMENT ONLY. Gate on the flag; never a tab that is empty 69% of the time
Post-match player statistics (55 fields)match/player_stats/detail — 2/20 finished, across 20 distinct competitionsNOT A SURFACE at 10%
Post-match team statistics (48 fields)match/team_stats/detail — 2/20NOT A SURFACE
Goal build-up / pass chainsmatch/goal/line/detail — 1/20NOT A SURFACE
Text commentarytlive — 0/33 live, 0/12 finished, 0/40 in an earlier probeCANNOT. Never once populated. The same field works for basketball on the same account and tier
Goalkeeper save pip—DOES NOT EXIST. Incident types run 1–38 with no save. 21 (shots on target) and 37 (blocked shots) are not saves
Odds, squads, injuries, transfers, brackets, historical seasons, rankings, TV, GIFs—NOT ENTITLED

Undocumented statistic type ids 39–87 arrive on covered matches while the provider's own table stops at 38. Observed: 39–46, 48, 49, 52, 54, 55, 61, 66, 67, 69–72, 74, 75, 78–83, 87. Treat as unsupported until the provider sends the table (§12).

surfacesourceverdict
Quarters + countdown clockscore = [id, status, remaining_s, [home Q1–Q4,OT], [away …]], timer = [running, counting_down, ts, remaining_s]RENDERS once gates 1+2 open
Running text commentary / play-by-playtlive, 2/3 live · 3/9 finishedAVAILABLE — the only sport where tlive populates
Full live box score — 12 players/side, 18 stat positions, photos, shirt numbersmatch/lineup_live, 2/4 liveAVAILABLE
Shot chart with x/ymatch/shoot/point[/live] — 5/12 · 2/4 liveAVAILABLE, competition-dependent
Point-differential trendmatch/trend/live 4/4 · trend/detail 12/12AVAILABLE — best-covered trend of any sport
Standings + playoff-berth legendseason/recent/table/detail/new — 4/8 seasons, exactly those with has_table=1AVAILABLE, flag-gated. Prefer the /new variant
Statisticsonly 7 types: 3pt, 2pt, FT, remaining pauses, fouls, FT%, total pausesFouls and timeouts reset per quarter by provider design — someone must own the running total
Rostersplayer/list.team_id 1/200CANNOT — the roster product is unentitled
Odds, injuries, transfers, brackets, FIBA rankings, historical seasons—NOT ENTITLED
data/update, deleted—ALWAYS EMPTY on this account — do not build cache invalidation on them for basketball

Basketball has 0 link rows and 0 classified tournaments. Everything above is theoretical until gate 1 opens.


6. Crests and logos — and a stale comment that costs two sports​

Two independent paths, both fail-closed to absence:

  1. Approved team link → thesports_team_links (status approved only) → provider team → logoUrl. 0 rows today, so this path never fires.
  2. Name-keyed logo index — thesports:teamlogo:{sportId}, one Redis hash rewritten every 12 h from <slug>/team/list, TTL 7 days. Two teams sharing a name suppress the entry rather than guess.

THESPORTS_TEAM_CATALOGUE_SPORTS defaults to cricket,tennis, so the catalogue cycle never runs for football or basketball.

And it could not run today even if it were enabled. TheSportsDataAdapter calls <slug>/team/list and its capability comment asserts that "team/additional/list is refused everywhere and is not used by any path here". The 2026-08-27 sweep contradicts both halves for football: football/team/list does not exist (it returns the same URL is not authorized string a dead path returns), and football/team/additional/list works as BASIC INFO, 1,000 rows/page with logos, market value and squad counts. Basketball's catalogue is basketball/team/list and does work.

So football crests are unreachable through the current code path, for a reason recorded in the code as the opposite of the truth. Per-sport resource names must be a property of the normalizer, not a shared constant — §10, and ticket DP-10.


7. The seam — where the data plane meets the money plane​

The two planes meet at one object and nowhere else:

   BETFAIR / BIFROST                CATALOGUE                    THESPORTS
(money plane) (identity) (data plane)

markets, prices, catalogue_matches match/diary
back/lay, exposure, ├─ id ┌───────▶ match/detail_live
settlement ├─ sport_id │ MQTT deltas
│ ├─ starts_at │ │
│ provider events └─ orientation_source │ ▼
▼ │ │ thesports:live:*
catalogue_match_sources ────────────┤ │ thesports:match:*
├─ provider │ │ │
├─ provider_fixture_id │ │ │
├─ raw_home_name / raw_away_name │ │ │
└─ raw_start_time │ │ │
▼ │ │
thesports_fixture_links ──────┘ │
├─ catalogue_match_id (UNIQUE) │
├─ thesports_match_id │
├─ swapped │
├─ status = 'approved' │
└─ resolution = composite_match │
│ │
└──────────┬────────────────────┘
▼
buildFixtureScoreOverlay
(purely additive; a fixture with
no link is returned byte-identical)

The join is a composite key, because no shared identifier exists. TheSports publishes no Betfair/Bifrost identifier — confirmed in writing by the provider on 2026-08-14 — and Betfair publishes no team id at all. Bifrost external_ids carry only bifrost (44,972) and betfair (9,098) tokens. So identity is sport + both team names + start time, and never a name alone: a team name in isolation is unique for only ~45% of soccer teams and ~44% of tennis players. Requiring both teams to agree inside a per-sport time window, refusing every tie, measured 96.0% soccer / 95.9% cricket unique-correct at ~0% wrong over 2,247 labelled fixtures.

Per-sport windows are measured, not assumed (matchResolutionPolicy.ts):

sportwindowwhy
soccer±5 minkickoffs scheduled to the minute; window absorbs clock skew only
basketball±15 mintip-off drifts; team naming is 100% unique so extra width costs no tie risk
tennis±6 ha tennis match starts when the previous one on that court ends. ±5 min catches 57% of correct pairs, ±24 h catches 84%, tie rate 0% at every width tested
cricket±6 daysa Test is filed once at day one while we re-date per day's play. Five scheduled days + one day of UTC+8 filing slack

ACCEPT_FLOOR = 0.7, on min(home, away) after both sides are scored and the better orientation kept. It sits in an empty band: real truncations score ≥0.889 (Se Baez → Sebastian Baez 0.889), a one-token coincidence (Man United / Man City) scores 0.500 and cannot reach 0.7.

swapped is not cosmetic. Providers disagreed on home/away in 130 of 209 measured cricket matches (62%). It is applied once, on the write path, to the stored team ids — so the read path reads them straight through and applies swapped only to the score. Re-applying it to the ids flips them back.

Orientation is compared against orientation_source, not against the card's own source. Those are often different providers, and comparing against the card was the 2026-08-17 prod defect: Bifrost's name India rendered above Betfair's Sri Lanka score.

Five fail-closed rules, and every one of them renders as a missing badge rather than a wrong one:

  1. a sport with no measured policy is not resolved at all;
  2. a tie or a below-floor score persists nothing;
  3. two sources of the same fixture resolving to different TheSports matches persists nothing — one is wrong and we cannot tell which;
  4. two different fixtures claiming the same TheSports match persists nothing for either;
  5. the read path filters on status === 'approved', never on "not rejected".

The write path stamps approved directly (INGEST_LINK_STATUS), deliberately: gating a composite-key match behind a human queue with no admin UI would leave the feature inert. The gate still exists structurally — changing that one constant to suggested makes every future resolution human-gated with no read-path change at all.

And this is where the real cricket defect lives. When TheSports ships the same match twice — one live record and one statusCode: 0 shell, both scoring exactly 1.000 — the resolver refuses (ambiguous_tie) and the fixture shows no score forever. Proven on Ind v SL: k82rejcjzgv2qep held the complete Day-5 card in Redis (homeScore 618, awayScore 503) while y0or5wc52j4krwz had no live key at all. Class size in the current diary window: cricket 3 groups of 270, soccer 2 of 2,670, tennis 10 of 645. Small, permanent, and unrecoverable by an operator — routes/admin.ts exposes /thesports/team-links/{approve,reject,unlink} and no fixture-link surface at all. ADR-0036 rules 7 and 8.


8. Roanuz retirement — the cutover, and what must be true before deletion​

Roanuz is already off everywhere. ROANUZ_ENABLED=false in dev, dev1, dev2, staging and prod, verified in both the running containers and on disk (2026-08-22). Code default is false. backend/src/exchanges/index.ts:267 is the only construction site, so getRoanuzProvider() returns undefined and every consumer — ballByBallService, matchGradeService, tossAnalysisService, cricketAnalysisService, cricketIdResolver, routes/cricket.ts — goes through that one function. Zero Roanuz API or WebSocket traffic in 24 h across all four running environments.

But ball-by-ball is still Roanuz-wired, and therefore dark. It has no other feed:

  routes/cricket.ts:125   cricketIdResolver.resolveToRoanuz(...)  → null (flag is false)
routes/cricket.ts:135 404 MATCH_NOT_COVERED
"This match is not available in our cricket data coverage.
Only major international matches are supported." ← FALSE
fixture/[id]/page.tsx:421 ballByBallAvailable = false → <LiveFeedSection> never mounts

Real users hit this: prod 1 subscribe → 404 (iPhone, 2026-08-22 08:33:27 UTC), dev 20 → 404.

Everything needed to replace it already exists and has zero callers.

piecewherestate
Ball pill strip, 28 px, W red / 6 green / 4 tealLiveFeedSection.tsx:29built, gated dark
ballGlyph() / describeBall() / currentRound()strykr-fe/src/lib/theSportsDetail.ts:1091/1063/1033built, unit-tested, zero production callers
formatIncidentMinute(), SoccerIncident decodetheSportsDetail.ts:1352, :610built, zero callers
CricketBall, CricketInningsTimeline typesstrykr-fe/src/types/thesports.tsbuilt
Cricket timeline[].overs / .wickets on the wireMQTT timeline deltasflowing now

SportScoreDisplay.tsx still contains zero occurrences of incident; the soccer strip never landed.

The cutover, in dependency order​

 R1  Replace the resolveToRoanuz gate in routes/cricket.ts with a TheSports match
resolution off thesports_fixture_links. ← unblocks the endpoint
R2 Repoint LiveFeedSection from Roanuz CricketBallUpdate to TheSports
timeline[].overs / .wickets, through decodeBallsPerRound(). ← unblocks the UI
R3 Re-home the ballByBallService consumers that are not ball-by-ball:
matchGradeService, tossAnalysisService, cricketAnalysisService,
milestoneAlertService, sessionBettingService, playerPhotoService.
R4 Delete adapters/roanuz/, its tests, RoanuzCricketKeys, the team-images asset,
the ROANUZ_* config block, and the roanuz-api-inspector skill.
R5 Revoke ROANUZ_PROJECT_KEY / ROANUZ_API_KEY at the provider and strip them from
every .env. They are dormant in all five environments today.

Roanuz may be deleted when all five of these are true — not before:

  1. Cricket ball-by-ball renders from TheSports on at least one live fixture, observed.
  2. No route, service or job calls getRoanuzProvider().
  3. matchGradeService / tossAnalysisService / cricketAnalysisService either have a TheSports source or are explicitly retired as products. Note: TheSports carries no toss information on any endpoint — tossAnalysisService has no replacement feed and must be retired, not ported.
  4. Player photos: TheSports player/list.logo is populated 593/1000 (59%) for cricket; the Roanuz team-images JSON asset must be replaced or the fallback made explicit.
  5. Credentials revoked at the provider.

Do this first, independent of all of it: routes/cricket.ts:135 still tells users "Only major international matches are supported" for every cricket match. PR #1414 fixed this at four call sites and never merged — it is not on dev. The lie is still live.

Routing decision, made explicitly rather than folded in. The false 404 copy is its own ticket (DP-08), not part of R1. It is a user-facing correctness fix that is valuable on its own the day it ships, it has a written and reviewed implementation already (PR #1414's four-call-site cricketDataUnavailableBody()), and it must not wait behind the TheSports repoint. R1 will later delete the branch that message sits on; that is a merge conflict worth having, not a reason to couple them.


9. Licensing hazard — encode it, do not design around it​

football/match/analysis (H2H) returns undecoded closing-odds strings on an account with no football ODDS subscription. All three football odds endpoints correctly refuse, yet odds-shaped values arrive inside an entitled BASIC DATA endpoint:

["0.97,0.25,0.82,0", "2.1,4.2,2.5,0", "1,4,0.8,0", ""]
handicap over/under 1x2

The same applies to basketball/match/analysis, whose info array embeds BET365 opening lines — and which the basketball lane recorded as "the only odds data our tier receives".

Rule: no product surface may display, derive from, or price against these strings until TheSports confirms in writing that they are licensed for our account. The provider's football docs page does not even decode the tuple layout. Using unlicensed odds is our risk, not theirs. ADR-0037.

The permitted use is diagnostic only, behind a flag that is off by default, never serialized to a client payload.


10. What is architecturally wrong today, ranked by how much it blocks​

#defectblocksevidence
1Tennis and basketball have zero classified tournamentsevery score, badge and statistic on two fully-decoded sportsdev 2026-08-27: 555 tennis + 46 basketball fixtures, 0 classified
2tournamentConfigUpdateSchema lets a visibility-only edit null the tierrecurrence of the Ind-v-SL outage, any nightroutes/admin.ts:4869-4878; prod write 2026-08-26 13:17:40
3Duplicate-shell ambiguous_tie is permanent and operator-unrecoverablea small fixed class, forever, incl. a marquee Test3 cricket / 2 soccer / 10 tennis groups; no admin fixture-link endpoint
4Cricket ball-by-ball is Roanuz-wired and dark; the 404 message is falsethe whole live-feed tab, and a misleading message on prodroutes/cricket.ts:135; PR #1414 unmerged
5useTheSportsFixtureScores is never mounted~35 s of avoidable score latency on every surfacezero consumers in strykr-fe
6Football/basketball team catalogue unreachable; the code comment states the opposite of the measured truthcrests on two sportsTheSportsDataAdapter.ts:222-226 vs the 2026-08-27 sweep
7thesports_team_links is emptythe approved-badge path never fires0 rows, dev
8Coverage flags are not read on the read pathpointless calls, and tabs that are empty most of the timecoverage.lineup 31% football / has_table basketball
9Soccer incident strip decoded but not renderedthe cheapest visible win in the whole integrationSportScoreDisplay.tsx has zero incident

11. Explicitly deferred — recorded so it is not re-discovered as a gap​

Scale and capacity are out of scope for this document by decision. No MQTT ingest sizing, no Redis keyspace budgeting, no socket fan-out cost. This is the integration contract: which feed, which endpoint, which cadence, which surface, which storage.

Five "Backend scale research" tasks were spawned 2026-08-27 15:36 and produced nothing — all five carried zero messages and artifacts/backend-scale-research/ was empty when the coordinating session exited at 15:55. They were re-dispatched from scratch on 2026-08-27 ~18:55 and are running now:

lanetaskowns
realtime socket layer01a043dd-1b9dartifacts/backend-scale-research/
ingest and fan-out01a043dd-1fb0same
REST API surface01a043dd-23e9same
data and state01a043dd-286fsame
runtime and deployment01a043dd-2c90same

They own artifacts/backend-scale-research/ and nothing else. This document owns docs/architecture/ and docs/adr/0032–0038. Recorded here so the branch is not re-discovered as a gap in the integration contract — it is a separate, live workstream.

One correction those lanes must carry, and it invalidates any sizing taken from today's traffic: the socket path carries approximately no real load, because useTheSportsFixtureScores is not mounted (§4c) and scores reach browsers on a REST poll — 10 s for the lists, 30 s for a connected detail page (useFixtures.ts:46, fixtureDetailReconciliation.ts:3), not the "20-second" figure this document used to quote, which matched nothing in the code. Measuring today's socket traffic as a baseline for a 10–20k target measures the wrong thing. And two of four sports are dark at gate 1 (§1), so any sizing built on today's rendered fixture count understates the target by the size of the classification backlog.


12. Open questions​

For TheSports (the seven already drafted for Telegram, plus):

  1. Football tlive is empty on 0/33 live and 0/12 finished while the identical field populates for basketball on the same account and tier. Entitlement, coverage, or discontinued? Strongest single item — it is named inside the tier we pay for.
  2. Which competitions are in our coverage for lineups / player stats / goal-line, and what tier expands it? The proposal sells "1970+ leagues"; delivery is ~31% / ~10% / ~5%.
  3. Confirm in writing whether the H2H odds strings are licensed for display (§9).
  4. The full football statistic type table — the published one stops at 38, we receive 39–87.
  5. match/trend semantics: meaning and scale of the per-minute [−100, 100] values.
  6. Is a goalkeeper save available as an incident on any tier? (Types 1–38 have none.)
  7. Cricket: is bracket/season ever populated? {} on 33/33. Are asia/bs cricket odds available at all? Is the ±30-day match/diary window widenable?
  8. A distinct "not subscribed" error code — today a nonexistent path and an unentitled one return byte-identical strings.
  9. IP allowlist: raise the cap from 3 (nothing we hold specifies 3, and IP pools are unsupported).
  10. Confirm MQTT-vs-WebSocket naming, and confirm the 2026-10-15 renewal terms.

For us:

  1. Who owns tournament classification, and on what cadence? It is the most binding gate in the system and it is currently a manual admin action with no backlog view.
  2. Does tennis get classified before or after the ball-by-ball work? It is the largest unrealised asset and the cheapest to unlock (no code).
  3. Is tossAnalysisService retired or re-sourced? TheSports carries no toss data.
  4. INGEST_LINK_STATUS stays approved, or moves to suggested once an admin fixture-link surface exists?

13. Claims corrected during this work​

Each of these was carried into this task as fact and did not survive re-measurement. They are recorded here with the superseded value, the fresh measurement and the date of each, because two of the three came out of durable memory and will otherwise be re-inherited.

C1 — "The cricket join is effectively empty: ~182 cricket rows vs ~2,296 soccer"​

  • Superseded value: 182 cricket / 2,296 soccer approved links. Measured 2026-08-22, written to durable memory, and passed forward as current.
  • Fresh measurement, dev, 2026-08-27 ~19:00 UTC: cricket 262, tennis 318, soccer 3,070, basketball 0. Prod, 2026-08-27 06:11 UTC: cricket 299, tennis 1,222, soccer 2,768.
  • Why the original conclusion was wrong even when its numbers were right: it compares a ratio across incomparable denominators. Soccer carries ~15× the fixtures. On the rate that actually measures the join — links per classified fixture — cricket is 50.7% against soccer's 44.7%. The cricket join is the healthier of the two.
  • Standing correction: never quote a link ratio. Quote a link rate on classified fixtures, per sport, with its date.

C2 — "The fixture-linking layer is the spine, and it is broken for cricket"​

  • Superseded framing: one gate. Fix the cricket join and cricket scores appear.
  • What the code says: selectUnlinkedFixtures (services/thesports/fixtureLinkService.ts:1016) offers the matcher only fixtures whose tournament carries a non-null tier. Classification is therefore upstream of linking, which is upstream of feed coverage — three gates in series (§1).
  • Fresh measurement, dev, 2026-08-27 ~19:00 UTC, current diary window: tennis 555 fixtures / 0 classified; basketball 46 / 0 classified. Both sports are dark at gate 1, on feeds that are 31/31 and 25/50 entitled and fully decoded.
  • Consequence: the Ind-v-SL outage, the ~36% curation withholding measured on prod, and the tennis/basketball blackout are one defect, not three.
  • Superseded framing: one defect, caused by link coverage.
  • What the thread established, and had already separated: two independent defects. (a) An admin write set is_visible=false, tier=NULL on the tournament at 2026-08-26 13:17:40 UTC, removing the whole series from the player view; restored 2026-08-27 05:33:50 UTC and verified four ways. (b) Separately, no score — ambiguous_tie, because TheSports ships that Test twice: k82rejcjzgv2qep (live, statusCode 534, full Day-5 card in Redis) and y0or5wc52j4krwz (statusCode 0, no live key at all). Both score exactly 1.000, so the resolver refuses rather than guess — correctly.
  • Why it matters: (a) is a curation-write defect and (b) is a tie-break defect. Neither is link coverage, and un-hiding the tournament could never have fixed (b).
  • Additional standing fact: an ambiguous_tie on a fixture is currently unrecoverable by an operator. routes/admin.ts exposes /thesports/team-links/{approve,reject,unlink} and no fixture-link surface at all, so the only remedies are a code change or a hand-written row encoding an orientation — which is precisely what the resolver refuses to guess. That is a product decision, not only a bug: we have shipped a class of fixture that can never show a score and that nobody in the building can repair without a deploy.

C4 — "PR #1414 fixed the misleading cricket 404"​

  • Superseded value: reported merged in the 2026-08-22 close-out.
  • Verified 2026-08-27: not on dev. routes/cricket.ts:135 still returns MATCH_NOT_COVERED with "Only major international matches are supported." — false for every cricket match, because the real cause is that Roanuz is disabled.

C5 — "team/additional/list is refused everywhere"​

  • Superseded value: asserted in TheSportsDataAdapter.ts:225-226 and used to justify calling <slug>/team/list for every sport.
  • Verified 2026-08-27 sweep: football/team/list does not exist — it returns the byte-identical URL is not authorized string that a dead path returns — while football/team/additional/list works as BASIC INFO, 1,000 rows/page with logos. The code comment states the opposite of the measured truth for football, and that is why football crests are unreachable (§6).

15. Score-delivery observability — what is measured, and what is deliberately not​

Added 2026-09-03, alongside the removal of the four delivery flags.

Why this exists​

The delivery chain was complete, merged, tested and delivering nothing, in every environment, for weeks. Four independent flags each gated one hop; no environment had them all on; and every wrong combination failed as silence. Nothing distinguished these four states, because all four render the same blank pill:

  1. no score was published;
  2. a score was published and nobody was subscribed;
  3. a score was published for a match with no approved fixture link;
  4. this fixture genuinely has no score.

The flags are gone, but that ambiguity is not — a link-backfill gap, an unapproved fixture, a Redis blip and an idle Saturday still all end in a blank pill. The counters make each of them a separate query.

The series​

All in backend/src/services/theSportsScoreMetrics.ts; the label vocabulary is a closed compile-time set in theSportsScoreMetricLabels.ts.

serieslabelssays
thesports_score_resolutions_totalsport, source, outcomeper MATCH — could we address this state to a fixture?
thesports_score_publishes_totalsport, source, outcomeper MESSAGE — did the delta reach Redis?
thesports_score_rejected_totalreasonevery refusal, with a bounded reason
thesports_score_fanout_totalsport, outcomewhat happened at the socket hop
thesports_score_stage_secondssport, stageseconds per server-side hop
thesports_score_payload_bytessportserialized delta size on the wire
thesports_score_fanout_recipientssportsockets one delta reached
thesports_score_subscribe_totaloutcomewhat the parser did with each submitted id
thesports_score_subscriptionskindcurrent demand, sampled at scrape
thesports_score_subscriptions_available—1 = the gauge above carries a real reading
thesports_score_client_ack_secondssportpublish → client ack, server clock, advisory
thesports_score_client_paint_secondssportreceive → paint, browser clock, advisory
thesports_score_client_discarded_totalmetricclient samples refused as implausible

source has three values and the split is load-bearing. recovery is the baseline refresh after a reconnect or gap — bursty by design, one publish per live match at once — and it is its own source so a reconnect LOOP shows as a recovery spike rather than hiding inside what reads as healthy in-play volume. Between the other two: MQTT carries every in-play change and cannot report a match ending; the diary is the only source of a terminal status and a final score (§4, ADR-0035). Without that split, "in-play updates have stopped" and "nothing has finished recently" are the same flat line, and they have completely different causes.

Two counters, two units, and that split is load-bearing. resolutions_total counts provider STATES (one per match); publishes_total counts DELTAS (one per message, and a cross-provider merge produces two from one state). They were one counter, which made the advertised coverage ratio a mix of units. The valid ratio is resolved / (resolved + unlinked) on the resolutions family alone.

outcome=unlinked is expected volume, not an error, while the link backfill is incomplete — which is exactly why resolve_failed is separate. fixtureLinkResolver returns no ids on a database error, and counting that as unlinked put a total delivery failure inside a label documented as normal and excluded from alerting. The resolver now reports {fixtureIds, failed} and TheSportsScoreLinkResolutionFailing watches it.

A missing reading is not a zero. The subscription gauges are the denominator every "is anyone watching?" question is read against, so the sampler returns either a complete reading or an explicit available: false. On unavailable the gauges are absent from the scrape, not published as 0 — a fabricated zero would answer that question confidently and wrongly at exactly the moment the answer is unknown. thesports_score_subscriptions_available is how a reader tells the two apart.

thesports_score_subscriptions{kind="memberships"} is not redundant with fixtures and sockets. Those count distinct rooms and distinct clients; memberships counts total (socket, fixture) joins, which is the number fan-out and reconnect cost actually scale with. Ten sockets each holding 200 fixtures reads as 10 and 200 on the first two and 2,000 here.

Cardinality — the guarantee, stated precisely​

Every label value is drawn from a closed set fixed at compile time, so each family's cardinality is a product of small constants and is O(1) in traffic, fixtures, subscribers and time. That per-family bound is the guarantee, and it is what the tests assert.

An earlier draft of this section claimed a single global ceiling of "40 series". That was one family's label space quoted as the total, and it was wrong: there are eleven families, and each histogram additionally carries one series per bucket plus _sum and _count. The property that matters is not a small exact number — it is that no label value is ever derived from data, so no amount of traffic can add a series.

Never label by fixtureId, providerMatchId, marketId, user or socket id. Per-fixture detail is the existing [TheSportsScoresWs] PUBLISH log line, which already carries both ids. One label value (sport, on the client sample) arrives from a browser and is normalized against the closed set at the metric boundary — an unnormalized client string reaching labels() is a client-controlled series namespace on a shared server.

What is O(1) here, and what is not​

Recording is O(1) per call: a label hash plus an increment or a bucket search. The work being measured is not. Fan-out is O(k) in a room's recipients and O(k × bytes) in transferred payload — which is exactly why thesports_score_fanout_recipients and thesports_score_payload_bytes exist, because no per-delta counter can see either.

Cost, and why this module looks different from providerIngestionMetrics​

That module flushes plain integer fields into prom-client once per scrape because a labelled Counter.inc() costs ~580 ns and Bifrost's market_book stream runs at up to 100k msg/s. This path was measured at ≈7 deltas/s at its busiest, so a labelled increment per delta is ≈4 µs of CPU per second. The accumulator machinery is correct there and would be cargo-culting here.

Two client metrics, because there are two questions​

They are separate on purpose, and each is measured on one clock:

  • client_ack_seconds — the browser echoes our publishedAtMs back untouched and the server subtracts its own clock. No browser skew can enter it. It includes the return network leg, so it is an upper bound on delivery and must never be quoted as a server-side latency.
  • client_paint_seconds — performance.now() in the browser at delta receipt, and again after a double requestAnimationFrame, which resolves only once the frame carrying the update has been painted. A single rAF fires before paint and would time the React commit instead. One clock at both ends, and no network time at all.

The ack answers "did push beat the poll"; the paint answers "is the client itself slow". Neither substitutes for the other, and they must not be subtracted: every client acks but only a foregrounded tab paints, so they are different populations with independently computed quantiles.

Sampled 1-in-20 at the client, so the paint measurement costs one rAF pair per twenty deltas rather than one per delta. Rate-limited per socket at the server. Implausible values are refused and counted by which metric refused them, rather than allowed to drag a distribution.

They arrive as two separate messages. The ack is emitted at receipt; the paint only once a frame has actually rendered. An earlier version sent one message after the paint frames resolved, which folded up to five seconds of paint and backgrounding delay into a number the server read as delivery time — an ack that waits for a paint is not an ack.

Both are ADVISORY and must never gate a release or feed an alert. The stamp is echoed by an unauthenticated browser and is not verified to be one we issued, and the paint duration is self-reported. They are trends. No rule in alerts.yml references either, and that is deliberate.

An earlier version of this had one number carrying both meanings, emitted from the delta handler immediately after setState and described as a paint. That point is application receipt — before React has committed, let alone painted — and the help text simultaneously claimed a browser clock while the server did the subtraction. Two quantities were wearing one name.

The alert that is deliberately absent​

There is no delivery-silence alert. Silence is correct whenever no linked fixture is in play, which is most of most days across four sports, and none of the available signals establishes the population that would make silence wrong: thesports_score_subscriptions{kind="fixtures"} is demand (someone is looking) and says nothing about whether a match has started; the MQTT connection being up is equally uninformative, since connected-and-quiet is indistinguishable from connected-and-broken.

Writing it needs a gauge of matches that are both in a live phase and hold an approved link. That is a scrape-time join across the Redis live-record store and Postgres, so it needs its own cost argument and is recorded here as deferred, not forgotten. The alerts that do exist — publish failures, link-resolution failures, unparseable frames — are each wrong whenever non-zero, whatever the fixture list looks like. See the comment block in infra/services/hannibal/alerts.yml.

There is deliberately no subscribe-truncation alert either, and this document previously claimed there was one. An earlier revision of alerts.yml shipped it, and its own annotation proved it would be permanently true: /sports?sportId=10 requests 291 ids against a cap of 200 (measured dev1 2026-08-29), so an ordinary page view truncates. Truncation stays fully visible — thesports_score_subscribe_total{outcome="over_cap"} is on the dashboard and clientWs logs every occurrence with the socket id — but there is no threshold anyone can defend yet. It returns when either the cap is raised against a measured PROD distribution with headroom, or the client bounds its own subscription set. alerts.yml carries the full argument.

TheSportsScoreLinkResolutionFailing is the alert that took its place, and it exists for a sharper reason: fixtureLinkResolver returning no ids on a database error used to count as unlinked — a label documented as expected volume and excluded from alerting — so a Postgres outage stopped every score reaching every browser while the dashboard read as healthy backfill volume.

Dashboard: infra/monitoring/grafana/dashboards/thesports-score-delivery.json, which carries the same caveat as a panel so a reader meets it before drawing a conclusion.

14. Reading order for whoever picks this up​

  1. This document, §1 and §7 — the spine and the seam.
  2. docs/adr/0032–0038 — the seven load-bearing decisions.
  3. artifacts/thesports-api-status/{cricket,tennis,football,basketball}.md — field-level population and decoded positional arrays. Untracked; read from /root/strykr.
  4. artifacts/thesports-api-status/proposal-vs-reality.md — contract vs measured.
  5. backend/src/services/thesports/matchResolutionPolicy.ts — every measured constant, with the measurement that produced it.
  6. docs/architecture/provider-integration.md — the money plane this composes against.