Annual Release Workflow

The engineering playbook for a new BDEW release: mirror the documents, import and validate the profiles, check PID coverage, roll out, migrate.

On this page 15 sections

This document is the step-by-step engineering playbook for incorporating a new BDEW release into the mako workspace. Follow the steps in order for every cutover. Cutovers are staggered per message type — January 1, April 1 and October 1 all carry format versions — so this playbook runs several times a year, once per message type that moves.


Prerequisites

  • cargo xtask is the primary tool for every step. Build it once before starting:
    cargo build -p xtask
  • The BDEW PDFs live in a local document mirror that cargo xtask sync-regulatories --download populates (Step 0), and it reports the directory it writes to. They are third-party publications and are not part of the repository.

Step 0 — See what changed upstream

cargo xtask sync-regulatories

Reports every document in force that the mirror does not hold — a new Formatversion appears as a block of MIG/AHB entries sharing a valid_from. Add --download to fetch them; --offline checks the mirror against its manifest without the network.

A corrected document keeps its version number, so only the manifest's content hash can tell you a local copy is stale. Such a file is reported as changed since it was mirrored.


Step 1 — Name the profile

Add one entry per moving message type (Strom and Gas are separate entries for UTILMD) under the profiles object of crates/edi-energy/profiles/sources.json:

"utilmd/fv20271001": {
  "release": "S2.3",
  "track": "Strom",
  "valid_from": "2027-10-01",
  "publikationsdatum": "2027-04-01",
  "ahb_version": "2.3",
  "mig": "UTILMD_MIG_Strom_S2.3.pdf",
  "ahb": "UTILMD_AHB_Strom_2.3.pdf"
}

The directory name is the Anwendungszeitpunkt; the two documents are the file names sync-regulatories mirrored. Give the predecessor its valid_until (the day before).


Step 2 — Import it from the PDFs

cargo xtask import-profiles --profile utilmd/fv20271001

The importer reads the MIG's Nachrichtenstruktur and every Segmentlayout, and the AHB's tables column by column, and writes mig.json and ahb.json. It refuses what it cannot read rather than guessing — a status that lands between two columns, an AHB row naming a segment Nr the MIG has no place for, a column without UNH. When it refuses:

BDEW_DEBUG=1 cargo xtask import-profiles --profile utilmd/fv20271001   # every row, its columns and cells
cargo xtask pdf-grid path/to/UTILMD_AHB_Strom_2.3.pdf                   # the grid the reader sees

The fix belongs in xtask/src/bdew/, never in the JSON.

A document defect the importer works around is printed as warn, and those lines are the places where the AHB and the profile differ. Two shapes exist today: an SG27 MOA row without any status in INVOIC AHB 1.0b gets its status from the MIG, and an operand cell printed without its operand letter (UTILTS AHB 1.1 SG8 DTM DE 2379 in two of its three columns) takes the letter the column that prints one uses for the same data element and code. Both are evidenced rather than defaulted; a defect with no such evidence fails the import instead.

Every Bedingung a status expression or an operand cites must have its text; a citation the reader cannot resolve fails the import, because the evaluator would otherwise read the place as unconditioned.

Then read the delta against the predecessor:

cargo xtask profile-diff utilmd fv20261001 fv20271001
cargo xtask profile-diff utilmd fv20261001 fv20271001 --pid 55001

It lists which Prüfidentifikatoren appeared or were withdrawn and, per Prüfidentifikator, the places whose status changed, the codes that gained or lost an operand, and the Bedingungen and Pakete that were rewritten. Places are named by where they sit and what the MIG calls them — SG4 Vorgangs-Identifikation / STS Transaktionsgrund — because the MIG renumbers its segments between Nachrichtentypversionen, and an Nr-keyed listing would report every segment of every column as changed. Put it in the PR summary.


Step 3 — Prove it

cargo test -p edi-energy --all-features

tests/skeletons.rs generates the minimal message of every column and validates it against that column: an extraction gap (a lost row, a mis-assigned status) or a validator gap shows up as a failing Anwendungsfall, with its skeleton and findings. The fixture snapshot names every verdict that moved.


Step 4 — Validate the set

cargo xtask validate-profiles            # sources ↔ files, dates and continuity, PIDs, AHB rows ↔ MIG, cited Bedingungen
cargo xtask check-pid-coverage           # the shipped columns against the Anwendungsübersicht
cargo xtask check-release-coverage --date 2027-10-01

validate-profiles also counts the status expressions the extraction cut in half. That count is zero, pinned in xtask/profile-expression-defects.json, and may only shrink — so a new publication whose typesetting the reader does not yet handle fails the import rather than shipping a place the validator would judge unconditioned. What it cannot see is an expression that lost an operator rather than a citation: two citations side by side read as a conjunction, so (…) ⊻ ([177] ∧ [178]) printed with the on a line of its own becomes (…) ([177] ∧ [178]) — a rule that parses, and demands both branches of an exclusive choice. Read the profile-diff output for operators that appear or vanish, and check a suspicious column against pdf-grid.

A Prüfidentifikator carried by the predecessor and absent from the new profile fails validate-profiles. Confirm the retirement in the AHB's Änderungshistorie and record it in RETIRED_PIDS (xtask/src/validate_profiles.rs) with the Änd-ID; without that entry it is an import regression. Update pid-overview.json when BDEW publishes a new Anwendungsübersicht (cargo xtask import-pid-overview <xlsx>).


Step 5 — What the senders must now fill

cargo run -p edi-energy --all-features --example 07_resolve -- --pruefschablone UTILMD S2.3 55001

prints the column: every segment the Anmeldung must carry and every data element with its operands. Hold the renderers in makod and the builders against it — a new Muss segment in the AHB is a new field the sender has to fill, and 07_resolve <file> on a rendered message says which places are still missing.


Step 6 — PR checklist

  • sources.json names the documents by their mirrored file names and the predecessor has its valid_until
  • import-profiles ran clean; every warn line is a documented AHB defect
  • profile-diff <type> <old fv> <new fv> read, and its listing in the PR summary
  • cargo test -p edi-energy --all-features — skeletons 100 %, snapshot re-blessed with the diff read
  • validate-profiles, check-pid-coverage, check-release-coverage --date <new fv> green
  • retired Prüfidentifikatoren recorded with their Änd-ID
  • just ci green

Step 7 — Deploy new binary with both FVs active (zero-downtime rollout)

Deploy the new binary so that both format versions are registered in the adapter registry simultaneously. The new binary can accept both old-FV and new-FV inbound messages; in-flight processes continue under their originating FV.

# Kubernetes example — rolling restart with the new image:
kubectl set image deployment/makod makod=registry.example/makod:FV2026-10-01
kubectl rollout status deployment/makod

Do not remove the old FV from the adapter config yet.

Watch for format-version substitution

makod derives the format version from each inbound message's release. When it cannot — an unknown message type, an unparseable release, or a release with no registered profile for today's date — it falls back to the newest known FV and logs at WARN:

format version could not be derived from the message — validating against the
newest known release instead; the AHB rules applied are not necessarily those of
the release the message claims
  reason="no profile registered for this release on today's date"
  substituted_fv="FV2026-10-01"

Falling back rather than rejecting is deliberate: during a cutover a counterparty may send a release this binary predates, and refusing the message outright is worse than dispatching it under the closest registered version.

The substitution does not change which AHB rules apply — validation derives its profile from the message's own release. What the FV selects is the MessageAdapter and the WorkflowId name, and since adapters accept every registered FV the substitution is usually invisible in behaviour. Two things make it worth watching anyway: it means mako could not read the release the counterparty stated, and the spawned process carries a WorkflowId naming a release the message never claimed.

Treat a burst during the transition window as a signal that a profile is missing from the deployed binary — not as noise.

Alert on reason="no profile registered for this release on today's date" specifically; the other reasons indicate a malformed message rather than a deployment gap.


Step 8 — Run in-flight process migration (online, no downtime)

While the daemon is running, call the migration endpoint to advance all in-flight processes from the old FV snapshot to the new FV.

Why online? makod holds an exclusive lock on its data directory via SlateDB. A separate makod migrate binary cannot open the same path while the daemon is live. The HTTP endpoint runs migration in-process using the daemon's own open store handles, avoiding the lock entirely.

# Replace FV dates as appropriate for the current release cycle.
curl -s -X POST \
     -H "Authorization: Bearer ${TOKEN}" \
     -H "Content-Type: application/json" \
     -d '{"from":"FV2025-10-01","to":"FV2026-10-01"}' \
     http://makod-admin:8080/admin/migrations | jq .

Expected response (success):

{
  "from": "FV2025-10-01",
  "to": "FV2026-10-01",
  "migrated": 47,
  "skipped": 18234,
  "errors": [],
  "runners_executed": 57,
  "workflows": ["esa-wertebestellung", "gabi-gas-allocation", ""],
  "workflows_not_migrated": [
    ["gpke-messwerte", "records inbound MSCONS Messwerte and completes"],
    ["gabi-gas-mmma", "delegates delivery to gpke-allokationsliste"]
  ]
}

Assert errors == [] before proceeding. Non-empty errors means some process streams could not be migrated (deserialization failure, missing state data, etc.) — investigate each failure before retiring the old FV.

workflows names every family the run covered. It is reported rather than merely counted because a count alone reads as complete whatever the migration happens to include, with nothing to say which families were missing. A build-time guard requires every dispatchable workflow to have either a migration arm or a recorded reason it needs none, and workflows_not_migrated reports those reasons so the sign-off covers them too. They are pure receive-and-record families: they record an inbound message and finish, so no process survives a cutover for a migration to repoint.

If a workflow's state schema changed between FVs, add a bespoke StateMigration implementation in the domain crate (see mako_engine::migration::StateMigration) and update services/makod/src/api/migration_api.rs to use it.


Step 9 — Retire old FV from adapter registry and redeploy

After a successful migration (Step 8, errors == []), remove the old FV from the adapter config and do a final rolling restart:

# Remove old FV profile registration from makod.toml or deployment config,
# then redeploy:
kubectl set image deployment/makod makod=registry.example/makod:FV2026-10-01-final
kubectl rollout status deployment/makod

Inbound messages for a process are parsed and validated against the format version active for that process; new events are written under the corresponding workflow_id.


Appendix A — Message type feature flags

Message typeFeature flagDefault?
UTILMDutilmd
MSCONSmscons
APERAKaperak
CONTRLcontrl
INVOICinvoic
REMADVremadv
ORDERSorders
IFTSTAiftsta
INSRPTinsrpt
REQOTEreqote
PARTINpartin
ORDCHGordchg
ORDRSPordrsp
QUOTESquotes
COMDIScomdis
PRICATpricat
UTILTSutilts

Appendix B — the cutover has no Übergangsfrist

Allgemeine Festlegungen 6.1 §2.5 gives the EDIFACT formats a single Anwendungszeitpunkt: before it the old format applies, from it the new one. There is no window in which both are normatively acceptable, so a cutover is a step, not a fade.

The 15-Werktage Übergangszeitraum in §8.5 belongs to the XML formats. It starts at the Anwendungszeitpunkt, counts Werktage, and selects the version by the Erfüllungsdatum stated in the message — none of which transfers to EDIFACT.

An operator who wants to keep accepting a late-arriving message in the old format sets ReleaseRegistry::with_receive_tolerance_days(n), which extends the trailing edge only. That is a local receiving policy and defaults to zero. See Release Lifecycle.


Appendix C — Formatversion effective dates

Publikationsdatum is not the Anwendungszeitpunkt

Allgemeine Festlegungen 6.1d §2.5.1/§2.5.2 give the cycle three instants. For the October changeover: consultation documents 01.02., Veröffentlichung der konsultierten Dokumente 01.04., Anwendungszeitpunkt 01.10. The April changeover mirrors it — published 01.10., applies 01.04. of the next year. The six months between are the Umsetzungsphase, and throughout them the previous format is the binding one.

Every EDI@Energy document prints its Publikationsdatum on the title page. It goes in the profile's publikationsdatum field; valid_from and the directory name carry the Anwendungszeitpunkt six months later. validate-profiles refuses two profiles claiming the same day, a gap between consecutive profiles of a track, and a chain whose newest profile is not open-ended.

Corrections issued in between ("Konsolidierte Lesefassung mit Fehlerkorrektur") take effect without a further BNetzA Mitteilung and do not move the Anwendungszeitpunkt; the latest consolidated version is the one to implement.

Which profile applies when

profiles/<type>/<fv>/mig.json is authoritative — publikationsdatum, valid_from, valid_until. To list them:

cargo xtask validate-profiles          # prints gaps and overlaps

The six-month gap is § 2.5's own timetable, so publikationsdatum is entailed by valid_from rather than looked up, and validate-profiles refuses a profile that states neither or states one that disagrees. The exception is by regulation rather than cycle: contrl/insrpt run on the ausserordentliche Veröffentlichung of 11.12.2025 (applies 2026-01-01), which neither § 2.5.1 nor § 2.5.2 covers, so those profiles state no publikationsdatum — a stated one there would be a guess.

Date or wire code?

UNH DE 0057 identifies the MIG, not the Formatversion — REQOTE AHB 1.1 and 1.2 both carry wire release 1.3c, so only the date distinguishes them. ReleaseRegistry::profile_on selects the greatest valid_from ≤ date, and ProcessContext::for_date does the same for a context. The date is always supplied by the caller — makod states mako_fristen::heute(), the German calendar date, because a Formatversion takes effect at German midnight.

Running processes continue under the FV they were spawned with — it is on their WorkflowId — while the inbound message's own FV picks the adapter, so a partial cutover needs no coordination.

EDIFACT has no overlap at the changeover: before the Anwendungszeitpunkt the old format applies, from it the new one. ReleaseRegistry::with_receive_tolerance_days widens only the trailing edge, as a local inbound policy for a late-arriving message.

xtask coverage check

After adding a new FV profile, verify that the check-release-coverage gate covers the new effective date:

cargo xtask check-release-coverage --date 2026-04-01
cargo xtask check-release-coverage --date 2026-10-01

Both commands must exit 0 for the workspace to be considered FV2026-ready.

Appendix D — Release naming conventions

Profile directoryWire release codeRule
fv20271001e.g. S2.3UTILMD Strom
fv20271001_gase.g. G1.3UTILMD Gas
fv20271001e.g. 2.5bMSCONS

The wire release code comes from UNH segment, data element 0057 (association- assigned code). It must match the release field in mig.json exactly.

Edit this page ↗