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):
| sport | catalogue matches | classified | linked | link rate on classified |
|---|---|---|---|---|
| basketball (1) | 46 | 0 | 0 | — |
| (sport 3) | 90 | 0 | 0 | — |
| tennis (6) | 555 | 0 | 0 | — |
| soccer (10) | 5,404 | 1,594 | 712 | 44.7% |
| cricket (27) | 369 | 136 | 69 | 50.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=NULLat 13:17:40 UTC and the whole Test series left the player view. Restored 2026-08-27 05:33:50. Root cause istournamentConfigUpdateSchema(backend/src/routes/admin.ts:4869-4878), which makestiera required key with a nullable value whileisVisibleis 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 == renderedFixtureson 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:
| sport | approved links | resolution | swapped |
|---|---|---|---|
| soccer (10) | 3,070 | 100% composite_match | 12 |
| tennis (6) | 318 | 100% composite_match | 0 |
| cricket (27) | 262 | 100% composite_match | 3 |
| basketball (1) | 0 | — | — |
| by provider | betfair-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:
| flag | predicts | measured reliability |
|---|---|---|
coverage.lineup | match/lineup/detail returns a lineup | cricket: 1→28/29, 0→0/4. Football: exactly the 2/12 that had it |
season/list.has_table | season/*/table/detail returns standings | basketball: exactly the 4 of 8 seasons with has_table=1 |
coverage.mlive | live animation/data exists | does NOT predict match/live/history — see §4 |
2. Entitlement, as it stands — design against this and nothing else
| sport | tier held | endpoints | odds | renews |
|---|---|---|---|---|
| Cricket | ALL DATA | 20/20 | ✅ odds/live + odds/history | 2026-10-15 16:00 UTC |
| Tennis | ALL DATA | 31/31 | ✅ odds/live + history + update | 2026-10-15 16:00 UTC |
| Football | BASIC INFO + BASIC DATA | 31/68 | ❌ all three refused | 2026-10-15 16:00 UTC |
| Basketball | BASIC INFO + BASIC DATA | 25/50 | ❌ all three refused | 2026-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
| concern | authority | never |
|---|---|---|
| Fixture existence, tournament, start time, orientation | Betfair / Bifrost via catalogue_matches + catalogue_match_sources | TheSports never creates a fixture |
| Markets, prices, back/lay, exposure, settlement | Betfair / Bifrost | TheSports is structurally barred (§7) |
| Live score, phase, per-period splits, timeline, statistics | TheSports | never a settlement input |
| Team crests, competition logos | TheSports team catalogue + logo index | only cricket + tennis today (§6) |
| Cricket ball-by-ball | TheSports (timeline[].overs / .wickets) | Roanuz — retiring, §8 |
| Match odds shown on a product surface | Betfair / Bifrost | never 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
| purpose | resource | cadence | window | lands in |
|---|---|---|---|---|
| Fixture list, forward | <slug>/match/diary?tsp= | 1 h | today … +2 d | thesports: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 d | same, and reconciles deletions |
| Live snapshot / baseline | <slug>/match/detail_live | event-triggered only — startup, MQTT (re)connect, detected gap, held fragment, staleness | in-play | thesports:live:{sportId}:{id} — TTL 4 × staleness, min 30 s |
| Team catalogue | <slug>/team/list | 12 h | whole register | thesports: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:
match/diaryhas a hard ±30-day window. Bisected exactly: ±30 d answers, ±31 d returns405 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 usematch/seasonormatch/list, which have no such limit.- 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 Bangladeshsat 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 carriedft [359,426]. Six = five scheduled days + one day of slack for the provider's UTC+8 filing calendar. - The diary is reconciled, not merely merged. Both cycles delete stored
scheduledfixtures the provider no longer lists. Without it a deleted fixture is never contradicted and lives out its 48 h TTL still reading "upcoming". - 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.
| sport | topic | verified |
|---|---|---|
| cricket | thesports/cricket/match/v1 | Granted QoS 0, 2026-08-07 |
| tennis | thesports/tennis/match/v1 | Granted QoS 0 |
| football | thesports/football/match/v1 | Granted QoS 0 |
| basketball | thesports/basketball/match/v1 | Granted 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 whatnormalizeLive()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:
storedfroze at 25,367 for four hours withheld == bufferedexactly. - 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
0x80SUBACK 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
| surface | source | cadence | verdict |
|---|---|---|---|
Headline score 170/4 (20) | detail_live score[4].innings (live) / scores.ft (diary) | MQTT + 5 min diary | RENDERS |
| Per-innings splits, Test 3rd/4th innings | score[3].p1..pN + score[4].innings | same | RENDERS |
| Break states (lunch/tea/water/innings/super over) | status_id 532–545 | same | RENDERS — needs its own banners |
| Ball-by-ball strip | timeline[].overs = [over, ball, runs, extra runs, extra code], .wickets = [over, ball] | MQTT timeline deltas | DECODED, NOT RENDERED — §8 |
| Full batting + bowling card, strike rates, economy | detail_live.players[] (live 4/8) · live/history.players[] (finished 23/23) | MQTT + on-demand | RENDERS where present |
| Lineups | match/lineup/detail — 28/33 (85%); coverage.lineup predicts exactly | on demand | RENDERS, flag-gated |
| Standings | season/table/detail — 13/33 seasons (39%) | on demand | PARTIAL — league tables real; bilateral series are position-only stubs with every counter 0; no net run rate anywhere |
| Odds | odds/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 |
| Brackets | bracket/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
| surface | source | verdict |
|---|---|---|
Sets / games / current point "40"/"AD" | score[3] p*/x*/pt/ft | RENDERS once gate 1 opens |
| Serving side | score[2] (1 home / 2 away / 0 none) | RENDERS |
| 16 serve/return statistics, scoped per set | stats[[period,[[code,home,away]]]], codes 301–317 | RENDERS — 11/16 live, 9/12 finished |
| Point-by-point rallies | timeline[].rounds[].points | RENDERS — 12/16 live, 12/12 finished |
| Draw / bracket | bracket/season — 8/8 seasons populated | RENDERS — the only sport where brackets work |
| ATP/WTA rankings, career totals, Davis Cup | ranking/team, */player/career/*, davis_cup/ranking | AVAILABLE, unused |
Odds — eu and asia and bs | odds/live, odds/history 12/12 | AVAILABLE (more than the proposal sells) |
| Standings | season/table/detail | STRUCTURALLY EMPTY for single-elimination — 0/8 tour seasons. Only round-robin/group formats populate. Not a bug |
| Localized names | language/list | CANNOT — total: 0 on all four types |
The in-progress game deliberately omits its
scoreobject. 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
| surface | source | verdict |
|---|---|---|
| Score, half-time score, red cards, yellow cards, corners | home_scores/away_scores positional array | RENDERS — cards and corners are free counters inside the score array |
| Ticking match clock | status_id + score[4] kickoff timestamp | RENDERS |
| Incident pips — 38 types, with scorer, assist, running score, second-precision | detail_live.incidents[], 25/33 live | DATA LIVE ON THE WIRE, DECODED, NOT RENDERED — §8 |
| Live team statistics | detail_live.stats, 24/33 | RENDERS |
| Attack-momentum trend | match/trend/live 15 rows · trend/detail 10/12 | AVAILABLE — per-minute values in [−100,100]; semantics undocumented, §12 |
| H2H, recent form, future fixtures | match/analysis 12/12 | RENDERS — but see §9 on its odds block |
| Current-season standings | season/recent/table/detail, table/live | RENDERS |
| Lineups + pitch coordinates + live ratings | match/lineup/detail — 2/12 live; coverage.lineup 117/379 = 31% provider-wide | ENHANCEMENT 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 competitions | NOT A SURFACE at 10% |
| Post-match team statistics (48 fields) | match/team_stats/detail — 2/20 | NOT A SURFACE |
| Goal build-up / pass chains | match/goal/line/detail — 1/20 | NOT A SURFACE |
| Text commentary | tlive — 0/33 live, 0/12 finished, 0/40 in an earlier probe | CANNOT. 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).
5d. Basketball (sportId 1) — BASIC only, richest live surface, zero links
| surface | source | verdict |
|---|---|---|
| Quarters + countdown clock | score = [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-play | tlive, 2/3 live · 3/9 finished | AVAILABLE — the only sport where tlive populates |
| Full live box score — 12 players/side, 18 stat positions, photos, shirt numbers | match/lineup_live, 2/4 live | AVAILABLE |
| Shot chart with x/y | match/shoot/point[/live] — 5/12 · 2/4 live | AVAILABLE, competition-dependent |
| Point-differential trend | match/trend/live 4/4 · trend/detail 12/12 | AVAILABLE — best-covered trend of any sport |
| Standings + playoff-berth legend | season/recent/table/detail/new — 4/8 seasons, exactly those with has_table=1 | AVAILABLE, flag-gated. Prefer the /new variant |
| Statistics | only 7 types: 3pt, 2pt, FT, remaining pauses, fouls, FT%, total pauses | Fouls and timeouts reset per quarter by provider design — someone must own the running total |
| Rosters | player/list.team_id 1/200 | CANNOT — 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:
- Approved team link →
thesports_team_links(statusapprovedonly) → provider team →logoUrl. 0 rows today, so this path never fires. - 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):
| sport | window | why |
|---|---|---|
| soccer | ±5 min | kickoffs scheduled to the minute; window absorbs clock skew only |
| basketball | ±15 min | tip-off drifts; team naming is 100% unique so extra width costs no tie risk |
| tennis | ±6 h | a 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 days | a 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:
- a sport with no measured policy is not resolved at all;
- a tie or a below-floor score persists nothing;
- two sources of the same fixture resolving to different TheSports matches persists nothing — one is wrong and we cannot tell which;
- two different fixtures claiming the same TheSports match persists nothing for either;
- 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.
| piece | where | state |
|---|---|---|
Ball pill strip, 28 px, W red / 6 green / 4 teal | LiveFeedSection.tsx:29 | built, gated dark |
ballGlyph() / describeBall() / currentRound() | strykr-fe/src/lib/theSportsDetail.ts:1091/1063/1033 | built, unit-tested, zero production callers |
formatIncidentMinute(), SoccerIncident decode | theSportsDetail.ts:1352, :610 | built, zero callers |
CricketBall, CricketInningsTimeline types | strykr-fe/src/types/thesports.ts | built |
Cricket timeline[].overs / .wickets on the wire | MQTT timeline deltas | flowing 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:
- Cricket ball-by-ball renders from TheSports on at least one live fixture, observed.
- No route, service or job calls
getRoanuzProvider(). matchGradeService/tossAnalysisService/cricketAnalysisServiceeither have a TheSports source or are explicitly retired as products. Note: TheSports carries no toss information on any endpoint —tossAnalysisServicehas no replacement feed and must be retired, not ported.- Player photos: TheSports
player/list.logois populated 593/1000 (59%) for cricket; the Roanuz team-images JSON asset must be replaced or the fallback made explicit. - 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-sitecricketDataUnavailableBody()), 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
| # | defect | blocks | evidence |
|---|---|---|---|
| 1 | Tennis and basketball have zero classified tournaments | every score, badge and statistic on two fully-decoded sports | dev 2026-08-27: 555 tennis + 46 basketball fixtures, 0 classified |
| 2 | tournamentConfigUpdateSchema lets a visibility-only edit null the tier | recurrence of the Ind-v-SL outage, any night | routes/admin.ts:4869-4878; prod write 2026-08-26 13:17:40 |
| 3 | Duplicate-shell ambiguous_tie is permanent and operator-unrecoverable | a small fixed class, forever, incl. a marquee Test | 3 cricket / 2 soccer / 10 tennis groups; no admin fixture-link endpoint |
| 4 | Cricket ball-by-ball is Roanuz-wired and dark; the 404 message is false | the whole live-feed tab, and a misleading message on prod | routes/cricket.ts:135; PR #1414 unmerged |
| 5 | useTheSportsFixtureScores is never mounted | ~35 s of avoidable score latency on every surface | zero consumers in strykr-fe |
| 6 | Football/basketball team catalogue unreachable; the code comment states the opposite of the measured truth | crests on two sports | TheSportsDataAdapter.ts:222-226 vs the 2026-08-27 sweep |
| 7 | thesports_team_links is empty | the approved-badge path never fires | 0 rows, dev |
| 8 | Coverage flags are not read on the read path | pointless calls, and tabs that are empty most of the time | coverage.lineup 31% football / has_table basketball |
| 9 | Soccer incident strip decoded but not rendered | the cheapest visible win in the whole integration | SportScoreDisplay.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:
| lane | task | owns |
|---|---|---|
| realtime socket layer | 01a043dd-1b9d | artifacts/backend-scale-research/ |
| ingest and fan-out | 01a043dd-1fb0 | same |
| REST API surface | 01a043dd-23e9 | same |
| data and state | 01a043dd-286f | same |
| runtime and deployment | 01a043dd-2c90 | same |
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):
- Football
tliveis 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. - 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%.
- Confirm in writing whether the H2H odds strings are licensed for display (§9).
- The full football statistic type table — the published one stops at 38, we receive 39–87.
match/trendsemantics: meaning and scale of the per-minute [−100, 100] values.- Is a goalkeeper save available as an incident on any tier? (Types 1–38 have none.)
- Cricket: is
bracket/seasonever populated?{}on 33/33. Areasia/bscricket odds available at all? Is the ±30-daymatch/diarywindow widenable? - A distinct "not subscribed" error code — today a nonexistent path and an unentitled one return byte-identical strings.
- IP allowlist: raise the cap from 3 (nothing we hold specifies 3, and IP pools are unsupported).
- Confirm MQTT-vs-WebSocket naming, and confirm the 2026-10-15 renewal terms.
For us:
- 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.
- 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).
- Is
tossAnalysisServiceretired or re-sourced? TheSports carries no toss data. INGEST_LINK_STATUSstaysapproved, or moves tosuggestedonce 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-nulltier. 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.
C3 — "Ind v Sri Lanka is the cricket-link gap surfacing as a user-visible bug"
- 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=NULLon 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) andy0or5wc52j4krwz(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_tieon a fixture is currently unrecoverable by an operator.routes/admin.tsexposes/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:135still returnsMATCH_NOT_COVEREDwith "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-226and used to justify calling<slug>/team/listfor every sport. - Verified 2026-08-27 sweep:
football/team/listdoes not exist — it returns the byte-identicalURL is not authorizedstring that a dead path returns — whilefootball/team/additional/listworks 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:
- no score was published;
- a score was published and nobody was subscribed;
- a score was published for a match with no approved fixture link;
- 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.
| series | labels | says |
|---|---|---|
thesports_score_resolutions_total | sport, source, outcome | per MATCH — could we address this state to a fixture? |
thesports_score_publishes_total | sport, source, outcome | per MESSAGE — did the delta reach Redis? |
thesports_score_rejected_total | reason | every refusal, with a bounded reason |
thesports_score_fanout_total | sport, outcome | what happened at the socket hop |
thesports_score_stage_seconds | sport, stage | seconds per server-side hop |
thesports_score_payload_bytes | sport | serialized delta size on the wire |
thesports_score_fanout_recipients | sport | sockets one delta reached |
thesports_score_subscribe_total | outcome | what the parser did with each submitted id |
thesports_score_subscriptions | kind | current demand, sampled at scrape |
thesports_score_subscriptions_available | — | 1 = the gauge above carries a real reading |
thesports_score_client_ack_seconds | sport | publish → client ack, server clock, advisory |
thesports_score_client_paint_seconds | sport | receive → paint, browser clock, advisory |
thesports_score_client_discarded_total | metric | client 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 ourpublishedAtMsback 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 doublerequestAnimationFrame, 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
- This document, §1 and §7 — the spine and the seam.
docs/adr/0032–0038— the seven load-bearing decisions.artifacts/thesports-api-status/{cricket,tennis,football,basketball}.md— field-level population and decoded positional arrays. Untracked; read from/root/strykr.artifacts/thesports-api-status/proposal-vs-reality.md— contract vs measured.backend/src/services/thesports/matchResolutionPolicy.ts— every measured constant, with the measurement that produced it.docs/architecture/provider-integration.md— the money plane this composes against.