Skip to main content

ADR-0036 — Fixture identity is a composite key, resolved once, and every ambiguity must be operator-recoverable

  • Status: Accepted
  • Date: 2026-08-27
  • Related: ADR-0017, ADR-0018, ADR-0019, ADR-0027, ADR-0032, docs/architecture/data-plane-integration.md §7

Context​

There is no shared identifier between TheSports and the betting side. TheSports publishes no Betfair or Bifrost id (confirmed in writing by the provider, 2026-08-14); Betfair publishes no team id at all; Bifrost external_ids carry only bifrost (44,972) and betfair (9,098) tokens. So a score can only be attached to a fixture by inference.

Name similarity as a team-identity mechanism is banned in this codebase (ADR-0018, #1160/#1161/#1162) and rightly: a team name in isolation is uniquely correct for ~45% of soccer teams and ~44% of tennis players over 8,628 real names. A fixture-level composite key — sport + both team names + start time, refusing every tie — is a materially stronger claim, measured at 96.0% soccer / 95.9% cricket unique-correct at ~0% wrong over 2,247 labelled fixture pairs.

That design works. Dev, 2026-08-27: 3,070 soccer, 318 tennis, 262 cricket approved links, 100% composite_match; 50.7% of classified cricket fixtures linked against soccer's 44.7%.

Where it fails, it fails permanently and silently. TheSports sometimes ships the same match twice — one real record and one statusCode: 0 shell. Both score exactly 1.000 against our fixture, the resolver refuses (ambiguous_tie), and the fixture shows no score forever. Proven on the India v Sri Lanka 2nd Test, 2026-08-27: k82rejcjzgv2qep held the complete Day-5 card in Redis (homeScore 618, awayScore 503, full innings array and ball-by-ball timeline) 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.

And no operator can repair it. backend/src/routes/admin.ts exposes /thesports/team-links/{approve,reject,unlink} and no fixture-link surface whatsoever. The only remedies are a code change or a hand-written thesports_fixture_links row — which would encode an orientation by hand, exactly what the resolver refuses to guess and exactly the shape of the 2026-08-17 prod defect where Bifrost's name India rendered above Betfair's Sri Lanka score.

Decision​

Identity is a composite key, scored once per fixture, persisted, and never re-scored — and an ambiguity is a state an operator can resolve, not a permanent silence.

The existing guarantees stand and are restated because they are load-bearing:

  1. Scoring happens once, ever. A fixture carrying a row of any status — including rejected, which is a human saying never — is excluded from the work set in SQL. The read path is an exact id join with no scoring, no name comparison and no similarity.
  2. Per-sport windows are measured, not assumed: soccer ±5 min, basketball ±15 min, tennis ±6 h (a tennis match starts when the previous one on that court ends), cricket ±6 days (a Test is filed once at day one). ACCEPT_FLOOR = 0.7 on min(home, away), sitting in the empty band between real truncations (≥0.889) and one-token coincidences (0.500).
  3. Five fail-closed rules, each rendering as a missing badge rather than a wrong one: no measured policy → not resolved; tie or below floor → nothing persisted; one fixture's two sources disagreeing → nothing persisted; two fixtures claiming one TheSports match → nothing persisted for either; read path filters on status === 'approved', never on "not rejected".
  4. swapped is applied exactly once, on the write path, to the stored team ids — the read path reads them straight through and applies swapped only to the score. Re-applying it flips them back. Providers disagreed on home/away in 130 of 209 measured cricket matches.
  5. Orientation is compared against catalogue_matches.orientation_source, not against the card's own source. Those are frequently different providers, and comparing against the card was the 2026-08-17 defect.
  6. matchResolver.ts may never promote a row on the strength of its own score. It returns a decision and writes nothing; what that decision means is INGEST_LINK_STATUS, one constant, changeable to suggested with no read-path change.

Two things are added by this ADR:

  1. A tie between a candidate carrying live data and a statusCode: 0 shell is broken in favour of the live one. This is not a similarity judgement and does not weaken rule 3: a shell with no live key and status 0 is not a competing claim about reality, it is an absence of one. A tie between two real candidates still refuses outright.
  2. Every refusal must be operator-recoverable. An admin fixture-link surface — approve / reject / re-point / unlink, with the candidate set and each candidate's score, status and live-data presence shown — is a requirement of this design, not an enhancement. A design that can produce a permanently unfixable fixture, and does, is not finished until a human can fix it without a deploy.

Consequences​

  • The duplicate-shell class closes without lowering any guard, and the guard that closes it is evidence the provider itself supplies.
  • Building the admin surface makes INGEST_LINK_STATUS = 'suggested' a real option for the first time. It stays approved until that surface exists.
  • Link health is reported as a rate on classified fixtures, per sport, dated — never as a cross-sport row-count ratio (ADR-0032, and data-plane-integration.md §13 C1).
  • thesports_team_links currently holds zero rows, so the approved-badge path never fires and every crest comes from the name-keyed logo index. That is a separate gap with the same shape: a curation queue with no throughput.