Foundations · Truth and ownership

Truth and ownership

Which value we publish, where it came from, and who may change it.

Design section
Section 2, agreed 3 Oct 2026
Main code
core/workflows/records.py, core/ingest/fixtures.py
Main tables
fixture, fixture_competitor, medal_standing, competition_entry
Read time
about 30 minutes

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.

2
levels today: person over integration (code)
4
tables with a pinned column today (code)
5
field groups each sport starts with (design doc)
11
decisions agreed, T1 to T11 (design doc)

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.

#QuestionWhy it matters
1Which source wins, for each kind of value?One match can have a scorer, two providers, Scout and an operator, all sending the same score
2What happens when the winning source goes quiet, or is wrong?Strict priority alone blocks everyone when the top source stops mid-match
3What is a hand edit, and when does it end?A hand edit that never ends hides later corrections from the source
4What is a result's status, and how do we change an official result?Clients must know whether a result can still change
5What do we keep from sources that lose?Without it we cannot compare sources, explain a value, or replay
6What 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.

WordMeaning
SourceAnything that sends data: a scorer's screen, a provider API, a file, Scout, or an operator's hand edit.
PinBuilt today. A field name in a row's pinned list. It means "a person set this; imports must leave it alone".
ClaimAgreed design. One value from one source at one time, for example "Provider A says 2–1, at 19:42:10, its message 4512".
Field groupAgreed design. Values that must come from one source together, because they must agree: the score and the goal list, for example.
Priority listAgreed 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 commandWhat the writer does
A personWrites the field and adds its name to the row's pinned list.
An integrationWrites 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_backRemoves 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.
Today's rule. A console edit writes the value and pins it. An import reads the pin and skips that field.
  1. An operator changes a field in the console, for example a start time. The console sends a command such as results.set_start.
  2. The engine checks the sender is a person, takes a lock, runs the rules, and calls the record writer fixture.set.
  3. The writer saves the new value and adds "start" to fixture.pinned.
  4. Later a Scout run sends the same fixture with the old time, through the import command games.import_units.
  5. The import writer compares field by field. For start it finds the name in pinned, so it keeps the operator's value and counts one "kept by hand".
  6. Other fields on the same row (status, venue, sides) are still written from the import, unless they are pinned too.
  7. If the operator clicks Let the feed decide, results.hand_back removes "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.

A sketch of the agreed design. Five sources on the left: our scorer, Provider A, Provider B, Scout, and operator hand edit. Each has an arrow into one workflow command box, labelled one road in. That goes into a blue cylinder, Claims, every value saved. From Claims an arrow goes into a box, priority list per field group, with a note: play: Provider A, our scorer, Provider B. An arrow labelled winner goes to a green box, published value, and then to a white box, clients. Below Claims, a dashed arrow goes to a box, compare losers with winner, and from there a dashed arrow labelled differs too long goes to a red box, alert to operator.
The agreed design. Every value is kept; one list per field group decides what clients see.
  1. 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.
  2. Nothing is thrown away. Every claim is saved, the winners and the losers (T1).
  3. 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).
  4. 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.
  5. 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).
  6. 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.
  7. 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).
  8. Order per source. An older claim from a source never replaces a newer one from the same source (T7).
  9. A result has a status and a version. Live, unofficial, official, plus protested. The version goes up with every change (T8).
  10. 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).
  11. Every value can say why (T11).

Who wins when two sources disagree

A sketch split in two halves. Left half, Today (built): a box Person (pin) beats a box Any integration, and under it a red box, two imports: last run wins. Right half, Agreed (to build): a numbered list of five sources, 1. Hand edit, 2. Provider A, 3. Our scorer, 4. Provider B, 5. Scout, with a bracket labelled one list per field group. Under it a green box, highest active source wins, and a small box, order set per sport, competition, match.
Today there are two levels. The agreed design has one ordered list per field group.
DisagreementToday Built todayAgreed design Agreed, to build
A person and an importThe 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 fieldThe last one to run wins. Nothing ranks them.The higher source on the group's list wins.
Two sources at the same rankNot 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 importThe 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 outNot 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 personThe 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.

  1. The console sends results.set_start with start: 19:30+05:30. The writer fixture.set saves scheduled_start and pins start. Now fixture.pinned = ["start"].
  2. The console sends results.edit_side for the home side with score: "2". The writer fixture.sides.set saves result = {"score": "2"} and pins result on that side. Now the home side's pinned = ["result"].
  3. Both commands are saved in timeline_item, with the sender in actor (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 SCHEDULED to RUNNING), the row is sent in games.import_units. The writer compares field by field:
FieldFeed saysRow holdsPinned?Result
statusRUNNING, read as LIVESCHEDULEDnowritten: LIVE
start19:0019:30yeskept, kept_by_hand + 1
home side resultscore 1score 2yeskept, kept_by_hand + 1
away side resultscore 1score 1nothe 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.

  1. 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.
  2. 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.
  3. If the difference lasts longer than the set time, the operator gets an alert: "Scout says 19:00, we publish 19:30".
  4. 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.
  5. 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.

TableNames that can be pinnedWhere the list is definedHand back command
fixturename, start, venue, status, result, medal, competitorsFIXTURE_FIELDS, ingest/fixtures.py:107results.hand_back
fixture_competitor (a side)participant, result, rank, statusSIDE_FIELDS, ingest/fixtures.py:108results.hand_back with a side
medal_standinggold, silver, bronze, total, rank, rankEqual, position, breakdown, disciplinesMEDAL_FIELDS, ingest/medals.py:78-88games.hand_back_medals
competition_entrycountry, events, status, membersmodel comment, models/entries.py:82-85games.hand_back_entry

Three pin names cover more than one column. They are easy to miss:

  • status on a fixture covers both status and results_official. Marking a result official pins status.
  • result on a side covers the score, the medal, the two record flags and qualified. They all live in the side's result JSON.
  • competitors on 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
  • pin builds 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_write is the whole rule in one function. No writer calls it today (checked with grep, 7 Oct). The person writers call pin directly, 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:

  1. 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).
  2. Is it different? same is true when the values already match. Then nothing happens and nothing is counted.
  3. 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

PlaceWhat the pin stopsCode
Sides of a fixtureIf competitors is pinned, the import skips every side of that fixture.ingest/fixtures.py:1227-1228, 1384
Removing a sideA 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 resultA 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 handAdding 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 importsA side whose result is pinned gets no medal or place from these imports.writers/medallists.py:473, 687, 775; writers/standings.py:134
Retiring unitsA unit with any pin is never cancelled because the feed stopped sending it.ingest/retire.py:150, 247-248
Medal table orderA 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_hash is a sha256 of the mapped row. _last_hashes reads the newest held row per subject from integration_row.
  • A row whose outcome was kept_by_hand still 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 fields means "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.
  • medal is special: handing it back also deletes meta.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_status moves the fixture between scheduled, live and completed, and sets results_official to match. It does not read pinned (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_official before it writes (checked with grep, 7 Oct). An import can change the score of an official result, unless a person pinned result. An import can also set a result official, by sending the site's OFFICIAL status (ingest/fixtures.py:121-138).
  • Version checks. fixture.version exists 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):

PartExample
The sourceProvider A
The field group and the valueplay: score 2–1
When we got it19:42:11
The source's own time and number, when it has them19:42:10, message 4512
The person, if there is onethe operator's account
The claim's own id, so a retry is saved oncefrom Section 4

The five field groups each sport starts with. A sport can add its own, for example "detailed stats" from a scraper.

GroupWhat is in itExample list, best firstTwo sources at one rank?
ScheduleStart time, venue, match status (postponed, cancelled)Provider A, then ScoutYes
Line-upTeams, players, captains, named substitutesProvider A, then our scorerYes
PlayEvents, score, clock, periodProvider A, then our scorer, then Provider BNo
CommentaryText lines about the matchOur commentary screenNo
ResultWinner, ranks, medals, qualification, recordsWorked out from playYes

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 setSet forExample
Which values belong to which field groupSport, competitionFootball adds a "detailed stats" group, filled by a scraper
The priority list of each group, including where hand edits sitSport, competition, matchHand edits at the bottom while a provider is trusted, at the top during cleanup
Two sources at the same rankAny group where a new value replaces the old oneTwo providers both give the start time
When a source counts as quietEach source, each sport60 seconds in football, 5 minutes in curling
What is compared, and how long a difference may lastSportScore and period, 60 seconds
Automatic switching when a source goes quietCompetition, matchOff by default
Who may mark a result official: a person, a source, or bothCompetitionThe federation's feed and the operator both can
Lock a match to one sourceMatchDuring a protest, nothing switches
Whether a source may change an official resultCompetitionOn for a site like the Asian Games; each change alerts the operator
Which values each input of a source may sendSourceTwo 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.

  1. Every claim is saved. Nothing a source sends is thrown away.
  2. 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.
  3. An older message never replaces a newer one from the same source.
  4. Any change to an official result, by a person or a source, goes out as a new version marked as a correction.
  5. 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 wrongWhat happens today Built todayWhat 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 orderA 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 onceThe 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 fieldThe last one to run wins.The higher source on the group's list wins.
A pin is forgottenIt 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 unchangedThe 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 scoreWritten, 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-matchNot handled.An alert after its quiet limit; an operator switches to the next source with one click.
A scorer's network dropsNot 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-matchNot 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 matchPins 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 matchA 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.

#DecisionIn plain words
T1Every 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.
T2Values 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.
T3Each 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.
T4A 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.
T5When 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.
T6Each 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.
T7An 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.
T8A 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.
T9Official 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.
T10A 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.
T11Every 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:

#RuleIn plain words
E2Each 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.
E3In 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.
E4Very 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.
E7A hand edit on a computed value is saved as an adjustment, not a number."−3, penalty", not "27", so every rebuild applies it again.
E10Quiet limits pause while the status is break or suspended.No false alerts over lunch; data that arrives in a break is still accepted.
E11Official 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.
E13Never delete on a source's word.A match the source stops sending is marked "gone from source", and a person decides.
E14Times are compared only within one source.A tablet with a slow clock cannot lose to everything.
E16Sources 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

PieceTodayAgreed designStatus
One road in for every writeEvery write is a workflow command; persons and integrations are split in workflows/engine.py:259-264Same, for every source and claim (D5)Built today
Hand edits survive importspinned 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 editresults.hand_back, games.hand_back_medals, games.hand_back_entryRelease from the "open overrides" screenPartly built
One screen of open hand editsPer fixture page and per medal table counts onlyOne screen for every open overrideAgreed, to build
Reason for a hand editNot storedOptional, one tap from a short listAgreed, to build
What losing sources saidOnly kept_by_hand counts and integration_row.rawEvery claim saved (T1)Agreed, to build
Field groupsNot a concept; pins are per fieldFive groups, configurable per sport (T2)Agreed, to build
Priority between sourcesNone; the last import winsA list per group, per sport, competition, match (T3)Agreed, to build
Quiet sources and take-overNoneAlert, one-click switch, optional automatic (T5)Agreed, to build
Compare losers with winnerNoneSummary compare and alert (T6)Agreed, to build
Order within one sourceNewest 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 versionfixture.status plus one flag, results_officialLive, unofficial, official, protested, with a version (T8)Agreed, to build
Official is lockedNo writer reads results_officialLocked against sources, review items, corrections (T9)Agreed, to build
Who may mark officialA person, an import (site status OFFICIAL) and the scoring fold can all set itA setting per competition (T10)Agreed, to build
Explain any valuetimeline_item per command; no link from a value to what wonEach published value keeps the id of its winning claim (T11)Agreed, to build
Scoring respects ownershipreflect_status ignores pins (scoring/pipeline.py:265-283)Status follows the priority listAgreed, to build

Numbers

NumberWhat it isSource
4Tables with a pinned column: fixture, fixture_competitor, medal_standing, competition_entryFrom the code
7, 4, 9, 4Pin names on a fixture, a side, a medal row, an entryFrom the code (FIXTURE_FIELDS, SIDE_FIELDS, MEDAL_FIELDS, entry model)
1,000Rows per games.import_units commandFrom the code, games/commands.py:52
6 hoursAfter its start, a finished unit with no feed sides is the person's when they add oneFrom the code, writers/fixtures.py:194
300 to 900Claim rows per match (3 sources × 100 to 300 events)Estimate, design doc
under 50,000Claim rows per day at 50 matches a dayEstimate, design doc
about 1 millionMessages in one F1 race (20 cars × 10 a second × 90 minutes)Estimate, design doc
132 of 270Units a day pull wrongly called gone at the Asian GamesMeasured over six runs, ingest/retire.py notes and design doc
180Events that lost their venue to a draws feed on 20 Sep 2026Code comment, ingest/fixtures.py:1164-1171
16Edge cases reviewed: 9 agreed, 3 dropped, 4 movedDesign doc, 3 Oct