Domain Model

The BDEW market role model, market objects (MaLo, MeLo, NeLo, NeBe), territories, identifier check-digit rules and their EDIFACT encoding.

On this page 16 sections

This page is the definitive reference for the BDEW Rollenmodell für die Marktkommunikation im deutschen Energiemarkt and all identifier types used across EDI@Energy messages. It is the shared vocabulary of the whole platform — all 17 services speak it — and every developer working on mako will need this material to understand what a MaLo is, why the NB sends UTILMD messages on behalf of the LF, and how to parse a 9900357000004 out of a NAD segment.

Source documents:

DocumentVersionDate
Rollenmodell für die Marktkommunikation im deutschen EnergiemarktV2.22026-01-08
Identifikatoren in der MarktkommunikationV1.22025-02-07
Allgemeine Festlegungen zu den EDIFACT- und XML-Nachrichten6.1d2026-04-01

Market Role Interaction Map

The following diagram shows how the core Marktrollen interact via the key MaKo processes. Gas-only roles (MGV, KN) are omitted for clarity — see the party role table below.

graph LR
    LF["LF<br/>Lieferant"]
    NB["NB<br/>Netzbetreiber"]
    MSB["MSB<br/>Messstellenbetreiber"]
    BKV["BKV<br/>Bilanzkreisverantwortlicher"]
    UNB["ÜNB<br/>Übertragungsnetzbetreiber"]
    BIKO["BIKO<br/>Bilanzkoordinator"]
    EIV["EIV<br/>Einsatzverantwortlicher"]

    LF -->|"GPKE: Lieferbeginn / Lieferende<br/>UTILMD 55001–55018"| NB
    NB -->|"GPKE: Bestätigung / Ablehnung<br/>UTILMD 55002/55003"| LF
    LF -->|"GeLi Gas: Anmeldung<br/>UTILMD 44001–44021"| NB
    NB -->|"WiM: MSB-Wechsel<br/>UTILMD 55039/55042"| MSB
    MSB -->|"WiM: Stammdaten<br/>UTILMD 55168"| NB
    MSB -->|"INSRPT: Ablesesteuerung<br/>INSRPT 23001/23003"| NB
    NB -->|"MSCONS: Messwerte<br/>(MSCONS 13xxx)"| BKV
    NB -->|"INVOIC: NNE/MMM<br/>31001/31002/31005"| LF
    LF -->|"INVOIC: Sperrung AWH Gas<br/>31011"| NB
    NB -->|"MaBiS: Summenzeitreihe<br/>MSCONS 13003"| BIKO
    BIKO -->|"MABIS: Abrechnungsdaten"| BKV
    BIKO ---|"settlement"| UNB
    EIV -->|"Redispatch 2.0<br/>ORDERS/ORDRSP"| NB

Table of Contents

  1. Glossary — the terms the rest of the docs link to
  2. Party Roles (Marktrollen)
  3. Market Objects (Objekte)
  4. Territories (Gebiete)
  5. Identifier Formats
  6. Check Digit Algorithms
  7. EDIFACT Encoding
  8. Rust API
  9. Quantities and money on the wire
  10. Dates and days
  11. Gas Domain — GaBi Gas
  12. The BO4E gate
  13. BO4E extensions: ZusatzAttribut
  14. Further Reading

Glossary

The vocabulary the rest of the documentation links back to. Each term has a stable heading anchor.

Prüfidentifikator (PID)

A five-digit code, 10000..=99999, naming one business transaction inside a message type55001 is the GPKE Anmeldung in a UTILMD, 31009 the MSB-Rechnung in an INVOIC. It is not the message type and not the process: one UTILMD carries dozens of PIDs, and one process is a sequence of them (55001 Anmeldung → 55002 Bestätigung or 55003 Ablehnung). On the wire it rides in BGM DE 1004 for most message types and in SG1 RFF+Z13 for the rest; edi_energy's pid_scan reads both regardless of which one the profile declares, because reading only the declared location makes a conformant partner's message undetectable — and an undetectable message is dropped without an APERAK. Both demand a plausible code, since BGM DE 1004 legitimately holds a Dokumentennummer. edi_energy::Pruefidentifikator validates the range at parse.

The PID is what everything else is keyed on: mako_engine::PidRouter routes it to a workflow, mako_fristen::antwort resolves its answer Frist, and mako-pruefung picks the decision tree the answer must come from. makod currently routes 469 PIDs across 71 workflows — a count services/makod/tests/published_counts.rs holds this page to.

AHB and MIG

Two BDEW documents per message type, and they answer different questions:

AnswersGoverns
MIG — Nachrichtenbeschreibung / Message Implementation Guide„what may this message type contain?"segment order, groups, cardinalities, which DE codes exist
AHB — Anwendungshandbuch„what must this PID contain?"per-PID M/K/X requirements and the conditional expressions on them

A message can satisfy the MIG and violate the AHB: the MIG permits a segment the AHB makes mandatory for one PID and forbids for another. mako stores them as profiles/<type>/<fv>/mig.json and ahb.json, and validates against both.

Their version numbers differ for every message type except UTILMD — an INVOIC MIG 2.1 pairs with an INVOIC AHB 1.0b — so citing „the AHB version" of a release is only meaningful per message type.

EBD — Entscheidungsbaumdiagramm

The published decision trees that say which answer a market participant owes. BDEW prints them as Entscheidungsbaum-Diagramme und Codelisten für die Antwortnachrichten: each tree names a prüfende Rolle, walks numbered Prüfschritte, and lands on a code from its own Codeliste — so the same Antwortcode string means different things in different trees, and a lookup must name the tree (mako_pruefung::codes::lookup).

mako-pruefung is those trees, executable, behind role-* features. The answer rides in SG4 STS+E01 of the outbound UTILMD — the code in DE 9013, the EBD id in DE 1131 — or, for an invoice, in REMADV AJT.

Bilanzkreis and Bilanzierungsgebiet

Both are 16-character ENTSO-E EIC codes and they look alike, which is the trap. They are different objects and ENTSO-E types them differently:

EIC object typeWhat it isLOC
Bilanzkreis (BK)X — Partythe account energy is balanced in, held by a BKV237
Bilanzierungsgebiet (BG)Y — Areathe grid region a Marktlokation balances in107

The German codes are issued on that basis by Energie Codes und Services (EIC functions Balance Group and Metering Grid Area). A Bilanzierungsgebiet consolidates one or more Netzgebiete for settlement, and the synthetic (SLP) or analytical (RLM) method applies uniformly within it. mako_mabis keeps them as separate newtypes so one cannot be passed where the other belongs.

Frist and Werktag

A Frist is a regulatory deadline stated by a Festlegung, resolved per PID by mako_fristen::antwort — never a flat duration. Windows come in four shapes: a wall-clock time on the n-th Werktag after the Übertragungstag (GPKE), the Ablauf of the n-th Werktag (GeLi Gas), a count of Werktage (WiM, NZR-EMob), and a clock time am ÜT itself. An unquantified PID returns Noneunknown, never unbounded.

A Werktag is, per GPKE (BK6-24-174) Teil 1 Kap. 7, „alle Tage …, die kein Samstag, Sonntag oder gesetzlicher Feiertag sind". Saturday is not a Werktag. A holiday observed in any single Bundesland counts nationwide, and 24.12. and 31.12. count as holidays. The count starts on the day of receipt whatever weekday that is; only the Werktage counted skip weekends. The whole calendar lives in mako-fristen, including what „today" means — see Dates and days.

EDIFACT message types

Seventeen types, one profile directory each under crates/edi-energy/profiles/:

TypeCarries
UTILMDStammdaten und Geschäftsvorfälle — the GPKE, GeLi Gas, WiM and MaBiS process messages
MSCONSMesswerte: Lastgänge, Zählerstände, Summenzeitreihen, Allokationslisten
INVOICRechnung — Netznutzung, Mehr-/Mindermengen, Messstellenbetrieb
REMADVZahlungsavis: the payer's answer to an INVOIC — 33001 confirms, 33002–33004 refuse
COMDISthe invoicer's refusal of a REMADV (29001)
ORDERSAuftrag — Sperren/Entsperren, Geräteübernahme, Wertebestellung
ORDRSPAuftragsantwort to an ORDERS
ORDCHGAuftragsänderung / Stornierung of an ORDERS
IFTSTAStatusmeldung — Auftragsstatus, WiM Umsetzungsstatus, MaBiS Datenstatus
APERAKAnwendungsfehler- und Bestätigungsmeldung — the technical acknowledgement
CONTRLSyntaxprüfung — the syntax-level acknowledgement, one layer below APERAK
UTILTSBerechnungsformel und Zählzeitdefinitionen
PRICATPreiskatalog — Preisblätter
QUOTESAngebot
REQOTEAnfrage — the request an Angebot answers
PARTINMarktpartner-Stammdaten (PartnerStore bootstrap)
INSRPTPrüfbericht — Störungsmeldung, Ablesesteuerung

An interchange (UNBUNZ) may carry several messages (UNHUNT) of one type; each message declares its own PID.


Party Roles (Marktrollen)

The market role model defines who the actors are. One company can hold multiple roles simultaneously (e.g., a DSO may also be its own MSB). A distinct MP-ID is required for each role per commodity (Strom / Gas).

Source: RME V2.2 §3.1

Abbr.German NameEnglishStromGasDefinition
LFLieferantEnergy SupplierResponsible for supplying energy to market locations, settling billing with the DSO, and financially compensating the balance between profiled and metered energy quantities.
NBNetzbetreiberDistribution System Operator (DSO)Responsible for grid operation, grid maintenance, and routing of energy. Creates and manages market locations, metering locations, and technical resources within the grid area. Aggregates energy quantities for settlement. Gas: responsible for forwarding metering values to trading partners.
ÜNBÜbertragungsnetzbetreiberTransmission System Operator (TSO)Responsible for transmission grid stability, EEG allocation time-series, and short-term plausibility checks within the control zone. One TSO = one Regelzone. In MABIS: bilateral MSCONS exchange with BKV (PID 13003).
MSBMessstellenbetreiberMetering Point OperatorInstalls, operates and maintains meters. Strom: distributes metered, substitute and preliminary values to authorised partners. Gas: determines and forwards them to the DSO. Three prefixes distinguish the parties to a switch — see below.
BKVBilanzkreisverantwortlicherBalance Responsible Party (BRP)Responsible for the energetic and financial balance within a Bilanzkreis. Counterparty to the BIKO (Strom) or MGV (Gas).
BIKOBilanzkoordinatorBalance CoordinatorResponsible for Bilanzkreisabrechnung (balance-circle settlement) and financial settlement between BKVs. See mako-mabis (PID 13003).
MGVMarktgebietsverantwortlicherMarket Area ManagerResponsible for gas balance circle settlement and procurement/dispatch of balancing energy. Operates the virtual trading hub.
KNKapazitätsnutzerCapacity UserAcquires transport capacity at bookable entry/exit points in the gas entry-exit system and allocates it to Bilanzkreise.
BTRBetreiber einer technischen RessourceTechnical Resource OperatorInstalls, operates, and maintains technical resources (generators, controllable loads). Does not change with DSO ownership transfer.
EIVEinsatzverantwortlicherDispatch Responsible PartyResponsible for deploying controllable resources. Assigns SR-IDs to steuerable resources. Central actor in Redispatch 2.0.
DPData ProviderData ProviderForwards information to authorised trading partners on behalf of the DSO or MSB.
ESAEnergieserviceanbieter des AnschlussnutzersConsumer-side Energy Service ProviderRequests and processes metering data on behalf of the end-customer (Anschlussnutzer). Unlike every other role it holds no Zuordnung to a Marktlokation — see below. Marktrolle::Esa.
RBRegisterbetreiberRegistry OperatorOperates a database for energy market data (e.g., the national Marktstammdatenregister).

The three MSB prefixes name the parties to a Messstellenbetreiberwechsel, not three kinds of company. The gMSB (grundzuständig) is the incumbent — the NB by default, per §41 MsbG. The nMSB (nicht-grundzuständig) is the challenger a customer may switch to. The aMSB (abgebend) is whoever is being replaced in a given switch, which is a role in that transaction: today's nMSB is tomorrow's aMSB. WiM processes are keyed on the pair, so reading the prefix as a property of the company rather than of the switch mis-routes every second Wechsel.

The ESA is the one role with no Zuordnung, and that is its defining feature. Every other market role reaches a Marktlokation through an assignment the NB records. The ESA reaches it through the customer instead: access rests on the Anschlussnutzer's consent (§49 Abs. 2 Nr. 9 MsbG) plus a bilateral contract with the MSB, which §34 Abs. 2 S. 2 Nr. 10 MsbG makes a mandatory, non-discriminatory Zusatzleistung — so the MSB may not refuse the contract, and may not price it discriminatorily. Data obtained this way may be used only in the consumer relationship it was consented for.

Role pairs in key processes

ProcessSender (NAD+MS)Receiver (NAD+MR)Crate
GPKE Lieferbeginn / LieferendeLFNBmako-gpke
GPKE Kündigung LieferbeginnLFLFA (outgoing supplier)mako-gpke
GPKE Antwort LieferbeginnNBLFmako-gpke
GPKE Sperrung / Entsperrung (NB-initiated)NBMSBmako-gpke
GPKE Sperrung / Entsperrung (LF-initiated, Strom)LFNBmako-gpke
WiM GerätewechselMSBNBmako-wim
WiM StammdatenNBLF / MSBmako-wim
GeLi Gas Lieferbeginn / LieferendeLFGNB (gas DSO)mako-geli-gas
GeLi Gas Sperrung / Entsperrung (LF-initiated, Gas)LFGNBmako-geli-gas
WiM Gas Anmeldung / Kündigung gMSBMSB (gas)NB (gas)mako-wim
MABIS SummenzeitreiheÜNBBKVmako-mabis
INVOIC AbrechnungNBLFmako-gpke

Market Objects (Objekte)

Objects are the entities that processes act on. The NB is responsible for creating and closing objects within the grid area and assigning MP identifiers to them.

Source: RME V2.2 §3.2; Allgemeine Festlegungen 6.1d §2.15, §2.19

Abbr.GermanEnglishStromGasDefinition
MaLoMarktlokationMarket LocationThe central billing entity. A point where energy is either produced or consumed, connected to a grid via at least one line. The NB is responsible for creating, managing, and closing MaLo. Identified by MaLo-ID (11-digit numeric).
MeLoMesslokationMetering LocationA location where energy is measured, containing all technical equipment required for measurement and value transmission. One MaLo may have one or more MeLo. Each physical quantity is measured at most once per timestamp. Identified by Zählpunktbezeichnung (VDE-AR-N 4400 Strom / DVGW G2000 Gas).
NeLoNetzlokationNetwork LocationAn interconnection point in a grid area. Connects one or more MaLo to the grid via exactly one line. Used for reactive-power billing (Blindarbeit) and monitoring power-curve limits. Identified by NeLo-ID (11-char alphanumeric, prefix E). Introduced by BNetzA BK6-22-128.
NeBeNetzbereichNetwork ZoneA sub-area within a grid for managing controllable consumption facilities (§14a EnWG). Identified by NeBe-ID (11-char alphanumeric, prefix F). Introduced by BNetzA BK6-22-300 / BK8-22/010-A.
BKBilanzkreisBalance CircleAn account that balances feed-in and consumption quantities, facilitating energy trading. One BKV manages one or more BK.
NKPNetzkopplungspunktGrid Coupling PointA physical point connecting two grid areas.
TRTechnische RessourceTechnical ResourceA physical asset that consumes and/or generates electricity. One TR may be assigned to two MaLo if it both consumes and produces. Identified by TR-ID (prefix D).
SRSteuerbare RessourceControllable ResourceA controllable asset that affects at least one grid connection point. One or more TR are assigned to each SR. Identified by SR-ID (prefix C).
SGSteuergruppeControl GroupA grouping of controllable resources for dispatch purposes. Identified by SG-ID (prefix B).
CRCluster RessourceCluster ResourceA bundle of control groups. Identified by CR-ID (prefix A).

MaLo vs MeLo — the critical distinction

These are not interchangeable. Confusion between them is the single most common domain modelling error in EDI@Energy implementations:

AspectMaLoMeLo
What it modelsCommercial supply point (billing)Physical measurement device location
Who manages itNB — registers and closesMSB — installs and operates the meter
How many per location1 per supply relationship1..n per MaLo
Identifier typeMaLo-ID (11-digit numeric)Zählpunktbezeichnung (33-char Strom / 11-char Gas)
EDIFACT contextUTILMD IDE+Z01, INVOICMSCONS, UTILMD (WiM)
Rust typemako_engine::types::MaLomako_engine::types::MeLo

Business scenario: When a consumer switches supplier (GPKE Lieferbeginn), the LF sends a UTILMD referencing the MaLo-ID. When the MSB reads the meter and sends measurements, those go in a MSCONS referencing the MeLo-ID (Zählpunktbezeichnung). They refer to the same physical site but are structurally different objects in the BDEW role model.


Territories (Gebiete)

Territories are spatial containers. Each DSO operates one or more grid areas. Each TSO operates one control zone.

Source: RME V2.2 §3.2

Abbr.GermanEnglishStromGasDefinition
NGNetzgebietGrid AreaA metrologically bounded area within a market area (Gas) or control zone (Strom). May span multiple voltage/pressure levels. Operated by the NB.
BGBilanzierungsgebietBalancing ZoneOne or more grid areas consolidated for settlement purposes. The synthetic (SLP) or analytical (RLM) balancing method is applied uniformly within a BG.
MGMarktgebietMarket AreaAggregation of gas transport networks sharing a virtual trading hub operated by the MGV.
RZRegelzoneControl ZoneA bounded area within which one TSO (ÜNB) is responsible for frequency and voltage stability. Each ÜNB operates exactly one RZ.

Identifier Formats

All market identifiers are:

  • Immutable once assigned — a MaLo-ID does not change when the DSO changes ownership
  • Centrally issued by bdew-codes.de (Strom) or codevergabe.dvgw-sc.de (Gas)
  • Locally assigned to objects by the responsible code holder (NB in most cases)

Source: Identifikatoren in der Marktkommunikation V1.2 (BDEW, 2025-02-07)


MP-ID — Marktpartner (13 digits)

Identifies a trading partner in a specific market role and commodity. One company holds one MP-ID per role per Sparte.

PositionsLengthContent
1–22Issuer + commodity: 99 = BDEW/Strom, 98 = DVGW/Gas
31Issue mode: 08 (BDEW), 9 (DVGW)
4–129Sequence number
131Check digit (Lok- und Waggon-Kennzeichnungsverfahren)

Alternative: GLN (GS1, 13 digits). When the code holder uses a GS1-issued Global Location Number, the GS1 check-digit algorithm applies (EAN-13).

EDIFACT DE3055 qualifier — three code schemes are in active use, and the qualifier is what says which:

SchemeShapeDE 3055Typical holders
BDEW- or DVGW-Codenummer13 numeric293LF, NB/VNB, MSB, BKV — the dominant scheme
GS1 GLN13 numeric9the global GS1 scheme; rare in German MaKo
ENTSO-E EIC16 alphanumeric305ÜNB, Regelzonen, cross-border

MarktpartnerCode stores the value without the agency qualifier; edi_energy::AgencyCode::for_mp_id derives the right one when rendering an outbound NAD segment.

Segments: UNB DE0004 (sender), UNB DE0010 (recipient), NAD DE3035 = MS (message sender), NAD DE3035 = MR (message recipient).

Databases:


MaLo-ID — Marktlokation (11 digits)

Identifies a supply point (electricity or gas — same pool) for the life of the market location. The first digit indicates which code authority issued the ID but does not indicate the Sparte (Strom or Gas).

PositionLengthContent
11Issuer: 49 = BDEW, 13 = DVGW
2–109Sequence number (auto-assigned)
111Check digit (Lok- und Waggon-Kennzeichnungsverfahren)

Who assigns: NB (network operator), who requests blocks of MaLo-IDs from bdew-codes.de or codevergabe.dvgw-sc.de.

Key rule: The same MaLo-ID identifies the location regardless of whether the grid was transferred to a new DSO — the NB keeps the ID.

Examples: 51238696012, 40130000551


MeLo-ID — Messlokation (Zählpunktbezeichnung)

The metering location identifier is the Zählpunktbezeichnung (metering code). It is not covered by the Identifikatoren AWH — format is defined by technical standards:

CommodityStandardTypical lengthFormat description
StromVDE-AR-N 4400 §6 (MeteringCode)33 charactersCountry code (2) + issuer (11) + sequence + check char
GasDVGW G200011 charactersIssuer code + sequence

Strom example: DE0000123400007002500000000001234
(DE = Germany; remainder identifies the DSO grid area and metering point sequence)

Segments: Referenced in MSCONS LOC, UTILMD WiM IDE+Z01, ORDERS/ORDRSP LOC.


NeLo-ID — Netzlokation (11 chars)

Strom only. Issued since 15 February 2023 (per BNetzA BK6-22-128). Used for reactive-power billing and power-curve limit monitoring.

PositionLengthContent
11Type code: always E
2–109Alphanumeric sequence (A–Z, 0–9), auto-assigned
111Check digit (ASCII-Verfahren)

Issuer: bdew-codes.de only.


NeBe-ID — Netzbereich (11 chars)

Strom only. Issued since 20 February 2025 (per BNetzA BK6-22-300 / BK8-22/010-A). Used to classify controllable consumption facilities under §14a EnWG.

PositionLengthContent
11Type code: always F
2–109Alphanumeric sequence (A–Z, 0–9), auto-assigned
111Check digit (ASCII-Verfahren)

Issuer: bdew-codes.de only.


Ressourcen-ID — TR / SR / SG / CR (11 chars)

Used in Redispatch 2.0 and Netzbetreiberkoordination. Four sub-types share one format, distinguished by the first character.

PositionLengthContent
11Type code: A = Cluster Ressource, B = Steuergruppe, C = Steuerbare Ressource, D = Technische Ressource
2–109Alphanumeric sequence (A–Z, 0–9), auto-assigned
111Check digit (ASCII-Verfahren)

Who assigns: NB assigns TR-IDs and SG-IDs; EIV assigns SR-IDs. Issuer: bdew-codes.de.


Paket-ID — Netzbetreiberwechsel (11 chars)

Identifies a bundle of market locations affected by a DSO ownership transfer (Netzbetreiberwechsel). Used in PARTIN messages.

PositionLengthContent
11Type code (assigned by Vergabestelle)
21Sub-type (assigned by Vergabestelle)
3–108Sequence number (auto-assigned)
111Check digit (ASCII-Verfahren)

Check Digit Algorithms

BDEW names two procedures, and they are the same arithmetic over different character sets. Weight the base by position, sum, and take the difference to the next multiple of ten.

Source: Identifikatoren V1.2 §8.1 and §8.2

  1. Map each character of the base to a number. Digits map to themselves; under §8.2 an uppercase letter maps to its ASCII value (A = 65 … Z = 90).
  2. Sum the values at odd 1-indexed positions (1, 3, 5, …).
  3. Sum the values at even positions and multiply that sum by 2.
  4. Check digit = the difference from (2) + (3) to the next multiple of ten — (10 - (total mod 10)) mod 10, so a total that is already a multiple of ten yields 0.
§NameApplies toBase
8.1Lok- und Waggon-KennzeichnungsverfahrenBDEW-Code, DVGW-Code, MaLo-IDnumeric
8.2ASCII-VerfahrenNeLo-ID, NeBe-ID, Ressourcen-ID, Paket-IDalphanumeric

Not Luhn, despite the wagon name: no "subtract 9" reduction, and the doubling falls on the even positions rather than alternating from the left.

BDEW's two worked examples, which rubo4e asserts:

§8.1  4137355924  →  (4+3+3+5+2) + (1+7+5+9+4)*2 = 69  →  70−69 = 1  →  41373559241
§8.2  A113735592  →  A = 65, same steps                            →  A1137355925

Never derive one by hand — makotest exposes malo_from_base and mp_id_from_base over the implementation the platform validates with.

An MP-ID in a fixture is deliberately not check-digit-valid

A check-digit-valid MP-ID is an assigned one: publication in the BDEW resp. DVGW code database is what makes an MP-ID usable (Identifikatoren V1.2 §2.1), so a valid code names a real company. Fixtures, demos and examples therefore use codes satisfying neither procedure — makotest id 9900357000004 reports neither, which is the expected answer.

Nothing in mako validates an MP-ID check digit: §2.3 defines two procedures and the prefix does not decide which applies, so MarktpartnerId::new checks thirteen decimal digits and stops. MaLo-IDs and EIC codes are the other case — metering::MaloId and meterstore::encode::parse_malo refuse a bad one at the parse, so cargo xtask check-malo-ids holds every literal valid. Enforce validity where something validates.


EDIFACT Encoding

Key segment positions where identifiers appear in EDI@Energy messages.

Source: Allgemeine Festlegungen 6.1d §2.13, §2.15, §2.16, §2.19

Interchange level (UNB)

DERoleContent
UNB DE0004Sender (Absender)MP-ID of the transmitting party
UNB DE0010Receiver (Empfänger)MP-ID of the receiving party

The MP-ID in UNB must be identical to the MP-ID in the corresponding NAD+MS / NAD+MR segment in the enclosed messages. Mismatches are rejected.

Message level (NAD segment, DE3035)

QualifierRole
MSMessage sender (Nachrichtenabsender) — always the same party as UNB DE0004
MRMessage receiver (Nachrichtenempfänger) — always the same party as UNB DE0010

Code scheme (DE 3055): 293 BDEW- or DVGW-Code, 9 GS1 GLN, 305 ENTSO-E EIC — see MP-ID.

Market / metering location — SG5 LOC, not IDE

IDE does not name the object. Its DE 7495 has exactly two values — 24 for a Vorgang and Z01 for a Liste — and its DE 7402 carries the Vorgangsnummer, not a location. The object a Vorgang is about is named one segment group down, in SG5 LOC: DE 3227 is the qualifier and DE 3225 the identifier value.

LOC DE 3227ObjectDE 3225 format
Z15MaBiS-Zählpunkt33-char Zählpunktbezeichnung
Z16Marktlokation (MaLo)11-digit MaLo-ID
Z17Messlokation (MeLo)33-char Zählpunktbezeichnung
Z18Netzlokation (NeLo)11-char NeLo-ID, prefix E
Z19Steuerbare Ressource (SR)SR-ID, prefix C
Z20Technische Ressource (TR)TR-ID, prefix D
Z21TrancheTranchen-ID
Z22Ruhende Marktlokation11-digit MaLo-ID (§ 20 Abs. 1d EnWG / § 10c EEG)
172Meldepunkt — the Gas qualifiersee below

The Rust mirror is edi_energy::Lokationstyp, whose qualifier_code() / from_qualifier_code() are the only place these strings are written. The codes above are the ones the MIG lists for LOC DE 3227 (Strom S2.2 Zähler 0330 Nr. 00046–00053; Gas G1.2 likewise).

Gas does not use Z16/Z17. UTILMD AHB Gas G1.1/G1.2 uses one qualifier, LOC+172 Meldepunkt, for every Lokation and distinguishes Marktlokation from Messlokation by the format of DE 3225 — an 11-digit MaLo against a 33-char Zählpunktbezeichnung — rather than by the qualifier. A parser keying on the qualifier alone reads every Gas Meldepunkt as neither.

MSCONS SG6 LOC — a different set, and a dangerous pair

MSCONS carries three LOC qualifiers whose values the MIG leaves as free text, so length is the only thing separating two of them:

LOC DE 3227ObjectDE 3225 format
172Meldepunkt — the point a Summenzeitreihe is filed under33-char Zählpunktbezeichnung
107Bilanzierungsgebiet16-char EIC, object type Y (Area)
237Bilanzkreis16-char EIC, object type X (Party)

A message that puts the Bilanzierungsgebiet EIC in LOC+172 parses, validates and is accepted by the BIKO, which then files the series against the wrong Meldepunkt — and nothing downstream can tell that apart from a correct submission. mako_mabis::MabisZaehlpunktId and mako_mabis::BilanzierungsgebietId are separate newtypes for exactly that reason: the 33-character constructor rejects a 16-character EIC before it reaches the wire, and passing one where the other belongs is a compile error.

Inbound commands deliberately keep a plain String. A counterparty's malformed Meldepunkt has to be representable before the workflow can reject it properly; parsing into a type belongs on values mako produces.

File naming convention

Per Allgemeine Festlegungen 6.1d §2.12:

{type}_{anwendungsref}_{sender-MP-ID}_{receiver-MP-ID}_{yyyymmdd}_{DAR}.txt

Example: UTILMD__9900123400007_4012345393651_20261001_A177.txt


Rust API

mako-engine::types provides typed newtypes that prevent cross-domain identifier confusion at compile time.

use mako_engine::types::{
    MaLo,           // Marktlokations-ID
    MeLo,           // Messlokations-ID (Zählpunktbezeichnung)
    MarktpartnerCode, // MP-ID: BDEW-Code (99...), DVGW-Code (98...), or GLN
    BkvId,          // Bilanzkreisverantwortlicher-ID
    UenbId,         // Übertragungsnetzbetreiber-ID (ÜNB)
    BikoId,         // Bilanzkoordinator-ID (BIKO)
    DeviceId,       // Geräte-ID / Zählernummer (WiM MSB processes)
};

// --- Market location (MaLo) ---
// 11-digit numeric; first digit 4-9 = BDEW-issued, 1-3 = DVGW-issued
let malo: MaLo = MaLo::new("51238696012");   // starts with 5 = BDEW

// --- Metering location (MeLo) = Zählpunktbezeichnung ---
// 33-char Strom (VDE-AR-N 4400) or 11-char Gas (DVGW G2000)
let melo: MeLo = MeLo::new("DE0000123400007002500000000001234"); // Strom

// --- Market participant (MP-ID) ---
// 13-digit numeric; 99... = BDEW/Strom; 98... = DVGW/Gas
let nb:  MarktpartnerCode = MarktpartnerCode::new("9900357000004"); // BDEW-Code, NB
let lf:  MarktpartnerCode = MarktpartnerCode::new("9900357000011"); // BDEW-Code, LF

// --- Balance-circle roles (MABIS) ---
let bkv:  BkvId  = BkvId::new("9900357000004");   // Bilanzkreisverantwortlicher
let uenb: UenbId = UenbId::new("4012345000023");   // ÜNB
let biko: BikoId = BikoId::new("9900357000005");   // Bilanzkoordinator

// --- WiM MSB device identifier ---
// Format depends on meter type and MSB; not standardised as a BDEW ID format
let device: DeviceId = DeviceId::new("1EMH0012345678");

All types:

  • Wrap Box<str>immutable, 1-word smaller than String, no heap realloc
  • Implement Serialize / Deserialize as transparent JSON strings
  • Implement Display, AsRef<str>, From<String>, From<&str>
  • Are not validated at construction — validation happens at the EDIFACT parsing boundary in edi-energy. The type system enforces that a MaLo cannot be passed where a MeLo is expected; the format correctness is a runtime concern of the adapter layer.

Why no validation in the constructor?
The identifier format standards themselves allow for future extensions. Validating at construction would require bumping the library version every time BDEW extends a format. Format validation belongs at the boundary where untrusted input enters the system — i.e., in the EDIFACT parser adapters, not in the typed wrappers.

Sparte across layers — deliberately distinct enums

Six Sparte enums coexist — five of mako's own plus the one in the external metering crate. This is by design, not duplication: each models a different domain with a different legal variant set, and two of the serde casings are load-bearing wire formats:

EnumVariantsWhy
metering::Sparte (external)Strom, Gas, Waerme, WasserPhysical metering commodities — heat/water submetering under HeizkostenV is metered but is not market communication
mako-engine::types::SparteStrom, GasMaKo message routing — selects the WiM APERAK Frist (5 vs 10 Werktage); serialized lowercase in stored process events
mako-markt::SparteStrom, GasMaKo master data — a Marktlokation exists only for Strom/Gas; serialized SCREAMING_SNAKE at the REST boundary
grid-billing::SparteStrom, GasLegal-reference selector (StromNEV vs GasNEV) — regulated grid settlement has no Waerme/Wasser
mako-pruefung::msb::types::SparteStrom, GasSelects the Entscheidungsbaum a WiM answer is read against — the trees differ per Sparte, so this is the discriminator a walk takes
mako-nbw::SparteStrom, GasSelects the Netzbetreiberwechsel Anwendungshilfe — the two publish different Fristenkalender, and Gas has no Paket-ID at all

Adding Waerme/Wasser to the MaKo-side enums would create invalid states (a Wasser MaLo cannot exist), and unifying the serde casings would break stored event streams and the public REST contract. The same reasoning applies to the SQL CHECKs: marktd restricts sparte to STROM/GAS, while edmd also accepts WAERME/WASSER for submetering series.


Quantities and money on the wire

A Decimal is a JSON string, in both directions. {"arbeitspreis_ct_per_kwh": "32.0"}, never 32.0.

Serde's data model has no arbitrary-precision number, so a JSON number has already been through f64 before any deserializer sees it. Two things are gone by then and nothing downstream can recover them: the scale (119.00 arrives as 119) and precision past ~15 significant digits. For a billed quantity or a unit price that is the same objection that makes the storage column NUMERIC(18,5) — a settled energy figure is a Buchungsbeleg (§ 147 AO / GoBD) and must be exact.

rust_decimal/serde-str is therefore enabled on the workspace pin, not inherited from a dependency that happens to turn it on. Cargo unifies features per build graph: taken from metering/serde, the rule would apply to a workspace build and not to cargo build -p billingd, so a service would accept in production the float its own workspace test run refuses.

Two deliberate exceptions, both on inbound-only surfaces:

SurfaceBehaviourWhy
MCP tool arguments (DecimalArg)string or numberA model emits a number; the value is re-read from the number's own digits, not through f64
BO4E payloads (rubo4e)string or numberBO4E-python writes "119.00", go-bo4e writes 119.00; the standard settles neither. mako always emits the string

Rounding is kaufmännisch (DIN 1333)

rust_decimal::Decimal::round_dp rounds half to even, and so does Decimal::round. German commercial practice, the EN 16931 / XRechnung validation ecosystem and every BDEW settlement figure round half away from zero (DIN 1333) — the strategy RoundingStrategy::MidpointAwayFromZero names, and the one every billing::Amount operation already applies internally.

The two modes agree everywhere except exact midpoints. That sounds rare and is not: a unit price quoted in ct with three decimals against a whole-kWh quantity lands on a half-cent routinely, so the wrong mode misstates a cent on a real invoice while passing every test written against ordinary numbers. It also propagates — a document's stated total and a consumer recomputing it from the positions must round the same way, or a correct document fails its own conformance check.

dec!(12.345).round_dp(2)   // 12.34 — half to even
dec!(12.345).round_kfm(2)  // 12.35 — half away from zero

Away from zero rather than literal half-up, so a Storno reverses to the same magnitude it booked: round(-0.005) = -0.01 mirrors round(0.005) = 0.01.

cargo xtask check-rounding refuses a bare round_dp(, a bare Decimal::round() — which rounds to an integer the same way, and is how a EUR amount reaches the ledger as cents — and any RoundingStrategy other than MidpointAwayFromZero, workspace-wide, tests included. f64::round is half away from zero already and is left alone. The billing crates define a local RoundMoney::round_kfm(dp) over the explicit form; everywhere else states the strategy inline.

Dates and days

Three different things in this domain are called a "day", and confusing any two of them is silent — the value looks right, and it is wrong by an hour or by a date.

KindWhat it isWhere it comes from
Instantan absolute point in time: a meter interval, an event timestamp, a delivery attemptOffsetDateTime, stored as TIMESTAMPTZ
Calendar daythe German civil day, 00:00 → 00:00 Europe/Berlin: a Lieferbeginn, a Rechnungsdatum, the day a Frist starts, an electricity billing periodmako_fristen::{heute, berlin_date, berlin_midnight}
Gastag06:00 → 06:00 Europe/Berlin, the day gas balances on (Art. 3 Nr. 6 VO (EU) 312/2014, KoV; DVGW G 2000 § 3.2)mako_gabi_gas::GasDay

Instants are never converted for storage or comparison — they are absolute. The other two are local, and both survive DST: a calendar day is 23, 24 or 25 hours long, and so is a Gastag.

A business date is a Berlin date

OffsetDateTime::now_utc().date() answers the UTC calendar date, which is still yesterday between 23:00 Berlin and midnight (22:00 in summer). SQL current_date answers the session time zone's date, which on a UTC server is the same thing. Either one, used for a business date, is off by one for an hour every night — dating an invoice into the previous month, starting a Frist a day early, or selecting the outgoing Formatversion for an hour after a cutover.

A calendar component off the clock is the same mistake, and the form it usually hides in: now_utc().year() for an Abrechnungsjahr, now.day() for the day-of-month cohort an Abschlagslauf raises. A binding in between changes nothing.

mako-fristen is the one answer, and every service reads it from there:

use mako_fristen::{heute, berlin_date, berlin_midnight};

let today = heute();                       // the German calendar date, now
let d     = berlin_date(some_instant);     // the German calendar date of an instant
let start = berlin_midnight(d);            // when that day begins, as a UTC instant

Every schema defines the SQL counterpart, so a comparison written in SQL agrees with one written in Rust however the connection was opened:

CREATE OR REPLACE FUNCTION heute() RETURNS date
    LANGUAGE sql STABLE
    AS $$ SELECT (now() AT TIME ZONE 'Europe/Berlin')::date $$;

just check-business-dates refuses the UTC idioms across the workspace — the inline reads, the ones that go through a local binding, and the SQL forms (current_date, now()::date, extract(… FROM now()), to_char(now(), …)). now() on its own is untouched: an instant is absolute, and TIMESTAMPTZ NOT NULL DEFAULT now() is how one is stamped.

A timestamp on the EDIFACT wire is not a business date — Allgemeine Festlegungen §3 puts EDIFACT times in UTC (DTM format 303 fixes DE 2380 to +00), and the receiver converts to gesetzliche deutsche Zeit before counting a Frist. The modules encoding those values are exempt from the check by path; nothing else is.

A library does not read the clock. edi-energy resolves a Formatversion against a date the caller states — ParseConfig::with_reference_date, validate_on_date, ProcessContext::for_date — and disambiguates nothing by date when none is given. A Formatversion takes effect at German midnight, so „which day is it" decides which profile applies, and a parser is the wrong place to decide it. makod supplies mako_fristen::heute() at its validation gates.

Periods are tiled from Berlin midnights

A billing period, a Bilanzierungsmonat and a § 41a Lastgang window are runs of German calendar days, so their bounds are [berlin_midnight(from), berlin_midnight(to.next_day())). A window of UTC midnights sits an hour into the German day: it drops the first four quarter-hours of the period and picks up four belonging to the next one.

A gas quantity is not settled on that grid. Use GasDay::start_utc / end_utc for anything booked against a Gastag — it is six hours later in every season, so a calendar-day window mis-books a quarter of each day.

Gas Domain — GaBi Gas

The mako-gabi-gas crate provides a dedicated domain vocabulary for the German gas market, in domain.rs (the measured quantities) and portfolio.rs (the BKV portfolio aggregation — GasMarketRole, PortfolioPosition, GasPortfolioBalance). All energy quantities use Decimal — no float arithmetic (DVGW G 685 requires ≥ 3 decimal places).

GasDay — typed gas market day

The German gas day is defined by DVGW G 2000 §3.2: it starts and ends at 06:00 CET (Central European Time), which is UTC-offset aware:

SeasonLocalUTC
Winter (CET, UTC+1)06:00 CET05:00 UTC
Summer (CEST, UTC+2)06:00 CEST04:00 UTC

DST transitions produce 23-hour (spring forward) or 25-hour (fall back) gas days. The day-ahead nomination deadline mako assumes is D-1 13:00 CET. KoV XV sets no clock time for the nomination cycle — that is the FNB's Netzzugangsbedingungen, harmonised across Europe — so mako treats a breach as an operational alert rather than a documented Fristverletzung.

let day = GasDay::new(date!(2026-01-15));
assert_eq!(day.start_utc().hour(), 5);          // 05:00 UTC (CET winter)
assert_eq!(day.duration_hours(), 24);
assert_eq!(GasDay::new(date!(2026-03-28)).duration_hours(), 23); // spring-forward day
assert_eq!(GasDay::new(date!(2026-10-24)).duration_hours(), 25); // fall-back day

GasBeschaffenheit + GasQuantity

The DVGW G 685 conversion formula:

$$kWh_{Hs} = m^3 \times H_s \times Z$$

let beschaffenheit = GasBeschaffenheit {
    brennwert_hs_kwh_per_m3: dec!(10.55),  // Abrechnungsbrennwert from MSCONS PID 13007
    zustandszahl: dec!(0.9764),             // pressure/temperature correction
    quality_class: GasQualityClass::HGas,
    ..
};
let q = GasQuantity::from_m3(dec!(100), beschaffenheit);
assert_eq!(q.energy_kwh_hs, dec!(1030.102));  // rounded to 3 decimal places

Gas quality classes per DVGW G 260:

ClassHs range (kWh/m³)Usage
H-Gas9.5–13.1Most German transmission grids
L-Gas7.5–10.3Parts of northern Germany
BiogasvariableInjected biomethane

AllocationVersion — §§46/47 KoV XV correction tracking

ALOCAT messages may be sent as initial, corrected, or final allocations:

VariantMeaning
InitialFirst ALOCAT for this gas day — preliminary
Correction(n)nth corrected allocation (1-based)
FinalBinding for imbalance settlement — no further corrections

Gas identifier formats

IdentifierFormatStandardExample
EIC (BKV / FNB / MGV)16 chars alphanumericENTSO-E EIC code21X000000001368W
Bilanzkreis-EIC16 chars, object type X (Party)ENTSO-E EIC code11XSUEDWESTSTRO8
Bilanzierungsgebiet-EIC16 chars, object type Y (Area)ENTSO-E EIC code10YDE-EON------1
DVGW-Codenummer (NB)13 digits, starts 98DVGW registry9800357000001
BDEW-Codenummer (LF)13 digits, starts 99BDEW registry9900357000004
Gas Zählpunkt (MeLo)11 charsDVGW G 2000DE000123400M

The BO4E gate

BO4E's machine-readable schema constrains almost nothing. Of the 35 Geschäftsobjekte at v202607.1.0, exactly two declare a required field — Lastgang and Tarif — and not one declares a oneOf, anyOf or not. A payload that satisfies Marktlokation.json can be an empty object. Every rule the standard actually has lives in prose: in a field description, in a class docstring, in a comment above three fields in the reference implementation.

So "it deserialises as a Marktlokation" is not validation, and mako does not treat it as such. Accepting a BO4E document is four decisions, and mako_markt::bo4e::decode is all four, once, for every endpoint:

StageRefusescode
1. Discriminatora Zaehler posted to the Geraet endpoint. _typ is injected when absent — the endpoint already fixes which BO it takes — and read off the type's own Default, so it cannot drift from what the type serialisesbo4e.discriminator
2. Schemaa value the type cannot holdbo4e.schema
3. Strict enums"sparte": "STROMM", at any depth, reported by JSON-path (rubo4e's Bo4eStrict::ensure_known_enums)bo4e.unknown_enum
4. BO4E rulesa document the standard's prose forbidsbo4e.rule

Stage 3 is the one that most needs to be unmissable, because BO4E's forward compatibility cuts both ways. Every BO4E enum carries an Unknown catch-all, so an unrecognised value decodes rather than failing — and Unknown serialises back as the literal string "UNKNOWN". At an endpoint that stores the canonical round-trip rather than the request body, a typo is therefore not merely accepted: the value the caller sent is overwritten by a marker meaning "something this build did not recognise". Eight write paths were missing this stage, and the ones that canonicalise are where it did that.

Stage 4: the rules BO4E states and enforces nowhere

Rules that live only in prose still have to be run. rubo4e's .validate() descends the whole tree and reports each failure at its path; mako's own module holds the two it does not check:

RuleSourceChecked by
marktlokation Ortsangabe„Es darf immer nur eine Art der Ortsangabe vorhanden sein (entweder eine Adresse oder eine GeoKoordinate oder eine Katasteradresse)" — at most one. A location reference (a Marktlokation embedded in a Rechnung to say which location it settles) carries none, and is conformantrubo4e
messlokation Ortsangabethe same, with messadresserubo4e
zeitraum states a period„Es muss daher eine der drei Möglichkeiten angegeben sein" — dauer, or start/enddatum, or start/enduhrzeitrubo4e
zeitraum orderingboth bounds documented inclusive, so a one-day period has start == endrubo4e
rechnung.gesamtbruttogesamtbrutto: „Die Summe aus Netto- und Steuerbetrag"rubo4e
rechnung.steuerbetraegesteuerbetraege: „die Summe dieser Beträge ergibt den Wert für gesamtsteuer"rubo4e
currency agreementthe premise of both sums: net and tax denominated differently have no grossrubo4e
vertrag / bilanzierung dates, kostenposition line totalsfield descriptionsrubo4e
rechnung.gesamtnettogesamtnetto: „Die Summe der Nettobeträge der Rechnungsteile"mako
rechnung.stornoistStorno: „im Falle 'true' findet sich im Attribut 'originalrechnungsnummer' die Nummer der Originalrechnung"mako

Both sides report in the same shape — rubo4e's own ValidationFailure { path, message } — so the stage returns one list and a caller never has to know which side found what.

At most one Ortsangabe, not exactly one. All three fields are optional, and a location reference carries none of them by design.

A rule earns a place only if BO4E asserts it. The rules apply to documents mako receives, so enforcing more than the standard does would reject a conformant counterparty. Endpoint requirements ("this endpoint needs a sparte") are a separate layer, stated at the endpoint that has them.

Money is compared at the scale of the stated total, with the cent as the floor. A position vector carried at six decimals sums to a figure no invoice states; demanding exact equality would reject the whole real-world corpus.

Received and emitted

Two call sites need something other than all four stages, and each says why:

  • decode_received — a market document from a counterparty. Stages 1–3 still refuse, because a document that will not type has nothing to adjudicate. The rules deliberately do not refuse: an invoice whose gesamtbrutto is not net plus tax is disputable, and the market's answer is a REMADV naming the defect (invoic-checker stage 3 already produces it). Refusing to parse would replace that answer with silence and a dead letter.
  • ensure_conformant — the outbound gate: stages 3 and 4 on a value mako built (the first two are the compiler's job there), plus the outbound-only field check. mako never sends a document it would refuse to receive. It runs in tests over every shape the billing engines emit, and at runtime at every emission site — the Sammelrechnung and Korrekturrechnung, the VPP and EEG Gutschriften, the self-issued INVOIC 31006, the Redispatch-Kostenblatt. A fixture test covers the shapes a builder produces; the gate's rules are arithmetic over the values a request supplies.

The gate is in the type — Bo4e<T>

decode is one function call, and the defect it kept producing is that somebody did not make it. A request struct declaring

/// Full BO4E `Vertrag` payload.
pub vertrag: serde_json::Value,

is a document nothing checks: wrong _typ, out-of-schema enums, arbitrary nesting depth, none of the rules — and what gets stored is the request body, not the gate's canonical round-trip. Nothing in the type system distinguishes such a field from one whose handler decodes it, and a handler that does call decode can still store the unvalidated body afterwards.

mako_markt::bo4e::Bo4e<T> moves the decision into the type:

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpsertRequest {
    pub sparte: Sparte,
    /// Full BO4E `Marktlokation`.
    pub data: Bo4e<Marktlokation>,
}

serde runs all four stages while it deserialises the request. There is no constructor that skips them — Deserialize is the only way in from untrusted JSON, and it is decode. The handler reads &*field for the typed BO and field.canonical_json() for what to store; a value mako built in Rust enters through Bo4e::from_built, which is honest about being a different thing.

The rejection survives serde: a Deserialize impl carries a string and nothing else, so the impl appends the machine-readable object behind a sentinel byte and mako_service::Json — the request extractor every handler uses — lifts it back into the problem body. A 422 from Bo4e<T> renders the same code / paths / failures keys as one from a hand-written decode call.

cargo xtask check-request-bodies refuses a serde_json::Value field whose name is a BO4E type's own — vertrag, kosten_json, standort_adresse, geschaeftspartner — in any request body. The exemption list is empty.

A request body refuses what it cannot store

serde ignores a key no field declares, so a request naming a field the API does not have succeeds and the value goes nowhere. demos/o2c posted a customer as {"vorname": "Erika", "nachname": "Mustermann", "strasse": …} to an endpoint that has none of those fields — it takes a BO4E geschaeftspartner — and got a 201. The customer was created with no name and no address, the invoice named nobody, and the demo passed.

Every Json<T> request body carries #[serde(deny_unknown_fields)], so the same mistake is a 422 naming the field. The same guard enforces it, with eight documented exemptions, each argued in writing: IssueDocumentRequest and RenderApiRequest carry a #[serde(flatten)] field, which serde refuses to combine with it; SmgwTyp2Push carries a payload BSI TR-03109 defines rather than mako; and five BDEW Energy API schemas — IdentificationParameter, MaloIdentResultPositive (the energy-api model and its client-side mirror), MaloIdentResultNegative and ApiRecord — belong to the BDEW, whose forward compatibility is not mako's to redefine by refusing a counterparty's whole document over one added field.

A nested value is not a third case. A COM or standalone BO read out of the extension map of the object that carried it — ZeitvariablePreisposition under a PreisblattMessung, Standorteigenschaften under a Messlokation — crosses the same decode, which injects an absent _typ rather than demanding it (BO4E marks the field required on no schema). A _typ that is present and wrong is still refused, because nothing downstream catches one: ensure_known_enums walks a value's fields and never reaches typ itself, so a nested {"_typ": "MARKTLOKATION"} would otherwise decode to typ: Some(Unknown) and go back out as the literal string "UNKNOWN".

A BO4E Rechnungsposition is a net supply line: tax lives in steuerbetraege/gesamtsteuer and advances in vorauszahlungen/zuZahlen, so emitting either as a position states the amount twice and leaves gesamtnetto irreconcilable. BillingPosition::is_rechnungsposition is the shared predicate for the BO4E and EN 16931 mappings and for billingd's Sammelrechnung position index.

One extra rule, outbound only

rubo4e::validation::current::quality holds rules that crate considers sensible and BO4E does not state, kept out of .validate() so a consumer can assert "this conforms to BO4E" without also asserting "…and satisfies rubo4e". That is right for an inbound gate and backwards for an outbound one, where mako controls the document.

rechnung_totals_are_complete — state all three invoice totals or none — therefore runs in ensure_conformant and nowhere else. Every mako engine already emits all three, so it pins that rather than requesting it.

BO4E extensions: ZusatzAttribut

BO4E cannot model everything mako bills for, and its answer is ZusatzAttribut — a {name, wert} pair on every BO and most COMs. The standard mandates no naming convention for it.

mako's is mako:<snake_case>, enforced — and mako is one of the four namespace prefixes rubo4e itself registers (mako, hems, edmd, mabis), so the guard reads the prefix from rubo4e::zusatz_attribut::Namespace::MAKO rather than writing a literal. A rename or a de-registration upstream then fails at the next CI run instead of mako shipping into a collision no schema and no counterparty can see.

Without a prefix, rechnungsart is indistinguishable from a field BO4E might introduce later and from an attribute the ERP on the other side already writes — and several crates emit into the same document.

cargo xtask check-bo4e-attributes (part of just ci) refuses any emitted attribute that is not namespaced and listed in its registry. The registry carries a one-line description per attribute and is the discoverable list: a consumer cannot learn mako's extensions from the BO4E schema, because not being in the schema is the point.

The gate decodes through rubo4e, not serde_json

Stage 2 is T::from_json_value, which enforces the crate's nesting-depth cap; plain serde_json::from_value enforces nothing, and the gate is the one place mako reads untrusted BO4E.

Not the hardened variant with the extension-field count closed to zero: that turns an undefined field into a decode error, which is right for a document you built and wrong for one a counterparty sent.

Out-of-schema fields are refused on the way out

Bo4eStrict::ensure_known_enums finds out-of-schema values; Bo4eExtensions::ensure_no_extension_data finds out-of-schema fields. Neither sees the other's finding, and the outbound gate runs both.

Only outbound: refusing an unknown field from a counterparty would throw away the forward compatibility the extension map exists for — a sender one BO4E release ahead is to be read, not rejected. On a document mako authored an undefined field can only be a mistake, and it is the mistake nothing else can see: a decode round-trip returns Ok for a misspelled key and reads the field back as None.

_additional is the counterparty's extension slot. Everything mako adds rides in zusatzAttribute — a real BO4E field — under the mako: namespace, where the registry and check-bo4e-attributes reach it.

A BO4E document is built typed, never assembled as JSON

cargo xtask check-bo4e-discriminants (also part of just ci) refuses a _typ written by hand — as typ: Some(BoTyp::X) beside a ..Default::default() that already stamps it, as typ: None (which serialises to a document carrying no discriminant at all), or as a "_typ": "X" key in a json! literal.

The discriminant is the visible half of the rule; the field names are the half that matters. A document assembled as json! never meets the typed constructor, so nothing checks its keys against the schema — rubo4e captures unknown keys in _additional rather than rejecting them, so a misspelled field decodes cleanly, reads back as None, and ships with the value missing.

A decode round-trip is not a defence against this: from_value::<T>(literal) returns Ok for a renamed key, because absorbing it is what the extension map is for. A struct literal fails to compile instead.

The same trap reaches documentation, because an example is copied. cargo xtask check-bo4e-examples decodes every fenced JSON block in the docs that carries a _typ and reports any field BO4E does not define.

Two rules follow:

  • A mako value never occupies a BO4E field. Where mako's vocabulary exceeds the standard's, the extra value goes in a ZusatzAttribut and the BO4E field is left absent. Rechnungstyp has no Gutschrift value, so a credit note leaves rechnungstyp unset and labels itself mako:rechnungsart; BO4E Preistyp has ten values against mako's thirty, so an EEG-Marktprämie rides as mako:preistyp.
  • What mako emits is checked, not just what it receives. Every Rechnung, Angebot and stored Tarifpreisblatt crosses the same gate on the way out (ensure_conformant, above) — no enum falling through to Unknown, and the BO4E-stated rules satisfied. The silent catch-all is rubo4e's own choice, not the market's: go-bo4e returns invalid <Enum> %q and declares no catch-all, BO4E-python raises a pydantic ValidationError, and both reject the whole document. A document that fails the outbound gate is therefore either one a reader silently cannot understand, one they cannot parse at all, or one they openly dispute.

Further Reading

TopicDocument
Full PID table for all process familiesPID Reference
BNetzA rulings governing each processBNetzA Regulatory Reference
How EDIFACT messages are parsed and validatedParsing Guide
How the engine routes messages to workflowsProcess Engine
BO4E objects in ERP integrationERP Integration
Gas balancing domain modelGaBi Gas domain (below)

Edit this page ↗