Schema Versioning
How BO4E schema releases map onto Rust modules, what rubo4e::current guarantees, and which imports to pin when enum membership must not move underneath you.
rubo4e exposes a single stable BO4E schema series (v202607), compiled conditionally behind the versioned feature flag.
πThree spellings of one release
BO4E names a release twice, and this crate needs a third name for it β the series, which is the granularity at which a module exists:
| Where | Spelling | Example |
|---|---|---|
| Git tag and schema directory | with v, full triple | v202607.1.0 |
| Rust module | with v, series only | rubo4e::v202607 |
The _version field inside a payload | no v, full triple | 202607.1.0 |
Bo4eTyped::SCHEMA_VERSION is the wire spelling, so it compares against a _version read off a message with no normalisation step β never against the tag.
The series β the bare YYYYMM prefix β is the one to dispatch on, because it is the one that maps onto a set of Rust types. Bo4eTyped::SCHEMA_SERIES is it.
Both are associated constants, so they are readable from a type alone:
use rubo4e::{current::Rechnung, Bo4eTyped};
assert_eq!(Rechnung::SCHEMA_VERSION, "202607.1.0");
assert_eq!(Rechnung::SCHEMA_SERIES, "202607");schema_version() and schema_series() remain as methods for when a value is in hand.
πMulti-version Dispatch
When your storage layer persists a bo4e_version column alongside the JSON payload (common in JSONB-column designs), the idiomatic dispatch is a plain match β on the series, not on the exact release:
use rubo4e::{v202607, Bo4eTyped as _};
/// The `YYYYMM` prefix of a `_version` value: `"202607.1.0"` β `"202607"`.
fn series_of(wire_version: &str) -> &str {
wire_version.split('.').next().unwrap_or(wire_version)
}
fn process_rechnung(json: &str, bo4e_version: &str) -> Result<(), Box<dyn std::error::Error>> {
// `bo4e_version` is the payload's own `_version` β no `v` prefix.
match series_of(bo4e_version) {
"202607" => {
let r: v202607::Rechnung = serde_json::from_str(json)?;
// r.schema_series() == "202607" β always matches this arm
handle_v202607(r)
}
// When the v202701 series ships, add one arm and a migration shim if needed:
// "202701" => handle_v202701(serde_json::from_str::<v202701::Rechnung>(json)?),
_ => Err(format!("unsupported schema series: {bo4e_version}").into()),
}
}Do not match on the full _version. BO4E ships patch releases inside a series β 202607.0.0, then 202607.1.0 β and a sender one patch ahead of you stamps a string an equality match rejects, for a payload the v202607 types deserialize perfectly. Bo4eTyped::SCHEMA_SERIES is exactly the value this match keys on, so a test can assert the two agree.
Key points:
SCHEMA_SERIESandSCHEMA_VERSIONare on every BO and COM via theBo4eTypedtrait, as constants and as methods β no new API needed- Each new schema series is exactly one
matcharm; patches inside a series need none - Business logic (
handle_v202607,handle_v202701, β¦) only handles the series it was written for - An older series is handled by a thin shim inside its own arm β this crate ships no migration API, because a mapping between two series is a decision about your data, not one a library can make for you
AnyBois the sum type over the GeschΓ€ftsobjekte, for a payload whose_typis unknown until it is read. It is not a version abstraction, and there is deliberately noAnyVersion: two series have different field sets, so anything unifying them would have to erase the difference that made the dispatch necessary
πVersion Module Layout
With the versioned feature enabled:
rubo4e::v202607::Vertrag // the v202607 series
rubo4e::v202607::Adresse
rubo4e::v202607::Sparte
rubo4e::current::Vertrag // moving alias β whichever series is newestWithout the versioned feature, none of these module paths exist. The default feature set (identifiers, which pulls in serde) does not include versioned types.
πFeature Gate
# Enable version modules (pure conditional compilation; no external deps)
cargo add rubo4e --features versionedπKnown Schema Series
| Series | Committed snapshot | Status | Released |
|---|---|---|---|
| v202607 | v202607.1.0 | Current stable | July 2026 |
The snapshot column is the exact BO4E tag src/generated/v202607/ was built from; it lives under generator/schemas/ and is committed, so the codegen is reproducible. It advances when BO4E ships a release inside the series β see the contract below.
πVersioning Scheme
BO4E uses vYYYYMM.minor.patch. Module names use the vYYYYMM prefix only:
v202607.1.0 β module: v202607
v202701.0.0 β module: v202701 (hypothetical next series)The generator pins the full tag for reproducibility but exposes only the series prefix in the public API.
A minor bump inside a series is not necessarily additive. BO4E removes enum values and whole types within a series, so anything treating one as a frozen value set will eventually be wrong. The contract below says what is stable.
πrubo4e::current β Moving Alias
rubo4e::current is a moving re-export module (a real pub mod, not a pub use β¦ as alias) that always points to the latest stable schema series. Use it when you always want the newest types and do not need to pin to a specific version.
use rubo4e::current::Vertrag; // equivalent to rubo4e::v202607::Vertrag todayPin to a concrete module if you need version-stability across crate updates:
use rubo4e::v202607::Vertrag; // stable even if rubo4e::current advancesπWhat pinning does, and does not, buy you
| Path | What a minor rubo4e bump can do to it |
|---|---|
rubo4e::v202607::Foo | Keep the series. Field names and types stay put; enum membership can still move, because BO4E itself moves it inside a series. |
rubo4e::current::Foo | Anything the above can do, plus jump to a new series β renamed fields, retyped fields, whole types added or removed. |
So the honest statement is:
The Rust module path pins the series. The
rubo4eversion pins the values.
If a variant set must not move under you, pin the crate version in Cargo.toml (rubo4e = "=0.13.0") and upgrade deliberately. Importing rubo4e::v202607::Sparte instead of rubo4e::current::Sparte narrows the blast radius β you will not silently jump a format-version cutover β but it does not freeze the enum.
The rest is a test, not a promise. Anything whose shape you guard should assert it structurally, so a schema bump fails in CI instead of in production:
- SQL
CHECK (col IN (...))lists generated from an enum's variants - Exhaustive
match/ mapping tables over an enum - Variant-count assertions (
assert_eq!(T::COUNT, N))
The strum-free introspection surface is there for exactly this:
use rubo4e::{Bo4eEnum, v202607::Zaehlertyp};
// Structural drift guard β no magic number to update by hand:
#[test]
fn sql_check_list_covers_every_variant() {
let sql: Vec<&str> = load_check_list(); // your migration's CHECK list
for v in Zaehlertyp::VARIANTS {
assert!(sql.contains(&v.as_wire()), "CHECK list missing {}", v.as_wire());
}
}Note the direction: this asserts the CHECK list covers every variant, so an added variant fails it. To catch a removed one too, assert set equality instead.
πSchema-delta changelog
Every release that changes schema-derived enum membership or codelist coverage records it in the CHANGELOG.md Schema deltas section, in the form:
### Schema deltas (<old tag> β <new tag>)
- <Enum> +2 (NEW_A, NEW_B) -1 (REMOVED_C)
- <OtherEnum> +1 (NEW_D)
- removed enums: <Type>, <Type>Removals are listed as prominently as additions: they are the half that breaks a build. T::COUNT and T::VARIANTS turn the drift into a test failure the moment you upgrade.
πUpgrading within a series
BO4E ships a new patch inside the series the crate is already on. The module path does not change, so this is three commands and a changelog entry.
just download-schemas <new tag> # vendors the snapshot under generator/schemas/
git rm -r generator/schemas/<old tag> # exactly one snapshot per series
just generate # discovers the tag from the directory
just ciThe generator rewrites every file in src/generated/v202607/ and deletes the ones the release retired, so a type BO4E drops leaves no orphan module behind.
Nothing in the tree writes the tag out: the justfile, the CI workflow, the test helpers, and the site config all derive it from that one directory name, and tests/pinned_tag.rs fails the build if anything starts pinning a literal. The only manual edits are the table above and the changelog.
Then read the diff. git diff src/generated/ shows every membership change, and the ones that matter are the removals β those are what break a downstream build, and they belong in the Schema deltas entry.
πAdding a New Schema Series
When BO4E's annual format-version cutover lands, with new or renamed types:
Download the schema snapshot using the provided script:
just download-schemas v202701.0.0Run the generator:
just generate v202701.0.0The generator writes
src/generated/v202701/with all types and automatically updatessrc/generated/mod.rs(by re-scanning the directory β no manual edit needed).In
src/lib.rs, add a versioned re-export module:#[cfg(feature = "versioned")] pub mod v202701 { pub use crate::generated::v202701::*; }Advance the
currentmodule to re-export the new series (it is a realpub mod, not apub use β¦ asalias, so IDE tooling resolves hovers asrubo4e::current::Foo):#[cfg(feature = "versioned")] pub mod current { pub use crate::generated::v202701::*; // was: v202607 }Advance every hand-written module that names a series. These are the ones the generator does not touch, and nothing in the compiler notices when one is left behind β the older module still exists and still type-checks, so the crate ships accessors for a series nobody is using:
File What it pins src/lib.rspub mod currentand thepub mod vYYYYMMre-exportssrc/convenience.rsZeitraum/Rechnung/Preisstaffelaccessorssrc/units.rsMengeneinheitdimensions andMengearithmeticsrc/timeseries.rsthe Bo4eTimeSeriesimpls forLastgang/Zeitreihesrc/validation/mod.rsimpl_validators!(vYYYYMM)andvalidation::currentvalidationis the one that keeps a copy per series, via theimpl_validators!macro β renamed fields and new rules can then diverge between series instead of silently applying stale logic. The other three targetcurrentonly.tests/current_series_alignment.rsenforces this: it reads the sources, and fails if any of them names a series other than the onecurrentre-exports β or if a new file starts naming one without being added to the table above. That test is the real checklist; this table is its documentation.A schema-breaking rename will surface as a compile error in these modules, which is the intended behaviour β see Semantic Field Typing.
Update the Known Schema Series table in this document, and keep the retiring series listed until you remove its module.
Record a Schema deltas section in
CHANGELOG.mdlisting every enum whose membership changed and every codelist code added/removed (e.g.Zaehlertyp +2 (β¦)). Downstream projects rely on this to update pinned guards deliberately. DiffingT::VARIANTSbetween the old and new series makes this mechanical.
πCOM and Enum Versioning
COM and enum types live inside the versioned module alongside BO types. They follow exactly the same conditional-compilation rules.
πSchema Breaking Changes
The BO4E annual format-version cutover can rename fields, change optionality, and add or remove whole types. What a given cutover changed is recorded in the CHANGELOG; the authoritative diff is between the snapshots under generator/schemas/.
The generator does not paper over such changes: a $ref, a "format", and "type": "number" are always authoritative, so a renamed or retyped field surfaces as a compile error rather than as a silent behaviour change. See Semantic Field Typing.