rubo4e 0.9.0

Rust implementation of the BO4E energy-market data standard
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
# rubo4e

Rust implementation of [BO4E](https://www.bo4e.de/) — *Geschäftsobjekte für die
Energiewirtschaft*, the object model the German energy industry uses to exchange
contracts, metering points, invoices, and the parties involved.

`rubo4e` generates the full object model from the official JSON Schema, then adds
what the schema cannot express: market identifiers that verify their own BDEW
check digits, enums you can parse strictly at an ingest boundary, and JSON that
stays byte-compatible with the Python, Go, and .NET implementations.

[![Crates.io](https://img.shields.io/crates/v/rubo4e.svg)](https://crates.io/crates/rubo4e)
[![Docs](https://img.shields.io/badge/docs-hupe1980.github.io%2Frubo4e-blue.svg)](https://hupe1980.github.io/rubo4e)
[![API](https://img.shields.io/badge/api-docs.rs-blue.svg)](https://docs.rs/rubo4e)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](LICENSE-MIT)
[![Rust 1.88+](https://img.shields.io/badge/rust-1.88%2B-orange.svg)](https://www.rust-lang.org/)

> **Independent implementation.** Not affiliated with or endorsed by the BO4E
> project or BDEW; the reference implementation is
> [BO4E-python]https://github.com/bo4e/BO4E-python.

## Features

- **Generated types** from the official BO4E JSON Schema (v202607)
- **Strong domain identifiers** — the complete BDEW identifier family (`MaloId`, `MeloId`, `NeloId`, `NebeId`, `CrId`, `SgId`, `SrId`, `TrId`, `PaketId`, `EicCode`, `ObisCode`, `MarktpartnerId`, …) with spec-accurate check digits and domain helpers
- **Three-layer validation** — constructor checks, `garde` struct rules, cross-field business logic
- **Strict enum parsing & introspection** — `from_wire` (reject out-of-schema values), `VARIANTS` / `COUNT` / `iter_known`, `Display` / `AsRef<str>`, `is_unknown`, unified by the `Bo4eEnum` trait — all **without** the `strum` feature
- **Recursive strict decoding** — `Bo4eStrict::ensure_known_enums()` rejects any `Unknown` enum value anywhere in a deserialized payload, with JSON-paths — one call replaces hand-written per-field checks
- **Typed builders** — readable, diffable construction via `typed-builder`; setters accept both `T` and `Option<T>` (note: BO4E BO fields are schema-optional, so AHB-mandatory contracts are enforced by your ingest layer, not the type system)
- **German / snake_case / canonical JSON** — BO4E wire format out of the box
- **Ergonomic convenience API** — extension traits, billing-period helpers, EDIFACT agency codes
- **JSON Schema** via `schemars`, OpenAPI via `utoipa`, PostgreSQL via `sqlx`
- **Golden corpus** and **fuzz harnesses** included; proptest round-trip tests run as dev tests

---

## Installation

```sh
cargo add rubo4e
```

That gives you the identifier types only. Add the features you need:

```sh
cargo add rubo4e --features versioned,time,decimal,json,validate
```

---

## Quick Start

```rust
use rubo4e::prelude::*;          // identifiers, BetragExt, MengeExt, PreisExt, Bo4eJsonExt
use rubo4e::v202607::{Vertrag, Sparte};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Typed builder — readable, diffable construction (requires `builder` feature).
    // BO4E BO fields are schema-optional, so any field you omit defaults to None.
    let vertrag = Vertrag::builder()
        .sparte(Sparte::Strom)
        .beschreibung("Jahresvertrag Strom".to_string())
        .vertragsnummer("VN-2026-001".to_string())
        .build();

    // Cross-field struct validation (requires `validate` feature).
    // The prelude re-exports the `Validate` trait, so no direct garde dependency.
    vertrag.validate()?;

    // German camelCase JSON — BO4E wire format (requires `json` feature)
    let json = vertrag.to_json_german()?;
    println!("{json}");

    Ok(())
}
```

---

## Feature Gates

| Feature       | Default | Description                                       |
|---------------|:-------:|--------------------------------------------------|
| `identifiers` | ✓       | Identifier types (`MaloId`, `EicCode`, `ObisCode`, …) + serde — zero schema overhead |
| `serde`       | ✓       | Serde derives + extension-data map                |
| `json`      |         | `serde_json` helpers (`to_json_german()`, …)      |
| `simd-json` |         | SIMD-accelerated JSON parsing backend             |
| `time`      |         | `time` crate — `Date` for date fields, `OffsetDateTime` for timestamps |
| `decimal`   |         | `rust_decimal::Decimal` for amounts and prices    |
| `builder`   |         | `typed-builder` derives on all BO/COM structs     |
| `validate`  |         | `garde` validation — constructor + cross-field rules |
| `schemars`  |         | JSON Schema generation with patterns and examples |
| `sqlx`      |         | `Type`/`Encode`/`Decode`/`PgHasArrayType` for every identifier **and** every enum (PostgreSQL) |
| `utoipa`    |         | `ToSchema` with pattern/example/description for OpenAPI |
| `strum`     |         | Enum iteration and string conversion              |
| `versioned` |         | Versioned schema modules (`v202607`, `current`)   |
| `tracing`   |         | Structured diagnostics via the `tracing` crate    |
| `metrics`   |         | Counter export hooks (metrics ecosystem)          |

> **Typical full setup:**
> ```sh
> cargo add rubo4e --features versioned,time,decimal,json,validate,builder
> ```

---

## Schema Versions

| Module    | Schema tag    | Status            |
|-----------|---------------|-------------------|
| `v202607` | v202607.0.0   | Current stable    |

```rust
use rubo4e::v202607::Marktlokation;  // pin to v202607
use rubo4e::current::Marktlokation;  // always the latest stable — advances with crate updates
```

**Versioning contract.** `rubo4e::current` re-exports the newest stable schema
series. A minor `rubo4e` bump *can* therefore change enum membership or codelist
coverage under `current` (a new variant, a new code) without a source change on
your side. **Pin to the version module (`rubo4e::v202607::…`) for anything whose
shape you guard** (SQL `CHECK` lists, exhaustive mappings, variant-count
assertions); use `current` for code that should always track the latest series.
Every release that changes schema-derived membership records it in the
[CHANGELOG](CHANGELOG.md) **Schema deltas** section. See
[Schema Versioning](https://hupe1980.github.io/rubo4e/docs/versioning/) for the full contract.

---

## Enum Introspection & Strict Parsing

Every generated BO4E enum carries an `Unknown` forward-compatibility catch-all,
so the lenient `serde` / `FromStr` path never fails on an unrecognized wire value
— it maps to `Unknown`. That is the right default for forward-compatibility, but
the wrong default at an ingest boundary that must reject typos, legacy codes, or
values from a newer schema. Every enum therefore also exposes a uniform,
**`strum`-free** surface (also unified by the [`Bo4eEnum`] trait for generic use):

```rust
use rubo4e::{Bo4eEnum, current::Zaehlertyp};

// Introspection without `strum` — drift-guard SQL CHECK lists & mappings:
assert_eq!(Zaehlertyp::COUNT, Zaehlertyp::VARIANTS.len());
for v in Zaehlertyp::iter_known() {           // never yields Unknown
    println!("{}", v.as_wire());              // canonical BO4E wire string
}

// Strict parsing at the boundary — Err instead of a silent Unknown:
assert_eq!(Zaehlertyp::from_wire("WASSERZAEHLER"), Ok(Zaehlertyp::Wasserzaehler));
assert!(Zaehlertyp::from_wire("LFG").is_err());          // legacy/typo rejected
assert!(Zaehlertyp::from_wire("UNKNOWN").is_err());      // catch-all is not a real value

// Detect lenient-decode fall-through after a serde round-trip, in one call:
let z: Zaehlertyp = serde_json::from_value(serde_json::json!("BOGUS")).unwrap();
assert!(z.is_unknown());
```

| Member                       | Feature | Purpose                                                        |
|------------------------------|---------|----------------------------------------------------------------|
| `T::VARIANTS`                | none    | `&'static [T]` of known variants (excludes `Unknown`)          |
| `T::COUNT`                   | none    | stable per-version variant count                               |
| `T::iter_known()`            | none    | iterator over known variants                                   |
| `T::as_wire(&self)`          | none    | canonical BO4E wire string                                     |
| `T::from_wire(s)`            | none    | **strict** parse → `Result<T, UnknownVariant>`                 |
| `T::is_known` / `is_unknown` | none    | detect the `Unknown` catch-all                                 |
| `Display`, `AsRef<str>`      | none    | canonical wire string — now available **without** `strum`      |
| `Bo4eEnum` trait             | `versioned` | the above, generic over the enum type                      |

> `Display`, `AsRef<str>`, `as_wire`, `from_wire`, `VARIANTS`, `COUNT`, and
> `iter_known` are all feature-independent. The `strum` feature now only adds
> `FromStr`, `EnumIter`, and `Into<&'static str>`.

### Strict decoding of whole payloads (`Bo4eStrict`)

Per-enum `from_wire` is strict at the *field* level. But the common pattern is a
lenient whole-object decode (`serde_json::from_value::<Rechnung>()`) used as a
schema gate — and that decode silently turns every unrecognized enum value into
`Unknown`, anywhere in the tree. `Bo4eStrict` closes that gap: **one call** finds
every out-of-schema enum value in a nested value and reports its JSON-path.

```rust
use rubo4e::{Bo4eStrict, current::Netzlokation};

let nelo: Netzlokation = serde_json::from_value(body)?;   // lenient decode (never fails on enums)
nelo.ensure_known_enums()?;                               // Err lists e.g. ["zaehler[1].zaehlertyp"]
```

`ensure_known_enums()` returns [`StrictError`] with the dotted, index-bracketed
paths of every `Unknown` enum value; `unknown_enum_paths()` returns them directly.
Implemented for every BO, COM, enum, and `AnyBo`. This replaces the hand-written
`record.field == T::Unknown` re-checks a strict ingest boundary would otherwise
need. Unlike `Bo4eObject`/`Bo4eEnum`, `Bo4eStrict` is **not sealed**, so you can
implement it on your own domain wrappers to extend the recursive check.

[`Bo4eEnum`]: https://docs.rs/rubo4e/latest/rubo4e/trait.Bo4eEnum.html
[`StrictError`]: https://docs.rs/rubo4e/latest/rubo4e/strict/struct.StrictError.html

---

## Identifiers

All domain identifiers validate their format at construction time. There are no panicking constructors.

| Type                | Format / Rule                                              |
|---------------------|------------------------------------------------------------|
| `MaloId`            | 11 digits, first `1`–`9`, BDEW §8.1 check digit — Marktlokation / Tranche |
| `NeloId`            | Codetyp `'E'` + 9 `[A-Z0-9]` + §8.2 check digit — Netzlokation (BK6-22-128) |
| `NebeId`            | Codetyp `'F'` + 9 `[A-Z0-9]` + §8.2 check digit — Netzbereich (BK6-22-300) |
| `CrId`              | Codetyp `'A'` + 9 `[A-Z0-9]` + §8.2 check digit — Cluster Ressource |
| `SgId`              | Codetyp `'B'` + 9 `[A-Z0-9]` + §8.2 check digit — Steuergruppe |
| `SrId`              | Codetyp `'C'` + 9 `[A-Z0-9]` + §8.2 check digit — Steuerbare Ressource |
| `TrId`              | Codetyp `'D'` + 9 `[A-Z0-9]` + §8.2 check digit — Technische Ressource |
| `PaketId`           | Codetyp `'P9'` + 8 `[A-Z0-9]` + §8.2 check digit — Netzbetreiberwechsel |
| `MeloId`            | 33 chars: 2-char ISO country code + 31 alphanumeric        |
| `EicCode`           | 16-char EIC with ENTSO-E check character and object type   |
| `BilanzkreisId`     | 16-char EIC restricted to object type `'X'` (Party) — Bilanzkreis, MaBiS / GaBi Gas |
| `BilanzierungsgebietId` | 16-char EIC restricted to object type `'Y'` (Area) — Bilanzierungsgebiet, MaBiS |
| `ObisCode`          | `[A-B:]C.D[.E][*F]`, value groups are octets; C=0 permitted (IEC 62056-61 general metering group) |
| `MarktpartnerId`    | 13 decimal digits — BDEW (99), DVGW (98), or GS1 GLN; check digit opt-in |
| `AkivId`            | 1–36 printable ASCII chars — Aktivierungsidentifikator Redispatch 2.0 (BK6-24-174) |
| `TranchennummerId`  | 1–6 decimal digits, no leading zeros — MABIS Bilanzkreisabrechnung (PID 13003) |

Section numbers refer to the BDEW Anwendungshilfe *"Identifikatoren in der
Marktkommunikation"* v1.2 (7 February 2025). Chapter 8 defines a single
check-digit arithmetic — sum the mapped values at odd positions, add twice the sum
at even positions, take the difference to the next multiple of 10 — in two flavours:
§8.1 for numeric IDs and §8.2 (the "ASCII-Verfahren", where `A`–`Z` map to their
ASCII codes) for alphanumeric ones. Both are implemented once and pinned to the
worked examples printed in the specification.

```rust
// Build from base — the check digit is computed, never typed by hand.
let malo = MaloId::from_base("4137355924")?;   // → "41373559241"  (BDEW §8.1 example)
let c    = MaloId::check_digit("4137355924")?; // → 1u8
assert_eq!(malo.vergabestelle(), MaloVergabestelle::Bdew);

// Every §8.2 identifier shares the same API and enforces its own Codetyp.
let nelo  = NeloId::from_base("E000000001")?;  // → "E0000000019"
let tr    = TrId::from_base("D000000001")?;    // → "D0000000010"
let paket = PaketId::from_base("P900000001")?; // → "P9000000010"
assert!(NeloId::new("D0000000010").is_err());  // Codetyp mismatch — that is a TrId

// Country code extraction (MeloId)
let melo = MeloId::new("DE0000000000000000000000000000001")?;
assert_eq!(melo.country_code(), "DE");
assert!(melo.is_german());

// EDIFACT agency codes (MarktpartnerId) — eliminates duplicate mapping tables
let mp = MarktpartnerId::new("9900357000003")?;
assert_eq!(mp.authority(), MpIdAuthority::Bdew);
assert_eq!(mp.nad_agency_code(), "293");  // EDIFACT NAD DE3055
assert_eq!(mp.unb_agency_code(), "500");  // EDIFACT UNB DE0007

// MP-IDs carry either a BDEW (§8.1) or a GS1/EAN-13 check digit; opt in explicitly.
assert!(mp.has_valid_bdew_check_digit());
assert!(MarktpartnerId::new_checked("9900357000000").is_err());

// Integer round-trip for legacy systems
assert_eq!(mp.to_i64(), 9_900_357_000_003_i64);

// Serde as integer (opt-in, field-level)
#[serde(with = "rubo4e::identifiers::marktpartner_id_as_i64")]
pub partner_id: MarktpartnerId,
```

### EIC codes and object types

Position 3 of an EIC carries the ENTSO-E **object type**, and the German market
leans on it: a Bilanzkreis is a market party (`11X…`), while a Bilanzierungsgebiet
is an area (`11Y…`). `EicType` exposes all seven types, and the two restricted
newtypes make the roles unswappable at a call site.

```rust
use rubo4e::identifiers::{BilanzierungsgebietId, BilanzkreisId, EicCode, EicType};

let area = EicCode::new("10YDE-EON------1")?;   // TenneT control area
assert_eq!(area.eic_type(), EicType::Area);     // 'Y'

let party = EicCode::new("11XSUEDWESTSTRO8")?;  // a Bilanzkreis
assert_eq!(party.eic_type(), EicType::Party);   // 'X'

// The restricted types pin position 3, so the two cannot be confused.
let bk = BilanzkreisId::new("11XSUEDWESTSTRO8")?;
assert!(BilanzierungsgebietId::new("11XSUEDWESTSTRO8").is_err());

let bg = BilanzierungsgebietId::new("11YN-0000-0001-Q")?;
assert!(BilanzkreisId::new("11YN-0000-0001-Q").is_err());

// The check character is derived, never typed by hand.
assert_eq!(BilanzkreisId::from_prefix("11XSUEDWESTSTRO")?.as_ref(), "11XSUEDWESTSTRO8");

// Widening is infallible; narrowing is checked.
let eic: EicCode = bk.into();
assert!(BilanzkreisId::try_from(eic).is_ok());
```

### OBIS codes (EDIFACT support)

`ObisCode` parses once at construction and stores a **canonical** form, so two
spellings of the same code are equal and hash alike. Value groups are single
octets, as IEC 62056-61 specifies.

```rust
// Standard OBIS codes
let obis = ObisCode::new("1-0:1.8.0")?;       // active energy total
let obis = ObisCode::new("0-0:0.0.0")?;       // C=0 — general metering group (IEC 62056-61)

// Canonicalisation: `&` becomes `*`, and leading zeros are dropped.
assert_eq!(ObisCode::new("1.8.1&255")?,      ObisCode::new("1.8.1*255")?);
assert_eq!(ObisCode::new("01-00:01.08.00")?, ObisCode::new("1-0:1.8.0")?);
assert_eq!(ObisCode::new("01-00:01.08.00")?.as_str(), "1-0:1.8.0");

// Value groups are octets — 256 is not an OBIS value.
assert!(ObisCode::new("1-0:1.8.256").is_err());

// Components are stored, so this neither re-parses nor allocates.
let parts = ObisCode::new("1-0:1.8.0*255")?.components();
assert_eq!((parts.a, parts.c, parts.f), (Some(1), 1, Some(255)));

// PIA item-number form drops the F component.
assert_eq!(ObisCode::new("1-0:1.8.0*255")?.to_pia_string(), "1-0:1.8.0");
```

---

## Multi-version Dispatch

When a storage layer (e.g. PostgreSQL `JSONB`) writes a `bo4e_version` column alongside
BO4E JSON, the idiomatic dispatch pattern is a plain `match`:

```rust
use rubo4e::{v202607, Bo4eObject as _};

fn process_rechnung(
    json: &str,
    bo4e_version: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    match bo4e_version {
        "v202607.0.0" => {
            let r: v202607::Rechnung = serde_json::from_str(json)?;
            // r.schema_version() == "v202607.0.0"
            handle_v202607(r)
        }
        // When v202801 ships, add one arm:
        // "v202801.0.0" => handle_v202801(serde_json::from_str::<v202801::Rechnung>(json)?),
        _ => Err(format!("unsupported schema version: {bo4e_version}").into()),
    }
}
```

This pattern:
- Requires no new rubo4e API — `schema_version()` is already on every BO type via `Bo4eObject`
- Is trivially extensible: each new schema version is one `match` arm
- Localises migration to the storage layer; business logic only handles the current version
- Avoids over-engineering (`trait` objects, `Any*` enums) for a straightforward branch

See [Schema Versioning](https://hupe1980.github.io/rubo4e/docs/versioning/) for the full upgrade workflow.

---

## Convenience API

### Extension traits — flatten `Option<Com>` to `Option<Decimal>`

```rust
use rubo4e::prelude::*;  // brings BetragExt, MengeExt, PreisExt into scope

// Before (v0.3 — two levels of unwrap):
let net = pos.gesamtpreis.as_ref().and_then(|b| b.wert);

// After (v0.4):
let net  = pos.gesamtpreis.wert_decimal();          // Option<Decimal> via BetragExt
let qty  = pos.positions_menge.wert_decimal();      // Option<Decimal> via MengeExt
let unit = pos.einzelpreis.wert_decimal();          // Option<Decimal> via PreisExt
```

### Billing and validity helpers

```rust
use rubo4e::v202607::{Rechnung, PreisblattNetznutzung, Zeitraum};
use time::macros::date;

// Rechnung — closed billing period
if let Some((from, to)) = rechnung.billing_period() {
    println!("Invoice period: {from} – {to}");
}

// Navigate rechnungsperiode fields directly
let start: Option<time::Date> = rechnung.period_start();
let end:   Option<time::Date> = rechnung.period_end();

// Iterate line items
for pos in rechnung.positions() {
    println!("  pos {:?}: {:?}", pos.positionsnummer, pos.gesamtpreis_decimal());
}

// Decimal totals — direct access
let net   = rechnung.gesamtnetto_decimal();    // Option<Decimal>
let tax   = rechnung.gesamtsteuer_decimal();   // Option<Decimal>
let gross = rechnung.gesamtbrutto_decimal();   // Option<Decimal>
let pay   = rechnung.zu_zahlen_decimal();      // Option<Decimal> — final amount due
let disc  = rechnung.rabatt_netto_decimal();   // Option<Decimal> — net discount
let next  = rechnung.zukuenftiger_abschlag_decimal(); // Option<Decimal>
let adv   = rechnung.vorauszahlungen_summe();  // Option<Decimal> — sum of advance payments

// Invoice flags — unwrap_or(false), no Option juggling
if rechnung.is_storno() { /* handle cancellation */ }
if rechnung.is_original() { /* handle original */ }

// Date fields
let due: Option<time::Date> = rechnung.faelligkeitsdatum_date();

// Rechnungsposition — delivery period from embedded Zeitraum
let von: Option<time::Date> = pos.lieferung_von_date();  // reads lieferungszeitraum.startdatum
let bis: Option<time::Date> = pos.lieferung_bis_date();  // reads lieferungszeitraum.enddatum
let in_period: bool = pos.lieferungszeitraum_contains(date!(2026-10-01));

// PreisblattNetznutzung — point-in-time validity check
let valid = preisblatt.is_valid_at(date!(2026-10-01));

// Zeitraum — open/closed range helpers
let closed    = z.as_closed_range();     // Option<(Date, Date)>
let half_open = z.as_half_open_range();  // Option<(Date, Option<Date>)>
let contains  = z.contains(date!(2026-01-15)); // bool — [start, end) half-open
```

---

## JSON Handling

```rust
use rubo4e::json::Bo4eJsonExt;
use rubo4e::v202607::Marktlokation;

let malo: Marktlokation = todo!();

// Serialize
let german     = malo.to_json_german()?;     // {"marktlokationsId":"…","sparte":"…",…}
let snake_case = malo.to_json_snake_case()?; // {"marktlokations_id":"…","sparte":"…",…}
let canonical  = malo.to_json_canonical()?;  // sorted keys, stable for hashing/signing

// Deserialize
let restored = Marktlokation::from_json_german(&german)?;
```

Unknown JSON fields are **preserved through round-trips** via the `_additional`
extension-data map (requires `json` feature). This allows forward-compatible
handling of new BO4E fields without library updates.

The snake_case mapping is an exact table emitted by the code generator, not a
runtime heuristic, so `from_json_snake_case(to_json_snake_case(x)) == x` holds
for every generated type. That is not achievable algorithmically: BO4E names like
`hoechstpreisHT`, `kundengruppeKA`, and `Sigmoidparameter`'s `A`/`B`/`C`/`D`
render to a snake form a heuristic maps back to a *different* camelCase name,
which silently diverts the value into `_additional`. BO4E metadata keys (`_typ`,
`_version`, `_id`) and unknown extension keys pass through byte-for-byte in both
directions. See [Serialization](https://hupe1980.github.io/rubo4e/docs/serialization/#how-snake-case-keys-are-mapped).

### Parsing untrusted input

Preserving unknown fields is a memory-growth surface, so every deserialization
path — hardened or not — caps extension fields at 128 per struct and extension
keys at 256 bytes, and rejects documents nested deeper than 128 levels.

For payloads from outside your trust boundary, the `_hardened` entry points add
four opt-in budgets on top:

```rust
use rubo4e::json::{Bo4eJsonExt, JsonParseLimits};

let malo = Marktlokation::from_json_german_hardened(
    &body,
    JsonParseLimits::untrusted_defaults(),   // 1 MB / depth 64 / 64 KB ext / 32 fields
)?;
```

All four limits are enforced **during** parsing and at **every** nesting level —
extension data buried in a nested COM is charged to the same budget as extension
data on the root — so an oversized payload is rejected while it is being read,
not after the object tree has been allocated. Every limit that fires bumps a
process-wide counter readable via `json_limit_hit_counters()`, exported to the
`metrics` ecosystem when the `metrics` feature is on.

See [Serialization](https://hupe1980.github.io/rubo4e/docs/serialization/#hardened-deserialization-for-untrusted-inputs)
for the exact scope of each limit.

---

## Validation

```rust
use garde::Validate as _;
use rubo4e::validation::Validated;
use rubo4e::v202607::Marktlokation;

// Direct validation — returns garde::Report on failure
let malo: Marktlokation = todo!();
malo.validate()?;

// Type-safe wrapper — only constructible via validation
let validated = Validated::new(malo)?;     // Err(garde::Report) if invalid
let inner: &Marktlokation = &validated;    // Deref to inner type
```

Cross-field rules (e.g. exactly one of `lokationsadresse` / `geoadresse` /
`katasterinformation` must be set) run automatically via `#[garde(custom(...))]`
attributes on the generated types.

---

## OpenAPI / JSON Schema

```rust
// schemars — JSON Schema (requires `schemars` feature)
let schema = schemars::schema_for!(rubo4e::v202607::Marktlokation);

// utoipa — OpenAPI 3.1 (requires `utoipa` feature)
// All identifier types emit pattern, description, and example values:
// MaloId → { type: string, pattern: "^[0-9]{11}$", example: "51238696781" }
```

---

## SQLx Integration

```rust
// Requires the `sqlx` feature — implements Type, Encode, Decode and
// PgHasArrayType for every identifier and every generated enum.
// No `json` feature needed: everything round-trips through &str.

// Bind directly as a typed identifier
sqlx::query("INSERT INTO malo (id) VALUES ($1)")
    .bind(&malo_id)
    .execute(&pool).await?;

// Decode directly — runs the same validation as new()
let id: MaloId = row.try_get("malo_id")?;

// Vec<Id> binds to a TEXT[] column
sqlx::query("SELECT * FROM malo WHERE id = ANY($1)")
    .bind(&malo_ids)
    .fetch_all(&pool).await?;

// Works in FromRow structs too
#[derive(sqlx::FromRow)]
struct MpRow {
    mp_id: MarktpartnerId,
}
```

Identifiers reject invalid values on decode. Enums decode **leniently** —
an out-of-schema string becomes `Unknown`, mirroring the serde path — so use
`from_wire` on a `String` column where that must be an error instead.

---

## Documentation

**[hupe1980.github.io/rubo4e](https://hupe1980.github.io/rubo4e)** — guides and design notes.
**[docs.rs/rubo4e](https://docs.rs/rubo4e)** — per-item API reference.
**[CHANGELOG](CHANGELOG.md)** — release history and upgrade notes.

| Guide | Covers |
|---|---|
| [Architecture]https://hupe1980.github.io/rubo4e/docs/architecture/ | Workspace layout, module tree, feature-gate reference |
| [Identifiers]https://hupe1980.github.io/rubo4e/docs/identifiers/ | Every identifier type, its validation rules, and the BDEW check-digit procedures |
| [Serialization]https://hupe1980.github.io/rubo4e/docs/serialization/ | JSON output modes, extension data, hardened parsing |
| [Validation]https://hupe1980.github.io/rubo4e/docs/validation/ | The three validation layers and `Validated<T>` |
| [Schema Versioning]https://hupe1980.github.io/rubo4e/docs/versioning/ | Version modules, `current`, and the upgrade workflow |
| [Ecosystem]https://hupe1980.github.io/rubo4e/docs/ecosystem/ | sqlx, schemars, utoipa, strum integrations |
| [Code Generator]https://hupe1980.github.io/rubo4e/docs/generator/ | How generation works and how to re-run it |
| [Testing]https://hupe1980.github.io/rubo4e/docs/testing/ | The seven testing layers and how to run each |

The site sources live in [`site/`](site/) and are built with [Zola](https://www.getzola.org).

---

## MSRV

The minimum supported Rust version is **1.88**, declared as `rust-version` in
`Cargo.toml` and verified in CI on every push. MSRV advances only when the
current floor is two stable releases behind, and a bump is a **minor** version
change, never a patch.

The floor is set by the dependency tree rather than by this crate's own source:
`time`, `simd-json`, and `home` (via `sqlx`) all require 1.88. Because Cargo's
default resolver picks the newest semver-compatible dependency without regard to
`rust-version`, a toolchain below the floor fails at *resolution* time with
`rustc 1.87.0 is not supported by the following packages` rather than at compile
time. On an older toolchain, either pin those dependencies back with
`cargo update <crate> --precise <version>` or enable Cargo's MSRV-aware resolver.

---

## License

Licensed under either of [Apache License 2.0](LICENSE-APACHE) or [MIT License](LICENSE-MIT),
at your option.