# Open Meet Exchange

**Version 1.0.** From 1.0 a minor version only adds and only a major breaks (see
[Versioning](#versioning)). Feedback from anyone building meet software is welcome at
[jason@2timer.app](mailto:jason@2timer.app?subject=Open%20Meet%20Exchange).

## Overview

Open Meet Exchange (OMX) is a JSON document format for moving a track and field meet, cross country
meet, or road race between the products that handle it: the entry site, the timing software, and the
results site. One format replaces a separate import and export between every pair of them.

```text
  Registration ──── entries ────▶ Meet Operations ──── results ────▶ Publisher
  (who entered)                   (what happened)                    (shows it)
```

- **Registration** collects entries and serves them as a registration document at a secret URL.
- **Meet Operations** fetches the document, seeds, times, and scores the meet, and hands results
  documents to every Publisher the document names.
- **The Publisher** shows the results to coaches, athletes, and fans, and recomputes nothing.

The meet moves in one direction only: Meet Operations does not send entries back to Registration,
and a Publisher does not send results back to Meet Operations.

A typical meet: the timer imports the registration link a week out and assigns bibs, then fetches
it again on race morning for late registrations. During the meet, results go to each Publisher as
often as it asked for them: start lists before a round, updates every few seconds while it runs,
and the round again when it goes official and whenever it is corrected.

One product may run more than one stage. A meet host that takes entries and shows results is both
Registration and Publisher: it hands entries out and gets results back. A meet does not need every
stage: a timer who builds a meet from a spreadsheet starts at Meet Operations and hands straight to
a Publisher.

Three design choices shape the rest:

- **Two kinds of document, one shape.** A registration document says who entered; a results
  document says what happened. Both describe the meet with the same records (venue, divisions,
  events, teams, athletes, entries), and `omx.kind` says which one it is.
- **Every update is whole where it counts.** A results document is either `full`, the whole meet as
  it stands, or `partial`, only some rounds, each of them whole. There are no field-level deltas and
  no revision to keep in step.
- **The URL is the setup.** No accounts, API keys, or per-vendor adapters. A registration document
  carries everything Meet Operations needs, including where to send results.

## What OMX is not

- **Not a universal meet format.** A document carries what the next stage needs, not everything a
  product holds. Meet Operations' timing evidence, settings, and history stay in that product.
- **Not a backup.** To move a meet between computers, use the product's own backup. A document
  cannot restore a meet in progress.
- **Not a scoreboard protocol.** A `live` destination gets the running round every few seconds
  (see [Live updates](#live-updates)), which is enough for a results page that follows a race. A
  stadium board that needs every tenth of a second needs its own connection, such as
  [Open Meet Display](/omd).
- **Not a way to send entries back.** A walk-up the timer adds reaches the Publisher in a results
  document, with no Registration id. Nothing in OMX asks Registration to add it to its roster;
  whether a product that runs both stages does so is its own decision.

## Terms

| Term                      | Meaning                                                                                                            |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **document**              | One JSON file describing one meet, validating against the [schema](/omx/schemas/meet-document-v1.schema.json)      |
| **kind**                  | `omx.kind`: `registration` or `results`                                                                            |
| **Registration**          | The stage that holds entries and serves a registration document                                                    |
| **Meet Operations**       | The stage that fetches a registration document, runs the meet, and writes results documents                        |
| **Publisher**             | The stage that accepts results documents from Meet Operations and shows them                                       |
| **timer**                 | The person running Meet Operations at the meet                                                                     |
| **registration document** | A document of kind `registration`: one entry per athlete or relay per event, no results                            |
| **results document**      | A document of kind `results`: one entry per athlete or relay per round, with what happened                         |
| **full** / **partial**    | `omx.update` on a results document: the whole meet, or only the rounds it carries                                  |
| **destination**           | One item in a registration document's `publish` array, or a destination card: a Publisher and how it wants results |
| **handoff**               | One delivery of one results document from Meet Operations to a destination                                         |
| **probe**                 | A `GET` on a destination URL asking which versions it reads and who it is; optional to answer                      |
| **system id**             | A product's short, fixed name in ids, such as `examplereg` for ExampleRegistration: its `omx.generator.id`         |
| **`id`**                  | The identifier chosen by the product that wrote the document                                                       |
| **`external_ids`**        | The same record's ids in other products, keyed by system id                                                        |

## Roles

A product runs one or more stages. It does not have to run the others.

| Stage                        | What you do                                                                                                                                | Typical products                                                  |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
| **Registration**             | Serve a registration document at a secret URL. You own the meet's identity, events, teams, athletes, and entries                           | Registration sites, entry systems, a school's export              |
| **Registration + Publisher** | Serve a registration document and name yourself as a destination, so the meet comes back to you with results                               | Meet hosts that also publish results                              |
| **Publisher**                | Accept results documents from Meet Operations. You may never have supplied any entries                                                     | Results sites, league standings, a school's site                  |
| **Meet Operations**          | Fetch a registration document, run the meet, hand results on. You own rounds, heats, seeds, marks, places, statuses, scores, and the track | 2Timer, and any timing or meet-management product that adopts OMX |

Stages differ in what they are allowed to decide, not in the shape of the records they write.
Registration says who entered. Meet Operations says what happened. A Publisher records what it is
given and recomputes nothing.

Each stage takes only the handoff before it. Meet Operations reads registration documents, never a
results document; a Publisher reads results documents, never a registration document. Two Meet
Operations products timing parts of one meet each hand their own results to the Publisher; they do
not hand to each other.

## Quick start

### Registration

1. **Build the document.** Serialise the meet as one JSON document with `"kind": "registration"`
   that validates against the registration check
   ([`registration-document-v1`](/omx/schemas/registration-document-v1.schema.json)). Start from the
   [example closest to your sport](/omx/examples).
2. **Serve it at a secret URL.** HTTPS only, `Content-Type: application/json`. Put a secret in the
   path or query (see [Secrets](#secrets)), scope it to one meet, and let the meet director revoke
   it. Sending an `ETag` and answering `If-None-Match` with `304` is recommended: a timer may fetch
   the link again for late entries.
3. **Show the link** to the meet director with the words "give this to your timer". Also offering the
   same document as a downloadable `.omx.json` file lets a timer import it without the link (see
   [Encoding and files](#encoding-and-files)).

That is a complete, conformant registration product. Stop here if you never want the meet back.

### Registration that wants results back

Add a `publish` array. Each item is one destination with its own id, credential, and preferences:

```json
"publish": [
	{
		"id": "examplereg",
		"label": "ExampleRegistration",
		"url": "https://registration.example.com/omx/inbox/8812",
		"token": "reg_pub_…",
		"deliver": "live",
		"content": ["results", "start_lists"]
	}
]
```

- **`deliver`** is how often you want results: `final` (the default) once the meet is official,
  `progressive` as each round is seeded, goes official, or changes, or `live`, which adds an update
  every few seconds while a round runs. See [When a handoff happens](#when-a-handoff-happens).
- **`content`** is what you want: `results` (the default), and `start_lists` if you also want heat
  sheets before a round runs.
- **`accepts`** (optional) lists the versions your endpoint reads, such as `["1.0"]`. Listing a
  version accepts every later minor of its major too: `["1.0"]` reads 1.0, 1.1, and every `1.x`.
  A Meet Operations product sends only a major you list; with no list, it sends a version of the
  major of the document or destination card that named you (OMX-R187).

What comes back is a results document, **written under Meet Operations' ids, not yours.** On the
meet, events, teams, and athletes, your id moves to `external_ids`, under your system id, the
`omx.generator.id` you wrote:

```jsonc
// You sent, with "generator": { "id": "examplereg", "name": "ExampleRegistration" }
{ "id": "a-1001", "first_name": "Maya", "last_name": "Chen" }

// You get back
{ "id": "at_301", "external_ids": { "examplereg": "a-1001" }, "first_name": "Maya", "last_name": "Chen" }
```

- **Find your meet, events, teams, and athletes by `external_ids` under your system id.** `"a-1001"`
  comes back unchanged, every time, on every record that came from you. Attaching a result is a
  lookup, never a name match.
- **Find your entries through the athlete and the event.** An entry carries no `external_ids`. Maya
  Chen's results in your Girls 60m are the entries whose `athlete_id` names the athlete carrying
  `"examplereg": "a-1001"` and whose `event_id` names the event carrying your event's id. A relay is found by
  its team, its letter, and its event. See [Ids](#ids).
- **One of yours can come back as several.** An athlete who ran a prelim and a final comes back as
  two entries, one per round. See [One entry per round](#one-entry-per-round).
- **Some come back with none of your ids.** A walk-up the timer added was never yours. See
  [Records Meet Operations creates](#records-meet-operations-creates).

### Publisher

You need no document of your own. Implement an HTTPS endpoint that accepts handoffs (see [Handing off](#handing-off)) and
show your user a [destination card](#destination-cards) to give the timer, so Meet Operations knows
where to send the meet. What arrives is a meet you can create from nothing.

- **Apply each document by its `omx.update`.** `full` replaces everything you hold for the meet;
  `partial` replaces only the rounds it carries. If you never ask for more than `final`, you only
  ever get `full`.
- **Read `meet.origin`.** It names the product the meet started in. When its `id` differs from
  `omx.generator.id`, the meet came from that Registration product, and records that came from it
  carry its id in `external_ids` under `origin.id`. When it is the same, the timer built the meet
  and no record carries a Registration id. Either way, a record may carry ids from other systems
  you know, such as an athlete's Athletic.net id under `anet`, so you can match results without
  ever having supplied an entry.

### Meet Operations

1. Accept a pasted URL or an opened file, check its version and kind, and build the meet from it
   with no wizard.
2. If the timer fetches the URL again for late entries, merge it however your product does. The
   format says nothing about how; it only promises the same shape and the same ids.
3. Hand results to every destination the document names, and let the timer add destinations by
   hand or by pasting a [destination card](#destination-cards). Respect each destination's
   `deliver` and `content`. Your first document to a destination is `full`; after that you may send
   `partial` documents, or always `full` ones.

The [checklist](#checklist) at the end doubles as a test plan, and the
[conformance tests](/omx/conformance) give each item a pass condition.

### Check your work

Every stage has the same three tools, all under [Files](#files):

- **Validate against your kind's check.** The registration check (`registration-document-v1`) and
  the results check (`results-document-v1`) are strict: a value this version does not define, a
  date-time without seconds, or a field your stage never writes fails. A field the format does not
  define never fails any schema (see [Extra fields](#extra-fields)), so check your spelling against
  the [field reference](/omx/reference). `meet-document-v1` is the lenient schema a reader
  validates with.
- **Run the checker** for what no schema can see, such as references that resolve, entries that can
  be joined, codes that belong to the meet's sport, and statuses that fit their round:
  `node omx-check.mjs meet.omx.json`.
- **Use the [test page](/omx/test)** to check a document, a registration URL, a Publisher endpoint,
  or a destination card from your own browser.

## The document

One JSON document, the same records in both kinds. A large invitational with a few thousand
entries is well under a megabyte from Registration, and a few megabytes once results are on it.

A minimal cross country registration document:

```json
{
	"omx": {
		"version": "1.0",
		"kind": "registration",
		"generator": { "id": "examplereg", "name": "ExampleRegistration", "version": "3.4" }
	},
	"meet": {
		"id": "8812",
		"name": "Riverbend XC Invitational",
		"sport": "xc",
		"start_date": "2026-10-03",
		"time_zone": "America/New_York",
		"venue": { "name": "Riverbend Park", "city": "Longwood", "region": "FL", "country": "US" }
	},
	"divisions": [
		{
			"id": "d-var",
			"name": "Varsity",
			"sequence": 1,
			"gender_names": { "M": "Boys", "F": "Girls" }
		}
	],
	"events": [
		{
			"id": "e-1",
			"number": 1,
			"name": "Varsity Girls 5K",
			"code": "5000",
			"division_id": "d-var",
			"gender": "F"
		},
		{
			"id": "e-2",
			"number": 2,
			"name": "Varsity Boys 5K",
			"code": "5000",
			"division_id": "d-var",
			"gender": "M"
		}
	],
	"teams": [{ "id": "t-lhs", "code": "LHS", "name": "Longwood High School" }],
	"athletes": [
		{
			"id": "a-1001",
			"first_name": "Maya",
			"last_name": "Chen",
			"gender": "F",
			"grade": "11",
			"team_id": "t-lhs",
			"bib": "101",
			"external_ids": { "anet": "18810342" }
		}
	],
	"entries": [
		{
			"id": "n-1",
			"event_id": "e-1",
			"athlete_id": "a-1001",
			"seed": { "value": 1183400, "unit": "ms", "display": "19:43.4" }
		}
	],
	"publish": [
		{
			"id": "examplereg",
			"label": "ExampleRegistration",
			"url": "https://registration.example.com/omx/inbox/8812",
			"token": "reg_pub_…",
			"deliver": "live",
			"content": ["results", "start_lists"]
		}
	]
}
```

### Kinds

`omx.kind` says which handoff a document is, and it is required:

- **`registration`**, written by Registration: who entered. One entry per athlete or relay per
  event, with its seed and an entry status of `e`, `scr`, or `wait`. No results, rounds, places,
  team scores, track, or markers.
- **`results`**, written by Meet Operations: what happened. One entry per athlete or relay per
  round, with `round` required. It also carries `omx.update`, `omx.updated_at`, and `meet.origin`,
  and never `publish`.

The kind is a closed list. A reader refuses a document whose kind is missing or one it does not
know (OMX-C107), and a reader never guesses the kind from the fields a document carries. Meet
Operations reads only registration documents; a Publisher reads only results documents.

### Sections

| Section       | Required | In                | Contents                                                                                                                                                |
| ------------- | -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `omx`         | yes      | both              | Format `version`, `kind`, and the `generator` (its system `id`, `name`, and optional `version`). A results document adds `update` and `updated_at`      |
| `meet`        | yes      | both              | Id, name, sport, dates, time zone, venue. Enough to create the meet without a wizard. A results document adds `origin`, and `track` for track and field |
| `divisions`   | no       | both              | Groupings that events belong to: Varsity, Masters 40+, U12. With gendered names                                                                         |
| `groups`      | no       | both              | Award groups within a race, such as age groups or JV, with award counts                                                                                 |
| `waves`       | no       | both              | Start waves in road and trail races, with each wave's offset from the race's start                                                                      |
| `events`      | yes      | both              | What is contested, in running order. A results document adds `rounds`, `markers`, and the component events of a multi-event                             |
| `teams`       | yes      | both              | Id, code, name. An athlete with no `team_id` competes for no team                                                                                       |
| `athletes`    | yes      | both              | Id, name, gender when stated, age or grade, team, bib, and an `exhibition` flag                                                                         |
| `entries`     | yes      | both              | Registration: who entered each event, with seeds. Results: one entry per round, with heat, lane, status, result, places, splits, and points             |
| `publish`     | no       | registration only | Destinations the results are handed to. Absent or empty means one-way, which is a complete implementation                                               |
| `team_scores` | no       | results only      | Standings, when the meet is scored                                                                                                                      |

The [field reference](/omx/reference) lists every field with its type. The rest of this section
covers the rules the schema cannot express.

### Encoding and files

A document is UTF-8 JSON ([RFC 8259](https://www.rfc-editor.org/rfc/rfc8259)) with no byte order
mark. Date-times are [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339) with seconds and a UTC
offset (`2026-10-03T09:00:00-04:00`); dates are `YYYY-MM-DD` on the meet's local calendar.
`meet.time_zone`, when present, names that calendar's zone, so a reader can show times across a
daylight-saving change; it is optional, and without it a reader takes dates as written and reads
each date-time by its own offset. A reader also accepts a date-time with no seconds and reads it as
`:00`; a writer always sends seconds.

A document is also a file. A product that cannot serve or fetch a URL, such as desktop software or
a meet with no connection, exchanges the same document as a file. Any product may also offer the
file as a download next to the link, and any timer may import it instead of pasting the URL:

- Name it with the extension `.omx.json`. A reader recognises a document by its top-level `omx`
  object and its `kind`, not by its name, so a plain `.json` file is read the same way.
- A file is read exactly like a fetched document.
- A registration file that names destinations holds their tokens. Handle it like the document URL:
  it is a credential.

### Ids

Every record carries an `id` chosen by the product that wrote the document: the meet, each
division, group, wave, event, marker, team, athlete, entry, and publish destination.

- An `id` is an opaque string. No reader parses it.
- An `id` never changes for the life of the meet. Regenerating ids on each export breaks the
  results join.
- An `id` is unique within its own list. Two lists may share a value: an event and the meet may
  both be `"8812"`, and two products may both write it.
- A meet made from another meet, such as next year's meet built from this year's, is a new meet. It
  gets new ids, and carries none of the old meet's Registration ids or the old meet's own
  `external_ids`. An athlete's or team's ids on other systems, which name the person or the team,
  may stay.

**`id` is always the writer's id. Every other product's id for the same record is in
`external_ids`,** keyed by that product's system id:

```json
{
	"id": "at_301",
	"first_name": "Maya",
	"last_name": "Chen",
	"external_ids": { "examplereg": "a-1001", "anet": "18810342" }
}
```

- Only the meet, events, teams, and athletes carry `external_ids`: the records a Publisher joins
  results through. Each value is a string, at most one per system. Divisions, groups,
  waves, markers, entries, and destinations carry only their `id`, for references within the
  document.
- A writer never lists itself: its own id for the record is `id`.
- In a registration document, `id` is Registration's id, and `external_ids` holds any ids it knows
  on other systems, such as an athlete's Athletic.net id.
- In a results document, `id` is Meet Operations' id. Every meet, event, team, and athlete that
  came from Registration carries Registration's id unchanged in `external_ids` under Registration's
  system id. Records Meet Operations created carry no Registration id.
- **Meet Operations writes back every id it received**, on the meet, events, teams, and athletes,
  unchanged, whether or not it knows the system (OMX-C104). The timer may correct or remove one.
  A results site that keys its athletes on its own system id, such as ExampleResults under `exampleresults`, gets
  its ids back from any timing product.

**A meet's entries come from one Registration product,** so `meet.origin` always names it, and every
Registration id in a results document is under that product's system id.

**An entry carries no `external_ids`.** One registration entry can come back as several entries, a
prelim and a final, so an entry id from Registration would not name one record. A result joins back
to Registration through what it is about:

| Entry                   | Joined through                                                                                    |
| ----------------------- | ------------------------------------------------------------------------------------------------- |
| An individual entry     | Its athlete's `external_ids` and its event's `external_ids`                                       |
| A relay entry           | Its team's `external_ids`, its `relay.letter`, and its event's `external_ids`                     |
| A multi-event component | Its athlete, and its component event, whose `multi_event_id` leads to the multi-event you entered |

So that each join lands on one entry, a registration document has at most one individual entry per
athlete per event, and at most one relay entry per team, letter, and event (OMX-C108). A relay entry
always carries its letter, in both kinds of document; a relay the timer created without one is
written `A`, or the team's next free letter in that event.

Nothing is ever matched by name. A Publisher that never supplied an entry joins the same way, on
whichever system it knows: a results site keyed on Athletic.net ids reads `external_ids.anet`.

#### System ids

`omx.generator.id` is the writing product's system id: any name it picks, in lowercase letters,
digits, and `_`, at most 32 characters, chosen once and never changed. Nobody assigns or registers
it; every document names its writer. The same id names that product's ids in every other document's
`external_ids`.

A system that writes no OMX document never names itself, so the ids it issues, such as an athlete's
Athletic.net id, go under the key below. Two products holding the same id then use the same key. A
product holding ids from a system not listed picks a key and asks for it to be added. Nothing checks
the list.

| System id   | System          | Ids it issues               |
| ----------- | --------------- | --------------------------- |
| `anet`      | Athletic.net    | Meet, athlete, and team ids |
| `milesplit` | MileSplit       | Meet, athlete, and team ids |
| `tfrrs`     | TFRRS           | Athlete and team ids        |
| `usatf`     | USATF           | Membership numbers          |
| `aau`       | AAU             | Membership numbers          |
| `rsu`       | RunSignup       | Race and registration ids   |
| `rr`        | RaceRoster      | Participant ids             |
| `da`        | DirectAthletics | Athlete ids                 |
| `alinks`    | Athlinks        | Athlete ids                 |
| `trackie`   | Trackie         | Athlete and club ids        |
| `usu`       | UltraSignup     | Participant ids             |

### Sports and event codes

`meet.sport` says what kind of meet this is. This version defines `xc`, `tf-indoor`, `tf-outdoor`,
and `run`; indoor and outdoor track are separate because their event catalogs differ. The list is
open: a later minor version adds sports. A writer sends only a sport this version defines.

Each event has a `code`, and the code is the event: what it is, how far, and how it ranks. No other
field says any of these, so nothing in an event can disagree with its code. Codes are defined per
sport, and a later sport may give a code such as `5000` its own meaning. The codes are an open
list too: a later minor version adds events. A writer sends only a code this version defines for the
meet's sport, never one of its own (OMX-C14).

| Event                   | Code                                 | Examples                                   | Ranked                       |
| ----------------------- | ------------------------------------ | ------------------------------------------ | ---------------------------- |
| A race                  | metres, or miles with `MILE`         | `100`, `1600`, `5000`, `1MILE`, `3.1MILE`  | lowest time first (`ms`)     |
| Half marathon, marathon | `HMAR`, `MAR`                        |                                            | lowest time first (`ms`)     |
| Hurdles                 | metres and `H`                       | `110H`, `300H`                             | lowest time first (`ms`)     |
| Steeplechase            | metres and `S`                       | `3000S`                                    | lowest time first (`ms`)     |
| Race walk               | metres and `W`, or miles and `MILEW` | `5000W`, `1MILEW`                          | lowest time first (`ms`)     |
| An even relay           | legs `X` each leg, `H` for hurdles   | `4X100`, `4X400`, `4X1MILE`, `4X110H`      | lowest time first (`ms`)     |
| A medley relay          | its total and `R`                    | `800R`, `1000R`, `1600R`, `3200R`, `4000R` | lowest time first (`ms`)     |
| A field event           | the field event table below          | `LJ`, `HJ`, `SP`                           | best mark first (`mm`, `in`) |
| A multi-event           | the multi-event table below          | `DEC`, `HEP`, `PENT`                       | most points first (`pt`)     |

Which codes each sport uses:

| Sport                     | Its codes                                                                                                              |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `tf-outdoor`, `tf-indoor` | Races, hurdles, steeplechases, race walks, even relays (shuttle hurdles included), medleys, field events, multi-events |
| `xc`                      | Races; even relays without hurdles                                                                                     |
| `run`                     | Races, `HMAR`, `MAR`; race walks; even relays without hurdles                                                          |

A race's code is the distance it was **advertised** at, not what the course measured: the
Riverbend "5K" course measures 4.92 km, and its code is `5000`. A race advertised in miles uses the
`MILE` form. **Each distance has one code:** a half marathon is always `HMAR` and a marathon always
`MAR`, never `21097`, `13.1MILE`, or `42195`. A 6 km cross country course is `6000`, and a 5K is
`5000`; `3.1MILE` is only for a race advertised as 3.1 miles.

A hurdle race's height is in `hurdle_height_mm`, and a throw's implement in `implement_weight_g`,
because the code does not say them: a 4 kg shot and a 12 lb shot are both `SP`. Registration may
suggest either; the timer may change them, and a results document carries what was used.

A medley's code gives its legs in their standard order:

| Code    | Legs, in running order |
| ------- | ---------------------- |
| `800R`  | 100, 100, 200, 400     |
| `1000R` | 100, 200, 300, 400     |
| `1600R` | 200, 200, 400, 800     |
| `3200R` | 1200, 400, 800, 800    |
| `4000R` | 1200, 400, 800, 1600   |

No event lists its legs; its code says them.

A value a reader does not know never fails the whole meet, and a reader never guesses an event
from its name:

- **An event code it does not know**, or one not defined for the meet's sport. Meet Operations
  leaves that event and its entries out, tells the timer which events it left out, and reads the
  rest of the meet: it would have to run an event it cannot define. A Publisher shows the event by
  its `name`, with places as given, marks by `display`, and its team scores as sent, and prints an
  `nr` entry in it as `NR`, since it cannot tell a race from a throw (OMX-R179).
- **A sport it does not know.** A Meet Operations product that does not run the sport refuses the
  document and names the sport. A Publisher shows the meet's events by their names and codes.

#### Field event codes

| Code  | Event              |
| ----- | ------------------ |
| `LJ`  | Long jump          |
| `TJ`  | Triple jump        |
| `SLJ` | Standing long jump |
| `HJ`  | High jump          |
| `PV`  | Pole vault         |
| `SHJ` | Standing high jump |
| `SP`  | Shot put           |
| `DT`  | Discus             |
| `HT`  | Hammer             |
| `JT`  | Javelin            |
| `WT`  | Weight throw       |
| `TJT` | Turbo javelin      |
| `SBT` | Softball throw     |
| `BBT` | Baseball throw     |
| `CT`  | Club throw         |

A jump or throw ranks by its best mark: the longest jump or throw, or the highest bar cleared.

#### Multi-events and their components

`DEC`, `HEP`, and `PENT` may be sent for any gender in a track meet. Meet Operations builds the
component events by its own rules, with this table as the standard set:

| Code   | Standard for        | Components, in order                         |
| ------ | ------------------- | -------------------------------------------- |
| `DEC`  | `tf-outdoor`, men   | 100, LJ, SP, HJ, 400, 110H, DT, PV, JT, 1500 |
| `HEP`  | `tf-outdoor`, women | 100H, HJ, SP, 200, LJ, JT, 800               |
| `HEP`  | `tf-indoor`, men    | 60, LJ, SP, HJ, 60H, PV, 1000                |
| `PENT` | `tf-indoor`, women  | 60H, HJ, SP, LJ, 800                         |

A masters meet that enters women in a decathlon sends `DEC` with gender `F`, and the timer runs it.
A multi-event Meet Operations cannot build, such as a youth pentathlon it has no rule for, is
reported to the timer and not created, as an unknown code is. A component's hurdle height and
implement weight follow the event's gender and division as for any event.

A registration document carries only the multi-event, never its component events. A results
document lists every component event actually contested, each naming its multi-event in
`multi_event_id`, so a Publisher never needs this table.

#### Adaptive events

A wheelchair race, a seated throw, a guided run, or another adaptive form of an event keeps the
event's ordinary code and says how it is contested in `adaptive.mode`:

```json
{
	"id": "e-12",
	"name": "Women Seated Shot Put",
	"code": "SP",
	"gender": "F",
	"adaptive": { "mode": "seated" }
}
```

| `adaptive.mode` | Meaning                                                   |
| --------------- | --------------------------------------------------------- |
| `wheelchair`    | A wheelchair race                                         |
| `seated`        | A seated throw                                            |
| `guided`        | Run with a guide                                          |
| `ambulatory`    | Contested standing or running, without a chair or a guide |

An adaptive event is a different event from the standard event with the same code. A reader that
cannot hold adaptive events tells the timer and never merges one into the standard event.

#### Event examples

```json
{
	"events": [
		{ "id": "e-1", "name": "Men 100m", "code": "100", "gender": "M" },
		{
			"id": "e-2",
			"name": "Men 110m Hurdles",
			"code": "110H",
			"gender": "M",
			"hurdle_height_mm": 1067
		},
		{ "id": "e-3", "name": "Women Mile", "code": "1MILE", "gender": "F" },
		{ "id": "e-4", "name": "Women 4x400m Relay", "code": "4X400", "gender": "F" },
		{ "id": "e-5", "name": "Mixed Distance Medley", "code": "3200R", "gender": "C" },
		{ "id": "e-6", "name": "Women Long Jump", "code": "LJ", "gender": "F" },
		{
			"id": "e-7",
			"name": "Men Shot Put",
			"code": "SP",
			"gender": "M",
			"implement_weight_g": 7260
		},
		{ "id": "e-8", "name": "Women Pentathlon", "code": "PENT", "gender": "F" }
	]
}
```

- `e-5`: a 3200R, whose legs are 1200, 400, 800, 800.
- `e-8`: in a `tf-indoor` meet, Meet Operations builds the standard five events unless the meet
  calls for others.

#### Gender

An athlete's `gender` says who they are; an event's says who the contest is for. Each is one
letter, and a letter means the same gender wherever it appears:

| Value | On an athlete | On an event, award group, or team-score scope                                                                                                           |
| ----- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `M`   | Male          | Men or boys                                                                                                                                             |
| `F`   | Female        | Women or girls                                                                                                                                          |
| `X`   | Non-binary    | Non-binary athletes                                                                                                                                     |
| `C`   | never         | Everyone: mixed, combined, or co-ed, including a relay whose team may mix genders. Each athlete is also placed within their own gender (`gender_place`) |

An event's, award group's, and team-score scope's `gender` is required. An athlete's `gender` is optional. Leave it out when the athlete did not state one, as road
registration commonly allows; never guess it. A reader that does not tell an unstated gender from
non-binary may treat it as `X`.

Registration decides who may enter an event, and Meet Operations takes an entry as given: Riley
Adams (`X`) entered in the Varsity Boys 5K runs in it, and is placed within their own gender.

### Marks and units

A seed or result is a `value` plus the `unit` **you measured in**, with optional `display` text:

```json
{ "value": 198.25, "unit": "in", "display": "16-06.25" }
```

| Unit | Meaning      | Value                   |
| ---- | ------------ | ----------------------- |
| `ms` | milliseconds | integer                 |
| `mm` | millimetres  | integer                 |
| `in` | inches       | decimal, two places max |
| `pt` | points       | integer                 |

A mark's unit is one its event's code ranks by: `ms` for a running event, `mm` or `in` for a field
event, `pt` for a multi-event. Do not convert imperial marks to metric before sending. Converting
16-06.00 to 5029.2 mm and back loses precision under field-event rounding rules. Meet Operations
converts once, if its meet uses the other system.

`display` is the writer's own formatting. It is shown, never parsed. A seed may also say where it
came from in `source` (`season_best`, `coach_entered`) and when in `achieved_on`; both are shown to
the timer, never interpreted.

The units are an open list: a later minor version adds a unit when an event needs one, such as a lap
count. A reader that meets a unit it does not know shows the mark's `display` and never compares or
sorts on it (OMX-C110). Places arrive computed, so the results are still in the right order.

### Bibs

The bib lives on the athlete. `athlete.bib` is the athlete's bib for the meet. Registration may
assign it, or leave bibs to the timer. In a results document it is the bib the athlete raced under:
if Emma Walsh was given 201 and raced in 118, the results document says 118. A runner with two bibs
in one meet, such as a Saturday 5K and a Sunday half marathon at a race weekend, is two athletes.

Meet Operations imports Registration's bibs through whatever conflict review it normally applies
when two athletes claim one number.

### Relays

A relay entry names a team, its squad letter, and legs in running order:

```json
{
	"id": "n-7",
	"event_id": "e-4x1-f",
	"relay": { "team_id": "t-lhs", "letter": "A", "legs": ["a-1", "a-2", "a-3", "a-4", "a-5"] },
	"seed": { "value": 50120, "unit": "ms", "display": "50.12" }
}
```

`letter` is required: one upper-case letter, `A` for the team's first squad in the event. Team,
letter, and event name one relay entry. `legs` may be shorter than the relay's legs, may be empty,
and may list alternates after the running legs.

### Waves

A road or trail race that starts in waves lists them in `waves`, and each entry names its wave with
`wave_id`:

```json
"waves": [
	{ "id": "w-1", "name": "Wave 1" },
	{ "id": "w-2", "name": "Wave 2", "offset_ms": 300000 }
]
```

`offset_ms` is how long after the race's start the wave went. In a results document, an entry's
`result` is already timed from its own wave's start, so a reader never subtracts the offset again.
Registration may assign waves; a results document carries the waves run.

### Markers and splits

An event that records intermediate times lists its timing points in `markers`, in course order.
Markers are the timing side's: they appear in a results document only.

```json
"markers": [
	{ "id": "m_1", "name": "1 Mile" },
	{ "id": "m_2", "name": "2 Mile" }
]
```

A results entry's `splits` each say where they were taken, at a `marker_id`, at the end of a relay
`leg`, or both, and carry one number, `time_ms`:

```json
"splits": [
	{ "marker_id": "m_1", "time_ms": 398200 },
	{ "marker_id": "m_2", "time_ms": 800900 }
]
```

- **`time_ms`** is elapsed from the same start as `result`: the gun for the athlete's race or wave.
  A relay leg's split is the running clock when that leg finished, with `1` for the first leg.
- **A stretch is a subtraction.** Carla Mendez ran the second mile in 800900 − 398200 ms, 6:42.7. A
  Publisher works it out for display.
- **A missed mat is absent.** If Carla's chip misses the 2-mile mat, her 3-mile split is still
  right, and the others still line up.
- A split with no marker and no leg cannot be placed on the course, so the schema refuses it.

### Multi-events

A decathlon, heptathlon, or pentathlon is one event with code `DEC`, `HEP`, or `PENT` (see
[multi-events and their components](#multi-events-and-their-components)). Registration enters an
athlete in it once, with one entry on the multi-event:

```json
{
	"id": "reg_8",
	"event_id": "e-pent-w",
	"athlete_id": "ath_b2e1",
	"seed": { "value": 3950, "unit": "pt", "display": "3950" }
}
```

- **Registration sends the multi-event only.** It writes the multi-event and one entry per athlete
  on it, never a component event or an entry on one. Meet Operations builds the component events
  and gives each athlete their component entries.
- A multi-event seed is a points total, in `pt`.

### Exhibition athletes

An athlete with `exhibition: true` competes and places normally, including awards, but never scores
for a team and never takes a scoring slot from someone who does, in every event they enter. It is a
property of the athlete, not the team or the entry. An athlete on an `unattached` team is not
exhibition, and an exhibition athlete may be on an ordinary team.

### Names

`last_name` is the athlete's family name, or their only name when they have one; `first_name` is
optional, so an athlete with one name is written as they are.

### Name privacy

A name is published as it is sent. A registration product that must not publish an athlete's full
name, such as a minor's or an opt-out's, sends the name the way it may appear: `"last_name": "C."`
for a last initial. Meet Operations may also apply its own meet-wide policy before anything is
published.

### Award groups

`groups` lists award groups within a race: age groups in a road race, JV within a varsity cross
country race. A group may name the event it awards within. Its `gender` uses the event list: `C`
groups everyone in the span, and `X` is a non-binary group. Age and grade spans are inclusive at both
ends. An age span is matched against the athlete's `age`.

In a registration document, an athlete carries `age` or `birth_date`, or neither, never both: how
an age follows from a birth date is Registration's rule, so a registration product that knows the
age sends it. An entry may name the group Registration placed it in with `group_id`, when that does
not follow from age and gender alone.

In a results document, an entry's `group_places` names each group it placed in, by its id in
`groups`, with the place. The athlete's `age` is the age Meet Operations placed them by, whether
Registration sent an age or a birth date.

### Extra fields

A product may add fields of its own to any record. A reader ignores any field it does not know and
never fails over one, and no schema fails over one either. To keep your field from clashing with one
a later version adds, you may prefix it with your product's name, such as `examplereg:season_id`;
the format reserves no prefixes and lists none.

- Meet Operations does not carry another product's extra fields into a results document. A
  registration product that needs a value back keeps it on its own side, keyed by its own id.
- A field that proves useful across products is added to the format in a later minor version.

A writer sends only the values this version defines, never one of its own (see
[Versioning](#versioning)).

## Reading a registration document

**Meet Operations reads only registration documents,** told by `omx.kind`. It refuses a document of
kind `results` before creating or changing anything, and tells the timer that a meet is moved
between computers with the product's own backup (OMX-C102). It refuses a document whose kind is
missing or unknown, naming it (OMX-C107).

**A stray field is not a refusal.** A registration document that carries a field Registration does
not write, such as a `bib` on Carla Mendez's 5K entry or a `result`, is still read. Meet Operations
ignores the field and tells the timer what it ignored.

**Fetching again.** A timer may fetch Registration's link more than once, to pick up late entries
and scratches. Each fetch returns a complete registration document of the same shape. How Meet
Operations merges a later fetch into the meet the timer is running is its own business; the format
promises only what Registration does:

- **Ids never change** for the life of the meet.
- **A scratch is a status.** A withdrawn athlete stays in `entries` with `status: "scr"`, never
  removed (OMX-C10).

Registration must not assume its URL will be read a second time. If entries change late, tell the
meet director to have the timer fetch the link again.

### Records Meet Operations creates

Some records exist only in Meet Operations and carry no Registration id: an athlete who shows up on
the day, a club that enters unattached, a JV race Registration never listed. They reach a Publisher in the results document. `meet.origin` says how to read
that. It is `{ "id", "name" }`, the product the meet started in, named as its `generator` was:

| `meet.origin`                                       | Meaning                                         | A record with no id under `origin.id` is…             |
| --------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------- |
| A Registration product, `{ "id": "examplereg", … }` | The meet was built from a registration document | a record the timer created                            |
| Meet Operations itself, `{ "id": "2t", … }`         | The timer built this meet; no Registration      | every record. There are no Registration ids to expect |

A meet with no Registration is ordinary. Do not treat it as incomplete.

What a Publisher does with a record that has no Registration id:

- **Keep it.** The record is self-describing: name, team, bib, mark, place. A dropped entry means
  incomplete results and a wrong team score, shown as if correct.
- **Never auto-match by name.** You may offer the timer a match-or-create action; never guess.
- **Key your storage on Meet Operations' `id`, not on an external id.** A record can acquire a
  Registration id mid-meet: the timer creates a walk-up, Registration's next document includes that
  same athlete, and the timer merges them. Storage keyed on Meet Operations' id sees a normal
  overwrite. Storage keyed on the external id sees a duplicate.

When the timer merges two athletes, the surviving athlete keeps its own Registration id.

## The results document

Meet Operations hands on the meet with the competition filled in. A results entry is one athlete or
relay in one round:

```json
{
	"id": "en_5001",
	"event_id": "ev_41",
	"athlete_id": "at_301",
	"round": 1,
	"result": { "value": 1176230, "unit": "ms", "display": "19:36.23" },
	"overall_place": 3,
	"gender_place": 3,
	"points": 3,
	"splits": [{ "marker_id": "m_1", "time_ms": 374200 }],
	"group_places": [{ "group_id": "g-jv", "place": 1 }]
}
```

A results entry may carry `id`, `event_id`, `athlete_id` or `relay`, `round` (required), `heat`,
`lane`, `wave_id`, `seed`, `status`, `result`, `net_result`, `overall_place`, `gender_place`,
`heat_place`, `group_places`, `qualified`, `flags`, `wind_mps`, `reaction_time_ms`, `points`,
`multi_points`, `splits`, `field_series`, and `official_note`. It carries exactly one of `status` and
`overall_place`: a place means the athlete placed, and a status says everything else (see
[Statuses](#statuses)). It never carries `external_ids`, a bib (the bib is the athlete's), or a
`group_id`.

Two things exist only in a results document:

- **`events[].rounds`**: one object per round, with the round number, a `label` such as "Final",
  `final`, `status`, `scheduled_at`, `timing`, and `place_by`. Entries point at a round by number. A round may list
  `heats` with what belongs to a heat rather than an entry: when it `started_at`, and its official
  `wind_mps`. A round run as one race, such as a cross country or road race, lists at most one
  heat, `1`, to say when its gun went; an entry with no `heat` belongs to that heat.
- **`team_scores`**: standings, when the meet is scored. See [Team scores](#team-scores).

### Who is in a results document

A results document lists only:

- **the athletes its entries name**, as the athlete or in a relay's legs, a heat-sheet entry
  included;
- **the teams of those athletes**, and the teams of its relays and standings.

Noah Brooks scratched from the JV race: he has an entry with `scr` in the registration document,
no entry in the results document, and so no athlete record there either.

A results document never carries an athlete's `birth_date`, `email`, `phone`, `notes`, or `chip`,
or a meet's or event's `notes`. See [Personal data](#personal-data).

### One entry per round

A registration document has one entry per athlete or relay per event. A results document has one
entry per athlete or relay per round, and `round` is required on it. A sprinter who ran a prelim and
a final comes back as two entries, each with its own `id`, with `round: 1` and `round: 2`. Both are
found through the same athlete and event.

**The round marked `final: true` is the event's result.** Its places are the event's places. On
any other round, `overall_place` is the place across that round only. Meet Operations marks the
final as soon as it knows which round that is, including the only round of a single-round event,
and omits `final` on every other round. An event with no round marked final has no result yet. A
Publisher never infers the final from a round's number or `label`: before the final runs, the
highest-numbered round is a prelim.

```json
"rounds": [
	{
		"round": 1,
		"label": "Prelims",
		"status": "official",
		"scheduled_at": "2026-10-03T09:00:00-04:00"
	},
	{
		"round": 2,
		"label": "Final",
		"final": true,
		"status": "upcoming",
		"scheduled_at": "2026-10-03T14:00:00-04:00"
	}
]
```

**A round's `scheduled_at` is the meet's schedule.** It says when the round is planned to start, so
prelims at 9:00 and the final at 2:00 each carry their own time. It is the only schedule in the
format: a registration document carries none, since Meet Operations sets the schedule, and a
results document carries it on each round it knows a time for. It is a plan, never a start: when a
heat actually went is that heat's `started_at`, and no time in an entry counts from `scheduled_at`
except a rolling start's, which has no start signal (see [How a round was timed](#how-a-round-was-timed)).

### Round status

`rounds[].status` says where a round stands, from before it runs to official:

| `status`       | Meaning                                       | Example                                  |
| -------------- | --------------------------------------------- | ---------------------------------------- |
| `upcoming`     | Not run yet                                   | The Girls 3200 final, scheduled for 2:30 |
| `started`      | Under way; some results may be in             | Flight 1 of the long jump is done        |
| `cancelled`    | Will not run                                  | The Boys 4x400 after lightning           |
| `unofficial`   | Every heat has run; results may still change  | Varsity Girls 5K, just finished          |
| `under_review` | A protest or review is open                   | The 100m after a lane-violation protest  |
| `official`     | Final, unless a later correction is handed on | Varsity Girls 5K once official           |

The list is open, and it fails safe: a reader that meets a status it does not know treats the round
as not official, so a later state can never make results look final. A cancelled event stays
listed, with its rounds `cancelled` and no entries, so a Publisher prints "Cancelled" rather than
"Results to come" (OMX-C113).

### A status or a place

A registration entry may carry a `status`; **an entry with none is `e`, entered** (OMX-R186). A
results entry carries exactly one of `status` and
`overall_place`, never both and never neither: **a place means the athlete placed, and a status says
everything else** (OMX-R177). There is no "finished" status to write beside a place.

What an entry may be depends on its document and round:

| Where                                                               | The entry may be                                                      |
| ------------------------------------------------------------------- | --------------------------------------------------------------------- |
| A registration document                                             | `e`, `scr`, `wait`                                                    |
| A results round that is `upcoming`                                  | `e`, a heat-sheet entry, for a `start_lists` destination only         |
| A results round that is `started`                                   | `e` (as above), `s`, placed so far, or `dnf`, `dns`, `dq`, `fs`, `nr` |
| A results round that is `unofficial`, `under_review`, or `official` | placed, or `dnf`, `dns`, `dq`, `fs`, `nr`                             |
| A results round that is `cancelled`                                 | none                                                                  |

One entry moves through these states (OMX-C117):

- **`e` is a heat-sheet entry:** `round`, `heat`, `lane`, `seed`, `wave_id`, and no result. Meet
  Operations writes heat sheets only to a destination whose `content` asks for `start_lists`
  (OMX-C118).
- **`s` is in the round with nothing to place yet:** no `result`, no place, and it may carry
  `splits` or a `field_series`.
- **A placed entry** carries `overall_place` and no status. In a `started` round the place is the
  place so far, for a finisher in a race still running or an athlete still competing in a field
  event or multi-event; the round's status says it may move.
- **`dnf`, `dns`, `dq`, `fs`, and `nr`** are where an entry ended without a place.

_Example:_ Maya Chen in the Girls 100m prelims. At 9:10 she is heat 2, lane 4, seed 12.31, `e`; at
9:32, `s`; at 9:33, `overall_place: 1` with 12.18, first in her heat. In the Riverbend Girls 5K, 12
minutes in, she is `s` with her 1-mile split. In the long jump, Ruth Okafor leads after her second
attempt: `overall_place: 1` with her best mark, while the round is `started`.

### Multi-events come back at both levels

A results document carries the multi-event and every component event actually contested. Meet
Operations built the component events, so they carry no Registration id; each names its multi-event
in `multi_event_id`.

- **The multi-event entry is the standing.** It carries the athlete's points total as `result` in
  `pt` and their place in the standings as `overall_place`. While the multi-event is `started`, both
  are the total and place so far.
- **Each component entry is that event's result.** It carries the time or mark as `result` and the
  points it earned as `multi_points`.
- **`points` on any entry means team points,** a multi-event entry's included.
- **An athlete who abandons the multi-event** is `dnf` on the multi-event entry and holds no place in
  its standings. Jake Ortiz pulls a hamstring after 7 of 10 decathlon events: his `DEC` entry is
  `dnf`, and his component entries keep their marks and `multi_points`.

A Publisher can publish both levels: the standings from the multi-event, and each component's
results from its own event. A Registration product finds an athlete's component entries through the
athlete and the component events whose `multi_event_id` names its multi-event.

### Unknown values are omitted

A key whose value is not known is left out. Do not write `null` to mean "no lane", "no wind", or
"no place"; the schema rejects it. A reader treats `null` exactly as an absent key, and removes
`null` values before validating so that one never fails an import.

A finisher placed by a recorded finish order with no time carries its place and no `result` key.

### Statuses

`status` is a closed list. A placed athlete carries
`overall_place` instead of a status (see [A status or a place](#a-status-or-a-place)).

| Code   | Name           | Meaning                                                                      | Written in                        | Holds a place |
| ------ | -------------- | ---------------------------------------------------------------------------- | --------------------------------- | ------------- |
| `e`    | Entered        | Entrant, confirmed or not; nothing has happened yet                          | registration; results heat sheets | no            |
| `scr`  | Scratched      | Withdrawn before competing                                                   | registration                      | no            |
| `wait` | Wait list      | Not an entrant yet; holding for a spot                                       | registration                      | no            |
| `s`    | Started        | In the round, with nothing to place yet                                      | results                           | no            |
| `dnf`  | Did not finish | Started and did not complete. A race, or a multi-event the athlete abandoned | results                           | never         |
| `dns`  | Did not start  | Expected to start, did not, or never took an attempt in a field event        | results                           | never         |
| `dq`   | Disqualified   | Competed, removed by rule; keeps the result it was recorded with             | results                           | never         |
| `fs`   | False start    | Charged with a false start and removed when the starter recalled the field   | results                           | never         |
| `nr`   | No result      | Competed and has no valid result                                             | results                           | never         |

Registration sends only `e`, `scr`, and `wait`. Each changes what Meet Operations does: the athlete
is on the start list, off it, or not in the race until a spot opens. An unconfirmed registration,
awaiting payment or a waiver, is sent as `e`, or left out. Checked In is set at the meet and is not
written in either kind: a checked-in athlete's heat-sheet entry is `e`. There is no Pending.

Where each status applies:

- **`dnf`** is for races and for a multi-event's own entry. A thrower or jumper with no valid mark is
  `nr`, never `dnf`.
- **`fs`** is for timed track events only. A false start found only after the race, from the start
  blocks, is `dq`: that athlete ran the race and keeps the time.
- Every other status applies to every event.

Four rules:

- **`NT`, `NM`, and `NH` are how `nr` prints, not statuses.** An athlete who competed and has no
  valid result (a runner with neither a time nor a recorded finish position, three fouls, no
  height cleared) is `nr`, in any event. Render it as `NT` for a race, `NM` for a throw or
  horizontal jump, and `NH` for a vertical jump, never as `NR`, which on a results sheet reads as
  National Record. Never write `NT`, `NM`, or `NH` into `status`.
- **Only a placed entry and `dq` carry a result.** A `dq` keeps the time or mark it was recorded with
  and holds no place. `dnf`, `dns`, `fs`, `nr`, `s`, and `e` carry no `result`. A false start never
  ran the race that counted, so an `fs` entry carries no `splits`, while a `dq` may keep its
  `splits` with its result. Whether an entry placed is decided by `overall_place`, never by whether
  `result` is present.
- **A placed entry's place** comes from its result, a recorded finish order, or a place typed by an
  official. `heat_place`, `gender_place`, `group_places`, and `qualified` appear only beside
  `overall_place`.
- **Two entries in one round with the same `overall_place` are tied.** There is no separate flag.

### Compare on `value`, render `display`

`result` carries the stored value and the display text. Times are stored to thousandths even where
an event displays hundredths. Given only `10.61`, a reader cannot separate 10.605 from 10.614, so it
cannot order a season list. Compare on `value`; print `display`. Ties arrive as equal places.

### How a round was timed

A round says how its times were taken in `timing`: `hand` (a stopwatch or hand-operated device),
`fat` (fully automatic, photo-finish), or `transponder` (chip). It lives on the round because one
race is timed one way. A reader needs it before comparing across rounds or meets: a hand 10.3 and an
automatic 10.30 are not the same performance.

`timing` says what captured the finish, not where the clock started. Those are separate:

- **`result` is always the gun time**: from the start signal for the athlete's race or wave, or from
  its scheduled start when there was no signal, as in a rolling start where every runner is started
  by a mat. A cross country race timed with chips still produces gun times.
- **`net_result` is the net time**: from the moment the athlete crossed the start line to the
  finish, where a start mat recorded it. Road and trail races have it; most other races do not.

A round with net times says which one decided places in `place_by`: `overall` (overall and gender
places) and `groups` (award group places), each `gun` or `net`. Road races commonly place overall
by gun and age groups by net. A round without `place_by` was placed by gun time.

### Team scores

Each `team_scores` block has an `id`, which stays the same from one document to the next, and says
what it covers in `scope`: one `event`, one `division` across its events, or the whole `meet`, and
`scope.gender` says whose standings they are, always. When teams are scored separately by gender, as
a co-ed school's boys and girls are, each gender's score is its own block, `M`, `F`, or `X`;
standings that count every gender together are `C`:

```json
{
	"id": "ts_41",
	"scope": { "type": "event", "id": "ev_41", "gender": "F" },
	"standings": [
		{
			"team_id": "tm_7",
			"place": 1,
			"points": 45,
			"complete": true,
			"entries": [
				{ "entry_id": "en_5001", "role": "scorer" },
				{ "entry_id": "en_5002", "role": "scorer" },
				{ "entry_id": "en_5003", "role": "scorer" },
				{ "entry_id": "en_5004", "role": "scorer" },
				{ "entry_id": "en_5005", "role": "scorer" },
				{ "entry_id": "en_5006", "role": "displacer" },
				{ "entry_id": "en_5007", "role": "displacer" }
			]
		}
	]
}
```

Each standing names the team, its `place`, `points`, and `complete`, whether the team fielded enough
scorers to score. Two standings in one block with the same `place` are tied. `entries` lists the
entries that scored or displaced, each with its `role`:

- **`scorer`** counted in the team total; **`displacer`** did not count but pushed other teams'
  runners back.
- **The number is on the entry.** An entry's `points` is its team-scoring value whether or not it
  counted: in cross country its scoring position, in track and field the points it earned. An entry
  has one team-scoring value, and every block that lists it uses that value: a sprinter's 8 points
  in the 100m are the same 8 in the event's block and in the meet's total (OMX-R178).
- **An incomplete team scores nothing.** A team without enough runners to score is listed with
  `complete: false`, no `place`, and no `points`, and lists no entries. Its runners, and runners on
  an `unattached` team, carry no `points`, and the scoring positions of every other team's runners
  skip them.

So a results page prints Longwood's line at Riverbend, "45 (3-7-9-12-14) [18-22]", by lookup. Emma
Walsh finished 21st overall, but three runners ahead of her ran for incomplete or unattached teams,
so her entry carries `points: 18`, and the standing lists her as a `displacer`.

A Publisher prints these values; it never recomputes them.

### Advancement, flags, and field series

These ride on the entry and are written only when Meet Operations has them.

- **`qualified`**: the letter the results print after the entry's result in a round before the
  event's last, as it advanced, following World Athletics' notation (CR 25.4): `Q` by place, `q` by
  time or mark, `qD` won a draw for the last spot, `qR` advanced by the referee, `qJ` advanced by the
  Jury of Appeal. A field-event qualifier is `q`, because a field `Q` means a qualifying standard
  was met. Absent on a final and on any result that prints no letter. Maya Chen's 60m prelim entry
  carries `qualified: "Q"`; her final entry carries no reason. A bye or an alternate is not carried.
- **`flags`**: the timer's own flags on the result, as typed, in the order typed: a record (`MR`), a
  best (`PB`, `SB`), a standard met, a state qualifier at a regional (`Q`). A Publisher prints them
  after the result and its `qualified`, as written: `12.44 Q MR`. They are never a qualifier letter
  in a round before the event's last, and their absence does not mean a result is not a record.
- **`field_series`**: a field-event series in order. Each attempt has an `order` and an `outcome`:
  `o` valid, `x` foul or miss, `-` pass, `r` retired. For a horizontal jump or throw, `mark` is the
  measured distance and is present when the outcome is `o`. For a vertical jump, `mark` is the bar
  height and is present on every attempt, so a reader can print the height card. A horizontal jump
  attempt may carry its own `wind_mps`.
- **`wind_mps`**: on a field entry, the wind for the mark that counted. A race's wind is on its
  heat, `rounds[].heats[].wind_mps`, never copied onto each entry.
- **`reaction_time_ms`**: the reaction time from the start signal, where the start was monitored.
- **`official_note`**: a note from the officials, published with the result: the rule a
  disqualification was under, a jump-off, a protest outcome.

## Handing off

A handoff is one `POST` of one results document.

```http
POST {url}
Authorization: Bearer {token}
Content-Type: application/json
Content-Encoding: gzip

{ "omx": { "version": "1.0", "kind": "results", "update": "full", … }, "meet": { … }, … }
```

The body is the document. Meet Operations may compress it with `gzip` and say so in
`Content-Encoding`, or send it uncompressed; every Publisher accepts both (OMX-C109). A
25,000-runner marathon with eight splits per runner is about 32 MB of JSON, and a few MB
compressed.

Rules for a Publisher:

- **Read `omx.version` before validating.** A version you do not implement gets `422` with a
  message naming the version offered and the versions you accept, not a schema error. Put the
  versions you accept in the body as `accepts`, so Meet Operations knows what you read:

  ```json
  { "accepts": ["1.0"], "errors": [{ "path": "/omx/version", "message": "2.0 is not read here" }] }
  ```

- **Read `omx.kind`.** A document that is not `results` gets `422`.
- **Accept a document of at least 64 MB**, uncompressed. `413` is for a document beyond any meet's
  size.
- **Key a document on `omx.generator.id` plus `meet.id`.** Two Meet Operations products can both
  send `"id": "8812"`. The pair is unique, whatever credential it arrives on.
- **One Meet Operations product never replaces another's document.** A large meet may be timed by
  more than one system. Each hands off documents of the meet as it holds it, under its own `meet.id`.
  If you are also Registration, your id in `meet.external_ids` finds your meet; it does not decide
  what to replace. A document for your meet under a `generator.id` and `meet.id` you have not seen
  before is a second Meet Operations product: keep both, tell your user, and apply each only to
  its own.
- **Apply each document by its `update`.** See [Full and partial documents](#full-and-partial-documents).
- **Never apply an older document.** See [Order](#order).
- **A correction is another handoff.** A DQ resolved three days later arrives as a new document.
- **Respond with the codes below.**

### Full and partial documents

`omx.update` says what a results document covers:

- **`full`**: the whole meet as it stands. The Publisher replaces everything it holds for the meet;
  anything not in the document is gone. A `full` document lists every event, including ones that
  have not run.
- **`partial`**: only the rounds it carries, each of them whole: the round's status, its heats, and
  every entry in it. The Publisher replaces those rounds, replaces the team score blocks it carries
  by their `id`, adds or updates the meet, divisions, groups, waves, events, teams, and
  athletes it carries by `id`, and changes nothing else (OMX-C111). An event it carries keeps the
  rounds the document leaves out: rounds merge by their `round` number. A partial document deletes
  nothing.

What a partial document carries (OMX-C115):

- `omx` and `meet`;
- the events of its rounds, each with only those rounds. A component event brings its multi-event
  and the multi-event's round, because the standing changes with every component;
- every entry in those rounds, and the athletes, teams, divisions, groups, and waves they name;
- the event-scope team scores of its events, and every division- and meet-scope team score, whole.
  A standing may name teams and entries the partial document does not carry; the Publisher already
  holds them.

There is no schedule message. A meet running 20 minutes behind sends one `partial` document carrying
the rounds that moved, each with its new `scheduled_at`. A cancelled race is a `partial` document carrying that round, `cancelled`.

A Publisher refuses an `update` value it does not know with `422`, and never reads it as `full`,
which would delete every other round (OMX-C112).

_Example:_ the Riverbend track meet. At 8:00, 2Timer's first document to the registration site is `full`. At
9:10 the Girls 100m prelims are seeded: a `partial` carrying that round, with Maya Chen in heat 2,
lane 4. At 9:40 the round is official: a `partial` carrying it again. At 4:30 the meet is done:
`full`.

### Order

A handoff can arrive late. A Meet Operations product on a poor meet-day connection queues it and
retries, and a retry can land after a newer document. `omx.updated_at` puts documents in order.

`updated_at` is when the meet's data last changed, not when the copy was sent. Meet Operations
writes it on every results document:

- **Millisecond precision** with a UTC offset: `2026-10-03T14:41:52.418-04:00`. Two handoffs can
  fall in the same second.
- **Any change moves it forward**, including a deletion. Resending unchanged data keeps it the
  same.
- **It never goes backwards.** Stamp `max(now, previous + 1 ms)`, so a clock corrected when the
  laptop reconnects cannot make new results look old. Keep the previous stamp with the meet, so it
  survives a restore or a move to another computer.

There is one stamp per meet, whatever a document's `update`. A Publisher compares `updated_at` as
an instant, not as text, against the last document it applied for that meet:

| Arriving document | Publisher does                          | Responds       |
| ----------------- | --------------------------------------- | -------------- |
| Newer             | Applies it                              | `200` or `204` |
| Equal             | Nothing. It already holds this document | `200` or `204` |
| Older             | Keeps what it holds                     | `409`          |

That works because of what Meet Operations does for each destination (OMX-C116):

- **It sends one document at a time.** The next waits until the last is answered.
- **It keeps the rounds changed since the Publisher last accepted a document.** Every send, a retry
  included, is built at that moment and carries all of them. A missed delivery is always inside the
  next document, and a late retry that lands after a newer one holds nothing the newer one lacks.
- **Its first document from any copy of the meet is `full`,** and so is any document after anything
  is removed: an event, round, team, athlete, division, group, wave, or team score block. A laptop
  swapped mid-meet starts with nothing pending.
- **It sends `full` next when a Publisher answers `412`.** See [Responses](#responses).
- **It may always send `full` instead of `partial`.** A timing product never has to write partial
  documents.

`updated_at` orders documents. It is not a security control: replaying a document already requires
the token.

### Responses

| Status             | Meaning                                                    | Meet Operations                                           |
| ------------------ | ---------------------------------------------------------- | --------------------------------------------------------- |
| `200` or `204`     | Accepted, or already held                                  | Is done                                                   |
| `400` or `422`     | The document is not valid, or not one this Publisher reads | Does not retry, and tells the timer                       |
| `401`              | The token is not recognised                                | Stops handing off to this destination and tells the timer |
| `404`              | No endpoint at this URL                                    | Stops handing off to this destination and tells the timer |
| `409`              | The Publisher holds a newer document                       | Does not retry, and tells the timer                       |
| `410`              | The credential was retired, and will not return            | Stops handing off to this destination and tells the timer |
| `412`              | The Publisher needs the whole meet before a `partial`      | Sends a `full` document next, at once                     |
| `413`              | The document is larger than the Publisher accepts          | Does not retry, and tells the timer                       |
| `429`              | Too many requests                                          | Waits for `Retry-After`, then retries                     |
| `5xx` or no answer | A temporary failure                                        | Retries with backoff                                      |

Meet Operations treats a `4xx` this table does not list as `400`, and a `5xx` it does not list as a
temporary failure.

**A Publisher answers a `partial` with `412`** when it cannot apply it: it does not hold the meet,
or it cannot vouch that it holds everything it accepted, because it restored a backup or refused the
first `full` with `413`. It answers every `partial` with `412` until a `full` arrives, and may say
so in the body (OMX-R180):

```json
{ "need": "full" }
```

A Publisher may add a body to a `410` so the timer sees a clearer message:

```json
{ "reason": "revoked" }
```

`reason` is `revoked` or `expired`. A `400` or `422` may say what was wrong, so the timer can pass
it on:

```json
{ "errors": [{ "path": "/entries/14/result/value", "message": "must be a number" }] }
```

A Publisher may rate-limit. When it does, it answers `429` with `Retry-After`. A handful of
documents per event is normal for a `progressive` destination, and one every few seconds while a
round runs for a `live` one.

### When a handoff happens

A destination says how often it wants results in `deliver`, and what in `content`. Meet Operations
preselects them for the timer, who may change the cadence:

| `deliver`         | The destination gets                                                                            |
| ----------------- | ----------------------------------------------------------------------------------------------- |
| `final` (default) | `full` documents once every round is official or cancelled, and again for each correction       |
| `progressive`     | A first `full` document, then `partial` ones as each round is seeded, goes official, or changes |
| `live`            | Everything `progressive` gets, plus a `partial` document every few seconds while a round runs   |

| `content`               | The destination gets                                     |
| ----------------------- | -------------------------------------------------------- |
| `results` (the default) | Entries once a round is under way                        |
| `start_lists`           | Also heat-sheet entries for rounds that have not run yet |

- A `final` destination only ever gets `full` documents, so the simplest Publisher needs one rule.
  `final` is the default so that a destination that says nothing is not handed numbers that are
  still moving.
- `deliver` and `content` are open lists. An unknown `deliver` reads as `final`, and an unknown
  `content` value is ignored, so a later value never reaches a destination that did not ask for it.

_Example:_ The registration site sets `"deliver": "live", "content": ["results", "start_lists"]` and shows heat
sheets, then each race as it is run. A school's site sets nothing and gets the meet once, at 4:30,
when it is done.

### Live updates

While a round is under way, each `live` update is a `partial` document carrying the round whole:
every entry, finishers with their places so far, and athletes still on the course as `s` with
their splits. A Publisher shows the round as running (OMX-C114). If the finish crew fixes a misread
(bib 181, not 118), the next update carries the round without the wrong entry. Leaving an
entry out of a round it carries whole is not a removal and needs no `full`; an athlete left with no
entries stays on the Publisher until the next `full` document.

How often needs no field. Meet Operations waits a few seconds between updates to one destination,
and a Publisher that wants fewer answers `429` with `Retry-After`. The Riverbend Girls 5K has 300
runners: about 100 KB per update, 15 KB compressed. A 2,000-runner road race is about 80 KB
compressed.

### Destination cards

A Publisher that never wrote a document still has to tell Meet Operations where to send results. It
does that with a destination card: one destination, with its token, wrapped in an object that names
it.

```json
{
	"omx_destination": {
		"id": "riverbend-results",
		"label": "Riverbend Results",
		"url": "https://results.example.org/omx/inbox",
		"token": "rr_push_6Hq2Lk9Vb3Xc8Nm1Pz4Rt7Wy0Ad5Gf2J",
		"deliver": "progressive",
		"content": ["results"]
	}
}
```

- A destination is `id`, `label`, `url`, `token`, `deliver`, `content`, and `accepts`.
- A Publisher shows the card as text with a copy button, or offers it as a file named
  `<label>.omx-destination.json`. It validates against
  [`destination-card-v1`](/omx/schemas/destination-card-v1.schema.json).
- Meet Operations accepts a pasted or opened card as a destination the timer added by hand.
  Pasting it is the timer's approval. It refuses a card whose `url` is not `https://`. Pasting a card
  whose `url` the meet already sends to changes that destination rather than adding a second.
- A card holds a token, so it is a credential: show it only to the person setting up the meet, and
  let them revoke it.

### Asking a destination first

A Publisher may answer a `GET` on its destination URL, with the same bearer token, so Meet Operations
can check a destination when the timer adds it, before the meet. This is the **probe**. [Open Meet Display](/omd/spec), the companion format
for scoreboards, uses the same probe.

```http
GET {url}
Authorization: Bearer {token}
```

```json
{
	"accepts": ["1.0"],
	"generator": { "id": "riverbend", "name": "Riverbend Results", "version": "2.1" }
}
```

| Answer                    | Meaning                                 | Meet Operations                                                                         |
| ------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------- |
| `200` with the body above | Ready, and reads these versions         | Tells the timer who it reached and which versions it reads, and sends what it `accepts` |
| `503` with `Retry-After`  | Running, but not ready yet              | Tells the timer, and asks again later                                                   |
| `401` or `410`            | The token is not recognised, or retired | Tells the timer the token was refused, before the meet rather than at the first result  |
| `404`, `405`, or another  | Does not answer probes                  | Nothing is wrong: uses the `accepts` the destination already gave                       |

- **`accepts`** (required) lists the versions the Publisher reads, with the meaning it has on a
  destination: `["1.0"]` reads every `1.x`.
- **`generator`** (required) names the Publisher exactly as a document names its writer: its
  system `id`, its `name`, and an optional `version`.
- **A probe's `accepts` wins.** It is newer than the one on the destination or card, so Meet
  Operations goes by it, as it does by the `accepts` in a `422`.
- **A probe changes nothing.** A Publisher never counts it as a handoff, and answers it the same
  however often it is asked.
- **Answering is optional.** A Publisher that does not answer probes is fully conformant
  (OMX-C125); one that answers does so as above.

### Trying it by hand

Every exchange is plain HTTPS, so `curl` is enough to see one work. With a document URL in
`$DOC`, and a Publisher endpoint and token in `$INBOX` and `$TOKEN`:

```sh
# Ask the destination first (optional for a Publisher): 200 with accepts and generator, or 404/405.
curl -sS -w '\n%{http_code}\n' "$INBOX" -H "Authorization: Bearer $TOKEN"

# Fetch a registration document, keeping its ETag. Then ask again: a 304 means nothing changed.
curl -sS -D headers.txt -o meet.omx.json "$DOC"
curl -sS -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $(grep -i '^etag:' headers.txt | cut -d' ' -f2- | tr -d '\r')" "$DOC"

# Hand off a results document. Send it twice: both answer 200 or 204, and the second changes nothing.
curl -sS -O https://2timer.app/omx/fixtures/c30-first.omx.json
curl -sS -w '%{http_code}\n' -X POST "$INBOX" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' --data-binary @c30-first.omx.json

# The same document, compressed: 200 or 204, and nothing changes.
gzip -c c30-first.omx.json | curl -sS -w '%{http_code}\n' -X POST "$INBOX" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -H 'Content-Encoding: gzip' --data-binary @-

# Hand off an older copy of the same meet: 409.
curl -sS -O https://2timer.app/omx/fixtures/c30-older.omx.json
curl -sS -w '%{http_code}\n' -X POST "$INBOX" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' --data-binary @c30-older.omx.json
```

The [fixtures](/omx/fixtures/index.json) hold a crafted document for each conformance test that
needs one, named by the test.

## Security

The document URL is the credential. Whoever holds it can read the meet.

- HTTPS only. Meet Operations refuses a plain `http://` document or destination URL.
- Scope the secret to one meet. Let the meet director revoke it and mint a new one.
- You may expire it, but the timer needs it until the meet starts, and may fetch it again for
  late entries.
- A revoked or expired URL answers `410`. An unknown one answers `404`. On a `410` Meet Operations
  keeps the meet and tells the timer the link no longer works.

Each destination's `token` is a separate secret, carried in the registration document and sent as an
`Authorization: Bearer` header. Holding the read link never grants the right to write results, and
the token never appears in a query string where proxies log it. Destinations do not share a
credential, so revoking one does not touch another.

**The Publisher owns its token's life.** It decides how long a token works and answers `410` once it
stops, as for any retired credential. Keep a token working long enough for late corrections: a DQ
at the Riverbend Invitational overturned three weeks later still has to arrive.

### Secrets

A meet involves two kinds of secret. Whoever runs an endpoint issues its secret; everyone else
treats it as an opaque string.

| Secret                         | Issued by                                                                         |
| ------------------------------ | --------------------------------------------------------------------------------- |
| The secret in the document URL | Registration                                                                      |
| A destination's `token`        | Whoever runs that destination: Registration for its own, a third party for theirs |

Every one of them:

- Has at least **128 bits** from a cryptographically secure random generator. That is 22 or more
  base62 characters; 32 is recommended. A 6-digit code is not a secret.
- May carry a readable prefix such as `reg_pub_`, which helps secret scanners catch a leak. The
  prefix does not count toward the 128 bits.
- Is compared in constant time and redacted from logs.

The schema requires a destination's `token` to be at least 22 characters, so validation catches a
short one. It cannot check randomness.

### Browser-based products

Meet Operations may run in a web browser. A browser fetches a document or posts a handoff to
another site only when that site allows it, so every OMX endpoint answers cross-origin requests:

| Endpoint            | Answers `OPTIONS` with                                                                                                                                                     | Adds to every response                                                         |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| Document URL        | `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods: GET`, `Access-Control-Allow-Headers: If-None-Match`                                                       | `Access-Control-Allow-Origin: *`, `Access-Control-Expose-Headers: ETag`        |
| A destination's URL | `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods: POST, GET`, `Access-Control-Allow-Headers: Authorization, Content-Type, Content-Encoding, Content-Digest` | `Access-Control-Allow-Origin: *`, `Access-Control-Expose-Headers: Retry-After` |

Allowing any origin is safe because OMX never uses cookies: the secret is in the URL or the bearer
token, and `Access-Control-Allow-Credentials` is never sent.

**Answer at the URL you give out.** A document URL and a destination URL answer the request
themselves, never with a redirect. A browser follows a cross-origin redirect only when the redirect
itself carries the headers above, never redirects a preflight, and drops `Authorization` when a
redirect leaves the origin. So a move from a bare domain to `www.`, or from one path to another,
belongs in the URL you write into the document or hand to the timer. A redirect without those
headers looks to a browser exactly like a server that does not answer cross-origin requests at all.

### Personal data

A document is personal data about athletes, many of them minors. Send what the meet needs: name,
gender, team, and a birth date, age, or grade.

- **What stays with the timer.** An athlete's `email`, `phone`, `notes`, `birth_date`, and `chip`, and
  a meet's or event's `notes`, are for the timer's use on meet day. Meet Operations leaves every one
  of them out of every results document (OMX-C65); the results check refuses them. A note such as
  "Hana S. has a custody order, do not release to her father" stays with the timer. Public text from
  the officials goes in an entry's `official_note`.
- **Age, not birth date.** A results document carries the athlete's `age` as Meet Operations placed
  them, never a birth date.
- **Only the athletes the entries name** are in a results document (see
  [Who is in a results document](#who-is-in-a-results-document)).
- A Publisher never publishes a birth date or contact detail.
- Keep a document no longer than you keep the meet.

### Integrity

OMX adds no checksum or signature. TLS already detects a corrupted or altered body in transit, and
a hash without a key would not stop anyone able to alter the body, who could recompute it.
The bearer token already says who sent the document. A sender may add a `Content-Digest` header
(RFC 9530); a Publisher may verify it and must not require it.

## Not in this version

Each of these is a known gap, left out until an implementer needs it. Each can be added in a minor
version without changing what anything already in the format means:

- **Changes-only live updates.** A `live` update carries the running round whole. A mode that sends
  only the entries that changed, for races of many thousands, waits for a destination to ask for it
  by name.
- **Records and bests as data.** A timer's `flags` say what the timer typed, such as `MR` or `PB`,
  not which record or best it is. Records with a name and type, and bests worked out from an
  athlete's history, are the Publisher's to compute from results.
- **Sessions.** A named block of the schedule, such as "Saturday Morning" at the main stadium, is
  not carried. Each round's `scheduled_at` is the schedule, and a Publisher groups rounds by day
  from it.
- **Webhooks.** Registration cannot tell Meet Operations that its document changed.
- **OAuth.** Destinations use bearer tokens. An OAuth profile may be added later as new destination
  fields, without changing the document.
- **Other ways to deliver.** Every destination receives a `POST`. A destination that would rather
  fetch a hosted document may come later with its own fields.
- **Para classification.** An athlete's sport classes (`T54`, `F37`), which can differ between
  running and throwing, and a guide on an entry. Adaptive events are defined; the athlete's classes
  are not.
- **Reasons beside a status.** Lapped, pulled, or over the time limit, for lap races and ultras.
  These are expected as an open reason beside the closed `status`, never as new statuses.
- **Laps and fixed-time events.** A lap count, a new mark unit, and a duration or lap target on an
  event (a one-hour run, a criterium, a backyard ultra).
- **Yard events.** A 600-yard run has no code yet. Event codes are an open list, so yard codes can
  come in a minor version.
- **A measured course distance.** A race's code is its advertised distance; what the course actually
  measured may come as its own field.
- **Segmented races.** Triathlon and similar events build on `markers`; a distance per marker and
  named segments wait for a producer.
- **Age grading and adjusted rankings.** Age-graded results, and events ranked on points from raw
  marks.
- **Dual-meet pairings.** Pairwise scores inside a larger meet. They would come as a new
  `scope.type` whose standings carry their own points, which a 1.0 Publisher skips.
- **Which events a document covers.** For a meet split between Meet Operations products; a Publisher
  keeps their documents apart meanwhile (see [Handing off](#handing-off)).
- **Sanctions and certification.** Sanction numbers and course certification. A meet's ids on
  other systems are already carried, in `meet.external_ids`.

## Versioning

- `omx.version` is `MAJOR.MINOR`.
- Minor versions only add: optional fields, and values in open lists. A reader accepts any minor
  within a major it implements and ignores what it does not know.
- Major versions may break. A reader that meets a major it does not implement refuses the document
  with a clear message rather than importing what it half-understands.
- One schema URL per major, republished in place with each minor.
- A reader of 1.0 accepts every `1.x` document, and a destination that `accepts` `1.0` is sent any
  `1.x` (OMX-R187). A destination's `accepts` may come from its card or `publish` item, from a
  probe, or from a `422`; the newest one counts (see [Asking a destination first](#asking-a-destination-first)).
- **A badly formatted value is a warning, never a failed meet.** The lenient reader schema checks
  types, not formats: a country that is not two letters, `updated_at` with no milliseconds, a short
  token, a long team code. A reader warns about such a value and keeps reading; the strict writer
  schemas keep the formats, so a writer never sends one (OMX-R181, OMX-C123). A writer that holds
  a value it read that way, such as a 20-character team code, writes it in a form that fits, a
  shortened code unique in the meet, and never holds back the whole meet for one value
  (OMX-R185).

### Open and closed lists

Every list of values is either closed or open, and an open one says what a reader does with a
value it does not know.

**Closed lists** change only in a major version, because a reader branches on them: `omx.kind`,
athlete `gender`, event `gender`, entry `status`, and `place_by`.

**Open lists** gain values in minor versions. A writer sends only a value this version defines; the
strict writer schemas enforce that. The lenient reader schema accepts any string, so a document from
a later minor still reads.

| Open list                  | A value the reader does not know                                                                       |
| -------------------------- | ------------------------------------------------------------------------------------------------------ |
| `meet.sport`               | Meet Operations refuses the document, naming the sport; a Publisher shows the events by name and code  |
| `events[].code`            | Meet Operations leaves the event and its entries out and tells the timer; a Publisher shows it by name |
| Mark `unit`                | Show the mark's `display`; never compare or sort on it                                                 |
| `omx.update`               | Refuse the document with `422`; never read it as `full`                                                |
| `rounds[].status`          | Treat the round as not official                                                                        |
| `events[].adaptive.mode`   | Show the event as adaptive with no mode                                                                |
| `publish[].deliver`        | Treat as `final`                                                                                       |
| `publish[].content` values | Ignore the value                                                                                       |
| `team_scores[].scope.type` | Skip the block rather than show standings it cannot label                                              |
| `field_series[].outcome`   | An attempt with no valid mark                                                                          |
| `qualified`                | Show the value as written                                                                              |
| Any other open list        | Treat as absent                                                                                        |

The other open lists are `timing`, `team_scores` `role`, and `meet.track.shape`. `flags` are free
text, printed as written. System ids (`omx.generator.id`, `meet.origin.id`, and the keys of
`external_ids`) are not a list of values: Meet Operations writes back every id it received, and a
Publisher keeps or ignores an id under a system it does not know.

### Pinned versions

- Every version's raw files stay at `/omx/<version>/`, for example
  [`/omx/1.0/spec.md`](/omx/1.0/spec.md), so an implementation can cite and re-check exactly what
  it was built against. The unversioned URLs always serve the newest version.
- A document carrying a draft number (`0.x`), written before 1.0, is refused by a 1.x reader. There
  are no converters.

### Changelog

- **1.0.** The first published version.

## Checklist

**Registration**, whether or not you want the meet back

- Document carries `"kind": "registration"`, validates against `registration-document-v1`, and
  passes `omx-check.mjs`
- Every id is a stable string, unique within its list
- Every `team_id`, `athlete_id`, `event_id`, `division_id`, `group_id`, and `wave_id`
  resolves to a record in the same document
- At most one entry per athlete per event; every relay has a letter, and at most one relay entry per
  team, letter, and event
- HTTPS, `application/json`, revocable per meet
- The URL secret has at least 128 bits of randomness; a revoked or expired URL answers `410`
- `ETag` sent and `If-None-Match` honoured with `304` (recommended)
- Cross-origin headers on the document URL: `Access-Control-Allow-Origin: *`, `ETag` exposed,
  `If-None-Match` allowed
- The URL you give out answers directly, not through a redirect (a bare domain to `www.` included)
- `omx.generator.id` is your system id, a name you pick once and never change
- Ids you know on other systems are in `external_ids` on the meet, events, teams, and athletes,
  never your own, and never on an entry
- Scratches are sent as `status: "scr"`, not omitted; entry statuses are only `e`, `scr`, and `wait`
- No `meet.origin`, track, markers, rounds, results, places, or team scores: those are Meet
  Operations'
- Marks are in the unit you measured, with integer `ms`, `mm`, `pt`
- Every event has a `code` defined for the meet's sport, at its advertised distance (`HMAR` and
  `MAR` for those races); an athlete in a multi-event has one entry, on the multi-event
- Every value is one this version defines
- An athlete's gender is left out when they did not state one; a race or relay open to every
  gender is `C`, and every event, award group, and team-score scope has a gender
- An athlete carries `age` or `birth_date`, never both
- Nothing assumes the URL will be fetched more than once

**Registration that wants results back**, in addition

- Each `publish` destination has a stable `id`, a human `label`, and a `token`
- `deliver` and `content` are set deliberately, knowing `deliver` defaults to `final`
- Finds its meet, events, teams, and athletes in a results document by `external_ids` under its own
  system id, and its entries through their athlete and event
- Each destination's `token` is a different secret from the document URL and from each other
- Each token you issue has at least 128 bits of randomness, and keeps working long enough for late
  corrections

**Publisher**

- Accepts `POST` of one results document with bearer auth; `401` on a token it does not recognise,
  `410` on a retired one
- Accepts a body sent with `Content-Encoding: gzip`, and a document of at least 64 MB uncompressed
- Answers cross-origin preflights, allowing `Authorization`, `Content-Type`, `Content-Encoding`, and
  `Content-Digest`, and exposes `Retry-After`
- The `url` in your destination answers directly, not through a redirect
- Reads `omx.version` before validating, and answers `422` naming both versions to one it does not
  implement, with `accepts` in the body
- Answers `422` to a document whose `kind` is not `results`, or whose `update` it does not know
- Removes `null` values before validating; answers `400` or `422`, with `errors`, to a document it
  cannot accept
- Shows its user a destination card to give the timer
- May answer the probe: `GET` on the destination URL with the token gets `200` with `accepts` and
  `generator`, or `503` while not ready; a refused token gets `401` or `410`
- Issues tokens with at least 128 bits of randomness
- Keys each document on `omx.generator.id` and `meet.id`
- Applies a newer `omx.updated_at`, treats an equal one as a no-op, and refuses an older one with
  `409`, comparing instants rather than text, whatever the document's `update`
- Applies a `full` document by replacing everything held for the meet; the same document twice is a
  no-op
- Applies a `partial` document by replacing only the rounds it carries and the team score blocks it
  carries by their `id`, adding or updating the records it carries by `id`, and keeping everything
  else
- Answers `412` to a `partial` it cannot apply, until a `full` arrives
- Creates a meet it has never seen, without Registration existing
- Reads `meet.origin` rather than inferring provenance from missing ids
- Joins records by an id in `external_ids` under a system it knows, and entries through their
  athlete and event, never by name
- Keeps entries with no Registration id
- Keys storage on Meet Operations' `id`, so a record that later acquires a Registration id is an
  overwrite, not a duplicate
- Expects one entry per round
- Takes an event's result from the round marked `final`, never from a round's number or label
- Shows a round by its `status`: a cancelled round as cancelled, a `started` round as running with
  its `s` entries on the course, and an unknown status as not official
- Compares marks on `result.value`, never on `display`, and shows a mark in a unit it does not know
  by its `display`
- Never recomputes a place, a tie, or a team score; reads a tie from equal places
- Reads a placed entry from `overall_place`, and every other entry's outcome from `status`
- Ignores fields it does not know
- Treats `null` as absent, and an absent `result` on a finisher as "no time", not an error
- Never publishes a birth date or contact detail
- Treats any other unknown open value as its table says; shows a meet of a sport it does not know,
  and an event whose code it does not know, by the events' names, printing an `nr` there as `NR`
- Never lets one Meet Operations product's document replace another's: a document for its meet under
  a new `generator.id` or `meet.id` is kept beside the first

**Meet Operations**

- Builds the meet from a pasted document URL or an opened file with no wizard
- Refuses a plain `http://` document or destination URL
- Refuses a major version it does not implement, naming both versions
- Refuses a document of kind `results`, and one whose kind is missing or unknown, before creating
  anything
- Reads a registration document carrying a field Registration does not write, ignores the field,
  and tells the timer
- Leaves out an event whose code it does not know, or that is not defined for the meet's sport,
  with its entries, tells the timer, and reads the rest; refuses a sport it does not run, naming it;
  never merges an adaptive event into the standard event of the same code
- Builds a multi-event's component events by its own rules, for any gender, and reports one it
  cannot build
- Writes its own system id as `omx.generator.id`, sets `meet.origin` honestly, writes its own ids in
  `id`, and carries Registration's id unchanged in `external_ids`, under Registration's system id,
  on every meet, event, team, and athlete that came from Registration
- Writes back every `external_ids` entry it received on the meet, events, teams, and athletes
- Writes no `external_ids` on an entry, and a letter on every relay entry
- Gives a meet made from another meet new ids, and none of the old meet's Registration ids
- Writes `"kind": "results"`, `update`, and `updated_at` on every results document
- Writes one entry per round, with `round`, and exactly one of `status` and `overall_place`: a place
  for a placed entry, and otherwise only a status its round's status allows
- Writes heat-sheet entries only for a destination whose `content` asks for `start_lists`
- Marks the round whose places are the event's result with `final: true`, and gives every round its
  `status`, `cancelled` included
- Writes a multi-event at both levels, with every component event contested
- Writes the gun time in `result`, any net time in `net_result`, and `place_by` on a round that has
  net times
- Writes every split at a marker the event lists or at a relay leg, as elapsed `time_ms`
- Writes a race's wind on its heat, and a field result's wind on the entry
- Writes team-scoring `points` on each entry, a displacer's included, one value in every block that
  lists it; gives each team score block an `id` that stays the same; lists each standing's entries by
  `entry_id` and `role`; and writes an incomplete team with no place, points, or entries
- Writes only the athletes and teams its entries name, with the age it placed each athlete by, and
  no `birth_date`, `email`, `phone`, `notes`, `chip`, or `publish`, and no other product's extra
  fields
- Writes each round of a `partial` document whole, with the athletes and teams its entries name and
  the team scores the spec lists
- Sends one document at a time to a destination, builds every send (a retry included) at that
  moment from everything pending, and sends `full` first from any copy of the meet and after any
  removal
- Respects each destination's `deliver`: `full` only for `final`, and a `live` update at most every
  few seconds while a round runs
- Every handoff is one document that validates against `results-document-v1`, sent uncompressed or
  with `Content-Encoding: gzip`
- Every handoff carries `omx.updated_at` to the millisecond, moved forward by any change and never
  backwards
- Retries `429`, `5xx`, and network failures with backoff; never retries `400`, `401`, `404`,
  `409`, `410`, `413`, `422`, or a `4xx` the table does not list
- Sends `full` next when a Publisher answers `412`
- May probe a destination when the timer adds it, tells the timer what it learned, and goes by a
  probe's `accepts` over the destination's; a destination that does not answer is not an error
- Publishes stored values in `result.value`, with `display` alongside
- Omits what it does not know; never writes `null`
- Never re-encodes a status, and writes no `NT`, `NH`, or `NM` into a status (they are how `nr`
  prints)
- Offers the timer a handoff cadence, with the destination's `deliver` preselected
- Lets a timer add a destination by hand, and by pasting a destination card

## Files

Rendered pages:

- [Field reference](/omx/reference): every field in the schema, as tables
- [Worked examples](/omx/examples): one validating registration document per sport, each with its
  results twin
- [Conformance tests](/omx/conformance): every rule above with a pass condition, grouped by stage
- [Test page](/omx/test): check a document, a registration URL, a Publisher endpoint, or a
  destination card from your browser
- [Integration prompts](/omx/prompt): ready to paste into a coding assistant
- [Field mapping](/omx/mapping): where Hy-Tek and other formats' fields go in a document

Raw files, for tools and coding assistants. [`/omx/llms.txt`](/omx/llms.txt) lists them all.

| File                                   | URL                                                                                                    |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Reader schema (lenient, JSON Schema 7) | [/omx/schemas/meet-document-v1.schema.json](/omx/schemas/meet-document-v1.schema.json)                 |
| Registration check (strict)            | [/omx/schemas/registration-document-v1.schema.json](/omx/schemas/registration-document-v1.schema.json) |
| Results check (strict)                 | [/omx/schemas/results-document-v1.schema.json](/omx/schemas/results-document-v1.schema.json)           |
| Destination card schema                | [/omx/schemas/destination-card-v1.schema.json](/omx/schemas/destination-card-v1.schema.json)           |
| Checker (Node 18+, no dependencies)    | [/omx/tools/omx-check.mjs](/omx/tools/omx-check.mjs)                                                   |
| Fixtures for the conformance tests     | [/omx/fixtures/index.json](/omx/fixtures/index.json)                                                   |
| TypeScript types                       | [/omx/types/omx.d.ts](/omx/types/omx.d.ts)                                                             |
| Python types (3.11+)                   | [/omx/types/omx.py](/omx/types/omx.py)                                                                 |
| This specification                     | [/omx/spec.md](/omx/spec.md)                                                                           |
| Conformance tests                      | [/omx/conformance.md](/omx/conformance.md)                                                             |
| Integration prompts                    | [/omx/integration-prompt.md](/omx/integration-prompt.md)                                               |
| Field mapping                          | [/omx/mapping.md](/omx/mapping.md)                                                                     |
| Examples index                         | [/omx/examples.md](/omx/examples.md), listing each document under `/omx/examples/`                     |
| This version, pinned                   | [/omx/1.0/](/omx/1.0/spec.md): the same raw files, never changed after a later version replaces it     |

Run the checker against a document, with the schema too if your project has `ajv` installed:

```sh
curl -sS -O https://2timer.app/omx/tools/omx-check.mjs
node omx-check.mjs meet.omx.json                       # kind read from omx.kind
node omx-check.mjs --kind registration meet.omx.json   # or check it as one kind
```

It prints each problem with its JSON path and the conformance test it fails, and exits non-zero when
there is one.

## Governance

OMX was started by 2Timer, which hosts this page, and is edited in the open with the products that
implement it.

- **Anyone may propose a change** by email. A proposal says what problem it solves for which stage.
- **A change is decided with the implementers it affects.** Nothing that changes what an
  implementer reads or writes is made without asking them, and every change is published here with
  its reason in the changelog.
- **The [versioning](#versioning) rules apply:** a minor only adds, and only a major breaks.

## License

The text of this specification is licensed under
[Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/) (CC BY 4.0): copy,
translate, and adapt it, crediting Open Meet Exchange. The schema, the example documents, the
conformance tests, and the integration prompts are dedicated to the public domain under
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/): copy them into your product or your
test suite with no notice required.

## Feedback

If something here only makes sense for one product, that is a bug in the format. Send thoughts to
[jason@2timer.app](mailto:jason@2timer.app?subject=Open%20Meet%20Exchange).
