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.

Design section
Section 6, agreed 6 Oct 2026
Main code
packages/contract (flows), packages/flows, core flows_host.py
Main tables
scoring_program_version, sport_definition_version; new: plugin_release, release_default
Read time
about 25 minutes

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.

10 / 245
workflows / components in the plugin, from the code, 7 Oct
20
golden matches in the repo, 7 sports
0.75 s
all 20 goldens replayed, laptop, 7 Oct
22
decisions agreed in Section 6

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.

#QuestionWhy it matters
1What does a sport plugin hold, and what stays in settings?New sports, competitions and clients must be added with settings, not code
2How is a new version tested before it can be used?One bug in a sport's code reaches every match of that sport
3How does a running match keep its rules while a new version ships?A deploy during a match must never re-score balls already played
4Can rules change during an event, and how?A competition changes a tie-break on day 3: new matches use it, finished ones keep theirs
5What 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
6Who 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 shapesCompetitions
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 writeThe mode: scorecard or events
How a format's numbers are usedIntegration mappings
A sport's stat cardsWhich release a competition uses (new)

How it works

A sketch titled How the engine finds a sport. On the left, a folder labelled football holds five files stacked top to bottom: constants.py, commands.py, validations.py, actions.py and stats.py. Arrows from commands, validations, actions and stats go into a sixth box, sport.py, labelled mixed in; the arrow from constants.py is labelled format. In the middle, a box pyproject.toml with the line football = omnium_flows.football points down into load_registry(). A dashed arrow labelled imported goes from sport.py into load_registry(). load_registry() points right to a Registry box that lists football.goal, football.state and cricket.delivery. The Registry points down to the Workflow engine, which asks by key. A red note under it says: never imports football by name.
Six files make one sport class. An entry point names the folder; the registry imports it and the engine asks for pieces by key.

A sport becomes runnable in five steps. Steps 1 to 4 are built today. Step 5 is where today and the agreed design differ.

  1. Six files, one class. A sport folder has constants.py, commands.py, validations.py, actions.py, stats.py and sport.py. The first five each define a mixin class. sport.py joins them into one Sport subclass with a permanent code, such as football.
  2. One line names it. The plugin's pyproject.toml lists the folder under the entry-point group omnium.flows. An entry point is a name a Python package publishes so other code can find it without importing it by name.
  3. The registry imports it. load_registry() reads every entry point in that group, imports the module, and registers each concrete Workflow class it finds. Each decorated method becomes a component with the key code.member, for example football.goal.
  4. 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.
  5. 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.

A sketch titled The road of one release. Six boxes in a row, joined by arrows left to right: Merge to main (notes name rule changes), CI proves it (gate, goldens, replay), Staging (automatic), Person approves (for prod), One competition (new matches only), and Sport default (new matches everywhere). Under CI proves it, an arrow labelled undeclared difference goes down to a red box Refused, match and ball named. Under One competition and Sport default, dashed arrows go down to a box Take back: one click, with the line matches already on it keep it.
The agreed release path. The red exit is CI; the white exit is a one-click take-back that never touches a match already using the release.

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.

A sketch titled Each match keeps its release. A time arrow runs left to right with ticks Day 1, Day 2 and Day 3; a flag above Day 3 says 0.8.0 default from day 3. Below, three match boxes: Match A, day 1, finished and Match B, day 2, live both have arrows labelled pinned to a database cylinder release 0.7.0. Match C, day 3, new has an arrow labelled pinned at creation to a cylinder release 0.8.0. A note says the two releases are installed side by side. A dashed arrow from Match B goes down to a box Move only after preview, a person confirms.
A day-3 rule change. Matches from days 1 and 2 keep 0.7.0; the new match gets 0.8.0. A live match moves only after a person sees the preview.

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 workPicks up the new releaseWhy
Scorecard workflows (most of the work)On the next commandA command changes records; it does not replay a log. Each log row records which release applied it
Operational workflows (Results, Games)On the next commandSame reason. Today they already run the newest published version (scoring/store.py:176)
A new events match, chosen competitionAt creation, once the release is that competition's defaultPinned in the same transaction as the match is created (R2)
A new events match, any competitionAt creation, once the release is the sport's defaultSame
An events match that is already runningOnly when a person moves itThe 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.

  1. An admin opens the sport wizard in the admin panel (frontend-admin/src/components/sport/SportWizard.tsx, built today).
  2. They set the unit types (a match), the formats (two halves of 20 minutes) and the master lists. This saves a sport_definition_version row.
  3. They add the line keys, such as points and raid_points (example names), and set the mode to scorecard. Agreed, to build The line catalogue and mode screen are agreed, not built.
  4. Fixtures use the shared results workflow. 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.

  1. Run make new-sport code=kabaddi. Partly built Today this scaffolds the old file shape (compute, triggers, procedures) and the new folder fails make check. P11 fixes the scaffold. Until then, copy football/ by hand.
  2. Write the six files. constants.py holds KabaddiFormat; commands.py holds raid, tackle, half_end; validations.py the checks; actions.py the step function and lines; stats.py the cards; sport.py joins them with code = "kabaddi" and the facts catalogue. The line-by-line guide is in Writing a workflow in code.
  3. Add one line to packages/flows/pyproject.toml: kabaddi = "omnium_flows.kabaddi".
  4. Add packages/flows/tests/kabaddi/, with a test that names every component, and one golden match file in tests/goldens/kabaddi/.
  5. Bump the plugin version, for example 0.6.0 to 0.7.0 (a new sport adds things, so minor).
  6. Run make check. The structure gate checks the six files, the test folder and that every component is named in a test.
  7. Today: make publish-flows from a laptop, then deploy with OMNIUM_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.

  1. 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: sport cricket, what "super over tie-break", and the keys it may move.
  2. 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.
  3. 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.
  4. Staging, then prod. The passing release is published to staging on its own. An admin presses "Approve for prod". The plugin_release row now has status prod and is never edited again, except to retire it.
  5. One competition, forward only (P5, P8). The admin adds a release_default row: cricket, this league, release 0.8.0, from_at = day 3, 00:00.
  6. 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_release and a reason.
  7. 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.
  8. 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:

  • code is permanent. It prefixes every component key, so renaming it breaks every pin.
  • format_model is the typed shape of fixture.format. A scoring workflow without one is refused at class definition.
  • facts is 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_VERSION is 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

StepWhat happensWhere
Build and publish the wheelBy hand, make publish-flows. Refuses a version already in CodeArtifactscripts/publish-flows.sh:48-61
DeployThe image installs the exact wheel when OMNIUM_FLOWS_VERSION is set.env.deploy.example:201, Jenkinsfile_stg:463-476
Generate programsinstall_all builds one program document per workflow from the registrycore/flows_publish.py:447
Skip unchangedIf the new document's checksum equals the published one, nothing is publishedcore/flows_publish.py:410-411
Goldens at installSkipped: replay=Falsecore/flows_publish.py:434-436
Goldens through the admin routePOST /flows/{sport}/publish runs the full gate, including the scoring_golden table replayadmin/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:

KindWhereRun by
File goldens20 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 goldensscoring_golden rows that point at a real fixtureThe 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_sha covers the whole wheel, through the hash of its RECORD file (the list of every file in the wheel with its hash). Any change anywhere, a shared helper included, makes a new fingerprint.
  • rule_changes is what the CI replay judges against. An undeclared difference cannot pass.
  • release_default only moves forward: a new row with a later from_at, never an edit to the past.
  • scoring_program_version stays as the generated document and gains the release it came from, so a document and its code can no longer drift apart.
  • scoring_checkpoint gains code_sha because 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
  1. 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.
  2. Replay twice. With the previous release and the new one, each in its own process with only its own package loaded.
  3. 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.
  4. 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

SettingWhereDefault
Which release new matches userelease_default, per sport, per competition, from a date (new)The sport's newest approved release
scoring.modecompetition.meta (Section 5)scorecard
Replay corpus locationCI secretThe nightly export bucket
Old releases kept installedAutomaticWhile any unfinished match uses them
OMNIUM_FLOWS_VERSION (today)Deploy environmentUnset in dev; replaced by the per-release code_sha check

Migration from today's code

  1. code_sha over the whole package; written on scoring_program_version.
  2. Create plugin_release and release_default. Register today's installed 0.6.0 as the first release and the default for every sport.
  3. Add fixture.release_id, filled for existing unfinished events matches from what is installed today.
  4. Pin at creation: store.pin moves from the bridge to fixture creation.
  5. Releases side by side in workers (Section 5, migration step 4).
  6. results.change_type and results.set_release.
  7. The CI release workflow and the replay corpus export.
  8. The admin releases screen, the release line on a match, the change type dialog.
  9. The new-sport scaffold and docs, fixed to the six-file shape.

Tests that prove it

TestProves
Start a match on release A, install B that changes a rule, score a ballThe ball uses A (P6)
Change a shared helper onlycode_sha changes for every sport (P3)
A release that changes a score with no noteCI fails, naming the match and ball (P4, R5)
The same change declared in the notesCI passes, and the report lists it
Take a release backDefaults move back; matches on it keep it (P7)
Rename a component in B; a match on A scoresWorks: A's code is still installed (P9)
Retire a release with an unfinished matchRefused (R3)
Change type on a scorecard unit with placesDry run lists the places; after confirm, type changed, places removed, sides kept (R6)
Change type on an events match with eventsRefused (P10)
make new-sport, then make checkPasses (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.

ScreenWhoWhat they seeWhat they can do
Sport releases (admin panel, new)AdminEach 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)AdminToday's wizard, plus the line keys and the modeAdd a sport with no code; add a format
Match page (console)Operator"Rules: cricket 0.7.0", and "pinned at creation" for an events matchMove a match that has not started, with a reason
Change type (console)OperatorBefore: type and sides. After: new type, sides kept, every value that will be removedConfirm 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 wrongWhat happens todayWhat happens in the agreed design
A deploy ships a cricket release that changes how wides are countedEvery running cricket match re-folds its whole log with the new code on its next ballRunning matches stay on their release; only new matches of the chosen competition use the new one (P5, P6)
A release changes scores by accidentCaught only if a golden covers itCI 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 showsReaches every match of the sportReaches 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 changesNo fingerprint changes; install publishes nothingcode_sha covers the whole package, so every sport's release changes and is proven again (P3)
A component is renamedA 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 3A deploy changes the rules for every match of the sport: live ones on their next command, finished ones if they are ever rebuiltA 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 rankingNeeds SQLresults.change_type: sides kept, removals listed and confirmed, logged (P10)
An events match needs a type change after it startedNeeds SQLRefused: its events were scored under the old type. Void and recreate it (P10)
The release a match needs is missing on a serverThe process refuses to score if OMNIUM_FLOWS_VERSION disagreesThe engine refuses that match and alerts; it never runs it with other code (P6)
Someone needs a sport today with no codeUse the Results workflowSame: describe it in settings and score it as a scorecard (P1)
A person deletes an old releaseNot possible; there is no release recordRefused while any unfinished match is pinned to it (P9, R3)
Two admins set defaults at onceNot applicableFor 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 urgentmake publish-flows from a laptopNo 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).

#DecisionIn plain words
P1A scorecard sport needs no code: unit types, line keys, formats and statuses are settings, and it uses the Results workflowMost new sports are added in an afternoon in the admin panel
P2Code holds how a sport works; settings hold how a competition uses itOvers, tie-breaks or sources change without a deploy
P3A release is immutable, numbered major.minor.patch, with a fingerprint of the whole package. A fix is always a new releaseA helper change can no longer alter behaviour without a new fingerprint
P4CI proves every release: the gate, the goldens, and a replay of every recorded match with old and new. An undeclared difference refuses publishingAn accidental change is caught before it exists anywhere, with the match and ball
P5Staging automatically, prod only on a person's approval; first one competition, then the sport. Take-back is one clickOne bad release reaches one competition's new matches at most
P6An events match is pinned at creation; releases run side by side; scorecard and operational workflows use the newest and each log row records itA deploy mid-match is safe, and every change says which rules made it
P7Taking a release back changes only defaultsA rollback never re-scores a match
P8Rules change during an event only forward; a match not started may be moved, with a reasonA day-3 change applies from day 3; days 1 and 2 stay as played
P9A release stays installed while any unfinished match uses itA renamed component never stops a running match
P10A unit's type can change by command, with a preview and confirm; an events match only before its first eventThe duel-to-ranking fix takes one click, not SQL
P11The new-sport scaffold and docs use the six-file shape, with a test and a golden from day oneA new sport passes the gate on its first commit
P12Scorecard 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 previewA fix is picked up at once where safe, and for a live match only when a person has seen what it changes
U1A sport releases screen with status, replay result, what uses each release, and buttons that say what they changeAn admin sees "14 live matches use this" before touching it
U2Every match page shows its release; an events match shows that it is pinnedNobody has to ask which rules a match used
U3A change type dialog with before and after and the values to be removedNobody removes a result by surprise
R1New plugin_release and release_default; fixture, scoring_program_version and timeline_item gain release_idEvery match and every change says which release made it
R2An events match gets its release in the same transaction as its creation, from the most specific defaultNo match is created without knowing its rules
R3A release row is never edited after prod, except to retire it; retiring is refused while an unfinished match uses itWhat was published stays exactly what was published
R4The CI corpus is the goldens plus finished prod matches without personal data, replayed with both releases in separate processes, compared after every eventA difference names its match and first ball
R5A difference passes only inside a declared rule change (sport and keys)Intended changes are written down; accidental ones cannot pass
R6results.change_type takes the match lock, refuses an events match with events, dry-runs, then removes only confirmed values with one log rowThe type change is as safe and visible as any edit
R7Publishing the wheel and programs moves into CI: staging on merge, prod on approvalNo release is made by hand from a laptop

Built today, or still to build

PieceTodayAgreed designStatus
Six-file sport shape and gateflows/scripts/check_structure.py:26-61, run by make checkSameBuilt today
Entry-point registrycontract/flows/registry.py:94-117; 9 entry points, 10 workflows, 245 componentsload_registry(path) per release folderPartly built
Engine never imports flows by namecore/flows_host.py:90-128Same, per releaseBuilt today
Formats as subclassescontract/flows/sport.py:118-179; cricket/hundred.pySameBuilt today
Scorecard sports with no codeResults workflow (results/sport.py:23-30) and the sport wizardPlus line keys and mode in settingsPartly built
Fingerprintsource_sha of one function (contract/flows/components.py:116-121), never checked at run timecode_sha of the whole wheel, checked per releaseAgreed, to build
Version pinOne per process, OMNIUM_FLOWS_VERSION (core/flows_host.py:57-87)Releases side by side, per-release checkAgreed, to build
Match pinProgram document of names, at first command (admin/routers/bridge.py:285)fixture.release_id, at creationAgreed, to build
plugin_release, release_defaultDo not existNew tables (R1)Agreed, to build
File goldens20 files, flows/tests/test_goldens.py, in CIPart of the replay corpusBuilt today
Compatibility replayDoes not existflows/scripts/replay_compat.py (new)Agreed, to build
PublishingBy hand, make publish-flows (scripts/publish-flows.sh)CI to staging; prod by approvalAgreed, to build
Canary on one competition and take-backDoes not existrelease_default rows and the releases screenAgreed, to build
Moving a live match after a previewDoes not existP12Agreed, to build
results.change_type, results.set_releaseDo not exist; type set only at creation (admin/routers/fixtures.py:346-352)New commands (R6, P8)Agreed, to build
New-sport scaffoldOld shape (flows/templates/sport/ has compute, triggers, procedures); fails the gateSix-file shape with a test and a golden (P11)Agreed, to build
Releases screen, match release line, change type dialogDo not existU1, U2, U3 (Section 7)Agreed, to build

Numbers

NumberValueWhere it came from
Plugin version installed0.6.0packages/flows/pyproject.toml, and importlib.metadata on a laptop, 7 Oct
Workflows and components10 workflows, 245 componentsload_registry() run on a laptop, 7 Oct
Components per workflowathletics 16, beach volleyball 13, boxing 23, cricket 33, The Hundred 33 (32 inherited), football 17, games 63, results 25, swimming 9, volleyball 13Same run
File goldens20, across 7 sports and The HundredCounted in packages/flows/tests/goldens/, 7 Oct
Golden replay time1.35 sDesign doc, measured 6 Oct, Python start-up included
Golden replay time0.75 s wall, 0.45 s reported by pytestRe-measured on a laptop, 7 Oct
CI replay for 200 matchesAbout 240,000 steps, under a minuteEstimate, from the design doc
Other catalogue sports with no codeAbout 55Design doc, not re-counted