Foundations · Data model
The data model
One set of tables holds every sport: a sport, a tree of competitions, the fixtures in it, the sides in each fixture, and one list of every person and team.
In one minute
Omnium stores every sport in the same five tables. A sport row says which sport. A competition row is one node in a tree of any depth: a Games, a league, a season, a round. A fixture is one contest: a match, a race, a bout. A fixture_competitor is one side or one entry in that contest. A participant is any person, team, horse or country, kept once and used everywhere.
A fixture has one of two common shapes, called archetypes. HEAD_TO_HEAD is two sides against each other, like a football match. RANKED_FIELD is many entries placed in order, like a 100 m final. Both use the same tables. Only the number of sides and the meaning of rank change.
Outside ids never go into our tables. A provider's id is linked to our id in one table, external_id_map. Data that only one sport needs goes into a JSONB field (meta, entry, result, config) on an existing row, never into a new sport-specific column.
What this part does
The data model answers the same small set of questions for every sport. A new sport is added by settings and a plugin, not by new tables.
| Question | Where the answer lives |
|---|---|
| Which sport is this? | sport |
| Which event, league, season or round is it part of? | competition (a tree), and competition_closure for fast "everything under X" reads |
| What is the contest, when, where, and what state is it in? | fixture |
| Who is in it, in what order, and how did each side do? | fixture_competitor (plus fixture_competitor_rank, fixture_attempt) |
| Who is this person, team or country? | participant, participant_sport, participant_membership |
| What is the provider's id for this row? | external_id_map |
| What happened, in order? | timeline_item, one ordered log per ledger_stream |
| What is the live headline right now? | scoreboard |
| What are the match numbers (goals, runs, times)? | stat_value (and the stats tables built from it) |
| Who is entered in a Games, and the medal table? | competition_entry, medal_standing |
Why it matters: every other part of omnium reads and writes these rows. Commands change them. Feeds are built from them. If you know these tables, you can follow any change from the scorer's tap to the client file.
How it works

- A sport is a row.
sporthas a uniquecodesuch asfootballorathletics. A sport can sit under a parent sport (Aquatics holds swimming and diving). - Competitions form a tree. Each
competitionrow points at its parent withparent_id. The tree can be any depth. Itstypeis a free word:games,league,season,session,event,stage. A Games root has no sport, so one tree can hold forty sports. - A fixture hangs off one competition node. It carries its own
sport_id, anarchetype, astatus, a start time and three JSONB documents:format(rules before),live_state(now) andresult(after). - Sides and entries are
fixture_competitorrows. A football match has two top-level rows. A 100 m final has eight. A player nests under their team throughparent_id. A slot that is not decided yet ("Winner of QF1") has aplaceholder_labelinstead of a participant. - Every party is one
participantrow. A person, team, pair, crew or horse. A country is a participant too, kept in the Commons: no sport, so every sport sees the same India. - Outside ids sit in a side table.
external_id_maplinks(source_code, entity_type, external_id)to ourinternal_id. One fixture can have many outside ids, one per source. - Change is logged, not lost. Each fixture has an ordered log in
timeline_item. Thescoreboardrow is a cache of the headline, rebuilt from that log. Structure edits (start time, sides) also write an insert-only row tofixture_amendment.
The two shapes: head-to-head and ranked field
Most contests in any sport are one of two shapes. The shape is stored on every fixture in fixture.archetype.

HEAD_TO_HEAD | RANKED_FIELD | |
|---|---|---|
| Example | A football group match | A men's 100 m final |
Top-level fixture_competitor rows | 2 | Many (8 in this final) |
alignment | Home / Away | Not used |
rank | Winner 1, loser 2. A draw: see below | Finishing place, 1 to 8. Ties allowed |
Per-side result | {"score": "1"} | {"score": "9.90", "medal": "ME_GOLD"} |
Fixture result | {"scoreline": "1-1"} | {"ranked": true} |
| Fixtures on the laptop database, 7 Oct | 9,887 | 2,888 |
The enum has two more values for later shapes. They exist in code, but no fixture on the laptop database uses them.
class Archetype(StrEnum):
"""How a fixture arrives at its result ...
``SERIES`` (a set of attempts: high jump, weightlifting, diving) and
``COMPOSITE`` (a result folded from child fixtures: a Davis Cup tie, a
best-of-three) join the two originals. The column is a bare VARCHAR ...
so the set grows without DDL, and Python is what enforces it.
"""
HEAD_TO_HEAD = "HEAD_TO_HEAD"
RANKED_FIELD = "RANKED_FIELD"
SERIES = "SERIES"
COMPOSITE = "COMPOSITE"
packages/contract/src/omnium_contract/models/enums.py:11-26
HEAD_TO_HEADandRANKED_FIELDare the two shapes in real use.SERIESis for attempt sports (high jump, weightlifting). Each attempt is afixture_attemptrow.COMPOSITEis a result built from child fixtures, linked byfixture_relation.- The column is a plain
VARCHAR(32)with no database check. Adding a value needs no migration. Python is the only guard.
How an import picks the shape. The Games fixture import decides per unit: two sides means head-to-head, anything else is a ranked field.
archetype=Archetype.HEAD_TO_HEAD if unit.head_to_head else Archetype.RANKED_FIELD,
packages/core/src/omnium_core/ingest/fixtures.py:1080
A draw is stored two ways today. A match scored live by the football plugin puts both sides on rank 1 ("A draw is both sides on rank 1", packages/flows/src/omnium_flows/football/actions.py:169-171). The imported Games match above has rank empty on both sides. A reader must handle both. This is a gap, not a design choice.
Where is the standings overlay? CLAUDE.md and the API spec describe a third idea, AGGREGATE_CLASSIFICATION: a table (a league table, a general classification) folded over many fixtures. It is not a fixture. The old classification and classification_standing tables were dropped in migration 041. A standings table today is a stat_table built by the stats engine:
classification, classification_standing the workflow's own standings path.
A table above the match is a
``stat_table`` now, computed the same
way for every sport from the finishing
place on each line
packages/core/alembic/versions/20260908_041_one_writer_one_queue.py:12-16
So the overlay still exists as an idea. Its home is the stats engine, covered in Stats, feeds and delivery.
A Games is a competition tree
A multi-sport event is one competition tree. The root has sport_id empty. Each sport is a child node with a sport. Everything below a sport node carries that sport.

The real Asian Games 2026 tree on the laptop database (7 Oct):
| Depth | Node types (count) |
|---|---|
| 0 | games (1) |
| 1 | programme (55), league (2), meet (2), series, tour, tournament (1 each) |
| 2 | event (363), session (91), card (11), season (4), round (2), stage (2) |
| 3 | stage (1,809), event (271), card (59), round (21), season (13) |
That is 2,709 nodes and 8,917 fixtures under one root. The type words come from each import, so they are not the same across sports. Code must not depend on a type word to find the sport. It must use sport_id.
Why the root has no sport, in the model's own words:
class Competition(UUIDPk, Timestamps, Base):
"""A container node in the sport's grouping tree (= the API ``Event`` resource).
...
``sport_id`` is **nullable**, and that is what makes a multi-sport event
possible. The Olympic Games belongs to no sport; the swimming node inside it
does, and everything below that inherits it. Without this an Olympics has to
be split into forty unrelated competitions with nothing tying them together,
and a medal table cannot be folded at all.
"""
__tablename__ = "competition"
__table_args__ = (
sa.UniqueConstraint("sport_id", "code"),
sa.Index(
"uq_competition_code_no_sport",
"code",
unique=True,
postgresql_where=sa.text("sport_id IS NULL"),
),
sa.Index("ix_competition_parent_id", "parent_id"),
)
sport_id: Mapped[uuid.UUID | None] = mapped_column(sa.ForeignKey("sport.id"), nullable=True)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
sa.ForeignKey("competition.id"), nullable=True
)
type: Mapped[str] = mapped_column(sa.String(64), nullable=False)
code: Mapped[str] = mapped_column(sa.String(64), nullable=False)
...
fixture_type: Mapped[str | None] = mapped_column(sa.String(64), nullable=True)
format_code: Mapped[str | None] = mapped_column(sa.String(64), nullable=True)
...
config: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")
)
packages/contract/src/omnium_contract/models/taxonomy.py:70-127 (trimmed)
codeis unique per sport. In Postgres two empty (NULL) values never clash, so the partial indexuq_competition_code_no_sportmakes codes unique among the sportless roots too.fixture_typeandformat_codeare inherited downward. A whole tournament is T20; a fixture takes the nearest answer above it unless someone picks otherwise.- A Games-wide record hangs off the root:
competition_entry(who is entered, for which country),medal_standing(the medal table) andevent_workflow(which workflow serves each sport).
Reading a whole subtree. Walking a tree with a recursive query is slow at this size. So the tree is also kept flat in competition_closure: one row for every (ancestor, descendant) pair, with the depth. Database triggers keep it right, so no code path can forget.
competition_closure = sa.Table(
"competition_closure",
metadata,
sa.Column("ancestor_id", sa.Uuid, sa.ForeignKey("competition.id", ondelete="CASCADE"), primary_key=True),
sa.Column("descendant_id", sa.Uuid, sa.ForeignKey("competition.id", ondelete="CASCADE"), primary_key=True),
sa.Column("depth", sa.Integer, nullable=False),
sa.Index("ix_competition_closure_descendant", "descendant_id"),
)
packages/contract/src/omnium_contract/models/stats_engine.py:60-78 (lines joined)
The migration that added it measured "every fixture under this season": 2.4 s with a recursive walk, 96 ms with the closure table (packages/core/alembic/versions/20260908_033_competition_closure.py:3-5; how and where it was measured is not written there, not checked).
Outside ids: external_id_map
A provider never sees our ids, and we never store theirs in our main tables. One table links them.
class ExternalIdMap(UUIDPk, Timestamps, Base):
__tablename__ = "external_id_map"
__table_args__ = (
sa.UniqueConstraint("source_code", "entity_type", "external_id"),
sa.Index("ix_external_id_map_entity_type_internal_id", "entity_type", "internal_id"),
)
source_code: Mapped[str] = mapped_column(sa.String(64), nullable=False)
# sport/competition/fixture/participant/venue/... — which kind of internal entity.
entity_type: Mapped[str] = mapped_column(sa.String(32), nullable=False)
external_id: Mapped[str] = mapped_column(sa.String(256), nullable=False)
# Deliberately NO ForeignKey: this map is polymorphic across entity types,
# so internal_id is a bare UUID resolved against the table named by
# entity_type at the application layer.
internal_id: Mapped[uuid.UUID] = mapped_column(sa.Uuid, nullable=False)
#: How the link was made: ``onboarding``, ``integration``, ``upload`` or
#: ``hand``. NULL for links made before this was recorded.
origin: Mapped[str | None] = mapped_column(sa.String(16), nullable=True)
created_by: Mapped[str | None] = mapped_column(sa.String(256), nullable=True)
packages/contract/src/omnium_contract/models/ingestion.py:22-40
- The key is
(source_code, entity_type, external_id). One outside id points at exactly one of our rows. - The reverse index
(entity_type, internal_id)answers "what ids does this fixture have elsewhere?". One of our rows can have many outside ids, one per source. - No foreign key.
internal_idcan point at a fixture, a participant or a competition, so Postgres cannot check it. If a row is deleted, its map rows stay behind. On the laptop database on 7 Oct, 25 map rows point at fixtures that no longer exist (15fixture, 10fixture_pairing).
The busiest links on the laptop database (7 Oct):
source_code | entity_type | Rows | What it is |
|---|---|---|---|
feed | participant | 20,099 | The number our client feeds send for a person or country |
ag2026 | participant | 12,969 | The Asian Games results site's athlete id |
feed | fixture | 12,525 | The feed's stage_id for a unit |
feed | fixture_pairing | 9,661 | The feed's id for one pairing inside a unit |
ag2026 | fixture | 8,932 | The results site's unit key |
feed is not a provider. It is the band of plain numbers we hand to clients, because client feeds use integers, not uuids (packages/core/src/omnium_core/catalogue/feed_ids.py:1-50). So the same table holds ids going in and ids going out.
How an import uses it. A scraped row is matched to our record by one of three keys, in order of trust: their id through this table, the three-letter country code, or the name.
async def _by_external_id(
session: AsyncSession, source_code: str, keys: set[str]
) -> dict[str, uuid.UUID]:
if not keys:
return {}
found = await session.execute(
sa.select(ExternalIdMap.external_id, ExternalIdMap.internal_id).where(
ExternalIdMap.source_code == source_code,
ExternalIdMap.entity_type == _ENTITY_TYPE,
ExternalIdMap.external_id.in_(sorted(keys)),
)
)
return {str(external): internal for external, internal in found.all()}
packages/core/src/omnium_core/ingest/resolve.py:82-94
- One query for the whole batch, never one per row. A calendar has over a thousand rows; a loop would time out (
resolve.py:158-162). - A row that cannot be matched is kept with a sentence saying why. It is not dropped and it is not guessed. The operator sees the list before turning a feed on (
resolve.py:28-34).
Where sport-specific data goes
The house rule, from CLAUDE.md: sport-specific tables and columns are a last resort. When data has no home in the shared columns, it goes into a JSONB field on an existing row. A new sport-specific table or column needs explicit human sign-off.
Each JSONB field has one job. Mixing jobs is what the split prevents.
| Row | Field | Holds | Example |
|---|---|---|---|
competition | config | Settings for everything under this node | Format and rules for a whole tournament |
fixture | format | Rules, known before; same for every fixture with this config | Halves and their length |
fixture | meta | This fixture's own facts, typed by a person or a feed | Attendance, toss winner, the source page's keys |
fixture | live_state | The current state while live | Current half, clock |
fixture | result | The settled headline | {"scoreline": "1-1"} |
fixture_competitor | entry | Known before: lane, seed, bib, feed slot | {"ref": "16362440", "country": "THA"} |
fixture_competitor | result | Known after: time, goals, medal | {"score": "9.90", "medal": "ME_GOLD"} |
participant | meta | Profile facts with no column | Date of birth, stance, breed |
venue | meta | Venue facts with no column | Surface |
Why format and meta are two fields, from the model:
# This fixture's own facts, typed by whoever entered it: the going, the
# attendance, the match number, the belts a bout is for, who won the toss.
#
# Separate from ``format`` because the two share only their timing. Rules come
# from the config and are identical across every fixture played to it; these
# belong to one fixture. Mixed in one blob there would be no telling them
# apart, so a changed config could not be re-applied without risking
# overwriting something a person typed ...
meta: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")
)
packages/contract/src/omnium_contract/models/fixtures.py:86-97
Who checks the shape? Not Postgres. Each sport's allowed fields are declared in a versioned sport definition (sport_definition_version.schema, models/metadata.py:40-67), and a fixture pins the version it was made with (fixture.definition_version_id). The sport's plugin validates commands against it. How a plugin is written is on Writing a workflow in code.
Numbers do not live in JSONB. A match's countable facts (goals, runs, cards) are stat_value lines with a values document per subject, written by the sport's action inside the command. Season totals and tables are built from those lines. See Stats, feeds and delivery.
A worked example
Example · The men's 100 m final at the Asian Games 2026, as rows
All values are from the laptop database on 7 Oct (a working copy on the laptop, not production).
1. The tree. The final is a fixture under four competition nodes:
asian-games-2026 (games, no sport) → asian-games-2026-athletics (meet, athletics) → asian-games-2026-m-100m (session) → asian-games-2026-m-100m-fnl (event).
2. The fixture row.
| Column | Value |
|---|---|
code | ag2026-m-100m-fnl-000100 |
name | Men's 100m Final |
archetype | RANKED_FIELD |
status | COMPLETED |
format | {} |
result | {"ranked": true} |
meta | {"feed": "ag2026-pages", "source": {"key": "M.100M...FNL-.000100--", "discipline": "ATH", "medal": "1", "status": "OFFICIAL", ...}} |
The provider's own fields sit under meta.source. Nothing about athletics needed a new column.
3. Eight entries. Eight top-level fixture_competitor rows, one per runner. The first three:
ordinal | rank | result_status | participant | entry | result |
|---|---|---|---|---|---|
| 1 | 1 | FINISHED | BOONSON Puripol (THA) | {"ref": "16362440", "country": "THA"} | {"score": "9.90", "medal": "ME_GOLD", "record": "GR"} |
| 2 | 2 | FINISHED | AL BALUSHI Ali (OMA) | {"ref": "15127494", "country": "OMA"} | {"score": "10.04", "medal": "ME_SILVER"} |
| 3 | 3 | FINISHED | AL BALUSHI Malham (OMA) | {"ref": "1276202", "country": "OMA"} | {"score": "10.11", "medal": "ME_BRONZE"} |
rank is a real column, so "who won" is plain SQL. The time and the medal are in result, because their shape differs by sport.
4. Outside ids. The fixture has two map rows: ag2026 → ATH:M.100M--------------.FNL-.000100-- (the results site) and feed → 2007921 (the number our client files send). The winner has ag2026 → 16362440 and feed → 5002871. One record, one id per source.
5. The same shape for football. The women's Group G match ag2026-w-team11-gpg-000100 is HEAD_TO_HEAD with two rows: Philippines (alignment = Home, result = {"score": "1"}) and Uzbekistan (Away, {"score": "1"}). The fixture result is {"scoreline": "1-1"}. It sits at depth 3 under Football (league) → Women (season) → Women Group G (season).
6. A query that uses all of it. Every gold medallist in Games athletics:
SELECT p.name, p.country, f.name AS fixture, fc.result->>'score' AS mark
FROM competition root
JOIN competition_closure cl ON cl.ancestor_id = root.id
JOIN fixture f ON f.competition_id = cl.descendant_id
JOIN sport s ON s.id = f.sport_id AND s.code = 'athletics'
JOIN fixture_competitor fc ON fc.fixture_id = f.id AND fc.parent_id IS NULL
JOIN participant p ON p.id = fc.participant_id
WHERE root.code = 'asian-games-2026' AND root.sport_id IS NULL
AND fc.result->>'medal' = 'ME_GOLD';
One join reaches every depth of the tree. No recursive query is needed.
Low-level design
Keys and ids
Two kinds of primary key are used. Both are made in one shared file.
class UUIDPk:
"""UUIDv7 primary key, generated app-side.
v7 is time-ordered, giving btree index locality on inserts. Generated
client-side (``default=uuid7``) so ids are assigned during flush and object
graphs can be wired up before the INSERT lands.
"""
id: Mapped[uuid.UUID] = mapped_column(sa.Uuid, primary_key=True, default=uuid7)
class BigIntPk:
"""Auto-incrementing bigint identity primary key."""
id: Mapped[int] = mapped_column(BigInteger, Identity(always=False), primary_key=True)
packages/contract/src/omnium_contract/models/base.py:23-46 (lines joined)
- UUIDv7 for almost every table. Its first bits are a time, so new rows land at the end of the index. The app makes the id, so related rows can be linked before the insert.
- Bigint identity for the busiest logs (
timeline_item, and the stats line tables). Nobody outside refers to these by their own id. - A human code too:
sport.code,competition.code,fixture.code,participant.code. Unique per sport. Used in URLs and logs. - Enums are strings. Every enum is a
VARCHARwith no native Postgres enum (base.py:64-76). New values need no migration.
The core tables, in SQL
Real columns from the laptop database at migration 059 (pg_dump --schema-only), trimmed to the columns that matter and re-ordered for reading. created_at and updated_at are on most tables and left out.
CREATE TABLE sport (
id uuid PRIMARY KEY,
code varchar(64) NOT NULL UNIQUE, -- 'football'
name varchar(256) NOT NULL,
parent_id uuid REFERENCES sport(id), -- Aquatics > swimming
archetype varchar(32), -- default for new fixtures only
competitor_kind varchar(32), -- PERSON / TEAM / PAIR / COMBO / CREW
status varchar(16) NOT NULL DEFAULT 'ACTIVE'
);
CREATE TABLE competition (
id uuid PRIMARY KEY,
sport_id uuid REFERENCES sport(id), -- NULL for a Games root
parent_id uuid REFERENCES competition(id), -- the tree
type varchar(64) NOT NULL, -- games / league / season / event ...
code varchar(64) NOT NULL,
name varchar(256) NOT NULL,
fixture_type varchar(64), -- inherited downward
format_code varchar(64), -- inherited downward
status varchar(32),
ordinal integer,
start_date date,
end_date date,
config jsonb NOT NULL DEFAULT '{}',
UNIQUE (sport_id, code)
);
CREATE UNIQUE INDEX uq_competition_code_no_sport ON competition (code) WHERE sport_id IS NULL;
CREATE TABLE fixture (
id uuid PRIMARY KEY,
sport_id uuid NOT NULL REFERENCES sport(id),
competition_id uuid REFERENCES competition(id),
venue_id uuid REFERENCES venue(id),
definition_version_id uuid REFERENCES sport_definition_version(id),
code varchar(64) NOT NULL,
name varchar(256),
archetype varchar(32) NOT NULL, -- HEAD_TO_HEAD / RANKED_FIELD / ...
fixture_type varchar(64),
format_code varchar(64),
status varchar(32) NOT NULL DEFAULT 'SCHEDULED',
results_official boolean NOT NULL DEFAULT false,
coverage_tier varchar(32) NOT NULL, -- LIVE / DELAYED / EOD
scheduled_start timestamptz,
format jsonb NOT NULL DEFAULT '{}',
meta jsonb NOT NULL DEFAULT '{}',
live_state jsonb,
result jsonb,
pinned jsonb NOT NULL DEFAULT '[]', -- fields a person set by hand
last_seq bigint NOT NULL DEFAULT 0, -- the log's position counter
version integer NOT NULL DEFAULT 0, -- optimistic-lock counter
scoring_program_version_id uuid, -- rules pinned at first command
scoring_ui_id uuid,
UNIQUE (sport_id, code)
);
CREATE TABLE fixture_competitor (
id uuid PRIMARY KEY,
fixture_id uuid NOT NULL REFERENCES fixture(id),
participant_id uuid REFERENCES participant(id),
placeholder_label varchar(128), -- 'Winner of QF1'
parent_id uuid REFERENCES fixture_competitor(id), -- player under team
ordinal integer, -- lane, leg, batting order
slot varchar(64), -- 'anchor', 'skip'
alignment varchar(16), -- Home / Away / RED / BLUE
result_status varchar(32) NOT NULL DEFAULT 'ENTERED',
status_code varchar(64), -- the sport's own word
rank integer, -- not unique: dead heats
win_method varchar(64), -- KO / DLS / PENALTIES
entry jsonb NOT NULL DEFAULT '{}',
result jsonb NOT NULL DEFAULT '{}',
pinned jsonb NOT NULL DEFAULT '[]',
CHECK ((participant_id IS NULL) <> (placeholder_label IS NULL))
);
CREATE TABLE participant (
id uuid PRIMARY KEY,
sport_id uuid REFERENCES sport(id), -- where it was created; NULL = Commons
code varchar(64) NOT NULL,
type varchar(32) NOT NULL, -- PERSON / PAIR / TEAM / HORSE / CREW
name varchar(256) NOT NULL,
country char(3) CHECK (country ~ '^[A-Z]{3}$'),
meta jsonb NOT NULL DEFAULT '{}',
identity_hash text UNIQUE, -- duplicate detection
merged_into_id uuid REFERENCES participant(id),
UNIQUE (sport_id, code)
);
CREATE TABLE participant_sport (
participant_id uuid REFERENCES participant(id) ON DELETE CASCADE,
sport_id uuid REFERENCES sport(id) ON DELETE CASCADE,
PRIMARY KEY (participant_id, sport_id) -- no rows = seen by every sport
);
CREATE TABLE external_id_map (
id uuid PRIMARY KEY,
source_code varchar(64) NOT NULL,
entity_type varchar(32) NOT NULL,
external_id varchar(256) NOT NULL,
internal_id uuid NOT NULL, -- no FK: points at any table
origin varchar(16),
created_by varchar(256),
UNIQUE (source_code, entity_type, external_id)
);
fixture: status, locks and the log counter
Three columns on fixture do quiet but important work.
status: Mapped[FixtureStatus] = mapped_column(
str_enum(FixtureStatus, "fixture_status"),
nullable=False,
server_default=sa.text("'SCHEDULED'"),
)
# Ratification sub-state (the old RESULT vs OFFICIAL); meaningful when COMPLETED.
results_official: Mapped[bool] = mapped_column(...)
...
# Internal optimistic-lock counter. NEVER a conflict resolver (per design).
version: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("0"))
...
# The ledger's high-water mark for this fixture. Taken with
# `UPDATE fixture SET last_seq = last_seq + 1 ... RETURNING last_seq`, which
# is one row lock and O(1) whether the fixture holds five events or five
# million — no MAX(), no scan, no race. It also serializes commands per
# fixture for free, which the async queue needs anyway.
last_seq: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, server_default=sa.text("0"))
# Field names a person set by hand — "name", "start", "venue", "status",
# "result", "competitors". A schedule feed writes every field except these,
# so a correction made on the console outlives the next scrape.
pinned: Mapped[list[str]] = mapped_column(JSONB, nullable=False, server_default=sa.text("'[]'::jsonb"))
packages/contract/src/omnium_contract/models/fixtures.py:68-132 (trimmed)
statusis one ofSCHEDULED,LIVE,SUSPENDED,COMPLETED,POSTPONED,CANCELLED,ABANDONED,VOID(enums.py:50-66). "Official" is not a status. It isresults_official = trueon a completed fixture.last_seqis the position counter of the fixture's log. Taking the next number locks the fixture row. That one lock is what keeps two scorers from writing out of order. The real code isnext_seqinpackages/core/src/omnium_core/scoring/ledger.py:184-202.pinnedlists fields a person changed by hand. An import skips them. The same idea is onfixture_competitor,competition_entryandmedal_standing. See Truth and ownership.
fixture_competitor: sides, players and placeholders
class FixtureCompetitor(UUIDPk, Timestamps, Base):
"""An entrant in a fixture (the competing parties).
``parent_id`` (self) nests a member under their side's entry — a footballer
under their team, a swimmer under their club's relay quartet; NULL for a
top-level entrant. ``result_status``/``rank`` are meaningful on top-level
rows; win/loss derives from ``rank``.
There is deliberately **no** ``unique(fixture_id, participant_id)``: a club
enters an A and a B relay team, and both entries name the same club. ...
"""
__table_args__ = (
sa.Index("ix_fixture_competitor_fixture_id", "fixture_id"),
sa.Index("ix_fixture_competitor_ordinal", "fixture_id", "parent_id", "ordinal"),
sa.CheckConstraint(
"(participant_id IS NULL) != (placeholder_label IS NULL)",
name="participant_xor_placeholder",
),
)
packages/contract/src/omnium_contract/models/fixtures.py:147-175 (trimmed)
- Win and loss are never stored. They come from
rank. Rank 1 is the winner.rankis not unique, so a dead heat is two rows on the same rank. result_statusis one of six canonical values:ENTERED,ACTIVE,FINISHED,DNS,DNF,DSQ(enums.py:91-112). The sport's own word ("pulled up") goes instatus_code, checked againstoutcome_code.- Exactly one of participant or placeholder is set. The database enforces it with the CHECK above.
- A player stays in the same fixture as their team. A trigger refuses a nested row whose parent is in another fixture (
packages/core/alembic/versions/20260718_004_integrity_guards.py:70-95).
Extra tables hang off one entry when a sport needs more than one number:
| Table | One row per | Example |
|---|---|---|
fixture_competitor_rank | entry and ranking | A marathon runner: overall 12th, age group 3rd |
fixture_attempt | entry, round and try | A high jumper's three tries at 2.29 m |
fixture_competitor_role | entry and role | This match's captain |
fixture_connection | entry, person and role | A jockey or trainer for a horse |
fixture_official | fixture, person and role | The referee |
fixture_relation | pair of fixtures and kind | Leg 2 LEG_OF leg 1; a heat QUALIFIES_TO the final |
participant: one record per party
class Participant(UUIDPk, Timestamps, Base):
"""A party: person, team, horse, pair, crew — competitor, official, or support.
**Which sports see this record is decided by ``sports``, not by ``sport_id``.**
...
* a record listing **none** is seen everywhere — India as a country, the
Wankhede as a ground. This is what makes an Olympic medal table possible,
because it needs one India rather than one per sport ...
"""
...
# App-computed normalized fingerprint for duplicate detection; unique when set.
identity_hash: Mapped[str | None] = mapped_column(sa.Text, unique=True, nullable=True)
# Set when this row was merged into a surviving duplicate — reads follow the
# chain; the merged row is kept so old external ids keep resolving.
merged_into_id: Mapped[uuid.UUID | None] = mapped_column(
sa.ForeignKey("participant.id"), nullable=True
)
packages/contract/src/omnium_contract/models/participants.py:21-75 (trimmed)
- Role is not a type. The same table holds athletes, teams, officials and coaches. What a party is in one fixture comes from how it is linked: as an entry, a connection or an official.
- Visibility is a list.
participant_sportlists the sports that see a record. No rows means every sport. On the laptop database, 97 records have no sport and no links: the Commons countries. - Teams change over time.
participant_membershiplinks a team to a member withvalid_fromandvalid_to. A player who re-signs gets a new row, so history is kept. - The flag is on the entry. The country an athlete represents at one Games is
competition_entry.country_id, notparticipant.country, because athletes change flags between Games (models/entries.py:7-13). - Duplicates are merged, not deleted. The losing row points at the winner with
merged_into_id, so its outside ids keep working.
The log: ledger_stream and timeline_item
Every change to a fixture or an event is logged in order. The details of how a command writes the log are on Commands and the workflow engine and The live scoring engine. The tables:
class LedgerStream(UUIDPk, Base):
"""One ordered, append-only log per subject."""
__tablename__ = "ledger_stream"
__table_args__ = (sa.UniqueConstraint("subject_kind", "subject_id"),)
#: ``fixture`` | ``event``.
subject_kind: Mapped[str] = mapped_column(sa.String(16), nullable=False)
subject_id: Mapped[uuid.UUID] = mapped_column(sa.Uuid, nullable=False)
#: The position counter for subjects without one of their own. A fixture
#: keeps counting in ``fixture.last_seq`` ...; an event counts here.
last_seq: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, server_default=sa.text("0"))
packages/contract/src/omnium_contract/models/workflows.py:107-124 (trimmed)
class TimelineItem(BigIntPk, Timestamps, Base):
"""An item on a fixture's timeline.
APPEND-ONLY. Items are never mutated in place; a correction is a NEW item
(status CORRECTED) whose supersedes_id points at the item it revises, and a
deletion flips an item to VOIDED — preserving full history and seq continuity.
"""
__table_args__ = (
sa.UniqueConstraint("stream_id", "source_code", "unit", "seq",
name="uq_timeline_item_stream_seq",
postgresql_nulls_not_distinct=True),
...
sa.Index("uq_timeline_item_idempotency", "stream_id", "source_code", "idempotency_key",
unique=True, postgresql_nulls_not_distinct=True,
postgresql_where=sa.text("idempotency_key IS NOT NULL")),
)
stream_id: Mapped[uuid.UUID] = mapped_column(sa.ForeignKey("ledger_stream.id"), nullable=False)
fixture_id: Mapped[uuid.UUID | None] = mapped_column(sa.ForeignKey("fixture.id"), nullable=True)
actor: Mapped[str | None] = ... # an account name, or integration:<code>
source_code: Mapped[str | None] = ... # which feed wrote it
event_type: Mapped[str | None] = ... # 'football.goal', 'games.set_medals'
unit: Mapped[str] = ... # the grain
seq: Mapped[int] = ... # position in the stream
payload: Mapped[dict[str, Any]] = ... # the command's input
supersedes_id: Mapped[int | None] = ...
idempotency_key: Mapped[str | None] = ...
packages/contract/src/omnium_contract/models/timeline.py:16-102 (trimmed, lines joined)
- One stream per subject. A fixture has one; so does an event (the medal table, entries and schedule are changed by commands too). A trigger fills
stream_idfromfixture_idfor older writers (timeline_item_fill_stream, migration 048). - No duplicates on retry. A resent command with the same
idempotency_keyhits the unique index and is recognised as the first one. - Corrections append. A fix is a new row that points at the old one with
supersedes_id. - Two grains are in use. The scoring path writes
unit = 'event'(scoring/ledger.py:36). The workflow engine writesunit = 'record'(workflows/engine.py:56). On the laptop database, the most commonevent_typevalues aregames.import_units(248) andgames.hand_back_medals(176), allrecord. timeline_actorlinks a log row to the people in it (scorer, carded player) by id, so "every goal by X" needs no JSON search.
Live headline and match numbers
scoreboard: one row per fixture, one JSONBsnapshot. It is a cache. Its only writer is the sport's state action through the engine (fixtures.py:476-499).stat_value: one line per (fixture, subject, role, segment), with avaluesdocument. Partitioned by year onoccurred_on; the laptop database has 2015 to 2028 plus a default. It copiessport_id,competition_idandformat_codefrom the fixture, so a season read never joins (stats_engine.py:83-130).
sa.UniqueConstraint(
"fixture_id", "subject_id", "subject_role", "segment", "occurred_on",
name="uq_stat_value_line",
),
...
postgresql_partition_by="RANGE (occurred_on)",
packages/contract/src/omnium_contract/models/stats_engine.py:115-130 (trimmed)
Football's tallies action is an example of who writes these lines: one per side per half (segment = 'half-1'), one per side for the whole match (goals_for, goals_against), and one per player (packages/flows/src/omnium_flows/football/actions.py:120-166).
Guards inside the database
Some rules are too important to leave to code. These triggers exist on the laptop database (checked in pg_trigger, 7 Oct):
| Trigger | Table | What it stops |
|---|---|---|
competition_no_cycle_trg | competition | A node becoming its own ancestor |
trg_competition_closure_insert / _update | competition | competition_closure going out of date |
fixture_competitor_same_fixture_trg | fixture_competitor | A player nested under a side in another fixture |
venue_no_cycle_trg | venue | A venue inside itself |
timeline_item_fill_stream | timeline_item | A log row with no stream |
The closure trigger, as it runs on insert:
IF TG_OP = 'INSERT' THEN
INSERT INTO competition_closure (ancestor_id, descendant_id, depth)
SELECT p.ancestor_id, NEW.id, p.depth + 1
FROM competition_closure p
WHERE p.descendant_id = NEW.parent_id
UNION ALL
SELECT NEW.id, NEW.id, 0;
RETURN NEW;
packages/core/alembic/versions/20260908_033_competition_closure.py:25-35
In words: the new node gets every ancestor its parent has, one level deeper, plus a row for itself at depth 0. A move (UPDATE OF parent_id) cuts the subtree from its old ancestors and hangs it under the new ones.
On main since 30 Sep (PR #155)
Commit 23cf8d5 on main adds migration 060, 20260925_060_unified_platform_evolution.py. It is not on the branch checked out for this page, and not in the laptop database (which stops at 059). Read with git show, not run.
| Bucket | What it adds | Read by running code? |
|---|---|---|
| A | A nullable merged_into_id self-reference on sport, competition, venue and fixture, copying the pattern participant already has | No |
| B | Three new Postgres schemas from the platform team's proposed DDL: xref (6 tables, including entity_map and code_registry), enrich (6 Wikidata tables) and recon (2 quality-check tables) | No |
| C | stat_code (one stat catalogue per sport) and stat (one long stat table tagged by origin), plus a stat_resolved view, in public | No |
The migration says it plainly: "Nothing here is read by any running code yet, so nothing changes behaviorally until a later migration wires it up." A git grep on main finds these names only in the models (models/stat_unified.py, models/__init__.py) and the migration.
The new column, as added to fixture on main:
# Reference-safe merge (mirrors participant.merged_into_id): point the loser
# at the winner instead of deleting, so existing references keep resolving.
merged_into_id: Mapped[uuid.UUID | None] = mapped_column(
sa.ForeignKey("fixture.id"),
nullable=True,
comment=(
"Reference-safe merge (mirrors participant.merged_into_id): never "
"delete a row, point the loser at the winner instead."
),
)
origin/main:packages/contract/src/omnium_contract/models/fixtures.py:58-67
What this means for the model:
- Merging, not deleting, for every core row. Two copies of one fixture or one competition can be joined the way two copies of a person already are. Old references and old outside ids keep working, so a merge would no longer leave dead
external_id_maprows. No code does the merge yet. - A second id map exists.
xref.entity_mapkeys on(entity, sport_code, source_table, source_id)and points at aunified_id.external_id_mapkeys on(source_code, entity_type, external_id). Two tables now answer "their id is our id". Which one wins is not decided in the code. - A second stat store exists.
statsits beside the stats engine'sstat_valueand is not written by anything. The migration calls retiring the old engine "a deferred follow-up migration".
Every table, by group
79 tables on the laptop database at migration 059, not counting 65 partitions, 4 procrastinate_* job-queue tables and alembic_version. The models are in packages/contract/src/omnium_contract/models/.
| Group | Tables | File |
|---|---|---|
| Sport and tree | sport, competition, competition_closure, venue | taxonomy.py, stats_engine.py |
| Parties | participant, participant_sport, participant_membership, membership_role, role_dictionary, transfer, name_history | participants.py, history.py |
| Fixtures | fixture, fixture_competitor, fixture_competitor_rank, fixture_attempt, fixture_competitor_role, fixture_connection, fixture_official, fixture_relation, fixture_amendment, scoreboard | fixtures.py |
| Games records | competition_entry, medal_standing | entries.py, medals.py |
| Log and outbox | ledger_stream, timeline_item, timeline_actor, domain_event, scheduler_cursor | workflows.py, timeline.py, scheduler.py |
| Workflows and rules | workflow, event_workflow, workflow_app, component_registry, sport_definition, sport_definition_version, outcome_code, scoring_program_version, scoring_ui, scoring_state, scoring_checkpoint, scoring_golden | workflows.py, component_registry.py, metadata.py, vocabulary.py, scoring.py |
| Imports | external_id_map, ingest_cursor, fixture_source_assignment, raw_ingested_result, integration, integration_version, integration_run, integration_row, integration_answer, integration_job | ingestion.py, integrations.py |
| Stats | stat_value, stat_total, stat_table, stat_stamp, stat_change, stat_period, stat_award, stat_edit, stat_snapshot, stat_definition, stat_pack, stat_pack_item, stat_attachment | stats_engine.py |
| Feeds and delivery | feed_definition, feed_document, published_document, client_feed_transform, delivery_client, feed_package, delivery_assignment, delivery_state, delivery_attempt, delivery_deadletter, delivery_credential, delivery_setting | feeds.py, publishing.py, transform.py, delivery.py |
| Content and accounts | editorial, event_media, admin_account, admin_session | content.py, accounts.py |
This list is the branch at migration 059. main adds stat_code and stat in public, and 14 tables in the new xref, enrich and recon schemas (see above).
docs/erd.md in the repo is out of date. It was generated on 7 Aug 2026, lists 45 tables, and still shows classification, scoring_job and other tables dropped since. Use this table, or run make erd.
When things go wrong
| What goes wrong | What happens |
|---|---|
| Two imports create the same competition code in one sport | The second insert fails on UNIQUE (sport_id, code). For a sportless root, the partial index uq_competition_code_no_sport catches it. |
| Someone sets a competition's parent to its own child | competition_no_cycle_trg raises an error and the write is refused. |
| A competition is moved to a new parent | The closure trigger rewrites the subtree's ancestor rows in the same transaction. Reads stay right. |
| A fixture row is deleted | Its external_id_map rows stay, because there is no foreign key. On the laptop database, 25 such rows exist (7 Oct). The next import with that outside id will find a uuid that points at nothing. Not checked how each import handles that. |
| A provider renames a country ("Taipei, China") | Matching by externalId still works. Matching by name fails and the row is listed as unmatched, not guessed (ingest/resolve.py:22-34). |
| The same person is loaded twice | identity_hash is unique when set, so a second insert with the same fingerprint fails. If two rows already exist, one is merged into the other with merged_into_id; old ids keep resolving. |
| A scorer's phone resends a command | The second row hits uq_timeline_item_idempotency and is treated as the first. |
| Two scorers send at the same moment | Both try to take fixture.last_seq. The row lock makes one wait. They get seq N and N+1, never the same number. |
| A feed overwrites a hand fix | It does not, if the field is in pinned. If the person's tool did not pin it, the feed wins. See Truth and ownership. |
| A JSONB field gets a wrong shape | Postgres accepts it. Only code checks shapes. A bad import can save {"score": null} and every reader must cope. |
| A new archetype value is written by mistake | The column has no CHECK, so Postgres accepts it. Python's Archetype enum is the only guard. |
| An imported draw has no ranks | Readers that look for rank = 1 find no winner and no draw. Scored football puts both on rank 1. Readers must handle both today. |
Decisions
There is no design section for this page. These are the choices written into the code, with the file that explains each one. All are built.
| # | Decision | In plain words |
|---|---|---|
| M1 | One shared schema for every sport | Five core tables hold every sport. A new sport is settings and a plugin, not tables. |
| M2 | The archetype is stored on each fixture | fixture.archetype is the truth; sport.archetype is only a default for new fixtures (taxonomy.py:46-52). |
| M3 | Competitions are one self-referencing tree | Any depth, any type word. Replaces a fixed competition → season → stage ladder (taxonomy.py:1-8). |
| M4 | A Games root has no sport | So forty sports share one tree and one medal table (taxonomy.py:76-82). |
| M5 | The tree is also kept flat | competition_closure, kept by triggers, makes "all under X" one join (migration 033). |
| M6 | One participant table for every party | Role comes from the link, not from a type (participants.py:1-6). |
| M7 | Countries live in the Commons | No sport and no links, so every sport sees one India (participants.py:24-40). |
| M8 | Outside ids in one map, no foreign key | Any source, any table, one lookup. The cost is no database check on internal_id (ingestion.py:33-36). |
| M9 | Per-sport data in JSONB, not columns | meta, entry, result, config, format; a sport-specific column needs sign-off (CLAUDE.md). |
| M10 | Win and loss come from rank | Never stored. Ties are allowed (fixtures.py:150-153, 208-211). |
| M11 | Logs are append-only | Corrections and voids are new rows, never edits (timeline.py:19-21, fixtures.py:425-443). |
| M12 | Enums are strings | No native Postgres enums; adding a value needs no migration (base.py:64-76). |
Built today, or still to build
| Piece | Today | Agreed design | Status |
|---|---|---|---|
| Core tables (sport, competition, fixture, fixture_competitor, participant) | packages/contract/src/omnium_contract/models/taxonomy.py, fixtures.py, participants.py | No change | Built today |
HEAD_TO_HEAD, RANKED_FIELD | In use: 9,887 and 2,888 fixtures (laptop) | No change | Built today |
SERIES, COMPOSITE | In the enum (enums.py:23-26) and tables (fixture_attempt, fixture_relation); no fixtures use them on the laptop | Not covered by a section | Partly built |
| Standings overlay | stat_table in the stats engine; classification dropped (migration 041) | See Stats, feeds and delivery | Built today |
| Competition tree and closure | taxonomy.py:70-134, migration 033 | No change | Built today |
external_id_map | ingestion.py:22-40; no cleanup of dead rows found | Not covered by a section | Built today |
| One draw rule for every source | Scored and imported draws differ (see above) | Not decided | Proposed |
| A record of every command, with its result | The log is timeline_item; rejected commands leave no row | The agreed design adds a command record. See Commands and the workflow engine | Agreed, to build |
| Alert tables | None | The agreed design adds alert storage. See Monitoring, logs and alerts | Agreed, to build |
| Date partitions for the big logs | stat_value, stat_change, stat_snapshot are partitioned; timeline_item is not (timeline.py:30-34) | The agreed design splits big tables by date. See Database and the read-only copy | Agreed, to build |
| Migration 060 (PR #155) | On main since 30 Sep: merged_into_id on four core tables, xref / enrich / recon schemas, stat_code and stat. Nothing reads or writes them yet | Not covered by a section. Which id map and which stat store win is open | Partly built |
docs/erd.md | Out of date (45 tables, 7 Aug) | Regenerate with make erd | Proposed |
The exact names of the agreed new tables are on the linked pages. They are not repeated here, so there is one place to change them.
Numbers
| Number | Value | Where it came from |
|---|---|---|
| Tables (not partitions) | 79 | Laptop database at migration 059, pg_class, 7 Oct |
| Partitioned tables / partitions | 3 / 65 | Same |
| Sports | 85 | Same, sport |
| Competitions | 4,568 | Same |
| Participants | 20,565 | Same |
| Fixtures | 12,775 (9,887 HEAD_TO_HEAD, 2,888 RANKED_FIELD) | Same |
fixture_competitor rows | 41,725 (6,740 nested) | Same |
external_id_map rows | 79,850 | Same |
| Map rows pointing at deleted fixtures | 25 | Same |
| Asian Games 2026 tree | 2,709 nodes, depth 0 to 3, 8,917 fixtures | Same, through competition_closure |
| Commons records (no sport, no links) | 97 | Same |
| Subtree read, recursive vs closure | 2.4 s vs 96 ms | Migration 033 docstring; how it was measured is not written there |
The laptop database is a working copy, not production. Production counts were not checked for this page.
Read next
- Truth and ownership: which of these rows is the truth, who may change it, and how
pinnedworks. - Commands and the workflow engine: how a change reaches these tables.
- The live scoring engine: how
timeline_item,scoreboardandstat_valueare written during a match. - Database and the read-only copy: growth, partitions and who reads where.