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:
- 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. - 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.7onmin(home, away), sitting in the empty band between real truncations (≥0.889) and one-token coincidences (0.500). - 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". swappedis applied exactly once, on the write path, to the stored team ids — the read path reads them straight through and appliesswappedonly to the score. Re-applying it flips them back. Providers disagreed on home/away in 130 of 209 measured cricket matches.- 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. matchResolver.tsmay never promote a row on the strength of its own score. It returns a decision and writes nothing; what that decision means isINGEST_LINK_STATUS, one constant, changeable tosuggestedwith no read-path change.
Two things are added by this ADR:
- A tie between a candidate carrying live data and a
statusCode: 0shell 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 status0is not a competing claim about reality, it is an absence of one. A tie between two real candidates still refuses outright. - 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 staysapproveduntil 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_linkscurrently 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.