Foundations · Truth and ownership
Truth and ownership
Which value we publish, where it came from, and who may change it.
In one minute
Today the database row is the only truth. A fixture, a side, a medal row or an entry holds one value per field, and whoever wrote last wins. There are only two levels: a person beats an integration. When a person edits a field in the console, the field is pinned: its name goes into the row's pinned list. An import writes every field except the pinned ones, and counts each one it skipped as "kept by hand". "Let the feed decide" (the hand_back command) removes the pin.
What today does not have: a ranking between two integrations (the last run wins), a record of what a losing source said, one screen of open pins, a result status beyond one yes/no flag, and a lock on official results.
The agreed design (Section 2, decisions T1 to T11) keeps the pin idea and makes it general. Every value from every source is saved as a claim. Values are split into field groups (schedule, line-up, play, commentary, result). Each group has a priority list of sources, and the highest source that is still active wins. A hand edit is one more source on that list, at the top by default. Every result gets its own status (live, unofficial, official, protested) and a version number, and official results are locked against sources. None of this is in the code yet.
What this part does
This part answers one question for every value a client sees: why is it this value, and not another one? One match can have a scorer, two providers, Scout and an operator all sending the same score. Something has to decide which one is published, keep the rest, and let a person step in.
Section 2 of the design set out six questions. Every later part (the engine, imports, feeds) builds on the answers.
| # | Question | Why it matters |
|---|---|---|
| 1 | Which source wins, for each kind of value? | One match can have a scorer, two providers, Scout and an operator, all sending the same score |
| 2 | What happens when the winning source goes quiet, or is wrong? | Strict priority alone blocks everyone when the top source stops mid-match |
| 3 | What is a hand edit, and when does it end? | A hand edit that never ends hides later corrections from the source |
| 4 | What is a result's status, and how do we change an official result? | Clients must know whether a result can still change |
| 5 | What do we keep from sources that lose? | Without it we cannot compare sources, explain a value, or replay |
| 6 | What do we record about every change? | "Who changed this, from where, and why did it win?" must always have an answer |
Five words are used on this page.
| Word | Meaning |
|---|---|
| Source | Anything that sends data: a scorer's screen, a provider API, a file, Scout, or an operator's hand edit. |
| Pin | Built today. A field name in a row's pinned list. It means "a person set this; imports must leave it alone". |
| Claim | Agreed design. One value from one source at one time, for example "Provider A says 2–1, at 19:42:10, its message 4512". |
| Field group | Agreed design. Values that must come from one source together, because they must agree: the score and the goal list, for example. |
| Priority list | Agreed design. For one field group, the order of sources, best first. |
How it works
Today: a person pins, an import skips the pin Built today
Every write in omnium is a workflow command (see Commands and the workflow engine). A command is sent either by a person (from the console, through the bridge) or by an integration (a Scout import). The engine refuses any mix: a person may never send an import, and an integration may send nothing else. The record writers then apply one rule, written at the top of records.py:
| Who sent the command | What the writer does |
|---|---|
| A person | Writes the field and adds its name to the row's pinned list. |
| An integration | Writes the field only if its name is not in pinned. Otherwise it leaves the value and adds 1 to kept_by_hand. |
A person sending hand_back | Removes the names from pinned, so a later import may write them again. |
![A sketch with two rows. Top row: an operator in the console sets start 19:30; the fixture.set arrow goes into a person writer that writes and pins, and that goes into the fixture row, which holds start = 19:30 and pinned = [start]. Bottom row: a Scout import with start 19:00 goes through units.import into the import writer, then into a question box, is the field pinned? A dashed arrow from the fixture row reads pinned into the question. Yes leads to a green box, keep 19:30, kept_by_hand plus 1. No leads to a white box, write 19:00. At the top right, a box labelled Let the feed decide has a dashed arrow into the fixture row: hand back removes the pin.](/diagrams/truth-and-ownership-pin-check.jpg)
- An operator changes a field in the console, for example a start time. The console sends a command such as
results.set_start. - The engine checks the sender is a person, takes a lock, runs the rules, and calls the record writer
fixture.set. - The writer saves the new value and adds
"start"tofixture.pinned. - Later a Scout run sends the same fixture with the old time, through the import command
games.import_units. - The import writer compares field by field. For
startit finds the name inpinned, so it keeps the operator's value and counts one "kept by hand". - Other fields on the same row (status, venue, sides) are still written from the import, unless they are pinned too.
- If the operator clicks Let the feed decide,
results.hand_backremoves"start"from the list. The next import that sends this row may change it again.
Agreed: every source sends claims, a list per group picks the winner Agreed, to build
The agreed design keeps the same road in, but stops throwing values away and replaces "person beats integration" with a written priority list.

- One road in. Every source sends claims through the same workflow commands (decision D5 from Section 1). A claim records the source, the value, when we got it, the source's own time and number when it has them, and the person, if there is one.
- Nothing is thrown away. Every claim is saved, the winners and the losers (T1).
- A priority list per field group. A sport has a default list. An operator can change it for a competition or a single match, and the change is recorded (T3).
- The winner is the highest source that is active. "Active" means not quiet: screens send a heartbeat, and for a provider a failed pull or a long silence counts as quiet. Each source has its own quiet limit.
- Take-over is by hand first. When the winner goes quiet in a live match, an alert fires and an operator switches to the next source with one click. Automatic switching is a setting, off at first. Switching back is always by hand (T5).
- A hand edit is an override. It is a source with its own place on each list, at the top by default. It shows on one "open overrides" screen until someone releases it (T4). This is today's pin, made visible.
- Losers are compared with the winner. If a lower source disagrees for longer than a set time, an alert fires: "Provider A says 2–1, we publish 1–1" (T6).
- Order per source. An older claim from a source never replaces a newer one from the same source (T7).
- A result has a status and a version. Live, unofficial, official, plus protested. The version goes up with every change (T8).
- Official is locked. No source changes an official result by itself, unless the competition allows a trusted source to. A person with the right role can still edit it; clients get a new version marked as a correction (T9, T10).
- Every value can say why (T11).
Who wins when two sources disagree

| Disagreement | Today Built today | Agreed design Agreed, to build |
|---|---|---|
| A person and an import | The person wins while the field is pinned. | The hand edit wins while it sits higher on the list (the default). It can be set lower. |
| Two imports on one field | The last one to run wins. Nothing ranks them. | The higher source on the group's list wins. |
| Two sources at the same rank | Not a concept. | Allowed in schedule, line-up and result: the source that changed its value last wins. Not allowed in play and commentary. |
| Two runs of the same import | The run Scout started last wins. An older run is kept as "older" and not sent. | Same idea per source, using the source's own number or time (T7). |
| A source that leaves a field out | Not a change. A row with no start, venue, status or score does not clear them. | Not changed by Section 2. |
| The scoring desk and a person | The scoring fold moves status and ignores pins. | Status follows the normal priority list (E8 was dropped for this reason). |
A worked example
Example · A console fix, then a Scout import, today
The fixture. A football match, key M.TEAM11------------.GPB-.000100--. The schedule feed says it starts at 19:00. Both sides have score 1. fixture.pinned is [].
19:02. The operator fixes two things. The official sheet says the match starts at 19:30, and the home side scored 2, not 1.
- The console sends
results.set_startwithstart: 19:30+05:30. The writerfixture.setsavesscheduled_startand pinsstart. Nowfixture.pinned = ["start"]. - The console sends
results.edit_sidefor the home side withscore: "2". The writerfixture.sides.setsavesresult = {"score": "2"}and pinsresulton that side. Now the home side'spinned = ["result"]. - Both commands are saved in
timeline_item, with the sender inactor(today every person shows as "admin"; see Who did it, below).
19:05. A Scout run arrives. The site still says 19:00 and 1–1. Two things can happen.
- If the site's row is exactly what it sent last time, the integration does not send the row at all. It hashes each row and sends only rows whose hash changed since the last held row for that key. Nothing is written, nothing is counted.
- If the site changed something else on the row (say the status went from
SCHEDULEDtoRUNNING), the row is sent ingames.import_units. The writer compares field by field:
| Field | Feed says | Row holds | Pinned? | Result |
|---|---|---|---|---|
status | RUNNING, read as LIVE | SCHEDULED | no | written: LIVE |
start | 19:00 | 19:30 | yes | kept, kept_by_hand + 1 |
home side result | score 1 | score 2 | yes | kept, kept_by_hand + 1 |
away side result | score 1 | score 1 | no | the same, nothing to do |
The run's record (integration_run.counts) shows kept_by_hand: 2, and the run's line says "2 fields kept as a person set them". The site's own values are not saved next to the fields. Only the raw row in integration_row.raw says the site still has 19:00.
19:40. The site catches up and now says 19:30 and 2–1. The rows match, so the operator clicks Let the feed decide on both fields. results.hand_back removes the pins. From now on the feed owns them again.
The trap. Had the operator clicked "Let the feed decide" while the site still said 19:00, the next run would not send the row if it had not changed. The fixture would keep 19:30 with no pin, until the site changes the row or someone runs a replay. This follows from the code (the hash check in integrations/service.py); I did not run it end to end.
Example · The same match, under the agreed design
The play list for this competition is: hand edit, Provider A, our scorer, Provider B. The schedule list is: hand edit, Provider A, Scout.
- 19:02, the operator's two edits are saved as claims from the source "hand edit". They win, because the hand edit is first on both lists. Both show on the "open overrides" screen. A reason is optional: one tap such as "official sheet", or none.
- 19:05, Scout's claim "start 19:00" is saved, not dropped. It loses to the override. The compare job (T6) sees the schedule summary differ.
- If the difference lasts longer than the set time, the operator gets an alert: "Scout says 19:00, we publish 19:30".
- 19:40, Scout says 19:30. The compare screen shows the two agree, and the operator releases the override. The list decides again, and Scout's newest claim is already saved, so nothing waits for the next run.
- At full time the result becomes unofficial, version 14. The operator marks it official: version 15. Next day Provider A changes who scored goal 2. Clients see no change; a review item opens. The operations lead accepts it, and clients get a new version, marked as a correction.
The fixed rules make this safe: every claim is saved, and two sources' play events are never mixed.
Low-level design
The pinned columns Built today
Four tables carry a pinned list. It is a JSONB array of field names, never null, empty by default. Migration 046 added it to the two fixture tables; 045 added it to competition_entry; medal_standing had it first.
packages/core/alembic/versions/20260914_046_fixture_pins.py:32-40
def upgrade() -> None:
op.add_column(
"fixture",
sa.Column("pinned", JSONB(), server_default=sa.text("'[]'::jsonb"), nullable=False),
)
op.add_column(
"fixture_competitor",
sa.Column("pinned", JSONB(), server_default=sa.text("'[]'::jsonb"), nullable=False),
)
The two fixture tables are separate on purpose: a fixture's time and status belong to the fixture, and a side's score or rank belongs to that side.
| Table | Names that can be pinned | Where the list is defined | Hand back command |
|---|---|---|---|
fixture | name, start, venue, status, result, medal, competitors | FIXTURE_FIELDS, ingest/fixtures.py:107 | results.hand_back |
fixture_competitor (a side) | participant, result, rank, status | SIDE_FIELDS, ingest/fixtures.py:108 | results.hand_back with a side |
medal_standing | gold, silver, bronze, total, rank, rankEqual, position, breakdown, disciplines | MEDAL_FIELDS, ingest/medals.py:78-88 | games.hand_back_medals |
competition_entry | country, events, status, members | model comment, models/entries.py:82-85 | games.hand_back_entry |
Three pin names cover more than one column. They are easy to miss:
statuson a fixture covers bothstatusandresults_official. Marking a result official pinsstatus.resulton a side covers the score, the medal, the two record flags andqualified. They all live in the side'sresultJSON.competitorson a fixture means "the sides are the person's". The import then does not touch any side of that fixture.
medal_standing and competition_entry also have source_code (which feed last wrote the unpinned fields) and fed_at (when). The fixture tables do not; the feed's code is kept in fixture.meta.source instead.
The person side: a writer pins what it touches Built today
The rule's helpers live in records.py.
packages/core/src/omnium_core/workflows/records.py:164-181
def pin(row: Any, *fields: str) -> None:
"""Mark fields set by hand. A new list, so the session sees the JSONB change."""
row.pinned = sorted({*(row.pinned or []), *fields})
def held(row: Any, name: str) -> bool:
return name in (row.pinned or [])
def may_write(ctx: WriteContext, row: Any, name: str) -> bool:
"""The hand-edit rule for one field. A person always may, and pins it."""
if ctx.actor.by_person:
pin(row, name)
return True
if held(row, name):
ctx.report.counts["kept_by_hand"] += 1
return False
return True
pinbuilds a new sorted list. Changing the old list in place would not be seen by SQLAlchemy as a change to the JSONB column, so the pin would not be saved.may_writeis the whole rule in one function. No writer calls it today (checked with grep, 7 Oct). The person writers callpindirectly, and the import code has its own copy of the check (next section). The behaviour is the same, but the rule lives in three places.
A person writer, trimmed to the fields that pin:
packages/core/src/omnium_core/workflows/writers/fixtures.py:107-141
@writer("fixture.set")
async def set_fixture(ctx: WriteContext, data: dict[str, Any]) -> None:
"""Correct a fixture. Every field sent is pinned, so the feed keeps the correction."""
fixture = _fixture(ctx)
if "name" in data:
...
fixture.name = name
pin(fixture, "name")
if "start" in data:
fixture.scheduled_start = _start(data["start"])
pin(fixture, "start")
...
if data.get("official") is not None:
fixture.results_official = bool(data["official"])
pin(fixture, "status")
...
if "scoreline" in data:
_set_scoreline(fixture, data["scoreline"])
pin(fixture, "result")
_touched(ctx)
Every field the person sends is written and pinned. The writer does not check who sent it: the engine has already made sure only a person can reach this writer. How a command, its validations and its action are written in the flows plugin is on Writing a workflow in code.
Only an integration sends an import Built today
The split between the two kinds of writer is enforced once, in the engine, before anything runs.
packages/core/src/omnium_core/workflows/engine.py:259-264
spec = flows_host.spec_for(node.impl)
if spec.imports and actor.by_person:
raise Refused("not_allowed", "only an integration may send an import")
if not spec.imports and not actor.by_person:
raise Refused("not_allowed", "an integration may only send import commands")
return pinned.program, node
A command is an import when its @command says imports=True (for example import_units in omnium_flows/games/commands.py:430-437). So "a feed never renames a fixture" is not a promise each writer keeps; it is impossible to send. An integration's actor is Actor(kind="integration", name="integration:<code>", source_code=<code>) (integrations/service.py:424-426).
The import side: the pin check, field by field Built today
The import writer units.import plans the rows, then calls apply_fixtures. For a fixture that already exists, _update_fixture brings it in line with the feed one field at a time.
packages/core/src/omnium_core/ingest/fixtures.py:1131-1180 (trimmed)
def _update_fixture(row, unit, stage_id, venue_id, source_code, report, round_name=None) -> bool:
"""Bring one known fixture in line with the feed, field by field. True if it changed."""
pinned = set(row.pinned or [])
changed = False
def allowed(name: str, same: bool) -> bool:
if same:
return False
if name in pinned:
report.kept_by_hand += 1
return False
return True
name = _fixture_name(unit) or _mend_name(row, unit, round_name)
if name and allowed("name", row.name == name):
row.name, changed = name, True
if unit.start is not None and allowed("start", row.scheduled_start == unit.start):
row.scheduled_start, changed = unit.start, True
if unit.venue and allowed("venue", row.venue_id == venue_id):
row.venue_id, changed = venue_id, True
if str(unit.source_status or "").strip():
status, official = map_status(unit.source_status)
if allowed("status", (row.status, row.results_official) == (status, official)):
row.status, row.results_official, changed = status, official, True
result = _result_of(unit)
if _says_how_it_went(unit) and allowed("result", row.result == result):
row.result, changed = result, True
...
Read each line as three checks, in order:
- Did the feed say anything?
unit.start is not None,unit.venue, a non-empty status,_says_how_it_went. A feed that leaves a field out is not saying it is empty. This rule came from real damage: a draws feed with no venue wiped the hall off 180 events on 20 Sep (code comment,ingest/fixtures.py:1164-1171). - Is it different?
sameis true when the values already match. Then nothing happens and nothing is counted. - Is it pinned? Only now. A pinned field that differs is left alone and counted in
kept_by_hand.
So kept_by_hand counts real disagreements with a person, not every pinned field the import passed. The same pattern, with its own allowed, is in _update_side (ingest/fixtures.py:1490-1538) for participant, result, rank and status. The entry import (ingest/entries.py:1026-1035) and the medal import (ingest/medals.py:265-272, 378) do the same against their own lists.
meta is outside the rule. The feed rewrites fixture.meta.source on every pull. That is why a person's medal-event answer is stored in a separate key, meta.medalEvent, which the feeds read first (writers/fixtures.py:173-185).
Where else a pin is read Built today
| Place | What the pin stops | Code |
|---|---|---|
| Sides of a fixture | If competitors is pinned, the import skips every side of that fixture. | ingest/fixtures.py:1227-1228, 1384 |
| Removing a side | A side the feed no longer sends is removed, unless it has any pin, or something points at it. | ingest/fixtures.py:1420-1422, 1608-1627 |
| Clearing a result | A row that puts someone new in a match clears the old result, unless result is pinned. | ingest/fixtures.py:1428-1442 |
| Adding a side by hand | Adding a side to a finished unit the feed gave no sides (6 hours after start) pins competitors, so the feed cannot add a second podium. | writers/fixtures.py:221-264 |
| Medallists and standings imports | A side whose result is pinned gets no medal or place from these imports. | writers/medallists.py:473, 687, 775; writers/standings.py:134 |
| Retiring units | A unit with any pin is never cancelled because the feed stopped sending it. | ingest/retire.py:150, 247-248 |
| Medal table order | A dragged row pins position; the import does not reorder the table while any row holds that pin. | ingest/medals.py:303, 344 |
Only changed rows are sent: why hand back can wait Built today
A schedule's rows stand alone, so the integration sends only the rows that changed since omnium last held them.
packages/core/src/omnium_core/integrations/service.py:108 and 835-851 (trimmed)
_HELD_VALUE = ("accepted", "unchanged", "kept_by_hand")
async def _changed(session, integration, target, ready, replay_of=None) -> list[Row]:
"""Where rows stand alone, only the rows whose value changed since omnium last held it."""
if not target.max_rows or replay_of is not None:
return ready
last = await _last_hashes(session, integration)
return [row for row in ready if not (row.key and last.get(row.key) == row.row_hash)]
row_hashis a sha256 of the mapped row._last_hashesreads the newest held row per subject fromintegration_row.- A row whose outcome was
kept_by_handstill counts as "held". So once a run has met a pin, the same unchanged row is not sent again. - After
hand_back, the feed's value comes back only when the site changes that row, or when someone runs a replay (a replay sends every row). - This applies to imports with a row limit:
games.import_units(1,000 rows per command,games/commands.py:52). The medal table and entry list are sent whole every run, so a hand back there takes effect on the next run.
The as-built review of 29 Sep listed this as its top finding: an import compares with its own last read, not with the database.
Hand back Built today
packages/core/src/omnium_core/workflows/writers/fixtures.py:356-375
@writer("fixture.hand_back")
async def hand_back(ctx: WriteContext, data: dict[str, Any]) -> None:
"""Hand fields back to the feed. The next run may change them again."""
fixture = _fixture(ctx)
side_id = data.get("side")
allowed = SIDE_FIELDS if side_id is not None else FIXTURE_FIELDS
fields = set(data.get("fields") or allowed)
...
if "medal" in fields and side_id is None:
_set_medal_event(fixture, None)
target.pinned = [name for name in (target.pinned or []) if name not in fields]
ctx.report.counts["handed_back"] += 1
- No
fieldsmeans "all of them" for that fixture or side. - The value is not changed. Only the pin goes. The old hand value stays until a feed writes over it.
medalis special: handing it back also deletesmeta.medalEvent, so the feed's own grade shows again.- The console shows "Set by hand" with "Let the feed decide" beside each pinned field, and a count such as "2 fields set by hand" (
omnium-console/src/pages/FixturePage.tsx:294-307,449-451). There is no one list of every open pin across an event; the medal overview only counts them (OverviewPage.tsx:565-585).
Who did it Partly built
Every accepted command adds one timeline_item row with actor (an account name, or integration:<code>), source_code, event_type (the command code) and the input payload (workflows/engine.py:142-154). An import's rows are kept in integration_row instead, with raw, mapped and an outcome per row. Per the as-built review of 29 Sep, sign-in is off on the admin service, so every person is recorded as "admin" (not re-checked for this page).
What does not read pins today Built today
- The scoring fold. After each scored ball,
reflect_statusmoves the fixture between scheduled, live and completed, and setsresults_officialto match. It does not readpinned(scoring/pipeline.py:265-283). The research notes of 30 Sep say the live scoring desks were not deployed to production during the Games (not checked on the server). - The official flag. No writer reads
results_officialbefore it writes (checked with grep, 7 Oct). An import can change the score of an official result, unless a person pinnedresult. An import can also set a result official, by sending the site'sOFFICIALstatus (ingest/fixtures.py:121-138). - Version checks.
fixture.versionexists but is described as an internal counter, "never a conflict resolver" (models/fixtures.py:103-104). A command does not say which version the sender saw, so two people editing one field: the last save wins.
Agreed design: claims, lists and results Agreed, to build
Agreed design, not in the code yet. Section 2 agreed what is saved and decided; it did not name new tables or columns. The engine (Section 4) and the low-level design will.
What a claim records (rule 1 of the design):
| Part | Example |
|---|---|
| The source | Provider A |
| The field group and the value | play: score 2–1 |
| When we got it | 19:42:11 |
| The source's own time and number, when it has them | 19:42:10, message 4512 |
| The person, if there is one | the operator's account |
| The claim's own id, so a retry is saved once | from Section 4 |
The five field groups each sport starts with. A sport can add its own, for example "detailed stats" from a scraper.
| Group | What is in it | Example list, best first | Two sources at one rank? |
|---|---|---|---|
| Schedule | Start time, venue, match status (postponed, cancelled) | Provider A, then Scout | Yes |
| Line-up | Teams, players, captains, named substitutes | Provider A, then our scorer | Yes |
| Play | Events, score, clock, period | Provider A, then our scorer, then Provider B | No |
| Commentary | Text lines about the match | Our commentary screen | No |
| Result | Winner, ranks, medals, qualification, records | Worked out from play | Yes |
A hand edit sits at the top of every list by default. The result is worked out from play by default, so the two cannot disagree. A separate source is listed for the result only where results arrive on their own, such as an official results list.
Settings, no code needed. An operator can change these during a live match.
| What you can set | Set for | Example |
|---|---|---|
| Which values belong to which field group | Sport, competition | Football adds a "detailed stats" group, filled by a scraper |
| The priority list of each group, including where hand edits sit | Sport, competition, match | Hand edits at the bottom while a provider is trusted, at the top during cleanup |
| Two sources at the same rank | Any group where a new value replaces the old one | Two providers both give the start time |
| When a source counts as quiet | Each source, each sport | 60 seconds in football, 5 minutes in curling |
| What is compared, and how long a difference may last | Sport | Score and period, 60 seconds |
| Automatic switching when a source goes quiet | Competition, match | Off by default |
| Who may mark a result official: a person, a source, or both | Competition | The federation's feed and the operator both can |
| Lock a match to one source | Match | During a protest, nothing switches |
| Whether a source may change an official result | Competition | On for a site like the Asian Games; each change alerts the operator |
| Which values each input of a source may send | Source | Two consoles on one match: one enters goals and cards, the other substitutions |
The example numbers (60 seconds, 5 minutes) are examples from the design doc, not agreed values.
Fixed safety rules. No setting turns these off.
- Every claim is saved. Nothing a source sends is thrown away.
- Values that add up, like events and the score built from them, come from one source at a time. Two sources' events are never mixed, so nothing is counted twice.
- An older message never replaces a newer one from the same source.
- Any change to an official result, by a person or a source, goes out as a new version marked as a correction.
- Every change records who made it, when, the old value, the new value, and why it won.
One source, several inputs. One source can fill values from two places, such as two consoles on one match. Each input is given the values it may send, so two inputs never write the same counted values. If two people must enter the same kind of value, the version check stops lost edits, and the console warns about a likely double entry.
Result status and version. Separate from the match status.
live ──► unofficial ──► official ──► protested ──► official ──► correction
v14 v15 v16 v17
The version numbers follow the design doc's own examples (T8 and T9): unofficial at the final whistle is version 14, official is 15, official again after a failed protest is 16, and an accepted correction the next day is 17. A match becomes unofficial when it ends. A person with the right role makes it official, or a trusted source if the competition allows it (T10). When the checker step is on, a second person must approve before official.
Order inside one source (T7). Use the source's own number or time. If it gives none: the time we asked, for a source we pull, or the time it reached us, for one that pushes. Times are compared only inside one source, never across sources (E14). A device's actions are ordered by its own counter, not its clock.
When things go wrong
| What goes wrong | What happens today Built today | What happens in the agreed design Agreed, to build |
|---|---|---|
| A source sends the same row twice (a retry) | The same idempotency key is found and nothing is written twice (engine.py:118-122). | Saved once; each claim carries its own id. |
| Rows from one source arrive out of order | A Scout run started before one already written is kept as "older" and not sent (service.py:587-590). | The newer claim stays published; the older one is saved, not published. |
| Two people change the same value at once | The last save wins, and nobody is told. | Each sends the version it saw. The second is refused and sees the new value. |
| Two imports write the same field | The last one to run wins. | The higher source on the group's list wins. |
| A pin is forgotten | It hides every later correction from the feed. Only the fixture page and counts show it. | It stays on the "open overrides" screen. Making a result official first shows that match's open overrides. |
| Hand back while the site is unchanged | The unchanged row is not re-sent, so the old hand value stays, now unpinned, until the site changes or a replay. | The source's newest claim is already saved, so releasing an override picks the winner at once. |
| An import changes an official score | Written, unless a person pinned result. | Becomes a review item; clients see no change until a person accepts it, unless the competition trusts that source. |
| The top source goes quiet mid-match | Not handled. | An alert after its quiet limit; an operator switches to the next source with one click. |
| A scorer's network drops | Not covered by this part. | The screen keeps every action and sends it later. Play always comes whole from one source. The operator switches back only after the compare screen shows both agree. A replay test must prove this before release. |
| A source sends data it is not listed for (Scout's page also shows a score) | Whatever the import's mapping sends is written. | Saved as a claim, never published, because Scout is not on the play list. |
| The priority list changes mid-match | Not a concept. | From that moment clients get the new source's whole event list; the log records who, when and why. |
| A deploy or restart during a match | Pins are columns in Postgres and survive. | Claims, lists and overrides live in the database, not in memory. |
| Bad input (an impossible score, an unknown player) | The command's validations refuse it, and nothing is written. | The sport's rules refuse it before it becomes a claim. |
| A feed stops sending a match | A unit with any pin is never cancelled; others are judged only inside the days and disciplines the run covered. | Never deleted on a source's word: marked "gone from source", and a person decides (E13). |
Decisions
All eleven decisions were agreed on 3 Oct 2026. T3, T5 and T6 changed that day after review comments.
| # | Decision | In plain words |
|---|---|---|
| T1 | Every source's value is saved as a claim; the published value is the winner among them. | Nothing a source sends is thrown away. Estimate: 300 to 900 rows a match. |
| T2 | Values are split into field groups: five to start, and a sport can add its own. | A group's values come from one source at a time, so the score and the goal list always agree. |
| T3 | Each group has a priority list: a sport default, changeable per competition or match, and recorded. | Two sources may share a rank only in schedule, line-up and result, where the last change wins. |
| T4 | A hand edit is a source with its own place on each list: top by default, can be set lower. | Today's pin, made visible: one screen of open hand edits, an optional reason, and a warning when a higher source hides one. |
| T5 | When the winner goes quiet in a live match, an alert fires and an operator switches with one click. | Automatic switching is a setting, off at first. Switching back is always by hand. A match can be locked to one source. |
| T6 | Each lower source's summary is compared with the winner's. | A difference that lasts too long raises an alert. It never changes what clients get. |
| T7 | An older claim never replaces a newer one from the same source. | With no number or time from the source, use when we asked or when it arrived. |
| T8 | A result has its own status (live, unofficial, official, protested) and a version number. | Clients can tell whether a result can still change, and which version they hold. |
| T9 | Official is locked against sources by default; a different claim becomes a review item. | A competition can trust a source to change it. A person with the right role edits directly, and clients get a correction at once. |
| T10 | A person, a trusted source, or both may mark results official, set per competition. | By default only a person. Give it to a source only when it is an official body's own feed. |
| T11 | Every published value can show which claim won, from which source, and why. | "Why did you send 1–1 at 19:43?" always has an answer. |
Edge cases. Of 16, nine were agreed as rules:
| # | Rule | In plain words |
|---|---|---|
| E2 | Each built value (table, bracket, medal table, totals) has its own version and is rebuilt when an input changes. | A corrected semi-final rebuilds everything built on it. If the next match already started with the old winner, a person decides. |
| E3 | In ranked fields a group can be split per participant. | Golf: each player's card comes whole from one source; the leaderboard is built from all of them. |
| E4 | Very frequent data is saved as changes of state or one packed batch per second. | Estimate: an F1 race is about 1 million messages; we do not save one row each. |
| E7 | A hand edit on a computed value is saved as an adjustment, not a number. | "−3, penalty", not "27", so every rebuild applies it again. |
| E10 | Quiet limits pause while the status is break or suspended. | No false alerts over lunch; data that arrives in a break is still accepted. |
| E11 | Official locks the result. A stat fix that does not change it goes out as a new version. | An assist moved to another player needs no reopen. A competition can be stricter. |
| E13 | Never delete on a source's word. | A match the source stops sending is marked "gone from source", and a person decides. |
| E14 | Times are compared only within one source. | A tablet with a slow clock cannot lose to everything. |
| E16 | Sources and matches can be marked "test". | A provider's test match is saved but never sent to clients. |
Dropped: E5 (status only moves forward), E8 (status as its own group) and E12 (line-up status), because the normal priority list and versions already cover them. Moved: E1 (partial lists) and E6 (unknown players) to Section 3, Sources and imports; E9 (format changes mid-match) and E15 (shared places) to Section 6. Q1 was answered: no contract stops a provider's data going to a client today, so every client gets the same winner.
Built today, or still to build
| Piece | Today | Agreed design | Status |
|---|---|---|---|
| One road in for every write | Every write is a workflow command; persons and integrations are split in workflows/engine.py:259-264 | Same, for every source and claim (D5) | Built today |
| Hand edits survive imports | pinned on four tables; imports skip pinned fields (ingest/fixtures.py:1144-1150, 1507-1513) | A hand edit is a source on each list (T4) | Partly built |
| Releasing a hand edit | results.hand_back, games.hand_back_medals, games.hand_back_entry | Release from the "open overrides" screen | Partly built |
| One screen of open hand edits | Per fixture page and per medal table counts only | One screen for every open override | Agreed, to build |
| Reason for a hand edit | Not stored | Optional, one tap from a short list | Agreed, to build |
| What losing sources said | Only kept_by_hand counts and integration_row.raw | Every claim saved (T1) | Agreed, to build |
| Field groups | Not a concept; pins are per field | Five groups, configurable per sport (T2) | Agreed, to build |
| Priority between sources | None; the last import wins | A list per group, per sport, competition, match (T3) | Agreed, to build |
| Quiet sources and take-over | None | Alert, one-click switch, optional automatic (T5) | Agreed, to build |
| Compare losers with winner | None | Summary compare and alert (T6) | Agreed, to build |
| Order within one source | Newest Scout run wins (integrations/service.py:587-590, 777-779) | Per claim, by the source's own number or time (T7) | Partly built |
| Result status and version | fixture.status plus one flag, results_official | Live, unofficial, official, protested, with a version (T8) | Agreed, to build |
| Official is locked | No writer reads results_official | Locked against sources, review items, corrections (T9) | Agreed, to build |
| Who may mark official | A person, an import (site status OFFICIAL) and the scoring fold can all set it | A setting per competition (T10) | Agreed, to build |
| Explain any value | timeline_item per command; no link from a value to what won | Each published value keeps the id of its winning claim (T11) | Agreed, to build |
| Scoring respects ownership | reflect_status ignores pins (scoring/pipeline.py:265-283) | Status follows the priority list | Agreed, to build |
Numbers
| Number | What it is | Source |
|---|---|---|
| 4 | Tables with a pinned column: fixture, fixture_competitor, medal_standing, competition_entry | From the code |
| 7, 4, 9, 4 | Pin names on a fixture, a side, a medal row, an entry | From the code (FIXTURE_FIELDS, SIDE_FIELDS, MEDAL_FIELDS, entry model) |
| 1,000 | Rows per games.import_units command | From the code, games/commands.py:52 |
| 6 hours | After its start, a finished unit with no feed sides is the person's when they add one | From the code, writers/fixtures.py:194 |
| 300 to 900 | Claim rows per match (3 sources × 100 to 300 events) | Estimate, design doc |
| under 50,000 | Claim rows per day at 50 matches a day | Estimate, design doc |
| about 1 million | Messages in one F1 race (20 cars × 10 a second × 90 minutes) | Estimate, design doc |
| 132 of 270 | Units a day pull wrongly called gone at the Asian Games | Measured over six runs, ingest/retire.py notes and design doc |
| 180 | Events that lost their venue to a draws feed on 20 Sep 2026 | Code comment, ingest/fixtures.py:1164-1171 |
| 16 | Edge cases reviewed: 9 agreed, 3 dropped, 4 moved | Design doc, 3 Oct |
Read next
- Imports from Scout: how a Scout run becomes import commands, and the guards around it.
- Commands and the workflow engine: the lock, the rules and the log every write goes through.
- The data model: the fixture, side and participant tables these pins sit on.
- Console and scorer apps: where "Set by hand" and "Let the feed decide" appear.