Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI

on:
pull_request:
types:
- opened
- synchronize
- reopened
push:
branches:
- 'main'
tags:
- 'v*'

# The only job here compiles schemas and validates XML; it needs nothing but read
# access to the checked-out tree.
permissions:
contents: read

jobs:
schema:
name: Schema conformance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
# No job pushes with git credentials; don't persist the token into
# .git/config where checked-out repo code could read it.
persist-credentials: false
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: temurin
java-version: '17'
# Compiles every schema the SDKs generate their bindings from, then
# validates the captured payloads in test/fixtures against them. Fails on
# a schema that does not compile, a payload that does not validate, an
# attribute a payload carries but the schema does not declare, and a
# message type or endpoint with no payload at all.
- name: Check schemas against captured payloads
run: make check
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.DS_Store
/.idea/
build/
14 changes: 14 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
BUILD_DIR := build

.PHONY: check clean

# Compile every schema and validate every captured payload against it.
check: $(BUILD_DIR)/SchemaCheck.class
@java -cp $(BUILD_DIR) SchemaCheck .

$(BUILD_DIR)/SchemaCheck.class: tools/SchemaCheck.java
@mkdir -p $(BUILD_DIR)
@javac -d $(BUILD_DIR) tools/SchemaCheck.java

clean:
@rm -rf $(BUILD_DIR)
106 changes: 106 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
Schema notes
=====

Why this file exists: the schema, the producers and the SDK models had drifted
apart, and every reviewer who noticed re-derived the same conclusions from
scratch. This records the decisions so the next one does not have to.

Conventions
-----

**No XML namespace, anywhere.** The AMQP messages and the REST responses are both
served without a namespace, and none of the three SDKs looks for one. The REST
schemas used to declare `targetNamespace="http://schemas.oddin.gg/v1"` in 18 of
26 files, which meant no real response validated against them; the declaration
was removed rather than added to the remaining eight.

**One definition per wire element.** `<scoreboard>` and `<period_score>` appear in
both the AMQP feed and the REST API and the producer emits the same shape in
both, so their attribute lists live once in `schema/common/` and are referenced
from `schema/feed/types.xsd` and `schema/rest/types/sport_event_status.xsd`.

`<sport_event_status>` is deliberately still two types. It is not the same
element on the two wires: the feed sends `status` as a numeric enumeration plus
`match_status`, the REST API spells the status out (`live`, `closed`) and sends
`match_status_code`. Only its two child elements are shared.

**Every added attribute is `use="optional"`,** because the producer emits a
sport-specific subset - a CS2 scoreboard carries rounds, a cricket scoreboard
carries overs and wickets.

**Where the two copies of a shared element disagreed, the producer decided.** That
meant three type corrections: the per-period sub-scores are `xs:int` and not
`xs:decimal` (the producer sends integers and every SDK decodes integers), the
scores are `xs:double` and not `xs:decimal` (Go's float formatting can emit
exponent notation, which `xs:decimal` rejects), and `period_score/@number` is
required (the producer always sends it).

**Every message type and endpoint has at least one captured payload** under
`test/fixtures/`, and `make check` validates them. No type declares
`xs:anyAttribute`, so an attribute a producer starts sending without declaring it
here fails the check. See [README.md](README.md#validating-the-schema).

Attributes that exist in an SDK but on no wire
-----

Legacy fields, carried over from the original code generation of the SDK models.
No Oddin producer emits them and nothing consumes them.

**Verdict: do not add them to the schema.** They should be dropped from the SDK
models instead, one SDK at a time; that is out of scope here. The counts below
are the SDKs that still carry a field for the attribute, checked 2026-08-26.

| Attribute | Where in the SDKs | Verdict |
| --- | --- | --- |
| `event_ref_id` | gosdk, netcoresdk, javasdk (as `ref_event_id`) | Legacy event alias. Not sent. |
| `ref_id` | all three, on nearly every element | Legacy id alias. Not sent. |
| `superceded_by` | javasdk; commented out in the producer as "not used" | Not sent. |
| `certainty` | javasdk | Not sent. |
| `betstop_reason`, `betting_status` | javasdk | Not sent. `bet_stop` carries `groups` and `market_status` instead - see `schema/feed/bet_stop.xsd`. |
| `odds_change_reason` | javasdk | Not sent. |
| `next_live_time`, `start_time_confirmed` | javasdk | Not sent. |
| `cashout_status`, `dead_heat_factor` | javasdk | Not sent. |
| `extended_specifiers` | gosdk, javasdk; commented out in the producer as "not used" | Not sent. `specifiers` is the only one. |
| `win_probabilities`, `lose_probabilities`, `half_win_probabilities`, `half_lose_probabilities`, `refund_probabilities` | javasdk | Not sent. The outcome carries a single `probabilities`. |
| `groups` on an odds_change market | javasdk | Not sent at market level. `groups` is real on `bet_stop` and on a REST `market_description`, which is what makes this one easy to misread. |
| `team` on an outcome | javasdk | Not sent. |
| `decided_by_fed`, `winning_reason`, `period` on the status | listed as SDK-only in the audit; not found in any SDK as of 2026-08-26 | Nothing to deprecate. |
| `void_reason_id`, `void_reason_params` on an odds_change or bet_settlement market | gosdk | Sent on `bet_cancel` markets only, where the schema declares them. |
| `match_status_code` on the **feed** `sport_event_status` | nowhere | The audit put it here by mistake. On the feed the attribute lives on `period_score` (declared, required) and on the SDK-only `results` element; the status itself sends `match_status`. Only `winner_id` was genuinely missing from the feed status. |

Child elements in the same category: `results` (netcoresdk, javasdk as
`resultType`; a legacy per-period result list), `clock` (netcoresdk as
`clockType`, declared but never wired to a field), `statistics` and
`reference_ids` (gosdk), `market_metadata`, `odds_generation_properties` and
`delayed_info` (javasdk). None is emitted; none is declared.

Questions the audit left open, now answered
-----

* **`result` / `void_factor` on an odds_change outcome.** Not sent. gosdk reuses one
`Outcome` struct for `odds_change` and `bet_settlement`, which is where the
ambiguity came from; the producer sets them only when building a settlement.
They stay declared on the bet_settlement outcome only.
* **`icon_path` on `sport`.** Belongs to `sportExtended`, which only the `sports`
endpoint returns. The plain `sport` nested inside a tournament never carries
it. The schema was already right; gosdk merges the two types, which is what
made it look wrong.
* **`start_time` / `next_live_time` on `fixture_change`.** Not sent. The AMQP
`fixture_change` carries `change_type` and the standard message attributes,
nothing else.

Known-latent, deliberately left alone
-----

Places where the producer's model permits something the schema forbids, but which
has never been observed on the wire. Loosening them would turn a single object
into a list in every generated binding, so they are recorded rather than changed:

* `teamExtended/sport` and `tournament/tournament_length` are slices in the
producer but `maxOccurs="1"` here.
* The producer can attach `<category>` elements to `teamExtended` and
`tournament`. The schema declares no `category` element at all, and no
response has ever contained one.

If any of these ever ships, `make check` will fail on the captured payload, which
is the point.
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,36 @@ Odds feed schema

The Oddin odds feed is separated into two parts: a high performance messaging feed through AMQP (Advanced Message
Queuing Protocol) and a classic REST based XML API.

### Layout

```
schema/feed/ one file per AMQP message, plus the shared types.xsd
schema/rest/ one file per REST endpoint response
schema/common/ attribute groups for the elements both wires share
test/fixtures/ captured payloads, one directory per schema
```

Neither wire uses an XML namespace, so no schema declares a `targetNamespace`.

### Validating the schema

`make check` compiles every schema and validates every captured payload in
`test/fixtures/<feed|rest>/<schema name>/` against
`schema/<feed|rest>/<schema name>.xsd`. It needs a JDK and nothing else, and it
is what CI runs on every pull request.

It fails when

* a schema does not compile - the SDKs generate their bindings from these files,
so a schema that does not compile is a schema nobody can consume;
* a captured payload does not validate;
* a payload carries an attribute the schema does not declare (no type here
declares `xs:anyAttribute`, so this is how silent producer drift is caught);
* a message type or endpoint has no captured payload at all.

When a producer starts sending something new, add the attribute to the schema and
a payload that exercises it in the same change.

[NOTES.md](NOTES.md) records the conventions, which attributes exist in an SDK but
on no wire, and why `<sport_event_status>` is defined twice on purpose.
51 changes: 51 additions & 0 deletions schema/common/period_score.xsd
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
The single authoritative attribute list for the <period_score> element.

<period_score> appears both inside the AMQP <odds_change>/<sport_event_status>
message and inside the REST match_summary <sport_event_status> response. The
producer serialises both from the same model, so both schemas reference this
one group instead of keeping their own copy.

As with <scoreboard>, the sport decides which of the optional per-period
attributes are present; type/number/match_status_code and the two scores are
always sent.
-->
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<xs:attributeGroup name="periodScoreAttributes">
<xs:attribute name="type" type="xs:string" use="required"/>
<xs:attribute name="number" type="xs:int" use="required"/>
<xs:attribute name="match_status_code" type="xs:int" use="required"/>
<xs:attribute name="home_score" type="xs:double" use="required"/>
<xs:attribute name="away_score" type="xs:double" use="required"/>
<!-- round based sports (CS2, Valorant, ...) -->
<xs:attribute name="home_won_rounds" type="xs:int" use="optional"/>
<xs:attribute name="away_won_rounds" type="xs:int" use="optional"/>
<!-- MOBA (League of Legends, King of Glory, Mobile Legends, Dota) -->
<xs:attribute name="home_kills" type="xs:int" use="optional"/>
<xs:attribute name="away_kills" type="xs:int" use="optional"/>
<!-- goal based sports (rush / virtual soccer) -->
<xs:attribute name="home_goals" type="xs:int" use="optional"/>
<xs:attribute name="away_goals" type="xs:int" use="optional"/>
<!-- point based sports (rush / virtual basketball, table tennis, ...) -->
<xs:attribute name="home_points" type="xs:int" use="optional"/>
<xs:attribute name="away_points" type="xs:int" use="optional"/>
<!--
Per-set games won, sent for set based classic sports (tennis and
friends). Not emitted by the esports feed, where a table tennis
period IS a game and games-won lives on <scoreboard> instead.
-->
<xs:attribute name="home_games" type="xs:int" use="optional"/>
<xs:attribute name="away_games" type="xs:int" use="optional"/>
<!-- rush cricket -->
<xs:attribute name="home_runs" type="xs:int" use="optional"/>
<xs:attribute name="away_runs" type="xs:int" use="optional"/>
<xs:attribute name="home_wickets_fallen" type="xs:int" use="optional"/>
<xs:attribute name="away_wickets_fallen" type="xs:int" use="optional"/>
<xs:attribute name="home_overs_played" type="xs:int" use="optional"/>
<xs:attribute name="away_overs_played" type="xs:int" use="optional"/>
<xs:attribute name="home_balls_played" type="xs:int" use="optional"/>
<xs:attribute name="away_balls_played" type="xs:int" use="optional"/>
<xs:attribute name="home_won_coin_toss" type="xs:boolean" use="optional"/>
</xs:attributeGroup>
</xs:schema>
62 changes: 62 additions & 0 deletions schema/common/scoreboard.xsd
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
The single authoritative attribute list for the <scoreboard> element.

<scoreboard> appears both inside the AMQP <odds_change>/<sport_event_status>
message and inside the REST match_summary <sport_event_status> response, and
the producer emits exactly the same shape in both places, so both schemas
reference this one group instead of keeping their own copy.

The producer picks one sport-specific subset per message - a CS2 scoreboard
carries the round attributes, a cricket scoreboard the runs/overs/wickets
attributes - so every attribute here is optional and only a subset ever
appears on a single element.
-->
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<xs:attributeGroup name="scoreboardAttributes">
<!-- CS2 / CS2 Wingman -->
<xs:attribute name="current_ct_team" type="xs:int" use="optional"/>
<!-- Valorant -->
<xs:attribute name="current_def_team" type="xs:int" use="optional"/>
<!-- round based sports (CS2, Valorant, ...) -->
<xs:attribute name="home_won_rounds" type="xs:int" use="optional"/>
<xs:attribute name="away_won_rounds" type="xs:int" use="optional"/>
<xs:attribute name="current_round" type="xs:int" use="optional"/>
<!-- MOBA (League of Legends, King of Glory, Mobile Legends, Dota) -->
<xs:attribute name="home_kills" type="xs:int" use="optional"/>
<xs:attribute name="away_kills" type="xs:int" use="optional"/>
<xs:attribute name="home_destroyed_turrets" type="xs:int" use="optional"/>
<xs:attribute name="away_destroyed_turrets" type="xs:int" use="optional"/>
<xs:attribute name="home_destroyed_towers" type="xs:int" use="optional"/>
<xs:attribute name="away_destroyed_towers" type="xs:int" use="optional"/>
<xs:attribute name="home_gold" type="xs:int" use="optional"/>
<xs:attribute name="away_gold" type="xs:int" use="optional"/>
<!-- goal based sports (rush / virtual soccer) -->
<xs:attribute name="home_goals" type="xs:int" use="optional"/>
<xs:attribute name="away_goals" type="xs:int" use="optional"/>
<!-- point based sports (rush / virtual basketball, rush madden) -->
<xs:attribute name="home_points" type="xs:int" use="optional"/>
<xs:attribute name="away_points" type="xs:int" use="optional"/>
<!-- game based sports (table tennis) -->
<xs:attribute name="home_games" type="xs:int" use="optional"/>
<xs:attribute name="away_games" type="xs:int" use="optional"/>
<!-- rush cricket -->
<xs:attribute name="home_runs" type="xs:int" use="optional"/>
<xs:attribute name="away_runs" type="xs:int" use="optional"/>
<xs:attribute name="home_wickets_fallen" type="xs:int" use="optional"/>
<xs:attribute name="away_wickets_fallen" type="xs:int" use="optional"/>
<xs:attribute name="home_overs_played" type="xs:int" use="optional"/>
<xs:attribute name="away_overs_played" type="xs:int" use="optional"/>
<xs:attribute name="home_balls_played" type="xs:int" use="optional"/>
<xs:attribute name="away_balls_played" type="xs:int" use="optional"/>
<xs:attribute name="home_batting" type="xs:boolean" use="optional"/>
<xs:attribute name="away_batting" type="xs:boolean" use="optional"/>
<xs:attribute name="home_won_coin_toss" type="xs:boolean" use="optional"/>
<xs:attribute name="inning" type="xs:int" use="optional"/>
<!-- clocks, in seconds -->
<xs:attribute name="time" type="xs:int" use="optional"/>
<xs:attribute name="game_time" type="xs:int" use="optional"/>
<xs:attribute name="elapsed_time" type="xs:int" use="optional"/>
<xs:attribute name="remaining_game_time" type="xs:int" use="optional"/>
</xs:attributeGroup>
</xs:schema>
18 changes: 18 additions & 0 deletions schema/feed/bet_stop.xsd
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<xs:include schemaLocation="types.xsd"/>
<xs:element name="bet_stop">
<xs:complexType>
<xs:attributeGroup ref="messageAttributes"/>
<xs:attribute name="groups" type="xs:string" use="optional">
<xs:annotation>
<xs:documentation>
Pipe separated list of the market groups the stop applies
to, or "all" for every market of the event.
</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="market_status" type="marketStatus" use="optional"/>
</xs:complexType>
</xs:element>
</xs:schema>
Loading