Getting data in · Sport plugins
Sport plugins and rule versions
How a sport's rules are written, found, released and pinned, so a deploy never changes a match that is already running.
In one minute
A sport plugin is the Python package fanos-omnium-flows. It holds one folder per workflow, and every folder has the same six files. The engine finds the folders through Python entry points and asks for each piece by a key like football.goal. It never imports a sport by name.
Most sports need no code. A sport scored as a scorecard is described in settings in the admin panel and uses the shared Results workflow. Code is written only for a sport scored event by event, or with a rule a setting cannot say.
Today, a match is pinned to a document of component names, not to code. A deploy with changed code therefore changes the rules of every running match on its next command. The agreed design fixes this: each release never changes, it is replayed against every recorded match in CI, it starts on one competition, and an events match is pinned to its exact release when the match is created.
Your question, answered (P12): a published change is picked up. Scorecard and operational workflows use it on their next command. New events matches use it. Only an events match that is already running keeps its old release, unless a person moves it after seeing a preview of every value that would change.
What this part does
This part decides what a sport's rules are made of, and how a new version of them reaches matches safely. Answered wrong, a deploy changes a running match's score, a new sport needs engine changes, or one bad release stops every match of a sport.
| # | Question | Why it matters |
|---|---|---|
| 1 | What does a sport plugin hold, and what stays in settings? | New sports, competitions and clients must be added with settings, not code |
| 2 | How is a new version tested before it can be used? | One bug in a sport's code reaches every match of that sport |
| 3 | How does a running match keep its rules while a new version ships? | A deploy during a match must never re-score balls already played |
| 4 | Can rules change during an event, and how? | A competition changes a tie-break on day 3: new matches use it, finished ones keep theirs |
| 5 | What may still change after a match is created? | At the Asian Games, units created as duels turned out to be rankings, and only SQL could fix them |
| 6 | Who may publish a new version, and how is a bad one taken back? | A wrong release must be stopped for new matches in minutes, without touching the matches it already scored |
The split between code and settings is the base of everything else (decision P2):
| Lives in code (the plugin) | Lives in settings (the database, no deploy) |
|---|---|
| Commands and their input shapes | Competitions |
| Checks (validations) | Format values: overs, periods, tie-breaks |
| The step function (the action that builds match state) | Priority lists and field groups (Section 2) |
| The line catalogue: every number a sport may write | The mode: scorecard or events |
| How a format's numbers are used | Integration mappings |
| A sport's stat cards | Which release a competition uses (new) |
How it works

A sport becomes runnable in five steps. Steps 1 to 4 are built today. Step 5 is where today and the agreed design differ.
- Six files, one class. A sport folder has
constants.py,commands.py,validations.py,actions.py,stats.pyandsport.py. The first five each define a mixin class.sport.pyjoins them into oneSportsubclass with a permanentcode, such asfootball. - One line names it. The plugin's
pyproject.tomllists the folder under the entry-point groupomnium.flows. An entry point is a name a Python package publishes so other code can find it without importing it by name. - The registry imports it.
load_registry()reads every entry point in that group, imports the module, and registers each concreteWorkflowclass it finds. Each decorated method becomes a component with the keycode.member, for examplefootball.goal. - The engine asks by key. A published program document lists component keys. When a command runs, the engine looks each key up in the registry and calls it. The engine package never names a sport.
- A match is pinned to rules. Today the pin is to a program document of names (
fixture.scoring_program_version_id). In the agreed design it is to an exact release of the whole package (fixture.release_id), set when the match is created.
Agreed, to build How a new version reaches matches. Each release climbs six steps. It can be refused at step 2, and taken back from steps 5 or 6 at any time without touching a match already on it.

Agreed, to build How a match keeps its release. Releases are installed side by side, so a deploy adds a release and never replaces one. Each events match points at the release it was created with.

Who picks up a published change (P12)
This answers the question asked on the design doc: "if I change logic inside the plugin and publish, will it not get picked?" It is picked up wherever that is safe.
| Kind of work | Picks up the new release | Why |
|---|---|---|
| Scorecard workflows (most of the work) | On the next command | A command changes records; it does not replay a log. Each log row records which release applied it |
| Operational workflows (Results, Games) | On the next command | Same reason. Today they already run the newest published version (scoring/store.py:176) |
| A new events match, chosen competition | At creation, once the release is that competition's default | Pinned in the same transaction as the match is created (R2) |
| A new events match, any competition | At creation, once the release is the sport's default | Same |
| An events match that is already running | Only when a person moves it | The engine replays its log with the new release, shows every value that would change, and on confirm rebuilds the state; the move is logged |
A critical bug in a live match is fixed in minutes, never by surprise.
A worked example
Example · Adding a new sport
Say a client wants kabaddi next week. The first question is the mode, not the code.
Path A: scored as a scorecard (most sports, P1). No release, no deploy.
- An admin opens the sport wizard in the admin panel (
frontend-admin/src/components/sport/SportWizard.tsx, built today). - They set the unit types (a match), the formats (two halves of 20 minutes) and the master lists. This saves a
sport_definition_versionrow. - They add the line keys, such as
pointsandraid_points(example names), and set the mode to scorecard. Agreed, to build The line catalogue and mode screen are agreed, not built. - Fixtures use the shared
resultsworkflow. A scorer or an import sets the score, the result and the places. Done the same day.
Path B: scored event by event, with its own rules. A code release.
- Run
make new-sport code=kabaddi. Partly built Today this scaffolds the old file shape (compute,triggers,procedures) and the new folder failsmake check. P11 fixes the scaffold. Until then, copyfootball/by hand. - Write the six files.
constants.pyholdsKabaddiFormat;commands.pyholdsraid,tackle,half_end;validations.pythe checks;actions.pythe step function and lines;stats.pythe cards;sport.pyjoins them withcode = "kabaddi"and thefactscatalogue. The line-by-line guide is in Writing a workflow in code. - Add one line to
packages/flows/pyproject.toml:kabaddi = "omnium_flows.kabaddi". - Add
packages/flows/tests/kabaddi/, with a test that names every component, and one golden match file intests/goldens/kabaddi/. - Bump the plugin version, for example 0.6.0 to 0.7.0 (a new sport adds things, so minor).
- Run
make check. The structure gate checks the six files, the test folder and that every component is named in a test. - Today:
make publish-flowsfrom a laptop, then deploy withOMNIUM_FLOWS_VERSION=0.7.0, then install the programs. Agreed: merge to main; CI builds, proves and publishes to staging; an admin approves for prod; kabaddi has no old matches, so the replay has nothing to compare and the release is made the default for kabaddi.
Example · Fixing a rule in the middle of an event
A cricket league changes its super over tie-break on day 3. The installed release is 0.7.0. Values are examples.
- Code. A developer changes the tie-break in
cricket/actions.py, bumps the version to 0.8.0, and writes release notes. The notes declare the change: sportcricket, what "super over tie-break", and the keys it may move. - CI replay (P4, R4, R5). CI replays every recorded cricket match with 0.7.0 and with 0.8.0, each in its own process. Say 212 matches replay; 3 tied matches now have a different winner. All 3 differences are inside the declared keys, so the build passes and the report lists them.
- A mistake caught. Suppose the change had also moved the count of wides in one match. That key is not declared, so publishing is refused with the match id, the ball, and the old and new value.
- Staging, then prod. The passing release is published to staging on its own. An admin presses "Approve for prod". The
plugin_releaserow now has statusprodand is never edited again, except to retire it. - One competition, forward only (P5, P8). The admin adds a
release_defaultrow: cricket, this league, release 0.8.0,from_at= day 3, 00:00. - What each match runs.
- Match A, day 1, finished: stays on 0.7.0.
- Match B, day 2, live: stays on 0.7.0. It does not switch mid-match.
- Match C, created on day 3: gets 0.8.0 in the same transaction as its creation.
- Match D, created on day 1 for day 4: it was pinned to 0.7.0 when created, because the day-3 row did not exist yet. It has not started, so an operator may move it to 0.8.0 with
results.set_releaseand a reason.
- If 0.8.0 is wrong. One click takes it back: the league's default returns to 0.7.0. Match C keeps 0.8.0, and the background check keeps comparing its state with its log.
- If match B truly needs the fix. An operator asks to move it. The engine replays B's log with 0.8.0 and lists every value that would change. On confirm it rebuilds B's state from its log with 0.8.0, and the move is logged (P12).
Low-level design
This section shows real code first, then the agreed new pieces. Commands, validations and actions themselves are explained line by line in Writing a workflow in code; here we stay on the contract, the registry, sport.py, releases and pinning.
The contract: what a plugin may import Built today
The plugin depends on one package only: omnium-contract. It holds the base classes, the five decorators and the registry. The engine and the plugin meet only here.
# packages/flows/pyproject.toml:11-23 (trimmed)
dependencies = [
# The ONLY runtime dependency: models + kit + the stat compiler, zero
# engine code. import-linter enforces this (see the root pyproject).
"omnium-contract>=0.2",
]
Every decorator attaches a ComponentSpec to the function and does nothing else. The spec is what the registry, the publish gate and the screens read.
# packages/contract/src/omnium_contract/flows/components.py:69-108 (trimmed)
@dataclass(frozen=True, slots=True)
class ComponentSpec:
"""``source_sha`` currently hashes the component's own source. Before M3
(publish pinning) it must grow to the transitive closure — the component
plus every helper it calls — per the round-eight flaw review, so a
``shared/`` edit cannot change behaviour without changing a pin."""
kind: ComponentKind
name: str # the member name; the key's last segment
reads: tuple[str, ...] = ()
input_model: type[BaseModel] | None = None
validations: tuple[str, ...] = ()
actions: tuple[str, ...] = ()
gives: Gives | None = None
when: When = When.NOW
keys: tuple[str, ...] = ()
source_sha: str = ""
# components.py:116-121
def _source_sha(fn: Callable[..., Any]) -> str:
source = inspect.getsource(fn)
return hashlib.sha256(source.encode()).hexdigest()
In plain words: each component carries a fingerprint, source_sha, but it is the hash of that one function's own text. A helper the function calls is not part of it. The code's own docstring says this must be fixed. The agreed design replaces it with one code_sha over the whole package (P3).
sport.py: one class per sport Built today
sport.py joins the mixins and declares what the sport is. This is the real football class.
# packages/flows/src/omnium_flows/football/sport.py:40-64 (trimmed)
class Football(
FootballCommands, # commands.py
FootballValidations, # validations.py
FootballActions, # actions.py
FootballTotals, # stats.py
Sport,
):
code = "football"
format_model = FootballFormat # constants.py
facts: ClassVar[dict[str, str]] = {
"goals": "Goals scored by a side, per half (segment) or the match",
"goals_for": "Goals a side scored in the match",
"own_goals": "Own goals put in by a player, counted for the other side",
"red_cards": "Red cards shown, a second yellow included",
# ... 8 facts in all
}
roles: ClassVar[frozenset[str]] = frozenset({"scorer", "assist", "carded"})
In plain words:
codeis permanent. It prefixes every component key, so renaming it breaks every pin.format_modelis the typed shape offixture.format. A scoring workflow without one is refused at class definition.factsis the line catalogue. The publish gate holds each action's declared keys to this list, so a typo in a fact name is refused before it becomes a column of zeroes.
The base class enforces its rules when Python reads the class, not later:
# packages/contract/src/omnium_contract/flows/sport.py:118-179 (trimmed)
def __init_subclass__(cls, **kwargs: Any) -> None:
lineage = [base for base in cls.__mro__[1:] if ... concrete Workflow ...]
if len(lineage) > 1:
raise TypeError("inheritance depth is capped at workflow → format")
parent = lineage[0] if lineage else None
code = _check_declarations(cls)
for name in dir(cls):
spec = spec_of(getattr(cls, name, None))
if spec is None:
continue
inherited_from = f"{parent.code}.{name}" if parent and name not in cls.__dict__ else None
components[name] = RegisteredComponent(
key=f"{code}.{name}", sport_code=code, spec=spec, fn=member,
inherited_from=inherited_from,
)
if parent is not None and not overrode_anything and cls.format_model is parent.format_model:
raise TypeError("... a numbers-only difference is a config in the dashboard, not a subclass")
if parent is not None and not cls.__dict__.get("scope"):
raise TypeError("... a format must say which configs it scores")
In plain words: a format is a subclass of a sport that changes a rule, such as The Hundred in cricket. It may be one level deep only. It must override something, because a numbers-only change (100 balls) is a setting. It must name its scope, the configs it scores. The real example, TheHundred(Cricket) in cricket/hundred.py:27-35, overrides one validation (bowler_quota) and inherits 32 of its 33 components (counted from the registry, 7 Oct).
The registry: found by entry point, never by name Built today
# packages/flows/pyproject.toml:26-37
[project.entry-points."omnium.flows"]
cricket = "omnium_flows.cricket"
swimming = "omnium_flows.swimming"
beach_volleyball = "omnium_flows.beach_volleyball"
volleyball = "omnium_flows.volleyball"
boxing = "omnium_flows.boxing"
football = "omnium_flows.football"
athletics = "omnium_flows.athletics"
games = "omnium_flows.games"
results = "omnium_flows.results"
# packages/contract/src/omnium_contract/flows/registry.py:94-117 (trimmed)
def load_registry(group: str = ENTRY_POINT_GROUP) -> Registry: # "omnium.flows"
registry = Registry()
for entry_point in importlib_metadata.entry_points(group=group):
module = entry_point.load()
found = [
member for member in vars(module).values()
if isinstance(member, type) and issubclass(member, Workflow)
and not member.__dict__.get("__abstract__", False)
]
if not found:
raise RegistryError(f"entry point {entry_point.name!r} ... defines no Workflow")
for workflow in found:
registry.add_workflow(workflow) # refuses a duplicate code or key (:35-43)
return registry
In plain words: nine entry points give ten workflows, because omnium_flows.cricket exposes both Cricket and TheHundred (cricket/__init__.py). A module with no workflow, a workflow code used twice, or a component key used twice stops loading with a clear error.
How the engine calls a component today Built today
# packages/core/src/omnium_core/flows_host.py:57-128 (trimmed)
def _check_version_pin() -> None:
pin = os.environ.get("OMNIUM_FLOWS_VERSION")
if not pin:
return # dev: whatever is installed
installed = importlib_metadata.version("fanos-omnium-flows")
if installed != pin:
raise SandboxError(f"flows version mismatch: the deployment pins {pin} "
f"but {installed} is installed — fix the image or the pin")
def registry() -> Registry:
"""The installed flows package's registry, discovered once per process."""
global _registry
if _registry is None:
_check_version_pin()
_registry = load_registry()
return _registry
@lru_cache(maxsize=512)
def _component_for(key: str) -> RegisteredComponent:
component = registry().component(key)
if component is None:
raise SandboxError(f"component {key!r} is not in the installed flows package — "
f"the pinned program and the deployed omnium-flows version disagree")
return component
def function_for(impl: str) -> FlowsFunction:
return FlowsFunction(_component_for(impl))
In plain words, three facts matter for releases:
- One registry per process. A server can hold only one version of every sport at a time.
- The process-wide pin.
OMNIUM_FLOWS_VERSIONis a deploy setting. If the installed wheel differs, scoring refuses to start. The Jenkins staging job passes it as a Docker build argument (Jenkinsfile_stg:463-476). - Lookup by name only. The pipeline resolves a node with
flows_host.function_for(node.impl)(scoring/pipeline.py:483-495). Whatever code is installed under that key runs.
Pinning today: names, at the first command Partly built
# packages/core/src/omnium_core/scoring/store.py:152-199 (trimmed)
async def for_fixture(session, fixture) -> Pinned:
"""Pinned if it has been scored before; otherwise the most specific published
one for its format and kind, which the first accepted command will pin."""
if fixture.scoring_program_version_id is not None:
return await load_version(session, fixture.scoring_program_version_id)
resolved = await published_for(session, fixture.sport_id,
fixture_type=fixture.fixture_type,
format_code=fixture.format_code)
...
async def for_workflow(session, workflow_id) -> Pinned:
"""An apply-mode workflow is not pinned per fixture: its commands change
records, and the newest rules apply."""
async def pin(session, fixture, version_id) -> None:
if fixture.scoring_program_version_id is not None:
return
fixture.scoring_program_version_id = version_id
# packages/admin/src/omnium_admin/routers/bridge.py:284-285
if result.accepted and not result.duplicate:
await store.pin(session, fixture, pinned.version_id)
In plain words: a match gets its pin on its first accepted command, unless an operator picked a program when creating it (admin/routers/fixtures.py:364-366). The pin points at a scoring_program_version row. That row's document lists component keys and each one's implSha, but nothing reads implSha at run time. So a match pinned to version 4 runs whatever code is installed under those keys today.
Publishing today Partly built
| Step | What happens | Where |
|---|---|---|
| Build and publish the wheel | By hand, make publish-flows. Refuses a version already in CodeArtifact | scripts/publish-flows.sh:48-61 |
| Deploy | The image installs the exact wheel when OMNIUM_FLOWS_VERSION is set | .env.deploy.example:201, Jenkinsfile_stg:463-476 |
| Generate programs | install_all builds one program document per workflow from the registry | core/flows_publish.py:447 |
| Skip unchanged | If the new document's checksum equals the published one, nothing is published | core/flows_publish.py:410-411 |
| Goldens at install | Skipped: replay=False | core/flows_publish.py:434-436 |
| Goldens through the admin route | POST /flows/{sport}/publish runs the full gate, including the scoring_golden table replay | admin/routers/scoring_programs.py:290-300, scoring/publish.py:146-147 |
The "skip unchanged" row is where today's gap bites. The document holds implSha = source_sha per node (flows_publish.py:110). Football's actions call a plain helper, _line (football/actions.py:27), that has no decorator. Change _line and every component's source_sha stays the same, so the checksum stays the same, so install publishes nothing, while the behaviour has changed.
The structure gate Built today
# packages/flows/scripts/check_structure.py:26-61 (trimmed)
REQUIRED_MODULES = (
"constants.py", "commands.py", "validations.py",
"actions.py", "stats.py", "sport.py",
)
def check_shape() -> None:
for package in sorted(SRC.iterdir()):
if not package.is_dir() or package.name in {"shared", "__pycache__"}:
continue
for module in REQUIRED_MODULES:
if not (package / module).exists():
problems.append(f"{package.name}: missing {module} — the shape is fixed")
if not (TESTS / package.name).is_dir():
problems.append(f"{package.name}: no tests/{package.name}/ directory")
def check_components() -> None:
registry = load_registry()
for component in registry.components():
if component.key.rsplit(".", 1)[-1] != component.spec.name:
problems.append(f"{component.key}: key does not end with member name ...")
if component.inherited_from is None and component.spec.name not in test_text:
problems.append(f"{component.key}: no test names it")
make structure runs this and check_clean_import.py, which imports every entry point in a subprocess and fails if import prints, opens files or uses the network. make check runs both, and CI runs make check (.github/workflows/ci.yml:70). The plugin's own ruff settings ban clocks, random, os.environ and file IO inside components (packages/flows/pyproject.toml:66-73), so the same log always gives the same answer.
The goldens Built today
A golden is a recorded match kept as proof: its format, its log and the state and lines it produced. Two kinds exist today:
| Kind | Where | Run by |
|---|---|---|
| File goldens | 20 JSON files in packages/flows/tests/goldens/ (cricket 5, football 4, swimming 3, beach volleyball 2, boxing 2, volleyball 2, athletics 1, The Hundred 1) | tests/test_goldens.py, inside make test, so in CI |
| Database goldens | scoring_golden rows that point at a real fixture | The publish gate, scoring/publish.py:320, only through the admin publish route |
The file test compares canonical JSON: keys sorted, whole floats as integers, null equal to absent (tests/test_goldens.py:20-30). The agreed compatibility replay uses the same idea, but against two releases and after every event.
The new tables Agreed, to build
Two new tables and four new columns (R1). Nothing below exists in the code yet.
-- NEW: one row per plugin release. Never updated after status 'prod', except to 'retired'.
CREATE TABLE plugin_release (
id uuid PRIMARY KEY,
version text NOT NULL UNIQUE, -- "0.8.0", the wheel version
code_sha text NOT NULL, -- sha256 of the wheel's RECORD (Section 5, M6)
status text NOT NULL, -- proven, staging, prod, retired
notes text NOT NULL,
rule_changes jsonb NOT NULL DEFAULT '[]', -- declared: [{"sport":"cricket","what":"super over tie-break","keys":[...]}]
replay_report jsonb NOT NULL, -- matches replayed, differences, by sport
approved_by text, approved_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
-- NEW: which release new matches use. competition_id NULL = the sport's default.
CREATE TABLE release_default (
sport_id uuid NOT NULL REFERENCES sport(id),
competition_id uuid REFERENCES competition(id),
release_id uuid NOT NULL REFERENCES plugin_release(id),
from_at timestamptz NOT NULL, -- forward only (P8)
set_by text NOT NULL, set_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (sport_id, competition_id, from_at)
);
ALTER TABLE fixture ADD COLUMN release_id uuid REFERENCES plugin_release(id); -- pinned at creation (events)
ALTER TABLE scoring_program_version ADD COLUMN release_id uuid REFERENCES plugin_release(id);
ALTER TABLE timeline_item ADD COLUMN release_id uuid; -- which release applied this command (P6)
ALTER TABLE scoring_checkpoint ADD COLUMN code_sha text; -- Section 5, M4
In plain words:
code_shacovers the whole wheel, through the hash of itsRECORDfile (the list of every file in the wheel with its hash). Any change anywhere, a shared helper included, makes a new fingerprint.rule_changesis what the CI replay judges against. An undeclared difference cannot pass.release_defaultonly moves forward: a new row with a laterfrom_at, never an edit to the past.scoring_program_versionstays as the generated document and gains the release it came from, so a document and its code can no longer drift apart.scoring_checkpointgainscode_shabecause saved checkpoints carry no version today (contract/models/scoring.py:185-213). After a deploy, an incremental fold could resume from old-code state.
How a match gets its release Agreed, to build
-- At creation of an events match, inside the same transaction as the INSERT:
SELECT release_id FROM release_default
WHERE sport_id = $sport
AND (competition_id = $competition OR competition_id IS NULL)
AND from_at <= $scheduled_start
ORDER BY competition_id NULLS LAST, from_at DESC
LIMIT 1;
-- fixture.release_id = that; never changed by the engine afterwards.
The most specific default wins: the competition's row before the sport's row, and the latest from_at that is not after the match's start (R2). A scorecard or operational command reads the same default at the moment it runs and writes it to timeline_item.release_id.
Retiring a release is refused while this finds a row (R3):
SELECT 1 FROM fixture
WHERE release_id = $r AND status NOT IN ('COMPLETED','CANCELLED','VOID');
Running releases side by side, each in its own worker process with its own folder and registry, is Section 5's decision M6. See The live scoring engine. The registry change it needs is small: load_registry(path) loads one release from its folder, and flows_host.registry() becomes registry_for(release).
The compatibility replay in CI Agreed, to build
# flows/scripts/replay_compat.py (new, from the design doc)
def main(old: str, new: str, corpus: Path, notes: ReleaseNotes) -> int:
with Worker(release=old) as a, Worker(release=new) as b: # separate processes, own folders
failures = []
for match in Corpus(corpus): # goldens + nightly prod export
ra = a.replay(match.format, match.log, every_event=True)
rb = b.replay(match.format, match.log, every_event=True)
for diff in first_differences(ra, rb): # canonical JSON, state and lines
if not notes.declares(match.sport, diff.keys):
failures.append(f"{match.id} ball {diff.seq}: {diff.key} {diff.old} -> {diff.new}")
print_report(failures)
return 1 if failures else 0
- The corpus. Today's 20 file goldens plus the logs of finished matches, exported nightly from prod to a read-only store without personal data. Each entry is the match's format, its log, and its final state and lines.
- Replay twice. With the previous release and the new one, each in its own process with only its own package loaded.
- Compare. Canonical JSON of the state after each event and of the final lines, so a difference names the first ball where the two releases part.
- Judge. Each difference must fall inside a declared rule change (its sport and keys). Anything else fails the build with the match, the ball, and the old and new value.
Cost. From the design doc: the 20 goldens replayed in 1.35 s in total on 6 Oct, Python start-up included. Re-measured for this page on a laptop on 7 Oct: 0.75 s wall time, pytest reporting 0.45 s. For 200 recorded matches of about 600 events, replayed twice, that is about 240,000 steps, well under a minute (estimate, from the design doc).
The release workflow in CI Agreed, to build
# .github/workflows/flows-release.yml (new, from the design doc)
on: { push: { branches: [main], paths: ["packages/flows/**"] } }
jobs:
release:
steps:
- run: make check # gate, tests, goldens
- run: uv build --package fanos-omnium-flows
- run: python packages/flows/scripts/replay_compat.py --old $PREV --new dist/*.whl --corpus s3://.../replay-corpus
- run: scripts/publish-flows.sh # refuses an existing version (today)
- run: python -m omnium_worker register-release --env staging # plugin_release row + install
Prod is not in this file. An admin presses "Approve for prod" on the releases screen, which runs the same register step against prod (R7). Who may press it is Section 13's question.
Changing a unit's type Agreed, to build
Today archetype and fixture_type are set only when a fixture is created (admin/routers/fixtures.py:346-352), and no command changes them. At the Asian Games a duel that was really a ranking needed SQL (F16, F33). The agreed command results.change_type (R6):
-- results.change_type, run as a dry run first (Section 4, C11), then for real:
SELECT ... FROM fixture WHERE id = $fixture FOR NO KEY UPDATE; -- the match lock
-- refused if the match is in events mode and has any timeline_item
-- dry run: list every rank, place, score shape and medal that the new type cannot hold
UPDATE fixture SET archetype = $archetype, fixture_type = $type, changed_seq = $seq WHERE id = $fixture;
DELETE FROM fixture_competitor_rank WHERE fixture_competitor_id IN (...); -- only what the person confirmed
INSERT INTO timeline_item (...); -- old type, new type, removed values
COMMIT;
Sides (fixture_competitor) are kept. After the change, the import's compare can fill the unit's places on its next run, as happened after the SQL fix on 1 Oct. results.set_release, for a match that has not started, is the other new command (P8).
Settings
| Setting | Where | Default |
|---|---|---|
| Which release new matches use | release_default, per sport, per competition, from a date (new) | The sport's newest approved release |
scoring.mode | competition.meta (Section 5) | scorecard |
| Replay corpus location | CI secret | The nightly export bucket |
| Old releases kept installed | Automatic | While any unfinished match uses them |
OMNIUM_FLOWS_VERSION (today) | Deploy environment | Unset in dev; replaced by the per-release code_sha check |
Migration from today's code
code_shaover the whole package; written onscoring_program_version.- Create
plugin_releaseandrelease_default. Register today's installed 0.6.0 as the first release and the default for every sport. - Add
fixture.release_id, filled for existing unfinished events matches from what is installed today. - Pin at creation:
store.pinmoves from the bridge to fixture creation. - Releases side by side in workers (Section 5, migration step 4).
results.change_typeandresults.set_release.- The CI release workflow and the replay corpus export.
- The admin releases screen, the release line on a match, the change type dialog.
- The new-sport scaffold and docs, fixed to the six-file shape.
Tests that prove it
| Test | Proves |
|---|---|
| Start a match on release A, install B that changes a rule, score a ball | The ball uses A (P6) |
| Change a shared helper only | code_sha changes for every sport (P3) |
| A release that changes a score with no note | CI fails, naming the match and ball (P4, R5) |
| The same change declared in the notes | CI passes, and the report lists it |
| Take a release back | Defaults move back; matches on it keep it (P7) |
| Rename a component in B; a match on A scores | Works: A's code is still installed (P9) |
| Retire a release with an unfinished match | Refused (R3) |
| Change type on a scorecard unit with places | Dry run lists the places; after confirm, type changed, places removed, sides kept (R6) |
| Change type on an events match with events | Refused (P10) |
make new-sport, then make check | Passes (P11) |
Screens
Agreed, to build Two people use these screens: an admin who adds sports and rolls out releases, and an operator who sees which rules a match runs. Each sees what one click will change before they click.
| Screen | Who | What they see | What they can do |
|---|---|---|---|
| Sport releases (admin panel, new) | Admin | Each release: number, status, the replay result ("212 matches replayed, 0 differences" or the named rule changes), and what uses it ("default for IPL 2027; 14 live matches") | Make default for a competition or the sport; take back; retire (refused while matches use it) |
| Sport settings (admin panel) | Admin | Today's wizard, plus the line keys and the mode | Add a sport with no code; add a format |
| Match page (console) | Operator | "Rules: cricket 0.7.0", and "pinned at creation" for an events match | Move a match that has not started, with a reason |
| Change type (console) | Operator | Before: type and sides. After: new type, sides kept, every value that will be removed | Confirm or cancel |
The numbers in the releases screen are examples from the design doc. Today the old program editor's routes return 410 Gone (admin/routers/scoring_programs.py:322), and no screen lists releases or shows a match's release.
When things go wrong
| What goes wrong | What happens today | What happens in the agreed design |
|---|---|---|
| A deploy ships a cricket release that changes how wides are counted | Every running cricket match re-folds its whole log with the new code on its next ball | Running matches stay on their release; only new matches of the chosen competition use the new one (P5, P6) |
| A release changes scores by accident | Caught only if a golden covers it | CI replays every recorded match with both releases; the undeclared difference refuses publishing, naming the match and ball (P4) |
| A release has a bug no recorded match shows | Reaches every match of the sport | Reaches one competition's new matches; one-click take-back; matches on it keep it and the background check watches them (P5, P7) |
| A helper shared by many sports changes | No fingerprint changes; install publishes nothing | code_sha covers the whole package, so every sport's release changes and is proven again (P3) |
| A component is renamed | A pinned match can no longer find its key. The design doc infers this becomes an unhandled error, not a clean refusal (not run) | Matches on the old release keep running the old code, which stays installed (P6, P9) |
| A competition changes a tie-break on day 3 | A deploy changes the rules for every match of the sport: live ones on their next command, finished ones if they are ever rebuilt | A release_default row from day 3; days 1 and 2 keep their release; a live match does not switch (P8) |
| A unit created as a duel is really a ranking | Needs SQL | results.change_type: sides kept, removals listed and confirmed, logged (P10) |
| An events match needs a type change after it started | Needs SQL | Refused: its events were scored under the old type. Void and recreate it (P10) |
| The release a match needs is missing on a server | The process refuses to score if OMNIUM_FLOWS_VERSION disagrees | The engine refuses that match and alerts; it never runs it with other code (P6) |
| Someone needs a sport today with no code | Use the Results workflow | Same: describe it in settings and score it as a scorecard (P1) |
| A person deletes an old release | Not possible; there is no release record | Refused while any unfinished match is pinned to it (P9, R3) |
| Two admins set defaults at once | Not applicable | For a competition, the unique key on release_default (sport, competition, from_at) refuses a second row with the same time; different times both stand and the latest wins. For a sport-wide row competition_id is NULL, and a plain Postgres UNIQUE treats NULLs as different, so two sport-wide rows with the same time would both save. Not covered in Section 6: it needs UNIQUE NULLS NOT DISTINCT (Postgres 15 and later) or a partial index |
| CI is down when a fix is urgent | make publish-flows from a laptop | No release is made by hand (R7). Not covered in Section 6: a break-glass path when CI itself is down |
Decisions
All 22 are agreed (closed 6 Oct 2026).
| # | Decision | In plain words |
|---|---|---|
| P1 | A scorecard sport needs no code: unit types, line keys, formats and statuses are settings, and it uses the Results workflow | Most new sports are added in an afternoon in the admin panel |
| P2 | Code holds how a sport works; settings hold how a competition uses it | Overs, tie-breaks or sources change without a deploy |
| P3 | A release is immutable, numbered major.minor.patch, with a fingerprint of the whole package. A fix is always a new release | A helper change can no longer alter behaviour without a new fingerprint |
| P4 | CI proves every release: the gate, the goldens, and a replay of every recorded match with old and new. An undeclared difference refuses publishing | An accidental change is caught before it exists anywhere, with the match and ball |
| P5 | Staging automatically, prod only on a person's approval; first one competition, then the sport. Take-back is one click | One bad release reaches one competition's new matches at most |
| P6 | An events match is pinned at creation; releases run side by side; scorecard and operational workflows use the newest and each log row records it | A deploy mid-match is safe, and every change says which rules made it |
| P7 | Taking a release back changes only defaults | A rollback never re-scores a match |
| P8 | Rules change during an event only forward; a match not started may be moved, with a reason | A day-3 change applies from day 3; days 1 and 2 stay as played |
| P9 | A release stays installed while any unfinished match uses it | A renamed component never stops a running match |
| P10 | A unit's type can change by command, with a preview and confirm; an events match only before its first event | The duel-to-ranking fix takes one click, not SQL |
| P11 | The new-sport scaffold and docs use the six-file shape, with a test and a golden from day one | A new sport passes the gate on its first commit |
| P12 | Scorecard and operational work picks up a release on its next command; new events matches at creation; a running events match only when a person moves it after a replay preview | A fix is picked up at once where safe, and for a live match only when a person has seen what it changes |
| U1 | A sport releases screen with status, replay result, what uses each release, and buttons that say what they change | An admin sees "14 live matches use this" before touching it |
| U2 | Every match page shows its release; an events match shows that it is pinned | Nobody has to ask which rules a match used |
| U3 | A change type dialog with before and after and the values to be removed | Nobody removes a result by surprise |
| R1 | New plugin_release and release_default; fixture, scoring_program_version and timeline_item gain release_id | Every match and every change says which release made it |
| R2 | An events match gets its release in the same transaction as its creation, from the most specific default | No match is created without knowing its rules |
| R3 | A release row is never edited after prod, except to retire it; retiring is refused while an unfinished match uses it | What was published stays exactly what was published |
| R4 | The CI corpus is the goldens plus finished prod matches without personal data, replayed with both releases in separate processes, compared after every event | A difference names its match and first ball |
| R5 | A difference passes only inside a declared rule change (sport and keys) | Intended changes are written down; accidental ones cannot pass |
| R6 | results.change_type takes the match lock, refuses an events match with events, dry-runs, then removes only confirmed values with one log row | The type change is as safe and visible as any edit |
| R7 | Publishing the wheel and programs moves into CI: staging on merge, prod on approval | No release is made by hand from a laptop |
Built today, or still to build
| Piece | Today | Agreed design | Status |
|---|---|---|---|
| Six-file sport shape and gate | flows/scripts/check_structure.py:26-61, run by make check | Same | Built today |
| Entry-point registry | contract/flows/registry.py:94-117; 9 entry points, 10 workflows, 245 components | load_registry(path) per release folder | Partly built |
| Engine never imports flows by name | core/flows_host.py:90-128 | Same, per release | Built today |
| Formats as subclasses | contract/flows/sport.py:118-179; cricket/hundred.py | Same | Built today |
| Scorecard sports with no code | Results workflow (results/sport.py:23-30) and the sport wizard | Plus line keys and mode in settings | Partly built |
| Fingerprint | source_sha of one function (contract/flows/components.py:116-121), never checked at run time | code_sha of the whole wheel, checked per release | Agreed, to build |
| Version pin | One per process, OMNIUM_FLOWS_VERSION (core/flows_host.py:57-87) | Releases side by side, per-release check | Agreed, to build |
| Match pin | Program document of names, at first command (admin/routers/bridge.py:285) | fixture.release_id, at creation | Agreed, to build |
plugin_release, release_default | Do not exist | New tables (R1) | Agreed, to build |
| File goldens | 20 files, flows/tests/test_goldens.py, in CI | Part of the replay corpus | Built today |
| Compatibility replay | Does not exist | flows/scripts/replay_compat.py (new) | Agreed, to build |
| Publishing | By hand, make publish-flows (scripts/publish-flows.sh) | CI to staging; prod by approval | Agreed, to build |
| Canary on one competition and take-back | Does not exist | release_default rows and the releases screen | Agreed, to build |
| Moving a live match after a preview | Does not exist | P12 | Agreed, to build |
results.change_type, results.set_release | Do not exist; type set only at creation (admin/routers/fixtures.py:346-352) | New commands (R6, P8) | Agreed, to build |
| New-sport scaffold | Old shape (flows/templates/sport/ has compute, triggers, procedures); fails the gate | Six-file shape with a test and a golden (P11) | Agreed, to build |
| Releases screen, match release line, change type dialog | Do not exist | U1, U2, U3 (Section 7) | Agreed, to build |
Numbers
| Number | Value | Where it came from |
|---|---|---|
| Plugin version installed | 0.6.0 | packages/flows/pyproject.toml, and importlib.metadata on a laptop, 7 Oct |
| Workflows and components | 10 workflows, 245 components | load_registry() run on a laptop, 7 Oct |
| Components per workflow | athletics 16, beach volleyball 13, boxing 23, cricket 33, The Hundred 33 (32 inherited), football 17, games 63, results 25, swimming 9, volleyball 13 | Same run |
| File goldens | 20, across 7 sports and The Hundred | Counted in packages/flows/tests/goldens/, 7 Oct |
| Golden replay time | 1.35 s | Design doc, measured 6 Oct, Python start-up included |
| Golden replay time | 0.75 s wall, 0.45 s reported by pytest | Re-measured on a laptop, 7 Oct |
| CI replay for 200 matches | About 240,000 steps, under a minute | Estimate, from the design doc |
| Other catalogue sports with no code | About 55 | Design doc, not re-counted |
Read next
- The live scoring engine: step functions, checkpoints, and the side-by-side workers (M6) this page relies on.
- Commands and the workflow engine: the one road every command takes, where the release is read.
- Writing a workflow in code: commands, validations and actions, line by line.
- What's next: what is built in which order.