Skip to content

Orienteering JSON (OJSON)

Audience: Developers and technically-minded organisers building something that needs a JSON based interchange format for Orienteering event information.

Each of Manager's three file exports — the entry list, the start list and the results — is written as IOF XML and as Orienteering JSON: one JSON document holding the event, its classes, its courses, its controls, its entries and its results. It is a published, versioned format with a JSON Schema you can validate against, and you are welcome to build on it.

Note: This page is the practical guide, and the schema below is the machine-readable contract. Behind both sits the full specification — .ai/JSON_EXPORT.md in the Manager source — which records the reasoning behind every decision. Where this page and the schema disagree, the schema wins.

Why the format exists

So OJSON is a JSON binding of the IOF 3.0 data model. The entities, the relationships, the status vocabulary and the export rules are the IOF's — only the encoding is different. A resultList and the ResultList XML of the same event are produced by the same code down to the same shared rules, so the two files can never disagree, and converting between them is mechanical.

What OJSON adds is the handful of things Manager knows and the XML schema has no room for:

IOF XMLOJSON
A class's split-time columnsAn XML comment inside <Class> — which is why so many tools never render splitssplitControls, a real array
A relay team's own status, time and placingNot expressible; you infer the team's outcome from its legsStated on the team
Where a split came fromIndistinguishable — a radio passing and a downloaded split are both <SplitTime>splitSource: card or radio
A finisher whose card is not read yetNot expressible; the export can only publish the downgrade to DidNotFinishprovisional: true beside it, so you can see why
Sub-second precisionWhole seconds by XML conventionMilliseconds — what Manager actually times to
The event's own id and its Eventor idOne Id slot; the export must chooseBoth
Control positions, types and clock correctionsOnly in CourseData, which Manager never writescontrols, stated once on the document
Whether a class is individual or relayNot expressibleisTeamClass
How a course is scoredNot expressible — a scatter or score course arrives as a line course and its whole field mis-punchescourseFormat on each course
Which fork each relay leg runsNot expressiblecourse and assignedVariationCode on the leg runner
An athlete's place on a ranking listNot on a start list or a result list at allranking on every entry
The operator's own competitor fieldsNowhere to put themcustomFields

Getting a file

From the app

DocumentWhere
Entry listCompetitors page → ActionsExport JSON
Start listStart list view → actions menu → Export JSON
ResultsResults view → PublishExport JSON

The results and start-list menus open the same scope dialog their IOF XML neighbours do. The entry-list menu always exports the whole event, never the rows the screen is filtered to.

From the API

Three plain GET endpoints on the Manager's own API, taking exactly the same query parameters as their IOF XML counterparts:

DocumentRoute
Entry list/manager/api/ResultsExport/{eventId}/entrylist-json
Start list/manager/api/ResultsExport/{eventId}/startlist-json
Results/manager/api/ResultsExport/{eventId}/json
Query parameterApplies toMeaning
kindallRelay or Individual. Required on an event that has both kinds of class — an export never mixes them. Omit it on a single-kind event.
classIdsallComma-separated class ids. Omitted or empty exports every class.
includeDidNotStartresultsWhen false (the default), non-starters and statusless entries are left out. On-course runners and not-yet-downloaded finishers are carried either way, as DidNotFinish.

The {eventId} is the event folder name, like 2025-06-15.01; it is also event.id in every document. Reaching the server: name or IP covers what host name to use from another machine.

bash
curl -o results.json \
  "http://localhost:5154/manager/api/ResultsExport/2025-06-15.01/json?includeDidNotStart=true"

Note: None of the routes sets a Content-Disposition header — the body is the document and the caller names the file, so pass -o to curl. On a Manager protected with an access PIN, these endpoints need the same authenticated session the app uses; for a one-off file, export from the menus above instead.

From an AI assistant

If you have AI assistants (MCP) switched on, the same three documents are available as the tools export_entry_list_json, export_start_list_json and export_results_json, taking the same parameters and returning the document as text.

The three kinds of document

documentType says which one you are holding. They are nested — each is the one before it plus something — so a consumer written for a start list already reads an entry list.

entryListstartListresultList
person, organisation, bibNumber, ranking, controlCards, course, assignedVariationCode, customFields, entrySourceyesyesyes
classes[].coursesyesyesyes
event.startTimenoyesyes
startTime, startTimeSource on an entrynoyesyes
classes[].splitControls, classes[].legsnonoyes
status, statusIsManual, provisional, finishTime, time, position, timeBehind, scorenonoyes
splitSource, splitsnonoyes

An entryList is a startList with exactly three properties removed: the entry's startTime, its startTimeSource, and the envelope's event.startTime. Nothing is added — a start list is an entry list that has been drawn.

The five rules that cover almost everything

  1. Every duration is an integer count of milliseconds. time, a split's time, timeBehind — all milliseconds. The IOF XML export's whole seconds are a lossy projection of these numbers, never a different number.
  2. Every time of day is an ISO 8601 timestamp carrying the event's UTC offset"2025-06-15T10:04:23.406+10:00". Absolute, unambiguous, and parseable by any date library. event.utcOffset states the offset once so you never have to guess a zone.
  3. Absent means "not known" or "does not apply". null never appears. Test for presence, never for presence-and-not-null. Empty arrays don't appear either — an entry with no splits has no splits property at all. Three properties are the exception and are always stated even when empty, because they are structural rather than optional: a document's classes, a class's courses, and a team's members.
  4. There are no formatted display strings. Rendering 2316000 as 38:36 is your decision, in your locale. Publishing our own string beside the number would be a second source of the same fact.
  5. Some vocabularies are closed, and the schema enforces them: documentType, event.type, event.competitionLevel, courseFormat, a control's type, a custom field's type, an entry's status and startTimeSource, splitSource, and a split's status. A new member of any of them costs a version — see Versioning. Everything else that looks enum-ish, event.discipline included, is an open string.

Property names are camelCase. Numbers are integers unless stated. Documents are written indented with unescaped Unicode, so athlete and club names survive as themselves and a file can be read and diffed by a human.

A worked example

A complete resultList — one class, one finisher, three card splits. This came out of the real exporter; only the event's name and time zone have been changed, to make it read like a document from a real event rather than from a test.

json
{
  "ojson": "1.0",
  "documentType": "resultList",
  "creator": "meshO Manager",
  "createTime": "2026-08-18T21:52:11.7038125+00:00",
  "event": {
    "id": "2025-06-15.01",
    "name": "Autumn Middle Distance",
    "date": "2025-06-15",
    "utcOffset": "+10:00",
    "startTime": "2025-06-15T10:00:00+10:00",
    "type": "Individual",
    "courseFormat": "Line",
    "scoreDefaultPoints": 1,
    "scorePenaltyPointsPerMinute": 0,
    "competitionLevel": "Local",
    "discipline": "Middle"
  },
  "controls": [
    { "id": "start-7", "name": "Start 7", "type": "start" },
    { "id": "finish-7", "name": "Finish 7", "type": "finish" },
    { "id": "117", "name": "117", "type": "normal", "codes": [117], "isRadio": true },
    { "id": "119", "name": "119", "type": "normal", "codes": [119] },
    { "id": "121", "name": "121", "type": "normal", "codes": [121] }
  ],
  "classes": [
    {
      "id": 1,
      "name": "M21A",
      "order": 0,
      "radioControls": [{ "controlCode": 117 }],
      "isTeamClass": false,
      "courses": [
        {
          "id": "7",
          "name": "M21A",
          "courseFormat": "Line",
          "numberOfControls": 3,
          "startControlId": "start-7",
          "finishControlId": "finish-7",
          "controls": [117, 119, 121]
        }
      ],
      "splitControls": [117, 119, 121],
      "persons": [
        {
          "person": {
            "givenName": "Ann",
            "familyName": "Smith",
            "name": "Ann Smith",
            "externalId": 4711,
            "externalIdProvider": "Eventor",
            "iofPersonId": 22222
          },
          "organisation": {
            "id": 5,
            "name": "Big Foot Orienteers",
            "shortName": "BFO",
            "country": "AUS"
          },
          "controlCards": [{ "system": "SI", "number": 8012345 }],
          "ranking": 12,
          "startTimeSource": "AllocatedIndividual",
          "bibNumber": "101",
          "status": "OK",
          "startTime": "2025-06-15T10:04:00+10:00",
          "finishTime": "2025-06-15T10:42:36+10:00",
          "time": 2316000,
          "position": 1,
          "timeBehind": 0,
          "splitSource": "card",
          "splits": [
            { "controlCode": 117, "status": "OK", "time": 95297 },
            { "controlCode": 119, "status": "OK", "time": 302125 },
            { "controlCode": 121, "status": "OK", "time": 1902400 }
          ]
        }
      ]
    }
  ]
}

Property order within an object is fixed and broadly meaningful — identity, then scope, then result, then splits — so two exports of one event diff cleanly. Don't depend on it; people reading the file benefit from it.

Field reference

Everything below is optional unless it appears in this table — which is the schema's required lists, gathered in one place for anyone writing a producer rather than a consumer:

ObjectMust state
the documentojson, documentType, createTime, event
eventid, name, date, utcOffset, type
a controlid, name, type
a class's radio controlcontrolCode
a leg distancefromControlId, toControlId
a custom-field definitionkey, name, type
a classid, name, isTeamClass
a course (and a course reference)id, name
a persongivenName, familyName, name
an organisationid, name
a control cardsystem, number
a splitcontrolCode, status
a person entryperson
a teamid, name
a team memberleg, person

Manager also always writes classes on the document, courses on a class and members on a team (rule 3 above). The schema does not require them, so that a producer may leave an empty array out; a consumer reading Manager's own files will always find them.

The envelope

PropertyTypeNotes
ojsonstringThe format and its version in one field, following OpenAPI's openapi. You can identify the document from its first property.
documentTypestringentryList, startList or resultList.
creatorstringThe software that wrote the document.
createTimetimestampWhen it was written, in UTC.
eventobjectBelow.
controlsarrayEvery control the event holds. Absent when it has none.
legDistancesarrayMeasured control-to-control distances. Absent when the bank is empty.
customFieldsarrayWhat each key in an entry's customFields means. Absent when no exported entry holds one.
classesarrayAlways stated, in the operator's own class order.

The three shared arrays sit on the document rather than inside a class or an entry because each is genuinely shared: one physical control is punched by several courses, one leg between two controls is the same leg in every course that runs it, and the custom-field definitions belong to the Manager rather than the event.

event

PropertyTypeNotes
idstringManager's event id — the event folder name, e.g. 2025-06-15.01.
eventorIdintThe Eventor event id. Absent unless the event came from Eventor.
namestring
dateYYYY-MM-DDThe event date, in event-local time.
utcOffset±HH:MMThe offset every timestamp in the document carries.
startTimetimestampThe event's published start ("zero") time — the earliest start in the event, floored to a half-hour boundary, so 10:04 publishes as 10:00. Absent when nobody has a start time, and always absent on an entryList.
informationstringThe organiser's note about the event.
typestringIndividual or Relay. Always stated.
defaultNumberOfLegsintHow many legs a relay class gets by default. Absent for an individual event.
courseFormatstringLine, Scatter or Score — what the organiser flags the event as. See the warning below.
scoreDefaultPointsintWhat a control on a score course is worth when it states no price of its own and no class overrides it.
scoreTimeLimitSecondsintHow long a score course's runners have. Absent when there is no limit — which is not 0, a limit everybody is instantly past.
scorePenaltyPointsPerMinuteintPoints given back per minute, or part minute, past the limit. 0 leaves the limit toothless.
competitionLevelstringLocal, Regional, State, National or International.
disciplinestringSprint, Middle, Long, and others.
isMassStarttrueAbsent unless the whole event starts at once.
massStartTimeSecondsintThe event-wide mass start, seconds past midnight.
restartTimeSecondsintThe event-wide restart, seconds past midnight.
allowPunchingStartOverridefalseAbsent when it takes the default, which is "allowed".
centreLatitude, centreLongitudenumberThe map centre, when the event has one.
punchCodesobject{ "start": [...], "finish": [...], "check": [...] } — which SI codes act as each.

Warning: event.courseFormat decides nothing. A card is scored under the format of the course it ran, because one event may hold line, scatter and score courses at once — a score course beside the ordinary ones at a club night is exactly the case this is built for. Read the course.

punchCodes is event-level rather than a property of a control because Manager's Start, Finish and Check controls are codeless by design — one physical unit can serve several roles. Without it a document cannot describe how the event reads a card.

controls

Every control the event holds — the punched ones, and the Start / Finish / Check anchors a course hangs off.

PropertyTypeNotes
idstringWhat a course's startControlId / finishControlId refers to.
namestringAs the operator sees it; often just the code as text.
typestringstart, finish, check, normal or unknown — lower-case.
codesint[]The punch codes that count as this control. Absent when it has none, which is normal for a Start, Finish or Check.
scorePointsintWhat this control is worth on a score course. Absent means "priced at nothing in particular", so the class's or event's default applies — absent and 0 are different answers.
latitude, longitudenumberReal-world position, when known.
isRadiotrueAbsent when the control has no radio.
radioNamestringThe radio's operator-facing name, e.g. Spectator.
timeOffsetMillisecondsintAn operator correction applied to every punch this control produced.
replacementCodeintA code standing in for this control — a replacement unit put out mid-event.
isFaultytrueAbsent when the control is fine.

The clock drift Manager estimates from radio punches is deliberately not published: it is live and derived, never persisted even in the event's own files, and a stale estimate in a document would read as a setting.

legDistances

PropertyTypeNotes
fromControlId, toControlIdstringThe pair is undirected — a leg is the same leg either way round.
metersintAbsent when the leg is unmeasured, which is not zero.
untimedAllowanceMillisecondsintA road-crossing allowance. Absent for a normally-timed leg.

Unmeasured is not zero. A course total that counted an unmeasured leg as 0 would understate the course, so keep the two apart.

customFields

PropertyTypeNotes
keystringThe key an entry's values are stored under. Stable across a rename of name.
namestringThe operator-facing label.
typestringtext or number — how it is entered and sorted. The value itself is always a string.

Only fields an exported entry actually holds a value for are declared. Tolerate an entry value whose key is not declared here: Manager keeps a value whose field was later deleted from the settings, and the export carries the value rather than dropping it — it simply has no label to state.

classes

An individual class holds persons. A relay class holds teamsand persons, for any entrant who is on none of them. A class with nobody in it is left out of the document entirely.

PropertyTypeNotes
idint
namestring
shortNamestringAbsent when it matches name.
orderintThe operator's own class ordering; classes appear in this order.
isTeamClassbool
numberOfLegsintRelay classes only.
coursesarrayAlways stated; empty when the class has no course.
scoreDefaultPoints, scoreTimeLimitSeconds, scorePenaltyPointsPerMinuteintThis class's own score settings, overriding the event's. Each inherits on its own, so a class that only wants longer on the course does not restate the other two. Absent means "the event's".
massStartTimeSecondsintThe class's mass start, seconds past midnight. Absent for an interval-start class.
restartTimeSecondsintThe class's restart.
excludeFromEventMassStart, excludeFromEventRestarttrueAbsent when the class takes part in the event-wide one.
radioControlsarray{ "controlCode": 31, "leg": 2 } — the radios configured for this class. leg is absent for an individual class.
splitControlsint[]The class's split-time columns, in course order. Result lists only.
legsarrayRelay classes only: { "leg": 1, "splitControls": [31, 32] } per leg.
personsarrayAn individual class's entries; on a relay class, the entrants on no team.
teamsarrayRelay classes only.

The start-regime properties are the rule an entry's startTime resolves from, not the resolved time. Both are published: the entry's time is what you display, and these are what re-derive it if the event is re-evaluated after an import.

courses

The same object serves two purposes. A class-level entry (in classes[].courses) fully describes a course; a reference (an entry's course) carries only id, name and variationCode, pointing at the class-level entry with the same id.

PropertyTypeNotes
idstringA string, because a fork variation's id is composed: "5-AD" is variation AD of course 5.
namestringThe class's full name, not the course's often-cryptic own one. A fork variation appends its code: "M21A AD".
familystringThe name grouping a forked course's variations. Fork variations only.
variationCodestringThe course setter's code, e.g. AD. Fork variations only.
courseFormatstringHow this course is scored: Line, Scatter or Score. On every class-level entry; absent on a reference.
scatterRequiredControlCountintHow many controls a Scatter course requires, in any order. Absent at any other format, and on a scatter course that requires them all.
numberOfControlsintFaulty controls included — they keep their place in the sequence and in the splits.
lengthMetersintA hand-entered length exactly as entered; a measured one rounded to the nearest 100 m, and absent altogether when any leg is unmeasured.
climbMetersintAbsent when unset.
startControlId, finishControlIdstringThe controls the course is anchored to. Absent on a reference.
informationstringThe course setter's note.
controlsint[]The control codes in order, start and finish excluded. Absent on a reference and on a forked parent course. At an any-order format this is not a sequence — see below.

A forked course expands to one class-level entry per variation, each with its own id, control sequence and length, sharing the parent's family, anchors and note.

Entries

Individual competitors, relay teams and relay team members share one set of fields.

PropertyTypeKindNotes
bibNumberstringallAbsent when unallocated.
rankingintallThe athlete's place on an external ranking list. 1 is the best-ranked and the number grows worse. Absent when they are not on the list, which is not 0. On a relay this belongs to a team member, never the team.
startRequestobjectallWhat the entrant asked for when the times were allocated: type is EarlyStart, LateStart, SeparatedFrom or GroupedWith, and the two paired kinds also carry one person. A request, never a guarantee — it says what was asked, not what the draw did, and nothing in Manager acts on it. Read the note below before you import one.
startTimetimestampstart, resultIn a start list always the allocated start (individual → class mass → event mass), never a punched one.
startTimeSourcestringstart, resultWhich rule produced the time beside it: AllocatedIndividual, AllocatedClass, AllocatedEvent, Punched, CheckPunch, Midnight.
statusstringresultBelow.
statusIsManualtrueresultThe status is an operator's ruling, not one derived from the punches.
provisionaltrueresultBelow.
customFieldsobjectallKeyed by the declaration's key. Always strings, a Number field included.
courseobjectallWhich of the class's courses this entry runs. Present when the class forks or has a course pool. On a relay team member, the leg's course.
assignedVariationCodestringallThe fork the entry is locked to, as the operator set it before the race — distinct from the variation named by course, which after evaluation is the fork they were judged to have run.
entrySourcestringallManual, IofXmlImport, CsvImport, EventorImport or OrienteeringJsonImport. Absent means "we cannot say", not "not an entry on the day".
finishTimetimestampresultStart plus running time. Absent when no time may be published.
timeint (ms)resultRunning time. On a team member this is that runner's own leg time, not the team's cumulative time.
positionintresultPlacing within the class. Ranked entries only.
timeBehindint (ms)resultGap to the class winner; the winner's is 0, stated rather than omitted. Absent for every entry of a class ranked on points.
scorenumberresultWhat the entry collected on a score course, net of any time-limit penalty. Present only for a ranked entry of a points-ranked class. Points, where bigger is better — the opposite of ranking.

A person entry also carries person, organisation and controlCards; a team carries id, name, organisations and members; a team member carries leg, person, organisation and controlCards.

person.externalId is the organiser's competitor number from the system named in externalIdProvider; iofPersonId is the athlete's stable IOF identity. Manager's internal competitor id is a private counter with no external meaning and is never published. organisation.country is an ISO 3166-1 alpha-3 code.

status

The IOF 3.0 ResultStatus vocabulary, written as the enum's own name so converting to IOF XML is an identity mapping:

OK, MissingPunch, DidNotStart, DidNotFinish, Disqualified, OverTime, NotCompeting, DidNotEnter, Cancelled, and — in a live export only — Active (out on course) and Inactive (not started yet).

That is the whole vocabulary the format uses. The wider IOF enum has members (Finished, SportingWithdrawal, Moved, MovedUp) that no OJSON document states, so you need no branch for them.

The three endpoints above all produce final exports, so they map an on-course runner to DidNotFinish and a statusless entry to DidNotStart — the IOF vocabulary has no equivalent those consumers honour. Active and Inactive are part of the format and reserved for a live feed; nothing writes JSON in live mode today (the scheduled live-results feed publishes IOF XML), so handle them if you like, but don't expect them yet.

splits

json
{ "controlCode": 117, "status": "OK", "time": 95297, "rank": 2, "timeBehind": 5297 }
PropertyTypeNotes
controlCodeint
statusstringOK (punched, on course), Missing (required, not punched) or Additional (punched, not on the course).
timeint (ms)Elapsed from the competitor's start. Absent for a Missing control.
rankintThis competitor's rank at this control within the class. Radio splits only.
timeBehindint (ms)Gap to the fastest at this control. Radio splits only.

The entry's splitSource says where the whole set came from. card is a downloaded e-card, the authoritative source: on-course controls first in course order (a required control that was not punched keeps its place, with status Missing and no time), then any spurious punches as Additional. radio is radio-control passings, published before any card is downloaded — only controls actually reached are listed, because a radio control the runner has not passed yet is not missing, just not visited yet.

Relay teams

json
{
  "id": 7,
  "name": "Big Foot 1",
  "organisations": [{ "id": 5, "name": "Big Foot Orienteers", "shortName": "BFO" }],
  "bibNumber": "12",
  "status": "OK",
  "time": 3600500,
  "position": 1,
  "timeBehind": 0,
  "members": [
    {
      "leg": 1,
      "person": { "givenName": "Ola", "familyName": "Berg", "name": "Ola Berg" },
      "organisation": { "id": 5, "name": "Big Foot Orienteers", "shortName": "BFO" },
      "controlCards": [{ "system": "SI", "number": 7012345 }],
      "course": { "id": "12-A", "name": "Relay A", "variationCode": "A" },
      "assignedVariationCode": "A",
      "bibNumber": "12.1",
      "status": "OK",
      "startTime": "2025-06-15T10:00:00+10:00",
      "time": 1800250,
      "splitSource": "card",
      "splits": [
        { "controlCode": 31, "status": "OK", "time": 421000 },
        { "controlCode": 32, "status": "OK", "time": 1102500 }
      ]
    }
  ]
}

The team states its own status, time and placing — the thing IOF XML's TeamResult cannot do. Members are in leg order, each with its own person, organisation (which may differ from the team's), controlCards, status and splits; a leg with nobody assigned is left out rather than emitted empty. In a start list only leg 1 carries a startTime; later legs start on changeover.

The schema

The machine-readable schema is published at:

https://docs.mesho.live/schemas/ojson-1.0.schema.json

That URL is also the schema's $id, and it is the file committed at docs/public/schemas/ojson-1.0.schema.json in the Manager repository — the same one the Manager's own test suite validates its exports against, so the shape it demands cannot drift from what Manager writes. Its description text is documentation rather than something a test enforces — this page states the same rules, and where one of them matters for an import it is written out in full here too.

Validate with whatever your stack uses. For example:

bash
# Node — ajv-cli
npx ajv-cli validate -s ojson-1.0.schema.json -d results.json --spec=draft2020

# Python — check-jsonschema
pipx run check-jsonschema --schemafile https://docs.mesho.live/schemas/ojson-1.0.schema.json results.json

The schema is deliberately strict about the things a breaking change would break, and lenient about everything else:

  • It matches ojson by pattern (^1\.[0-9]+$) rather than pinning 1.0, so a document from a later 1.x build still validates against the file you pinned.
  • It does not set additionalProperties: false, so a property added in a later minor version validates rather than failing.
  • It does reject a 2.x document, a missing required property, a wrong type, and a value outside a closed vocabulary.

That combination is the point: pin this URL in your CI and it should stay green as the format grows, and go red only when something genuinely changed under you.

The full schema, exactly as published:

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://docs.mesho.live/schemas/ojson-1.0.schema.json",
  "title": "Orienteering JSON (OJSON) 1.0",
  "description": "Start-list and result-list documents for orienteering events — a JSON binding of the IOF 3.0 data model. Every duration is an integer count of milliseconds; every time of day is an ISO 8601 timestamp carrying the event's UTC offset. Absent means 'not known' or 'does not apply'; null never appears.",
  "type": "object",
  "required": ["ojson", "documentType", "createTime", "event"],
  "properties": {
    "ojson": {
      "description": "The format and its version, following OpenAPI's 'openapi' and JSON-RPC's 'jsonrpc'. This schema describes 1.0 and accepts any 1.x document: within a major version the format is additive, so a 1.1 document is a valid 1.0 one carrying properties this schema does not list. A consumer pinning this file therefore keeps validating as the format grows. A 2.x document may mean something different and is rejected.",
      "type": "string",
      "pattern": "^1\\.[0-9]+$"
    },
    "documentType": {
      "description": "Which document this is. A startList omits every result-derived property; an entryList omits those AND every start time, stating only who has entered. This is a CLOSED vocabulary, so a new document kind is the one change to this format that a schema pinned to an earlier version cannot accept.",
      "enum": ["startList", "resultList", "entryList"]
    },
    "creator": {
      "description": "The software that wrote the document.",
      "type": "string"
    },
    "createTime": {
      "description": "When the document was written, in UTC.",
      "type": "string",
      "format": "date-time"
    },
    "event": { "$ref": "#/$defs/event" },
    "controls": {
      "description": "Every control the event holds — the punched controls and the Start/Finish/Check anchors courses hang off. They sit on the document because a control is shared: one physical control is punched by several courses and carries one position, one offset and one radio setting.",
      "type": "array",
      "items": { "$ref": "#/$defs/control" }
    },
    "legDistances": {
      "description": "The measured distance between one control and the next, for every leg the event has a measurement or an untimed allowance for. This is the geometry a course total is computed from — a total alone cannot be re-measured or re-split when a course changes.",
      "type": "array",
      "items": { "$ref": "#/$defs/legDistance" }
    },
    "customFields": {
      "description": "What each key in an entry's customFields means. Only the fields exported entries actually hold a value for are declared. A consumer must tolerate an entry value whose key is not declared here: meshO keeps a value whose field was later deleted from the settings, and the export carries the value rather than dropping it.",
      "type": "array",
      "items": { "$ref": "#/$defs/customField" }
    },
    "classes": {
      "description": "Every exported class, in the operator's own class order. A class with no entries is left out.",
      "type": "array",
      "items": { "$ref": "#/$defs/class" }
    }
  },
  "$defs": {
    "control": {
      "description": "One control: its identity, where it is, and how it behaves. A Start/Finish/Check control normally carries no punch code of its own — which codes act as start, finish and check is an event-level setting, because one unit can serve several roles.",
      "type": "object",
      "required": ["id", "name", "type"],
      "properties": {
        "id": {
          "description": "The control id a course's startControlId / finishControlId refers to.",
          "type": "string"
        },
        "name": { "type": "string" },
        "type": { "enum": ["start", "finish", "check", "normal", "unknown"] },
        "codes": {
          "description": "The punch codes that count as this control. Normally one; several when more than one unit stands for the same control.",
          "type": "array",
          "items": { "type": "integer" },
          "minItems": 1
        },
        "scorePoints": {
          "description": "What this control is worth on a score course. Absent when the organiser priced it at nothing in particular, in which case the class's (or the event's) default applies — so absent and 0 are different answers, 0 being a control deliberately worth nothing.",
          "type": "integer",
          "minimum": 0
        },
        "latitude": { "type": "number" },
        "longitude": { "type": "number" },
        "isRadio": {
          "description": "Present and true when the control has a radio unit reporting passings.",
          "const": true
        },
        "radioName": { "type": "string" },
        "timeOffsetMilliseconds": {
          "description": "An operator correction applied to every punch this control produced.",
          "type": "integer"
        },
        "replacementCode": {
          "description": "A code standing in for this control — a replacement unit put out mid-event.",
          "type": "integer"
        },
        "isFaulty": {
          "description": "Present and true when the operator has marked the control non-functional.",
          "const": true
        }
      }
    },

    "radioControl": {
      "description": "One radio control a class is configured to report passings at. A relay configures them per leg, so the leg travels with the code.",
      "type": "object",
      "required": ["controlCode"],
      "properties": {
        "controlCode": { "$ref": "#/$defs/controlCode" },
        "leg": {
          "description": "The relay leg this applies to. Absent for an individual class, which has no legs.",
          "type": "integer",
          "minimum": 1
        }
      }
    },

    "legDistance": {
      "description": "The distance from one control to the next, and any allowance the leg carries. Identified by the pair of control ids rather than by a course: a leg between two controls is the same leg in every course that runs it. The pair is UNDIRECTED.",
      "type": "object",
      "required": ["fromControlId", "toControlId"],
      "properties": {
        "fromControlId": { "type": "string" },
        "toControlId": { "type": "string" },
        "meters": {
          "description": "The measured distance. Absent when the leg is unmeasured — which is NOT zero: a course total that counted an unmeasured leg as 0 would understate the course.",
          "type": "integer",
          "minimum": 0
        },
        "untimedAllowanceMilliseconds": {
          "description": "How much of this leg's time is not counted — a road-crossing allowance. Absent for a normally-timed leg.",
          "type": "integer",
          "minimum": 0
        }
      }
    },

    "customField": {
      "description": "One operator-defined custom competitor field. The definitions are event-independent in meshO, so they sit on the document rather than on a class or an entry.",
      "type": "object",
      "required": ["key", "name", "type"],
      "properties": {
        "key": {
          "description": "The key an entry's values are stored under. Stable across a rename of the field.",
          "type": "string",
          "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
        },
        "name": {
          "description": "The operator-facing label.",
          "type": "string"
        },
        "type": {
          "description": "How the value is entered and sorted. The value itself is always a string, whatever this says.",
          "enum": ["text", "number"]
        }
      }
    },

    "entrySource": {
      "description": "Where a competitor came from. Absent when the event has no record of it, which means 'we cannot say' rather than 'not an entry on the day'.",
      "type": "string"
    },

    "customFieldValues": {
      "description": "An entry's custom-field values, keyed by the field's stable key. Always strings, including a number field's value.",
      "type": "object",
      "additionalProperties": { "type": "string" }
    },

    "duration": {
      "description": "A duration, as an integer count of milliseconds.",
      "type": "integer"
    },
    "timestamp": {
      "description": "A time of day, as an ISO 8601 timestamp carrying the event's UTC offset.",
      "type": "string",
      "format": "date-time"
    },
    "controlCode": {
      "description": "A control's punch code.",
      "type": "integer"
    },

    "event": {
      "type": "object",
      "required": ["id", "name", "date", "utcOffset", "type"],
      "properties": {
        "id": {
          "description": "The exporting system's own event id.",
          "type": "string"
        },
        "eventorId": {
          "description": "The Eventor event id — the key a federation reconciles results against. Absent unless the event came from Eventor.",
          "type": "integer"
        },
        "name": { "type": "string" },
        "date": {
          "description": "The date the event is held, in event-local time.",
          "type": "string",
          "format": "date"
        },
        "utcOffset": {
          "description": "The UTC offset every timestamp in the document carries.",
          "type": "string",
          "pattern": "^[+-][0-9]{2}:[0-9]{2}$"
        },
        "startTime": {
          "description": "The event's published start ('zero') time, floored to a half-hour boundary. Which start it is derived from follows the document: a startList uses the earliest ALLOCATED start, a resultList the earliest time anybody ACTUALLY started, punched starts included. The two differ for an event with punching starts, and using the allocated rule on a result list would head it with a time nobody ran to. Absent when no start time is known.",
          "$ref": "#/$defs/timestamp"
        },
        "information": {
          "description": "The organiser's note about the event.",
          "type": "string"
        },
        "type": {
          "description": "Which kind of event this is. Always stated: an event's type cannot be changed after it is created, so a document that omitted it could not be repaired.",
          "enum": ["Individual", "Relay"]
        },
        "defaultNumberOfLegs": {
          "description": "How many legs a relay class gets by default. Absent for an individual event.",
          "type": "integer",
          "minimum": 1
        },
        "courseFormat": {
          "description": "What the organiser flags the event as, and the default a new course is created with. It decides no runner's result: every course states how IT is scored, because one event may hold line, scatter and score courses at once.",
          "$ref": "#/$defs/courseFormat"
        },
        "scoreDefaultPoints": {
          "description": "What a control is worth on a score course when it states no price of its own and no class overrides it. Absent when the event states nothing.",
          "type": "integer",
          "minimum": 0
        },
        "scoreTimeLimitSeconds": {
          "description": "How long a score course's runners have, in seconds. Absent when there is no limit — which is not the same as 0, a limit everybody is instantly past.",
          "type": "integer",
          "minimum": 1
        },
        "scorePenaltyPointsPerMinute": {
          "description": "Points given back per minute, or part minute, past the limit. Absent when the event states nothing; 0 is a stated choice that leaves the limit toothless.",
          "type": "integer",
          "minimum": 0
        },
        "competitionLevel": { "enum": ["Local", "Regional", "State", "National", "International"] },
        "discipline": { "type": "string" },
        "isMassStart": {
          "description": "Present and true when the whole event starts at once.",
          "const": true
        },
        "massStartTimeSeconds": {
          "description": "The event-wide mass start, as seconds past midnight.",
          "type": "integer",
          "minimum": 0
        },
        "restartTimeSeconds": {
          "description": "The event-wide restart, as seconds past midnight.",
          "type": "integer",
          "minimum": 0
        },
        "allowPunchingStartOverride": {
          "description": "Present and false when a punch at a start unit may NOT override an allocated start. Absent when it takes the default (allowed).",
          "const": false
        },
        "centreLatitude": { "type": "number" },
        "centreLongitude": { "type": "number" },
        "punchCodes": {
          "description": "Which punch codes act as start, finish and check. Event-level rather than a property of a control because one unit can serve several roles, and meshO's Start/Finish/Check controls are codeless by design.",
          "type": "object",
          "properties": {
            "start": { "$ref": "#/$defs/controlCodeList" },
            "finish": { "$ref": "#/$defs/controlCodeList" },
            "check": { "$ref": "#/$defs/controlCodeList" }
          }
        }
      }
    },

    "controlCodeList": {
      "type": "array",
      "items": { "$ref": "#/$defs/controlCode" },
      "minItems": 1
    },

    "courseFormat": {
      "description": "How a course is scored. Line: every control, in the printed order — missing one, or taking them out of order, is a mis-punch. Scatter: a stated number of them (scatterRequiredControlCount), in any order, ranked on time exactly as a line course. Score: every control optional, in any order, each worth points. The vocabulary is closed — a format a consumer had never heard of would have to be guessed at, and the guess is somebody's result.",
      "enum": ["Line", "Scatter", "Score"]
    },

    "class": {
      "type": "object",
      "required": ["id", "name", "isTeamClass"],
      "properties": {
        "id": { "type": "integer" },
        "name": { "type": "string" },
        "shortName": {
          "description": "Absent when it matches the name.",
          "type": "string"
        },
        "order": {
          "description": "The operator's own class ordering — classes appear in this order.",
          "type": "integer"
        },
        "scoreDefaultPoints": {
          "description": "This class's own default control price, overriding the event's. Absent means \"the event's\", never 0 — which would be a class whose controls are all worth nothing. Each of the three score settings inherits on its own.",
          "type": "integer",
          "minimum": 0
        },
        "scoreTimeLimitSeconds": {
          "description": "This class's own time limit in seconds, overriding the event's — the six-hour class at a three-hour score event. Absent means \"the event's\".",
          "type": "integer",
          "minimum": 1
        },
        "scorePenaltyPointsPerMinute": {
          "description": "This class's own penalty rate, overriding the event's. Absent means \"the event's\".",
          "type": "integer",
          "minimum": 0
        },
        "isTeamClass": {
          "description": "True for a relay class, whose entries are teams rather than persons.",
          "type": "boolean"
        },
        "numberOfLegs": {
          "description": "The number of relay legs. Absent for an individual class.",
          "type": "integer",
          "minimum": 1
        },
        "massStartTimeSeconds": {
          "description": "The class's mass start, as seconds past midnight in event-local time. Present only when the class has one. This is the rule an entry's startTime resolves FROM, not the resolved time itself — both are published, so an import can re-derive as well as display.",
          "type": "integer",
          "minimum": 0
        },
        "restartTimeSeconds": {
          "description": "The class's restart time, as seconds past midnight. Present only when set.",
          "type": "integer",
          "minimum": 0
        },
        "excludeFromEventMassStart": {
          "description": "Present and true when the class opts out of the event-wide mass start.",
          "const": true
        },
        "excludeFromEventRestart": {
          "description": "Present and true when the class opts out of the event-wide restart.",
          "const": true
        },
        "radioControls": {
          "description": "The radio controls configured for this class. Distinct from splitControls, which says which split columns THIS document publishes: this is the operator's configuration, and without it a round trip brings the event back with every radio unconfigured.",
          "type": "array",
          "items": { "$ref": "#/$defs/radioControl" }
        },
        "courses": {
          "description": "The class's course(s). A forked course expands to one entry per variation.",
          "type": "array",
          "items": { "$ref": "#/$defs/course" }
        },
        "splitControls": {
          "description": "The control codes this class's split-time columns are built from, in course order. In a live export these are the class's radio controls (the only splits that exist before a card is downloaded); in a final export, every course control. Absent for a forked course, whose runners take different sequences, and in a start list.",
          "type": "array",
          "items": { "$ref": "#/$defs/controlCode" }
        },
        "legs": {
          "description": "A relay class's per-leg split-time columns. Absent for an individual class and in a start list.",
          "type": "array",
          "items": {
            "type": "object",
            "required": ["leg"],
            "properties": {
              "leg": { "type": "integer", "minimum": 1 },
              "splitControls": {
                "type": "array",
                "items": { "$ref": "#/$defs/controlCode" }
              }
            }
          }
        },
        "persons": {
          "description": "The individual entries in this class. For a relay class these are its entrants who are not on a team — a relay entered per person has them until the operator builds the teams — never a runner a team already lists.",
          "type": "array",
          "items": { "$ref": "#/$defs/personEntry" }
        },
        "teams": {
          "description": "The teams in this class. Absent for an individual class.",
          "type": "array",
          "items": { "$ref": "#/$defs/teamEntry" }
        }
      },
      "if": { "properties": { "isTeamClass": { "const": false } }, "required": ["isTeamClass"] },
      "then": { "not": { "required": ["teams"] } }
    },

    "course": {
      "description": "A course, or a reference to one. A class-level entry carries the full description; a reference on an entry carries only id, name and variationCode.",
      "type": "object",
      "required": ["id", "name"],
      "properties": {
        "id": {
          "description": "A string, because a fork variation's id is composed: '5-AD' is variation AD of course 5.",
          "type": "string"
        },
        "name": {
          "description": "The course's published name — the linked class's full name, with a fork variation's code appended.",
          "type": "string"
        },
        "family": {
          "description": "The name grouping a forked course's variations together. Present only on a fork variation.",
          "type": "string"
        },
        "variationCode": {
          "description": "The course setter's variation code. Present only on a fork variation.",
          "type": "string"
        },
        "courseFormat": {
          "description": "How this course is scored. Stated on every class-level course entry; absent on a course reference, which carries identity only. READ IT BEFORE READING 'controls' AS A SEQUENCE: at Scatter or Score that list is every control in play rather than an order anybody printed on a map. The course owns this, not the event — one event may hold all three formats at once — so a consumer scoring a run reads it here, falling back to event.courseFormat and then to Line.",
          "$ref": "#/$defs/courseFormat"
        },
        "scatterRequiredControlCount": {
          "description": "How many of the course's controls a competitor must visit, in any order, to have completed it. Present only on a Scatter course requiring a subset — absent means every control the course carries, which is how a scatter course requiring all of them is stated, and no other format states it at all. This is the operator's setting as stored: a count above the course's own control count is clamped to it when a card is scored, so a consumer judging completion itself wants min(this, numberOfControls).",
          "type": "integer",
          "minimum": 1
        },
        "numberOfControls": {
          "description": "How many controls the course has, faulty ones included. At an any-order course this is how many are in play — every control the event has.",
          "type": "integer",
          "minimum": 0
        },
        "lengthMeters": {
          "description": "The course length in metres. A hand-entered length is published exactly as entered; a measured one is rounded to the nearest 100 m, and is absent when any leg is unmeasured.",
          "type": "integer",
          "minimum": 0
        },
        "startControlId": {
          "description": "The id of the control the course starts at. An importer that finds one uses it rather than synthesizing an anchor of its own.",
          "type": "string"
        },
        "finishControlId": {
          "description": "The id of the control the course finishes at.",
          "type": "string"
        },
        "information": {
          "description": "The course setter's note about the course.",
          "type": "string"
        },
        "climbMeters": {
          "description": "The course climb in metres, when set.",
          "type": "integer",
          "minimum": 0
        },
        "controls": {
          "description": "The course's control codes in order, start and finish excluded. Absent on a course reference and on a forked parent course. At an any-order courseFormat this is NOT a sequence — it is every control in play, in code order because there is no other order to give them.",
          "type": "array",
          "items": { "$ref": "#/$defs/controlCode" }
        }
      }
    },

    "person": {
      "type": "object",
      "required": ["givenName", "familyName", "name"],
      "properties": {
        "givenName": { "type": "string" },
        "familyName": { "type": "string" },
        "name": {
          "description": "Given and family name joined for display, so a consumer need not guess the order.",
          "type": "string"
        },
        "externalId": {
          "description": "The organiser's competitor number from the registration system named in externalIdProvider. Absent when there is none — the exporting system's internal id is never published in its place.",
          "type": "integer"
        },
        "externalIdProvider": {
          "description": "The registration system externalId belongs to, e.g. 'Eventor'.",
          "type": "string"
        },
        "iofPersonId": {
          "description": "The athlete's IOF Person ID — a stable identity across events and systems.",
          "type": "integer"
        }
      }
    },

    "startRequest": {
      "description": "An entrant's start-time preference, and one other member of the group it is about.\n\nThe request is about a GROUP, not a pair. \"Start apart from\" and \"start beside\" are asked by a family, a car-load or a club, so what the request concerns is a set of entrants. This object has room for exactly one other person — the shape IOF 3.0's StartTimeAllocationRequest has, kept deliberately — so a group is published as one EDGE per member: every member states the request and each names one other member, and those edges form a CYCLE over the whole group, the members in some order, each naming the next one along and the last naming the first. A group of two, the ordinary case, is unchanged by that: each of the pair names the other.\n\nAn importer must UNION, never assign. Resolve the person and ADD them to the group the entrant already holds, then close the group so everybody named by any member holds the whole of it. Assigning instead reads a family of four back as one pair and drops the other two out of the arrangement. Because the rule is union-and-close, ANY CONNECTED set of edges reassembles the group — a chain, a star, or every pairing all import to the same group — so a producer need only leave the group connected, and need not reproduce this cycle.\n\nWhat a SCOPED document states. When a document is scoped — to some classes, to one kind of entry, or by a result list's status filter — meshO names only members the document itself publishes, walking on around the cycle past every member it leaves out (StartRequestExportRules.ResolveExportPartner). So the members a scoped document DOES publish are joined up by the edges it states, whichever members those are; the members it leaves out are simply absent, and their part of the arrangement is not in this file to be recovered. That is a promise about meshO's own output, not a rule an importer may lean on — another producer may name somebody who appears nowhere else in the file, so the person is resolved against the importing event rather than against this document.\n\nThe person is a person, not an id. meshO's competitor id is a private counter with no meaning outside the installation that issued it — the same reason person.externalId exists rather than publishing the internal one. So the member is written the way every other person in the document is, and an importer matches them back on their IOF id, their organiser number or their name.",
      "type": "object",
      "required": ["type"],
      "properties": {
        "type": {
          "description": "EarlyStart, LateStart, SeparatedFrom or GroupedWith — the IOF 3.0 StartTimeAllocationRequest vocabulary, written as the standard's own name so converting a document to IOF XML is a copy rather than a second lookup table that could drift. IOF's fifth value, Normal, is never published: it means \"no preference\", which is what the absence of this whole object already says.",
          "enum": ["EarlyStart", "LateStart", "SeparatedFrom", "GroupedWith"]
        },
        "person": {
          "description": "One other member of the group, for the two kinds that have one — not the whole group, and not a partner. Absent for an early or late start, which are about the entrant alone.\n\nThe entries of a group name each other in a cycle (see this object's description), so an importer joins the group by UNION: resolve this person, add them to whatever group the entrant already holds, and close it.\n\nAbsent, too, when the document publishes no other member of the group — the kind is still worth stating on its own. meshO never names a member the document leaves out (see this object's description), so in a document meshO wrote this person is always somebody the reader can find in it; a document from another producer need not be, which is why an importer resolves the person against its own event rather than against the file.",
          "$ref": "#/$defs/person"
        }
      }
    },

    "organisation": {
      "type": "object",
      "required": ["id", "name"],
      "properties": {
        "id": { "type": "integer" },
        "name": { "type": "string" },
        "shortName": { "type": "string" },
        "country": {
          "description": "ISO 3166-1 alpha-3 country code.",
          "type": "string",
          "pattern": "^[A-Z]{3}$"
        }
      }
    },

    "controlCard": {
      "type": "object",
      "required": ["system", "number"],
      "properties": {
        "system": {
          "description": "The punching system, e.g. 'SI'.",
          "type": "string"
        },
        "number": { "type": "integer" }
      }
    },

    "split": {
      "description": "One control on an entry's run. Present in a result list only.",
      "type": "object",
      "required": ["controlCode", "status"],
      "properties": {
        "controlCode": { "$ref": "#/$defs/controlCode" },
        "status": {
          "description": "OK (punched, on course), Missing (on the course, not punched) or Additional (punched, not on the course) — the IOF 3.0 SplitTime vocabulary. On an any-order course (scatter or score) Missing does NOT mean the entry fell short: no particular control is required there, so a clean score run reports Missing for every control in the forest it chose not to visit. Read the class's completion from status and score, not by counting these.",
          "enum": ["OK", "Missing", "Additional"]
        },
        "time": {
          "description": "Elapsed time from the competitor's start to this punch. Absent when the control was not punched.",
          "$ref": "#/$defs/duration"
        },
        "rank": {
          "description": "This competitor's rank at this control within their class. Radio splits only.",
          "type": "integer",
          "minimum": 1
        },
        "timeBehind": {
          "description": "Gap to the fastest entry at this control. Radio splits only.",
          "$ref": "#/$defs/duration"
        }
      }
    },

    "entryFields": {
      "description": "The properties every kind of entry shares. A startList document carries only bibNumber and startTime from this set.",
      "type": "object",
      "properties": {
        "course": {
          "description": "Which of the class's courses this entry runs — a reference to a class-level course entry. Present when the class forks or has a course pool. On a relay team member this is the leg's own course.",
          "$ref": "#/$defs/course"
        },
        "assignedVariationCode": {
          "description": "The variation of a forked course this entry is LOCKED to, as the operator set it before the race — distinct from the variation named by 'course', which after evaluation is the fork they were judged to have run. On a relay team member this is the leg's own fork, which nothing else in the document records.",
          "type": "string"
        },
        "ranking": {
          "description": "The athlete's place on an external ranking list — a national ranking, a club's seeding order, whatever the organiser was handed. 1 is the BEST-ranked athlete and the number grows worse. Absent when they are not on the list, which is not 0: 0 is not a place anybody holds, and sorting on it would seat every unranked entrant ahead of rank 1. Do not confuse it with 'score', which is score-course points, where bigger is better. IOF XML has no rank element: the nearest thing is a Score of type 'Rank' on an EntryList, and neither a StartList nor a ResultList can carry one at all. Published on every document kind, and gated by nothing — a ranking is not a result, so a mis-punch states theirs exactly as a winner does.",
          "type": "integer",
          "minimum": 1
        },
        "startRequest": {
          "description": "What this entrant asked for when the start times were allocated — an early start because they have to leave, a late one because they arrive late, or a placing relative to somebody else. Absent when they asked for nothing, which is the ordinary case.\n\nA request, never a guarantee: it says what was asked, not what the draw did. The times themselves are the answer to that, and a start list carries both.\n\nThe same fact as IOF XML's PersonEntry/StartTimeAllocationRequest, and the same vocabulary — but XML can only carry it on an EntryList, where every other entry fact travels on all three kinds. Here it does too.",
          "$ref": "#/$defs/startRequest"
        },
        "bibNumber": {
          "description": "Bib (start) number, when allocated.",
          "type": "string"
        },
        "status": {
          "description": "The entry's result status in the IOF 3.0 vocabulary. Active and Inactive appear in live exports only, where they carry the two states a final export has resolved: still out on course, and never started. Absent in a start list. This is the whole vocabulary the format uses — the wider IOF ResultStatus enum has members (Finished, SportingWithdrawal, Moved, MovedUp) that no OJSON document states, so a consumer needs no branch for them.",
          "enum": [
            "OK",
            "MissingPunch",
            "DidNotStart",
            "DidNotFinish",
            "Disqualified",
            "OverTime",
            "NotCompeting",
            "DidNotEnter",
            "Cancelled",
            "Active",
            "Inactive"
          ]
        },
        "provisional": {
          "description": "Present and true when the result is a finish inferred from a radio or finish punch while the e-card is still to be downloaded, so the controls have not been verified. Both document kinds publish it, and a FINAL export is where it matters most: IOF XML cannot say this, so a final XML export can only downgrade such a result to DidNotFinish, whereas here the status stays honest and this flag carries the caveat. A consumer reading a DidNotFinish beside provisional:true is looking at a runner who did finish but whose card is still to be read, not at somebody who retired.",
          "const": true
        },
        "startTime": {
          "description": "The start time. In a start list this is always the allocated start, never a punched one.",
          "$ref": "#/$defs/timestamp"
        },
        "finishTime": {
          "description": "The finish time. Absent in a start list, and whenever no time may be published.",
          "$ref": "#/$defs/timestamp"
        },
        "time": {
          "description": "Running time. On a team member this is that runner's own leg time, not the team's cumulative time.",
          "$ref": "#/$defs/duration"
        },
        "startTimeSource": {
          "description": "Which rule produced the start time. The same 10:00 can be an individually drawn time, a class mass start or the event's, and which it was decides what a re-evaluation after an import derives.",
          "enum": ["Midnight", "CheckPunch", "AllocatedIndividual", "AllocatedClass", "AllocatedEvent", "Punched"]
        },
        "statusIsManual": {
          "description": "Present and true when the status is an operator's ruling rather than one derived from the punches.",
          "const": true
        },
        "position": {
          "description": "Placing within the class. Ranked entries only.",
          "type": "integer",
          "minimum": 1
        },
        "timeBehind": {
          "description": "Gap to the class winner. Ranked entries only; the winner's is 0, stated rather than omitted. Absent for every entry of a class ranked on points, winner included: at a score event the entry ahead is not the faster one, so a time gap would be a gap to somebody who did not win. Such a class carries score instead.",
          "$ref": "#/$defs/duration"
        },
        "score": {
          "description": "What the entry collected on a score course, already net of any time-limit penalty — the key its class was ranked on, ahead of race time. Present only for a ranked entry of a points-ranked class; its absence is what tells a consumer that position was ordered on time. Matches the IOF XML export's Result/Score.",
          "type": "number",
          "minimum": 0
        }
      }
    },

    "personEntry": {
      "description": "A competitor in an individual class.",
      "allOf": [{ "$ref": "#/$defs/entryFields" }],
      "type": "object",
      "required": ["person"],
      "properties": {
        "person": { "$ref": "#/$defs/person" },
        "organisation": { "$ref": "#/$defs/organisation" },
        "controlCards": {
          "type": "array",
          "items": { "$ref": "#/$defs/controlCard" }
        },
        "customFields": { "$ref": "#/$defs/customFieldValues" },
        "entrySource": { "$ref": "#/$defs/entrySource" },
        "splitSource": {
          "description": "Where the splits came from: a downloaded e-card (authoritative) or radio-control passings (published before any download).",
          "enum": ["card", "radio"]
        },
        "splits": {
          "type": "array",
          "items": { "$ref": "#/$defs/split" }
        }
      }
    },

    "teamEntry": {
      "description": "A team in a relay class. Unlike IOF XML's TeamResult, a team states its own status, time and placing.",
      "allOf": [{ "$ref": "#/$defs/entryFields" }],
      "type": "object",
      "required": ["id", "name"],
      "properties": {
        "id": { "type": "integer" },
        "name": { "type": "string" },
        "organisations": {
          "type": "array",
          "items": { "$ref": "#/$defs/organisation" }
        },
        "members": {
          "description": "The team's runners in leg order. A leg with nobody assigned is left out.",
          "type": "array",
          "items": { "$ref": "#/$defs/teamMember" }
        }
      }
    },

    "teamMember": {
      "description": "One runner on one leg of a relay team.",
      "allOf": [{ "$ref": "#/$defs/entryFields" }],
      "type": "object",
      "required": ["leg", "person"],
      "properties": {
        "leg": {
          "description": "The leg this runner runs.",
          "type": "integer",
          "minimum": 1
        },
        "person": { "$ref": "#/$defs/person" },
        "organisation": {
          "description": "The runner's own club, which may differ from the team's.",
          "$ref": "#/$defs/organisation"
        },
        "customFields": { "$ref": "#/$defs/customFieldValues" },
        "entrySource": { "$ref": "#/$defs/entrySource" },
        "controlCards": {
          "type": "array",
          "items": { "$ref": "#/$defs/controlCard" }
        },
        "splitSource": {
          "enum": ["card", "radio"]
        },
        "splits": {
          "type": "array",
          "items": { "$ref": "#/$defs/split" }
        }
      }
    }
  }
}

Things that catch people out

splitControls is not radioControls. splitControls says which columns this document publishes — every course control in a final export, the class's configured radio controls in a live one. radioControls is the operator's configuration, which is a different fact and is what makes a round trip come back with the radios still configured. Build your splits table from splitControls and match each entry's splits against it.

A DidNotFinish beside provisional: true is not a retirement. It is somebody who finished and whose card has not reached the download tent yet. A final export publishes the status it can stand behind, with the time withheld; a live export keeps the real OK and the time. The flag is there so you can tell the two apart, which IOF XML cannot.

score and ranking run in opposite directions. score is score-course points, where bigger is better. ranking is a place on a ranking list, where 1 is best. They are unrelated and must never be confused.

Don't derive "is this entry ranked?" from the status — read whether position is there. NotCompeting in particular is not enough to go on: Manager exports both an out-of-competition runner (ranked) and an untimed one (not ranked) as NotCompeting. Only a ranked entry carries position and timeBehind, so their presence is the answer. position is also a finish placing rather than a live standing, which is why a provisional finisher publishes a time and no place.

An any-order course's controls is not a sequence. At Scatter and Score any control in the forest counts, so the list is every control in play, given in code order because there is no other order to give. Read courseFormat before you read controls as a route. On such a course Missing splits do not mean the runner fell short either — a clean score run reports Missing for every control it chose not to visit.

A startRequest names a group, not a partner. SeparatedFrom and GroupedWith are about a set of entrants — a family of three who travelled together is one arrangement — but the format has room for exactly one person per entry, as IOF XML does. So every member of the group carries the request and each names one other member, and those edges form a cycle over the group: the members in some order, each naming the next one along and the last naming the first. A group of two, the ordinary case, is unchanged by that — each of the pair names the other. When you import one, union, never assign: resolve the person, add them to the group that entrant already holds rather than replacing it, then close the group so everybody in it holds all of it. Assign instead and a family of four arrives as one pair, with the other two dropped out of the arrangement. Because the rule is union-and-close, any connected set of edges reassembles the group — a chain, a star, or every pairing all import to the same group — so if you write OJSON yourself you need only leave the group connected, not reproduce the cycle. When Manager writes a document covering only part of an event — some classes, one kind of entry, or a result list that filters on status — it walks on around the cycle past any member it is not publishing, so the members it does publish are still joined up, and an entry left as the only member of its group in the file states its type and names nobody. Another producer may not be so careful, so resolve the person against your own event rather than against the file.

Start, Finish and Check controls carry no codes. Which SI codes act as each is event.punchCodes, because one physical unit can serve several roles.

A relay class publishes teams and persons. The persons are entrants who are on no team — which is how a relay entered per person arrives before the operator has built the teams. A runner a team already lists is never repeated there.

A start list skips a competitor in no class, their class having been deleted out from under them. No class publishes them, so nothing in the document accounts for their time.

Importing OJSON back into Manager

Everywhere Manager imports event data from a file, it accepts OJSON alongside the format it already took — and it dispatches on the file's first non-whitespace character, not on its name, so a document saved as .txt still imports as what it is.

ImportReads
Entry listAny OJSON document — classes, clubs, competitors and relay teams
ClassesAny OJSON document
CoursesAny OJSON document — courses, controls, leg distances, course pools and per-competitor course locks
RankingsAny OJSON document — every entry's ranking
Start timesAn OJSON startList only

Start times are the one exception, in both directions: a resultList is refused because its start times are the times competitors actually started at, and importing those as allocated starts would rewrite the draw; an entryList is refused because it has no start times at all, and would report a clean import having written nothing.

One document describes classes, courses, competitors, teams and start times together, so the same file can feed all four of the others — run them in whatever order suits.

Two things an import will ask about rather than assume, both answered in the same dialog: custom field definitions the file declares that this Manager has never heard of, and the event's own start regime (its mass start, restart and punching-start override), which is the rule the file's own start times were derived from. See Importing event data.

Results are deliberately not imported. Both document kinds carry the people, so last year's result list is this year's entry list — but a status and a time belong to this event's timing.

Note: OJSON carries the event's data — settings, controls, courses, leg distances, classes, clubs, entries, teams and stated results. It does not carry the raw timing evidence behind those results: card downloads, radio punches, punch overrides. An imported event states its results as facts but cannot recompute them from the punches. For a byte-exact copy of an event, use backup and restore instead — that is what it is for.

Versioning and what we promise

ojson carries the format version, and only the major version gates compatibility.

  • Additive changes do not bump the major version. A new optional property is not breaking: a consumer that doesn't know it ignores it, and one that does must already tolerate its absence. These bump the minor version and get a schema file of their own, published beside the old ones — every schema already published stays exactly where it is.
  • The major version bumps for anything that would make an existing consumer wrong: removing or renaming a property, changing a unit or a type, changing what a value means, or making an optional property required.
  • A name is never repurposed. A property whose meaning changes gets a new name.
  • A new member of a closed vocabulary is a minor bump — with a caveat. It doesn't make anything you already read mean something different, so it isn't major; but unlike a new property it will not validate against an older schema, because a closed enum is the one thing those schemas are strict about on purpose.
VersionWhat it added
1.0The format.

Manager itself reads any 1.x document and refuses a 2.x one rather than guessing.