makod Operator Guide

makod operator guide: port layout, CLI flags, config file, persistent and volatile storage, AS4 inbound, HTTP REST API, health checks, and Kubernetes deployment.

makod Operator Guide

makod is the production daemon for the Mako process engine. It assembles all domain modules (GPKE, WiM, GeLi Gas, MABIS), wires them to a durable SlateDB event store, and exposes three independent server ports — AS4 inbound, HTTP REST ingest, and BDEW API-Webdienste Strom.


Port Layout

┌───────────────────────────────────────────────────────────────┐
│  makod                                                        │
│                                                               │
│  :4080  ← AS4/ebMS3 inbound (EDIFACT + Redispatch XML)       │
│  :8080  ← HTTP REST API  (POST /edifact, admin endpoints)    │
│  :8090  ← API-Webdienste Strom (iMS REST/JSON)               │
│                                                               │
│  /health, /health/live, /health/ready — on every enabled port│
└───────────────────────────────────────────────────────────────┘

All three ports are optional and independently enabled via CLI flags or environment variables. A minimal deployment can use a single port; a full production deployment uses all three.

Companion daemons complete the production stack:

DaemonPortRole
marktd:8180Master data (MaLo/MeLo/contracts), webhook fan-out, price sheets
invoicd:8280INVOIC plausibility, receipt persistence, REMADV auto-dispatch
edmd:8380Meter-data store (MSCONS), time-series API, Mehr-/Mindermengen
obsd:8480Business-process observability, BNetzA KPI reports, alerting
netzbilanzd:8680NNE/MMM/MSB INVOIC generation

See the individual service READMEs for setup details.


Quick Start

Volatile in-memory mode — development and CI only

⚠ WARNING — VOLATILE MODE IS NOT FOR PRODUCTION USE ⚠

When --data-dir is omitted and no cloud object store is configured, makod starts in volatile in-memory mode: all event streams, outbox messages, snapshots, process registry entries, and deadlines are stored in RAM only.

Any of the following immediately and permanently loses all in-flight process state:

  • Process exit (including graceful shutdown with Ctrl-C)
  • Process crash or OOM kill
  • Container restart or pod rescheduling
  • Host reboot

In volatile mode you cannot:

  • Resume in-flight MaKo processes after restart
  • Guarantee delivery of APERAK and CONTRL responses
  • Meet regulatory audit requirements (§ 147 AO / GoBD, BDEW AHB)

Use volatile mode only for automated integration tests, local debugging, and CI pipelines where data loss is acceptable.

# makod.toml
[[party]]
mp_id   = "9900357000004"
roles   = ["NB"]
primary = true

[storage]
allow_volatile = true          # in-memory; data is lost on exit

[http]
addr      = "127.0.0.1:8080"
auth_keys = ["dev=dev-token-change-me"]

[as4]
allow_no_signing = true        # no AS4 credentials: log outbound EDIFACT
cargo run -p makod -- --config makod.toml

Three refusals are visible in that file, and each is deliberate:

  • allow_volatile — without it makod refuses to start in volatile mode, so a production deployment cannot lose its event store by accident. Also available as MAKOD_ALLOW_VOLATILE=1 or --allow-volatile.
  • auth_keys[http] addr submits commands and triggers migrations; it never runs open. An [oidc] issuer satisfies the same requirement.
  • allow_no_signing — with neither AS4 signing material nor [erp] edifact_outbox_webhook_url, outbound EDIFACT would be logged and rescheduled forever. The flag makes that a development choice rather than a silent regulatory failure.

Persistent local storage

[storage]
data_dir = "/var/lib/makod"

[http]
addr      = "0.0.0.0:8080"
auth_keys_file = "/etc/makod/auth-keys"

Full production deployment

A production deployment carries key material, per-partner certificates and credentials, so it belongs in the config file rather than on the command line — a secret passed as a flag is visible in ps output. The launch reduces to:

makod --config /etc/makod/makod.toml

Validate the same file in the deployment pipeline before promoting it:

makod --check --config /etc/makod/makod.toml

Configuration file

Every CLI flag has a config-file equivalent, and every secret additionally has a *_file companion that reads the value from disk at startup. A guard test in the build fails when a new flag is added without a path to it from the file, so the two surfaces cannot drift apart.

Unknown keys are rejected — a typo in a security-relevant field would otherwise be a silently weakened deployment. Supplying both an inline secret and its *_file companion is an error rather than a hidden precedence rule.

# /etc/makod/makod.toml

[logging]
level  = "info"     # trace | debug | info | warn | error
format = "json"     # pretty | compact | json

[otel]
# endpoint     = "http://otel-collector:4317"   # enables OTLP span export
# service_name = "makod"

[storage]
backend = "s3"      # local | s3 | gcs | azure
# data_dir       = "/var/lib/makod"   # required for backend = "local"
# allow_volatile = false              # in-memory store; development only
# max_stream_events = 100000          # per-stream quota; 0 disables

[storage.s3]
bucket   = "my-makod-events"
prefix   = "makod"                    # key prefix within the bucket
# endpoint = "http://minio:9000"      # MinIO / S3-compatible

# One entry per Marktpartner-ID. Allgemeine Festlegungen §2.13 requires a
# separate code per Energieart and Marktrolle, so a Strom NB and a Gas GNB are
# always two entries.
[[party]]
mp_id   = "9900357000004"       # 13-digit BDEW code, DVGW code, or 16-char EIC
roles   = ["NB"]                # this identity's Marktrollen
primary = true                  # storage partition key + default sender MP-ID

[http]
addr           = "0.0.0.0:8080"
max_body_bytes = 10485760                       # 10 MiB (default)
auth_keys_file = "/etc/makod/auth-keys"         # NAME=TOKEN per line
# auth_keys    = ["erp-sap=<token>"]            # inline alternative

[authz]
# cedar_policy_dir  = "/etc/makod/cedar"        # extra *.cedar policy files
# no_default_policy = false                     # drop the permit-all baseline

[oidc]
# issuer            = "https://login.microsoftonline.com/{tenant-id}/v2.0"
# audience          = "api://makod"
# jwks_refresh_secs = 300

[webdienste]
addr = "0.0.0.0:8090"
# allow_unauthenticated = false   # only behind an mTLS-terminating proxy

[engine]
# shutdown_timeout_secs          = 30
# snapshot_interval              = 100
# projection_checkpoint_interval = 60
# deadline_poll_interval_secs    = 30
# worker_threads                 = 8
# marktrollen                    = ["NB"]   # defaults to the [[party]] union
# deployment_roles               = ["NB"]

[as4]
addr     = "0.0.0.0:4080"
party_id = "9900357000004"      # must match the signing certificate subject

# BDEW AS4-Profil v1.2 §2.2.6.2.2 mandates sign *and* encrypt, which means three
# distinct pieces of key material — all EC (BrainpoolP256r1).
signing_key_pem_file    = "/etc/makod/signing.key.pem"
signing_cert_pem_file   = "/etc/makod/signing.cert.pem"
decryption_key_pem_file = "/etc/makod/decryption.key.pem"
trust_anchor_pem_file   = "/etc/makod/bdew-pki-ca.pem"

# Trading partners — bootstrapped into the durable PartnerStore at startup.
# Runtime updates via PUT /admin/partners/{mp_id} or inbound PARTIN messages.
partners = [
  "9900000000001=https://partner-a.example/as4/inbox",
  "9900000000002=https://partner-b.example/as4/inbox",
]
# One encryption certificate per partner. A partner without one cannot be
# delivered to at all, so the daemon refuses to start rather than dead-letter
# every message to it.
partner_cert_files = [
  "9900000000001=/etc/makod/partners/9900000000001.pem",
  "9900000000002=/etc/makod/partners/9900000000002.pem",
]
# partner_certs   = ["9900000000001=<PEM>"]   # inline alternative
# allow_unencrypted = false   # dev/test: downgrade the encryption refusals
# allow_no_signing  = false   # dev/test: log outbound EDIFACT instead of sending
# lenient_receipts  = false   # interop debugging: tolerate a missing eb:Receipt

[erp]
webhook_url         = "https://erp.example.com/mako/events"
webhook_secret_file = "/etc/makod/erp-webhook.secret"
# edifact_outbox_webhook_url = "http://webhook:8000"  # dev transport substitute
# netzzugang_endpoint_url    = "https://…"            # §20b EnWG platform

[marktd]
# url          = "http://marktd:8180"
# api_key_file = "/etc/makod/marktd.key"

[maloid]
# partners             = ["9900000000001=https://partner-a.example/maloid"]
# verzeichnisdienst_url = "https://verzeichnisdienst.example/api"

Secrets

Prefer the *_file form for everything below. A value passed as a CLI flag appears in ps output; a value passed by environment variable is readable by anything that can inspect the process environment or the container spec.

InlineFile companion
as4.signing_key_pemas4.signing_key_pem_file
as4.signing_cert_pemas4.signing_cert_pem_file
as4.decryption_key_pemas4.decryption_key_pem_file
as4.trust_anchor_pemas4.trust_anchor_pem_file
as4.partner_certsas4.partner_cert_files
http.auth_keyshttp.auth_keys_file
erp.webhook_secreterp.webhook_secret_file
marktd.api_keymarktd.api_key_file

The file forms compose with Kubernetes Secrets and volume mounts, the Secrets Store CSI driver, a vault-agent tmpfs sink, or systemd LoadCredential=.

Configuration precedence

CLI flags  >  Environment variables  >  Config file  >  Built-in defaults

All Configuration Options

[logging] / environment / CLI

TOML keyEnv varCLI flagDefaultValues
levelMAKOD_LOG_LEVEL--log-levelinfotrace debug info warn error
formatMAKOD_LOG_FORMAT--log-formatprettypretty compact json

Use format = "json" in production for log aggregators (Loki, OpenSearch, CloudWatch).

[storage] — event store backend

TOML keyEnv varCLI flagDefaultDescription
backendMAKOD_OBJECT_STORE--object-storelocallocal s3 gcs azure
data_dirMAKOD_DATA_DIR--data-dir(in-memory)Local FS path (backend=local only)
allow_volatileMAKOD_ALLOW_VOLATILE--allow-volatilefalseMust be true to run without data_dir; never production

When backend = "local" and data_dir is omitted, makod refuses to start unless allow_volatile is also set. This is a hard safety guard; it prevents silent accidental volatile deployments. A WARN is emitted at startup. Never omit data_dir in production.

[storage.s3]

TOML keyEnv varCLI flagDescription
bucketMAKOD_S3_BUCKET--s3-bucketS3 bucket name (required)
prefixMAKOD_S3_PREFIX--s3-prefixKey prefix (default: "makod")
endpointMAKOD_S3_ENDPOINT--s3-endpointCustom endpoint for MinIO/compat

S3 credentials are read from the standard AWS environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION.

[storage.gcs]

TOML keyEnv varCLI flagDescription
bucketMAKOD_GCS_BUCKET--gcs-bucketGCS bucket name (required)
prefixMAKOD_GCS_PREFIX--gcs-prefixKey prefix (default: "makod")

GCS credentials: GOOGLE_SERVICE_ACCOUNT_KEY (JSON content) or GOOGLE_APPLICATION_CREDENTIALS (path to key file).

[storage.azure]

TOML keyEnv varCLI flagDescription
containerMAKOD_AZURE_CONTAINER--azure-containerBlob container name (required)
accountMAKOD_AZURE_ACCOUNT--azure-accountStorage account name (required)
prefixMAKOD_AZURE_PREFIX--azure-prefixKey prefix (default: "makod")

Azure credentials: AZURE_STORAGE_ACCOUNT_KEY, or service-principal via AZURE_CLIENT_ID + AZURE_TENANT_ID + AZURE_CLIENT_SECRET.

[engine]

TOML keyEnv varCLI flagDefaultDescription
shutdown_timeout_secsMAKOD_SHUTDOWN_TIMEOUT_SECS--shutdown-timeout-secs30Shutdown grace period in seconds
deadline_poll_interval_secsMAKOD_DEADLINE_POLL_INTERVAL_SECS--deadline-poll-interval-secs30How often the deadline scheduler polls for due deadlines (minimum 1 s; set ≤30 s for Redispatch 2.0 Activation 5-minute constraint)
(CLI/env only)MAKOD_MARKTROLLEN--marktrollen(all [[party]] roles)Optional override of the Marktrollen this instance accepts commands for (comma-separated)
(CLI/env only)MAKOD_DEPLOYMENT_ROLES--deployment-roles(all roles)Roles that gate PID registration: NB, LF, MSB, NMSB, AMSB, BKV, UENB/FNB, BIKO, ESA
(CLI/env only)MAKOD_MARKTD_URL--marktd-url(unset)Cluster-internal marktd base URL. Enables the ESA consent gate + M1 Konfigurationsprodukt guard — see below
(CLI/env only)MAKOD_MARKTD_API_KEY--marktd-api-key(empty)Bearer token for machine-to-machine calls to --marktd-url

Operator identity does not live in [engine] — it comes from the [[party]] entries (see below). All process streams and inbox keys are scoped to the primary party's MP-ID.

[[party]] — operator identities (required)

At least one [[party]] entry is required; makod refuses to start without one. An operator holding multiple Marktpartner-IDs (e.g. separate BDEW registrations for NB, LF, and MSB subsidiaries) lists one entry per identity.

TOML keyRequiredDescription
mp_idyes13-digit BDEW-Codenummer (99…), DVGW-Codenummer (98…), GS1 GLN, or 16-char EIC
rolesyesMarktrollen this identity is registered for: NB, LF, MSB, GNB, LFG, gMSB, MGV, BKV, UNB, ANB, VNB, NMSB, AMSB
primarynoMarks the storage partition key (derives the engine TenantId and the default EDIFACT sender MP-ID). When absent, the first entry is primary.
[[party]]
mp_id   = "9900001000001"
roles   = ["NB"]
primary = true

[[party]]
mp_id = "9900001000002"
roles = ["LF", "LFG"]

ESA messages

REQOTE 35003 ("Anfrage von Werten") is ESA-specific: REQOTE AHB 1.2 §4.3 gives the Kommunikation as ESA an MSB and labels SG1 RFF+Z13 "35003 Anfrage von Werten für ESA". It routes to wim-wertebestellung on the Prüfidentifikator alone — no sender-role or content classification is involved, and no ESA counterparty list has to be configured.

Do not confuse it with 35002, "Anfrage zur Rechnungsabwicklung des Messstellenbetriebs über den LF" (§4.2), which is LF → MSB in WiM Teil 1 and belongs to a different process.

deployment-roles ESA is for a deployment that is an ESA: it registers the inbound answers (QUOTES 15003, ORDRSP 19011/19012/19013/19014). An MSB serving an ESA registers ORDERS 17007 under MSB; it answers on the wire by rendering QUOTES 15003 (Angebot/Ablehnung) and ORDRSP 19011–19014 (Ab-/Bestellung and Stornierung), so the 5-WT / 2-WT windows can actually be closed. The two sets are disjoint, so an integrated deployment may hold both.

The ordering handshake (WiM Teil 2, Kap. 4)

The whole Wertebestellung — Werteanfrage, Angebot, Bestellung, delivery, and either cancellation path — is one correlated process on each side (esa-wertebestellung for the ESA, wim-wertebestellung for the MSB):

sequenceDiagram
    autonumber
    participant ESA as ESA · esa-wertebestellung
    participant MSB as MSB · wim-wertebestellung
    ESA->>MSB: REQOTE 35003 Werteanfrage · LOC+172 · PIA Messprodukt · DTM+76
    MSB-->>ESA: QUOTES 15003 Angebot · RFF+AAV · CUX · PRI je Artikel-ID · PIA OBIS · DTM+273/469
    Note over ESA,MSB: 5 WT · E_0252 · keine Preisposition ⇒ Ablehnung der Anfrage
    ESA->>MSB: ORDERS 17007 Bestellung · RFF+AAG · IMD+7081 · DTM+203 = max(Wunsch, DTM+469)
    MSB-->>ESA: ORDRSP 19011 / 19012 · RFF+ON · AJT code · E_0256
    loop per the ordered Messprodukt
        MSB-->>ESA: MSCONS 13027 Werte nach Typ 2
    end
    alt Stornierung before first delivery (UC 4.1 Nr. 5)
        ESA->>MSB: ORDCHG 39002 Storno · RFF+ON
        MSB-->>ESA: ORDRSP 19013 / 19014 · RFF+ACW · AJT code · E_0257
    else Abbestellung during delivery (UC 4.3)
        ESA->>MSB: ORDERS 17008 Abbestellung · RFF+ACW · IMD++Z02
        MSB-->>ESA: ORDRSP 19011 / 19012 · RFF+ON · AJT code · E_0254
    end

What is ordered. The Werteanfrage names a Messprodukt from Codeliste der Konfigurationen 1.4 Kapitel 4.6 (SG27 PIA+5 … :Z11), the Wunschtermin for the first delivery (DTM+76) and — from the Bestellung on — the Abo mode (IMD+7081: Z01 running series, Z03 single transmission). makod validates the product against the catalogue before rendering: a code outside Kapitel 4.6, one defined for a different Lokationsebene than the request addresses, or a Kapitel-4.6.2 (SM-PKI) product without its target address and certificates is refused with 422.

The Messprodukt decides the Lokationsebene. LOC+172 DE 3225 has four permitted shapes and the Marktlokations-ID format ([950]) serves both the Marktlokation and the Tranche (REQOTE AHB 1.2 §4.3, hints [502]/[504]), so the identifier cannot resolve it — and 9991 00000 306 4 is a Pflichtprodukt defined for the Tranche alone. Both sides read the level from mako_wim::esa::ebene_fuer_messprodukt.

The Angebot is a priced offer. UC 4.1.1 has the ESA asking for „die Übermittlung von Werten und die damit verbundenen Kosten", and QUOTES AHB 1.1a §4.3 makes the substance Muss: SG4 CUX, one to three SG27 PIA+Z02 Artikel-IDs, one SG31 PRI+CAL each (Z01 Einrichtungs- / Z02 Transaktions- / Z03 Betriebspreis), one to 23 PIA+5 … :SRW OBIS-Kennzahlen, plus DTM+469 and DTM+273. So wim.wertebestellung.anbieten requires preise and obis, and the prices — not the Bindungsfrist, which is Muss on both — are what tell an Angebot from an Ablehnung in either direction. An offer that prices nothing is wim.wertebestellung.anfrage-ablehnen.

The ORDERS' DTM+203 Ausführungsdatum is max(Wunschtermin, DTM+469): the Angebot's „Startdatum, frühestes/r" is Muss, so an earlier date asks for something the offer excluded.

A subscription is the (Meldepunkt, Messprodukt) pair: several Kapitel-4.6 products exist for one Marktlokation, so a second Werteanfrage naming a different product is a new subscription, not a 409 duplicate_process. Every follow-up command (esa.bestellung.beauftragen, the Storno/Abbestellung, and the MSB-side answers) therefore accepts a messprodukt alongside the location to say which subscription it means; omit it while only one exists at that location.

Correlation. Only the REQOTE is keyed on a location. Every later message carries no LOC at all and is matched by the Belegnummer it echoes, under the Zuordnungsschlüssel the BDEW Anwendungsübersicht der Prüfidentifikatoren 4.0 publishes per PID:

PIDSchlüsselSegmentPoints at
35003ZO-T17SG11 LOC+172the Meldepunkt
15003ZG-T16SG1 RFF+AAVthe REQOTE
17007ZG-T24SG1 RFF+AAGthe QUOTES Angebot
17008ZG-T41SG1 RFF+ACWthe ORDERS Bestellung
39002ZG-T51SG1 RFF+ONthe ORDERS Bestellung
19011 / 19012ZG-T14SG1 RFF+ONthe ORDERS answered
19013 / 19014ZG-T50SG1 RFF+ACWthe ORDCHG
21042ZG-T47SG15 RFF+AGIthe ORDERS Bestellung

Both sides index their process under every Belegnummer they emit, so each answer finds its process. The renderer and the ingest dispatcher read the same table (mako_wim::esa::korrelation), so the qualifier emitted and the one looked for cannot drift. The 39002 Stornierung is part of the same subscription lifecycle; it is not a standalone process.

The MSCONS 13027 delivery carries the same RFF+AGI (as ZG-T42, in SG1 rather than SG15) — the first hop of the PID overview's EZ-03 (ZG-T42ZO-T20 Gerätenummer → ZO-T21 OBIS-Kennzahl). It is what closes the ESA-side Stornierung window and what edmd records against the values, so a Typ-2 gap can name the subscription that stopped.

What an answer means. SG2 AJT is Muss on all four ORDRSP PIDs, and those use cases publish no free-text segment at all — the only FTX a conformant 19011 may carry is SG27 FTX+Z27, the MSB's IP address for an SM-PKI delivery (FTX+Z28 for a range). The Antwortcode and its EBD are therefore the whole content of a refusal in both directions: makod reads them into mako_wim::esa::Antwort and resolves them against mako_pruefung's catalogue, which is what tells A08 (Einwilligung abgelaufen) from A10 (Lokationsbündel) from A09 (Gerätetechnik). A code whose Cluster contradicts its PID is recorded as a conflict and resolved by the PID.

Every inbound step notifies. An MSB deployment emits ProcessInitiated on 35003/17007/39002/17008, delivered as de.mako.process.initiated. That is the entire input to processd's ESA module, and its payload is a contract: every field is a Prüfschritt input.

When --marktd-url is set, an inbound ESA Werteanfrage (REQOTE 35003) and Bestellung (ORDERS 17007) are gated against the marktd consent registry before the Wertebestellung workflow runs. makod calls GET /api/v1/esa/consent-check with the sender (ESA), receiver (MSB) and location, and:

  • revoked consent — a consent for the location was granted and then withdrawn (GDPR Art. 7(3)) with nothing superseding it → the message is answered with an Ablehnung (QUOTES 15003 for the Anfrage, ORDRSP 19012 for the Bestellung). This is the Widerruf clearing case.
  • unestablished framework agreement — a framework agreement is on record but has no EDI agreement or carries a negative cert state → Ablehnung (the UC 4.1.1 Vorbedingung is unmet).
  • active consent or no consent record at all → the message proceeds. A missing record is never a rejection: the MSB holds the ESA's self-assertion, and BNetzA Mitteilung Nr. 3 (07.02.2024) forbids rejecting a request because the consent deviates from the BDEW template.

The gate fails open: if marktd is unreachable the message proceeds and a warning is logged. It is defence-in-depth — the durable stop signal for a withdrawn consent is the 17008 Abbestellung that marktd fires on revocation. Without --marktd-url the gate is disabled and every ESA message proceeds.

The check above uses perspective=msb_inbound, because this deployment is the MSB receiving an ESA order: it holds only the ESA's self-assertion, so a missing consent record is never a rejection.

The ESA (outbound) direction is stricter

Consent has asymmetric force. When a deployment is the ESA and originates outbound requests (Werteanfrage 35003, Bestellung 17007), it is the data controller that obtained the Einwilligung — a missing consent record means no lawful basis (GDPR Art. 7), not self-assertion. The same endpoint answers this with perspective=esa_outbound, which blocks a missing record (code: no_consent) as well as revoked consent and unestablished framework agreements. Revocation additionally obliges the ESA to stop by sending the 17008 Abbestellung (GDPR Art. 7(3)).

ESA-outbound origination workflow

An ESA deployment (--marktrollen ESA) originates the order handshake through the esa-wertebestellung workflow, driven by these commands:

CommandMessageConsent gate
esa.werteanfrage.stellenREQOTE 35003 Werteanfrageesa_outbound (strict)
esa.bestellung.beauftragenORDERS 17007 Bestellungesa_outbound (re-checked)
esa.stornierung.beauftragenORDCHG 39002 Stornierung— (before delivery)
esa.abbestellung.beauftragenORDERS 17008 Abbestellungnone — the stop action

Werteanfrage MSB resolution. esa.werteanfrage.stellen addresses the MSB responsible for the Messlokation. msb_mp_id in the payload is optional: supply it to address an MSB directly, or omit it and let makod resolve the responsible MSB from marktd's per-MeLo dated MSB timeline (GET /melos/{id}/msb?at=). This is the WiM Teil 2 UC 4.1.1 historical Werteanfrage case — a request for a past interval must reach the MSB that operated the MeLo then, not today's MSB. When resolving, the payload provides melo_id (or a Messlokation as the location) and the period start zeitraum_von (alias von, YYYY-MM-DD); an optional zeitraum_bis (bis) guards against a period that spans an MSB change — the dispatch is refused (422) with an instruction to split the request per MSB period rather than silently mis-addressing part of it.

esa.werteanfrage.stellen also carries the order itself: messprodukt (a Kapitel-4.6 Messprodukt-Code, spaced or bare), wunschtermin (or zeitraum_von, YYYY-MM-DD), an optional abonnement (Z01 default / Z03), and for a Kapitel-4.6.2 product an smgw object with the IPv4 and IPv6 target URIs, the certificate issuer and subject, and any Schwellwerte. messprodukt is mandatory and uncatalogued codes are refused — a REQOTE that named a placeholder product would be an order the ESA never placed. The optional contact and contact_comm fill the SG14 CTA+IC/COM Ansprechpartner and reach the wire verbatim.

A werteanfrage/bestellung is refused (422) unless the strict esa_outbound consent check passes — the ESA is the consent holder and must not request values it has no lawful basis for. On the outbound side the gate fails closed: if marktd is unreachable the request is refused rather than sent without a confirmed basis. The Abbestellung is never gated — it is the GDPR Art. 7(3) act of stopping, so it must always be possible.

This closes the revocation loop end-to-end: marktd's consent revocation (DELETE /api/v1/esa/einwilligungen/{id}) emits de.markt.einwilligung.widerrufen and posts esa.abbestellung.beauftragen to makod, which resumes the running esa-wertebestellung process and sends the 17008 Abbestellung to the MSB.

A Widerruf stops every subscription at the location. marktd fires the command per covered location and names no messprodukt — a subscription is the (Meldepunkt, Messprodukt) pair and it does not know how many sit behind one. An omitted messprodukt therefore means all of them: makod resolves every esa-wertebestellung process still occupying a business key there and sends one stop message each, logging any that fail. Naming a messprodukt addresses exactly that subscription.

beendigung_zum (alias ausfuehrungsdatum, YYYY-MM-DD) sets the DTM+203 the 17008 carries; it defaults to today, which is what a Widerruf wants, but a planned end — a service contract running out at month end — has to be nameable. The stop message follows the Abo mode. A one-shot is stornierbar, nicht abbestellbarE_0254 Prüfschritt 1 refuses its Beendigung with A01 by construction — so the workflow declines a 17008 for one. A Widerruf covers whatever is running, one-shots included, so the dispatch picks per subscription: ORDERS 17008 for an Abo, ORDCHG 39002 for a one-shot. esa.stornierung.beauftragen addresses a single one-shot deliberately.

MSB → ESA value delivery (UC 4.2)

The counterpart to the ordering handshake: once the MSB holds a confirmed Bestellung it owes the ESA the ordered values — the §60 Abs. 1 MsbG delivery duty. The cadence and deadline come from the ordered Messprodukt: the Rohdaten products publish „unverzüglich, jedoch spätestens bis 9:30 Uhr", the aufbereitete-Daten products defer to WiM Teil 2 Kapitel 2.5.5. The command wim.wertebestellung.liefern (role MSB) emits an outbound MSCONS 13027 "Werte nach Typ 2" addressed to the ESA (NAD+MR = the ESA's MP-ID — a recipient that is neither NB nor LF):

POST /api/v1/commands
{ "command": "wim.wertebestellung.liefern",
  "payload": { "malo_id": "",
               "reads": [ { "dtm_from": "", "dtm_to": "",
                            "quantity_kwh": "0.250", "obis_code": "1-0:1.29.0" } ] } }

It resumes the MSB-side wim-wertebestellung process and runs LiefereWerte, which is admissible only while the process is in lieferung_erlaubt (a confirmed Bestellung). So an MSB can neither accept a Bestellung it cannot fulfil nor deliver without one; ProcessNotFound (404) means no active subscription. Each delivery leaves an auditable WerteUebermittelt event. The values are non-authoritative and land in the ESA deployment's separate Typ-2 store (edmd.esa_typ2_reads), never a billing path.

On the receiving side the readings travel the same key set in reverse. The inbound MSCONS adapter decodes every SG9/SG10 group and the workflow puts them on the ProcessCompleted event as reads, alongside the malo_id edmd keys on:

{ "pid": 13027, "malo_id": "51238696781", "sender": "9900357000004",
  "sparte": "STROM",
  "reads": [ { "dtm_from": "2026-06-01T00:00:00+00:00",
               "dtm_to":   "2026-06-01T00:15:00+00:00",
               "quantity_kwh": "0.250", "quality": "MEASURED",
               "obis_code": "1-0:1.29.0", "melo_id": null } ] }

quality is SG10 QTY DE 6063 in the vocabulary edmd maps: 220 Wahrer Wert → MEASURED, 67 Ersatzwert → SUBSTITUTED, Z18 Vorläufiger Wert → PRELIMINARY. Periods are converted from DTM+163/+164 in format 303 (CCYYMMDDHHMMZZZ) to RFC 3339. A reading in any other format is skipped and counted, not dated by guesswork — 102 and 203 carry no UTC offset, and reading one as UTC is a silent one-hour error for half the year.

malo_id is present whether or not the message carried decodable readings, because edmd refuses an event without one before it looks at anything else; a delivery whose intervals could not be decoded still leaves a receipt.

MSB-side answer commands (the loopback half)

The esa.* commands drive the ESA half; these drive the MSB half, so mako can play both roles and a Wertebestellung runs end to end in one deployment (or against a real ESA). Each resumes the MSB-side wim-wertebestellung process for the MaLo (role MSB):

CommandAnswer on the wireEBD
wim.wertebestellung.anbietenQUOTES 15003 Angebot (CUX + PRI je Artikel-ID + OBIS + DTM+273/469)E_0252 (survived)
wim.wertebestellung.anfrage-ablehnenQUOTES 15003 Ablehnung (no priced position; grounds in FTX+ACB)E_0252
wim.wertebestellung.bestellung-beantwortenORDRSP 19011 / 19012E_0256
wim.wertebestellung.stornierung-beantwortenORDRSP 19013 / 19014E_0257
wim.wertebestellung.abbestellung-beantwortenORDRSP 19011 / 19012E_0254

anbieten takes preise (one {artikel_id, betrag} per priced position; art is derived from the Artikel-ID's 01/02/03 suffix unless stated), obis, and waehrung (default EUR) — see The Angebot is a priced offer above. E_0252 has no AJT to carry a code, so an Ablehnung states its grounds in FTX+ACB.

The three ORDRSP answer commands take an antwort_code, not an accept flag: SG2 AJT is Muss on all four ORDRSP PIDs (ORDRSP AHB 1.1b §4.15) and its code must sit in the named EBD's Zustimmungs- or Ablehnungs-Cluster, so the cluster selects the PID. Resolve the code by running the matching walk in mako_pruefung::esa::wertebestellung; an unpublished code, or one off the agreement axis, is refused with 422.

Three consequences worth knowing:

  • E_0257 refuses a Stornierung of a delivery that has already run with different codes per Abo mode — A02 for a running Abo, A03 for a one-shot that was already transmitted.
  • E_0254 publishes four refusals, so an Abbestellung is not always confirmable. A01 says the order was a one-shot and must be storniert instead; a refused Beendigung leaves the delivery running.
  • 19011/19012 answer both the Bestellung and the Beendigung. The IMD+7081 the answer carries is what says which tree the code came from.

The Angebot and the Anfrage-Ablehnung both travel as QUOTES 15003; the ESA tells them apart by the Bindungsfrist — an Angebot carries DTM+273, an Ablehnung does not (its reason rides FTX+ACB). DTM+273 is a duration (a count plus 802 Monat / 803 Woche / 804 Tag), not a date.

Callers name the command, they do not spell it

An unknown command name is refused with 422, which means a caller that writes the wire name a second time can drift from the registry and only find out when a real message arrives: the work is done, the dispatch fails, and the Frist expires on a process that looked healthy. mako_markt::commands therefore holds one constant per command an out-of-process caller posts, DISPATCHED_BY_SERVICES lists them, and a registry test here asserts every one is registered. Services name those constants; cargo xtask check-answer-commands refuses a bare literal.

marktrollen declares which market-participant roles this deployment is authorised to issue commands for. Every command submitted to POST /api/v1/commands is checked against this list before any workflow is touched; commands for unlisted roles are rejected with 422 role_not_configured. This setting is required when --http-addr is enabled — makod refuses to start without it to prevent accidentally exposing an unrestricted command gateway.

Typical values:

Operator type--marktrollen value
Electricity supplier onlyLF
Dual-fuel supplierLF,LFG
Electricity DSO onlyNB
Integrated DSO + MSB (Stadtwerke)NB,MSB
Balancing-zone responsibleBKV

Role Feature Flags

makod uses Cargo feature flags to determine which workflow modules are compiled in. This allows building trimmed binaries that omit processes that are irrelevant for a particular operator — reducing binary size and attack surface.

Granular flags

Feature flagCompiled modules
role-lf-strommako-gpke (LF side): gpke-lf-anmeldung, gpke-lf-abmeldung, gpke-beendigung-zuordnung, gpke-ankuendigung-zuordnung-lf, gpke-abrechnung, gpke-messwerte, gpke-allokationsliste, gpke-datenabruf, gpke-anfrage-bestellung, gpke-utilts
role-lf-gasmako-geli-gas (LF side): geli-gas-stornierung-lf, geli-gas-sperrung-lf, geli-gas-mscons
role-nb-strommako-gpke (NB side): gpke-supplier-change, gpke-zuordnungsmeldung, gpke-sperrung, gpke-konfiguration, gpke-konfiguration-aenderung, gpke-neuanlage, gpke-partin, mako-wim (NB side); mako-mabis: mabis-billing, mabis-zp-lifecycle, mabis-listenabgleich, mabis-clearingliste, mabis-anforderung, mabis-profile; mako-redispatch: redispatch-stammdaten, redispatch-aktivierung, redispatch-verfuegbarkeit, redispatch-netzengpass, redispatch-kaskade, redispatch-planungsdaten, redispatch-statusanfrage, redispatch-kostenblatt (Redispatch 2.0 is gated to NB Strom / ÜNB — LF and MSB deployments are out of scope per BK6-20-059/060/061)
role-nb-gasmako-geli-gas (GNB side): geli-gas-supplier-change, geli-gas-zuordnungsmeldung, geli-gas-sperrung-nb, geli-gas-stornierung, geli-gas-datenabruf, geli-gas-partin, geli-gas-sperrprozesse-invoic
role-msb-strommako-wim: wim-device-change, wim-ersteinbau, wim-geraeteubernahme, wim-stammdaten, wim-preisanfrage, wim-rechnungsabwicklung, wim-preisliste, wim-invoic, wim-insrpt, wim-wertebestellung, wim-technik-aenderung, esa-wertebestellung
role-msb-gasmako-wim: the WiM workflows on the Gas Prüfidentifikatoren

Composite flags

Composite flagExpands to
role-lfrole-lf-strom + role-lf-gas
role-nbrole-nb-strom + role-nb-gas
role-msbrole-msb-strom + role-msb-gas

| role-esa-strom | mako-wim (ESA side): esa-wertebestellung and its wim-wertebestellung counterpart (WiM Strom Teil 2 Kap. 4) |

Default

The default feature enables every role, so a plain cargo build -p makod produces the all-roles binary shipped in the container image — the right choice for development and for a combined multi-role (VIU) deployment.

A role-scoped build turns the default off and names the roles it wants:

# Lieferant-only image
FROM rust:1.94 AS build
RUN cargo build -p makod --release \
    --no-default-features \
    --features role-lf

Selecting no role at all is refused at startup rather than silently producing an all-roles binary. Each role build registers a strict subset of the default's 63 workflows over 458 Prüfidentifikatoren, and the startup log records both counts for whichever roles were compiled, so the binary's scope is evidence for a BNetzA audit.

Runtime --marktrollen is separate from compile-time feature flags. Feature flags determine which code is compiled; --marktrollen determines which commands are accepted at runtime. In a full binary, setting --marktrollen LF still loads the NB-side modules in memory — they simply reject NB-addressed commands. Use feature flags to remove them from the binary entirely.


[http] — REST admin API

TOML keyEnv varCLI flagDefaultDescription
addrMAKOD_HTTP_ADDR--http-addr(disabled)TCP listen address
max_body_bytesMAKOD_HTTP_MAX_BODY_BYTES--http-max-body-bytes10485760Max POST /edifact body in bytes
auth_keysMAKOD_AUTH_KEYS--auth-key(none)Named API keys NAME=TOKEN. Repeatable. At least one key or an [oidc] issuer is required when the port is enabled.
auth_keys_file(none)File of NAME=TOKEN lines; keeps tokens out of the config file and out of ps
authz.cedar_policy_dirMAKOD_CEDAR_POLICY_DIR--cedar-policy-dir(none)Directory of extra .cedar policy files appended to the built-in policy
authz.no_default_policyMAKOD_CEDAR_NO_DEFAULT_POLICY--cedar-no-default-policyfalseOmit the built-in permit-all baseline; requires a policy directory

makod refuses to start when --http-addr is set and neither --auth-key nor --oidc-issuer is provided. The /health probes are always public. Every other endpoint requires Authorization: Bearer <token>.


Authorization

{: #authorization }

makod uses Cedar — the same policy engine used by Amazon Verified Permissions — for attribute-based access control (ABAC) across all HTTP endpoints. The reusable mechanics (named-key registry with constant-time matching, Bearer/JWT routing, schema-validated policy loading) live in mako_service::cedar_schema, shared with the rest of the platform; makod contributes only the typed MaKo:: domain layer (actions, resource entities, the embedded schema). OIDC verification likewise comes from mako_service::oidc. The verifier itself does not check mako_tenant — a service that derives its tenant from the token has nothing to compare against — so the gate lives one level up: the Claims extractor uses ExpectedTenant, and the Cedar path (BearerAuthenticator) is given makod's primary MP-ID. See Tenant isolation below.

How it works

Every authenticated caller maps to a MaKo::Principal entity identified by the key name from --auth-key NAME=TOKEN. On each request the engine builds a Cedar Request with the principal, action, and resource, then evaluates it against the active policy set.

The built-in default.cedar policy permits all actions to every authenticated principal — a reasonable default for single-tenant operator deployments.

A Cedar request is allowed when any permit matches and no forbid does. While that baseline is active it therefore sets a floor that additional permit statements cannot lower: layering policies on top can only remove access, via forbid. Two ways to tighten, and the choice matters:

GoalHow
Carve exceptions out of a broadly trusted deploymentKeep the baseline, add forbid rules via --cedar-policy-dir
Grant nothing that is not written down--cedar-no-default-policy — the baseline is omitted and --cedar-policy-dir becomes the only source of access

The second is required for least privilege and for §9 EnWG role separation; the shipped conservative.cedar is written for it. makod refuses to start if the flag is set without a policy directory, rather than denying every request.

At startup, Cedar Validator runs in strict mode against the built-in schema. A policy file with type errors prevents startup — misconfigured policies are caught before they could silently over-permit or under-permit.

Identity model

MaKo namespace
├── Principal          — caller identity (keyed by --auth-key NAME)
├── Command            — attrs: name, marktrolle, pid, tenant
├── EdifactIngest      — attrs: tenant
├── AdminMaloRecord    — attrs: tenant, malo_id (optional)
└── AdminPartnerRecord — attrs: tenant, gln (optional)

Actions
├── SubmitCommand
├── IngestEdifact
├── AdminMalo (group)
│   ├── AdminMaloRead / AdminMaloWrite / AdminMaloDelete / AdminMaloStats
├── AdminPartner (group)
│   └── AdminPartnerRead / AdminPartnerWrite / AdminPartnerDelete / AdminPartnerImport

Action groups

Every mutating or data-bearing endpoint is behind a Cedar action: SubmitCommand, IngestEdifact, the AdminMalo*/AdminPartner* families, ReadMetrics, UseMcp, ReadRechnung (GET /api/v1/invoic/{id}/rechnung — BO4E billing data), AdminMigrations (POST /admin/migrations), UseWebdienste (every :8090 route), and ReadProcess (MCP get_process, list_overdue_deadlines and list_dead_letters). The conservative policy grants AdminMigrations to no standing principal: grant it to a break-glass principal for the FV-cutover window, then remove it.

ReadProcess carries the process's workflow name in the Cedar context, so a combined-role (VIU) deployment enforces §9 EnWG Informatorisches Unbundling with policy alone — an NB-scoped principal can be limited to NB-side workflows and never sees LF process state. It governs both process-reading MCP tools: get_process denies as not_found to avoid an existence oracle, and list_overdue_deadlines filters entries per workflow, since a missed regulatory window names the process it belongs to. Role separation needs --cedar-no-default-policy; under the permit-all baseline a workflow-scoped permit adds nothing and both tools stay readable by every principal. On the MCP transport, UseMcp only opens the endpoint; submit_command additionally evaluates the same SubmitCommand action as the REST handler, with the identity the transport authenticated.

The Cedar schema defines AdminMalo and AdminPartner action groups. Policies can reference the group name to match all member actions at once, without enumerating each one individually:

// Deny ops-grafana everything except MaLo stats and partner read.
forbid(
  principal == MaKo::Principal::"ops-grafana",
  action in [MaKo::Action::"AdminMalo", MaKo::Action::"AdminPartner"],
  resource
)
unless {
  action == MaKo::Action::"AdminMaloStats"
  || action == MaKo::Action::"AdminPartnerRead"
};
// Deny a gas-ERP key all partner admin and all Malo write/delete.
forbid(
  principal == MaKo::Principal::"erp-gas",
  action in [MaKo::Action::"AdminPartner"],
  resource
);
forbid(
  principal == MaKo::Principal::"erp-gas",
  action in [MaKo::Action::"AdminMalo"],
  resource
)
unless { action == MaKo::Action::"AdminMaloRead"
      || action == MaKo::Action::"AdminMaloStats" };

Provisioning keys

# Single integration (e.g. SAP IS-U ERP)
makod --auth-key erp-sap=$(openssl rand -hex 32) ...

# Multiple integrations with separate keys
makod \
  --auth-key erp-sap=$(openssl rand -hex 32) \
  --auth-key ops-grafana=$(openssl rand -hex 32) \
  --auth-key ci-tests=$(openssl rand -hex 32) \
  ...

Environment variable (comma-separated NAME=TOKEN pairs):

export MAKOD_AUTH_KEYS="erp-sap=<token1>,ops-grafana=<token2>"

In the TOML config file, API keys are set via the environment variable only (MAKOD_AUTH_KEYS) — they are not a TOML config field.

Custom Cedar policies

Drop .cedar files into a directory and set --cedar-policy-dir:

// /etc/makod/cedar/read_only_grafana.cedar
// ops-grafana may only query MaLo stats — use the AdminMalo group.
forbid(
  principal == MaKo::Principal::"ops-grafana",
  action in [MaKo::Action::"AdminMalo"],
  resource
)
unless { action == MaKo::Action::"AdminMaloStats" };
makod --cedar-policy-dir /etc/makod/cedar ...

That example uses forbid because it narrows the permit-all baseline. To go the other way — deny everything and grant back only what is listed — copy the shipped conservative.cedar into the directory and add --cedar-no-default-policy.

Or via the environment variable:

export MAKOD_CEDAR_POLICY_DIR=/etc/makod/cedar

Multiple .cedar files in the directory are merged into a single policy set. The Cedar Validator validates all policies (including custom ones) at startup.

OIDC / JWT authentication

makod supports JWT bearer tokens issued by any standards-compliant OIDC identity provider — Azure AD/Entra ID, Keycloak, Okta, Google Workspace, AWS Cognito, Kubernetes workload identity, and others.

Configuration:

TOML keyEnv varCLI flagDescription
oidc.issuerMAKOD_OIDC_ISSUER--oidc-issuerOIDC issuer URL
oidc.audienceMAKOD_OIDC_AUDIENCE--oidc-audienceExpected aud claim
oidc.jwks_refresh_secsMAKOD_OIDC_JWKS_REFRESH_SECS--oidc-jwks-refresh-secsJWKS refresh interval (default: 300 s)

At startup, makod fetches <issuer>/.well-known/openid-configuration to locate the JWKS endpoint, downloads the public keys, and caches them in memory. Token verification is synchronous and non-blocking — no per-request network round-trips. A background task refreshes the JWKS every jwks_refresh_secs seconds to handle key rotation without restarting.

Security constraints:

  • Only asymmetric algorithms are accepted: RS256/384/512, ES256/384, PS256/384/512.
  • HMAC algorithms (HS256, HS384, HS512) are unconditionally rejected.
  • The JWT iss and aud claims are validated on every token.
  • JWT expiry (exp) is enforced.
  • The mako_tenant claim must equal this deployment's primary MP-ID.

Tenant isolation. iss and aud alone do not identify an operator: in a shared OIDC realm every operator's tokens carry the same issuer and audience, so a token minted for another operator is cryptographically valid here. makod therefore pins its primary MP-ID as the expected tenant and rejects any token whose mako_tenant differs — or is missing, since "absent" is not "matching". The check sits in BearerAuthenticator, which every Cedar-gated endpoint authenticates through, so a newly added route cannot opt out of it without also opting out of authentication.

Identity mapping: The JWT sub claim becomes the Cedar principal entity ID — identical to API-key names. All Cedar policies work unchanged regardless of authentication method.

Coexistence: --auth-key and --oidc-issuer can be active simultaneously. This enables gradual migration: add OIDC without removing existing API keys.

TOML example:

[oidc]
issuer   = "https://login.microsoftonline.com/{tenant-id}/v2.0"
audience = "api://makod"
jwks_refresh_secs = 300

Azure Managed Identity example (CLI):

makod --oidc-issuer "https://login.microsoftonline.com/$TENANT/v2.0" \
      --oidc-audience "api://makod" \
      --http-addr "0.0.0.0:8080"

Cedar policy scoping an OIDC service account:

// Allow the Azure Managed Identity (identified by its object-id `sub`)
// to submit commands only — no admin access.
forbid(
  principal == MaKo::Principal::"<azure-object-id>",
  action in [MaKo::Action::"AdminMalo", MaKo::Action::"AdminPartner"],
  resource
);

Kubernetes workload identity example:

[oidc]
issuer   = "https://token.actions.githubusercontent.com"
audience = "api://makod"

The Kubernetes service-account token sub typically looks like system:serviceaccount:<namespace>:<name> — use that string as the Cedar principal entity ID in your policies.

[as4] — AS4/ebMS3 inbound and outbound

TOML keyEnv varCLI flagDescription
addrMAKOD_AS4_ADDR--as4-addrTCP listen address
party_idMAKOD_AS4_PARTY_ID--as4-party-idOperator MP-ID (defaults to the primary [[party]] MP-ID)
signing_key_pemMAKOD_AS4_SIGNING_KEY_PEM--as4-signing-key-pemPEM key (inline)
signing_key_pem_filePath to PEM key file (preferred)
signing_cert_pemMAKOD_AS4_SIGNING_CERT_PEM--as4-signing-cert-pemPEM cert (inline)
signing_cert_pem_filePath to PEM cert file (preferred)
decryption_key_pemMAKOD_AS4_DECRYPTION_KEY_PEM--as4-decryption-key-pemOperator's own EC (BrainpoolP256r1) private key for inbound decryption (inline)
decryption_key_pem_filePath to the inbound decryption key file (preferred)
trust_anchor_pemMAKOD_AS4_TRUST_ANCHOR_PEM--as4-trust-anchor-pemBDEW/BNetzA PKI CA certificate used to verify counterparty signatures (inline)
trust_anchor_pem_filePath to the trust anchor file (preferred)
partnersMAKOD_AS4_PARTNER--as4-partnerTrading-partner MP-ID=HTTPS-URL pairs
partner_certsMAKOD_AS4_PARTNER_CERT--as4-partner-certTrading-partner encryption certificates, MP-ID=<PEM> pairs (see AS4 / BDEW). Required for every partner: a send to a partner with no registered certificate fails with a policy violation rather than going out unencrypted
partner_cert_filesTrading-partner encryption certificates as MP-ID=/path/to/cert.pem pairs (preferred)
allow_unencryptedMAKOD_ALLOW_UNENCRYPTED_AS4--allow-unencrypted-as4Dev/test only: downgrade missing-encryption-material startup refusals to warnings
allow_no_signingMAKOD_ALLOW_NO_AS4_SIGNING--allow-no-as4-signingDev/test only: start without signing material and without an EDIFACT outbox webhook; outbound EDIFACT is logged instead of sent
allow_no_trust_anchorMAKOD_ALLOW_NO_AS4_TRUST_ANCHOR--allow-no-as4-trust-anchorDev/test only: run the AS4 listener with no counterparty trust anchor, accepting that every partner's signature is rejected
lenient_receiptsMAKOD_AS4_LENIENT_RECEIPTS--as4-lenient-receiptsInterop debugging: treat a missing or unverifiable synchronous eb:Receipt as a warning

The --as4-partner flag is repeatable. Using the env var, provide a comma-separated list:

MAKOD_AS4_PARTNER="9900000000001=https://a.example/as4,9900000000002=https://b.example/as4"

Partners are bootstrapped into the durable PartnerStore on startup. Changes made at runtime via the REST API (PUT /admin/partners/{mp_id}) survive restarts without requiring a redeploy.

The trust anchor is fail-closed. Counterparty signing certificates are issued by the BDEW/BNetzA PKI, so verifying them needs that CA certificate in trust_anchor_pem / trust_anchor_pem_file. With none configured the session falls back to the operator's own signing certificate as its only anchor, which trusts exactly one signer — itself — and rejects every inbound message from every partner. The daemon would bind :4080, report healthy and receive nothing, so this is a startup refusal. --allow-no-as4-trust-anchor is the explicit opt-out for a loopback or single-operator test where both ends share one certificate.

Encryption is fail-closed. BDEW AS4-Profil v1.2 §2.2.6.2.2 requires every production AS4 message to be encrypted. makod refuses to start when AS4 is active but the inbound decryption key is missing, or when a registered partner has no --as4-partner-cert encryption certificate — outbound deliveries to such a partner would fail at send time anyway, since the sender refuses encrypt = true without a recipient certificate. --allow-unencrypted-as4 downgrades both refusals to warnings for dev/test.

The profile itself is checked too. The BDEW stack declares a security floor of sign-and-encrypt, and asx-rs enforces it across the base profile and every override layer, rejecting a relaxing layer with ProfileValidationCode::SecurityFloorViolation.

All of these refusals run in --check as well, and so does a trial build of the signing session from the supplied PEM material: a malformed key, a certificate that does not match it, a partner endpoint on plain http://, or a partner with no encryption certificate all fail the check rather than the boot.

The floor is what makes this strict enough: the generic AS4 invariant only rejects disabling signing and encryption, which is weaker than §2.2.6.2.2's mandate of both. ProfileStack::overrides and partner_overrides are public, so a partner overlay turning encryption off is reachable by configuration. Being a startup check, a downgraded profile never serves traffic.

Signed receipts and receipt-verified delivery. Inbound messages are answered with a signed eb:Receipt echoing the inbound signature digests as NonRepudiationInformation. Outbound deliveries are acknowledged only after asx-rs's verify_sync_response proves the Non-Repudiation-of-Receipt guarantee rather than assuming it — the receipt signature must cover the acted-on eb:SignalMessage, RefToMessageId must match, every NonRepudiationInformation digest must match what the sent message was signed over, and the whole thing must fall inside a replay window.

A returned eb:Error is surfaced as a typed rejection with its ebMS3 code and, like an unverifiable receipt, backs off and retries. That is safe because the ebMS MessageId is the stable outbox id and the AS4-Profil mandates receiver duplicate elimination; P-Mode, partner certificate and rendering are resolved per attempt, so a configuration fix heals delivery without re-enqueueing. The retry budget is stated as time — 72 hours from creation (max_retry_window) — with an attempt belt only against runaway loops. outbox_delivery_attempted separates counterparty_error, receipt_unverified and transport_error. --as4-lenient-receipts drops to asx-rs's relaxed() policy (unsigned / non-NRR receipts) for interop bring-up.

Per-sender rate limiting. The AS4 port applies two independent GCRA limits: per peer IP (100 req/s, burst 50) and per sender MP-ID (50 req/s, burst 25), the latter keyed on the eb:From PartyId extracted before the costly receive pipeline runs. The pre-verification value is spoofable, which is acceptable for a limiter: both limits always apply, so spoofing can only cause extra rejections, never extra capacity.

OpenTelemetry. Set OTEL_EXPORTER_OTLP_ENDPOINT and makod initialises the shared mako-service telemetry stack — spans export via OTLP/gRPC with W3C propagation. Without it, the local pretty/compact/json subscriber is used unchanged.

End-to-end tracing. The W3C traceparent of an inbound request is scoped into a task-local, captured into every OutboxMessage.trace_context created while handling it, and re-injected on delivery as the ERP webhook traceparent header and the CloudEvents traceparent extension, and forwarded as the traceparent header on outbound AS4 HTTP — one trace across the asynchronous outbox boundary and on to the counterparty MSH.

Outbound wire format. Every outbound message is a complete EDIFACT Übertragungsdatei: UNB … UNH … UNT … UNZ. The UNB sender/receiver MP-IDs are the same values as the message's NAD+MS/NAD+MR (Allgemeine Festlegungen 6.1d, Kap. 2), the DE0007 qualifier is derived from the MP-ID (500 BDEW, 502 DVGW, 14 GS1), and the UNB DE0020 Datenaustauschreferenz — repeated in UNZ and in the §2.12 Content-Disposition filename — is derived from the outbox message id, so delivery retries reuse the same DAR.

AS4 security test coverage

makod ships 12 automated tests in services/makod/tests/as4_security.rs that verify BDEW AS4-Profil v1.2 compliance without WIRK certificates:

graph LR
    A[BdewTestPki<br/>BrainpoolP256r1] -->|generate| B[sender PKI]
    A -->|generate| C[receiver PKI]
    B -->|with_signing_material| D[SessionContext]
    D -->|send_async| E[SOAP envelope<br/>signed + encrypted]
    C -->|with_decryption_key_pem| F[MockAs4Endpoint]
    E -->|send_to_localhost| F
    F -->|next_received| G[plaintext payload<br/>decrypted ]
TestWhat it provesBDEW spec
sign_encrypt_pmode_defaultsbdew_pmode() defaults to encrypt=true§2.2.6.2.2
policy_with_key_requires_encryptionbdew_push_policy enforces require_encrypted_inbound§2.2.6.2.2
sign_encrypt_policy_is_bdew_compliantSOAP constants satisfy §2.2.6.2.1 + §2.2.6.2.2§2.2.6
tampered_signature_is_rejectedReal As4WsSecVerifier rejects payload tampering§2.2.6.2.1
inbound_encryption_enforced_when_decryption_key_setUnencrypted inbound is rejected§2.2.6.2.2
replay_dedup_blocks_duplicate_message_id72-hour dedup window prevents replay attacks§4.2
sign_encrypt_round_trip_via_mock_endpointFull sign+encrypt→transport→decrypt pipeline§2.2.6

Run these tests with:

cargo test -p makod --test as4_security

[webdienste] — BDEW API-Webdienste Strom

TOML keyEnv varCLI flagDescription
addrMAKOD_API_WEBDIENSTE_ADDR--api-webdienste-addrTCP listen address
allow_unauthenticatedMAKOD_WEBDIENSTE_ALLOW_UNAUTHENTICATED--webdienste-allow-unauthenticatedDisable the built-in bearer/OIDC + Cedar auth layer on :8090 — only behind an mTLS-terminating proxy

Authentication & mTLS: By default every :8090 route sits behind bearer/OIDC authentication and the Cedar UseWebdienste action — the same auth layer as the REST API — plus a body-size limit. On top of that, the BDEW API-Webdienste Strom specification requires mutual TLS (mTLS) with certificates issued by the BDEW PKI CA; makod does not terminate TLS itself, so deploy it behind a reverse proxy (Nginx, Envoy, AWS ALB) that enforces mTLS with the BDEW PKI CA in production.

--webdienste-allow-unauthenticated (env MAKOD_WEBDIENSTE_ALLOW_UNAUTHENTICATED) turns the built-in bearer/OIDC + Cedar auth layer off — set it only when a fronting proxy terminates mTLS with the BDEW PKI CA and enforces access itself. When set, makod emits a WARN at startup: "--webdienste-allow-unauthenticated: API-Webdienste Strom port has NO authentication."

Caller identity — X-Mako-Client-MP-ID

Authorization and identity are two different things here, and both are needed.

BDEW identifies the calling market participant by their mTLS client certificate, which the proxy validates and terminates — the Control Measures request carries no sending party in its body or query. The proxy must therefore forward the certificate's Marktpartner-ID:

# Nginx terminating BDEW PKI mTLS
proxy_set_header X-Mako-Client-MP-ID $ssl_client_s_dn_cn;

A request without it is refused with 400: the Endantwort to a §14a EnWG Steuerungsauftrag and the Bestätigung to a WiM Anmeldung are addressed to whoever sent them, so an order whose originator cannot be established has nowhere to be answered. The value must be a 13-digit Marktpartner-ID or a 16-character EIC; anything else is treated as absent.

The WiM Order API also carries netzbetreiber_id in its request body. That is an assertion, not an authentication, so the two must agree — a body naming a different participant is refused with 403. Same rule edi-energy enforces between UNB and NAD+MS (Allgemeine Festlegungen §2.13). The check runs before the transactionId idempotency guard, so a spoofed request cannot consume the key and turn the legitimate order into a swallowed duplicate.

§20b EnWG Netzzugangsplattform adapter

§20b EnWG (in force 23.12.2025) obliges the Netzbetreiber to run a joint nationwide internet platform carrying, at minimum, three use cases (Abs. 2): Bestellung/Änderung/Abbestellung von Zählpunktanordnungen (Nr. 1, umgangssprachlich Messkonzepte) und Verrechnungskonzepten (Nr. 2), and the Registrierung von Energy-Sharing-Vereinbarungen nach §42c (Nr. 3). The statute sets no dates — timing and interfaces are BNetzA Festlegungskompetenz (Abs. 3), and no Festlegung or platform API has been published. makod therefore ships the client side with a pluggable transport:

Command§20b anchorRoles
netzzugang.zaehlpunktanordnung.beauftragenAbs. 2 Nr. 1 (aktion: bestellung|aenderung|abbestellung)LF, MSB
netzzugang.verrechnungskonzept.beauftragenAbs. 2 Nr. 2 (same aktion triple)LF, MSB
netzzugang.energysharing.registrierenAbs. 2 Nr. 3LF

Payload: netzanschluss_id, nb_mp_id, antragsteller_ref (opaque Anschlussnehmer/-nutzer reference, no PII) and an optional free-form details object.

Each accepted command projects an erfasst record into marktd's netzzugang_antraege registry and enqueues a NetzzugangAntrag outbox message — the same at-least-once delivery machinery every market message uses. The sender then:

  1. --netzzugang-endpoint-url / MAKOD_NETZZUGANG_ENDPOINT_URL set — POSTs the request to the platform endpoint (for when the BNetzA Festlegung publishes an interface, or an interim per-NB endpoint) and advances the projection to uebermittelt (capturing a platform_ref when the response carries one).
  2. Unset — delivers the request to the ERP webhook as a de.mako.netzzugang.uebermittlungsbedarf CloudEvent: the operator submits it via the Netzbetreiber's Webportal, which is the statutory minimum interface (an API only "soll Berücksichtigung finden"). When --erp-webhook-secret is configured, the POST is HMAC-SHA256-signed with the same webhook-signature header the general ERP adapter uses.
  3. Neither configured — the request is marked fehlgeschlagen in the registry instead of poison-looping the outbox. A stored payload that fails to deserialize is treated the same way (permanent failure — logged, projected fehlgeschlagen, acked), never retried.

The answer (bestaetigt/abgelehnt, plus the platform reference) is recorded via marktd's PATCH /api/v1/netzzugang/antraege/{id}/status; every state change emits de.markt.netzzugang.antrag.updated.


MCP Server

makod exposes an Model Context Protocol (MCP) server at /mcp on the same --http-addr port. This allows LLM tooling (Claude Desktop, VS Code Copilot, any MCP-capable client) to directly inspect process state and submit MaKo commands without writing integration code.

Transport

Uses the MCP Streamable HTTP transport (spec 2025-11-25). Clients POST to /mcp for JSON-RPC requests and GET /mcp for SSE event streams. Stateful sessions are maintained in-memory (no separate session store required for single-instance deployments).

Authentication

Every HTTP request to /mcp (including SSE stream connections) must carry an Authorization: Bearer <token> header. The same Cedar ABAC layer enforced on all other HTTP endpoints applies — unauthenticated requests are rejected with 401 Unauthorized before reaching the MCP session layer.

Both static auth keys and OIDC tokens are accepted, whichever is configured.

Tools

makod ships 12 MCP tools covering process management, operational monitoring, and incident response:

ToolAnnotationsDescription
list_commandsread_onlyAll commands for this instance's configured Marktrollen
submit_commanddestructiveTrigger a MaKo process command — same as POST /api/v1/commands (with progress notifications)
get_maloread_onlyRead a cached Marktlokation by 11-digit ID
list_partnersread_onlyList all registered trading partners for this tenant
get_partnerread_onlyGet a trading partner by 13-digit MP-ID (BDEW 99…, DVGW 98…)
get_healthread_onlyDaemon version, tenant ID, Marktrollen, MaLo cache stats
get_processread_onlyBusiness-key lookup (malo_id/melo_id/vorgang) → active process identity
list_overdue_deadlinesread_onlyAll APERAK/response deadlines currently overdue — alert if non-empty
list_active_processesread_onlyTotal count of registered process instances (capacity planning)
get_outbox_statusread_onlyPending outbox count + oldest message age — alert when stuck > 5 min
list_dead_lettersread_only20 most recent permanently dead-lettered messages (§ 147 AO / GoBD — requires investigation)
get_format_version_coverageread_onlyPer message type: is today inside a BDEW format-version transition window, and which releases are in force

get_format_version_coverage answers the question the annual cutover raises — are both the outgoing and incoming releases dispatchable right now? It reports stable or transition per message type, naming the outgoing and incoming release during the grace window, so an operator can confirm dual-run readiness without reading profile metadata by hand.

Adapter coverage is not in question at runtime: makod panics at startup if any workflow lacks a MessageAdapter for a known format version, so a running instance always covers every release the tool lists. What changes daily during a cutover is which releases are in force, and that is what it reports.

The tool list is pinned by tool_inventory_tests in mcp_server.rs — adding a tool without updating this table fails the build.

Authorization

Reaching /mcp at all requires the Cedar UseMcp action. That grant authorizes the transport, not the data: every tool that touches tenant state additionally evaluates the same action its REST equivalent enforces, so an MCP principal can never read or change more through an agent than it could through the API directly.

ToolAdditional Cedar action
submit_commandSubmitCommand (per command name, Marktrolle and PID)
get_maloAdminMaloRead
list_partners, get_partnerAdminPartnerRead
get_process, list_overdue_deadlinesReadProcess, per workflow
list_dead_lettersReadProcess across workflows
list_commands, get_health, get_format_version_coverage, list_active_processes, get_outbox_statusnone — they expose no tenant data

get_process and list_overdue_deadlines evaluate ReadProcess per workflow, so a combined-role (VIU) deployment can scope an NB principal to grid-side workflows and keep supply-side process state out of reach (§9 EnWG Informatorisches Unbundling). list_dead_letters spans every workflow, so a workflow-scoped principal is denied it outright rather than shown a filtered view that would misrepresent the queue.

The every_tool_evaluates_a_cedar_action guard fails the build when a new tool reads tenant data without an action, so this table cannot drift.

Call list_commands first — it returns every command name, its Marktrolle(n), primary Prüfidentifikator, and whether a marktrolle override is required at dispatch time. Results are pre-filtered to the Marktrollen this instance was started with.

submit_command parameters

FieldTypeRequiredDescription
commandstringDotted command name: <domain>.<prozess>.<aktion> — e.g. gpke.lieferbeginn.anmelden
payloadobjectCommand-specific payload, e.g. {"malo_id": "10001234558", "lieferbeginn_datum": "2026-10-01"}
marktrollestringMarktrolle override (LF, NB, MSB, …); required for multi-role commands
idempotency_keystringStable key for this business request. Supplied: a repeat replays the recorded response (24 h) and reuse for a different request is refused. Omitted: a random UUID is echoed back and no replay record is kept

get_malo / get_partner parameters

get_malo takes malo_id (11-digit string). get_partner takes mp_id (13-digit MP-ID string — BDEW 99…, DVGW 98…, or GS1).

Resources

URI templateDescription
malo://{malo_id}Full MaloIdentResultPositive record from the MaLo cache
partner://{mp_id}Full partner record including AS4 URL, market roles, and channels

Clients that support MCP Resources can read these directly (e.g. drag-and-drop into a Claude conversation, or @resource malo://10001234558 in VS Code Copilot Chat).

Prompts

Six guided workflow prompts are built in and pre-fill the relevant tool calls with context and step-by-step instructions:

PromptArgumentsDescription
gpke-lieferbeginnmalo_id, lieferbeginn_datumGuided GPKE Lieferbeginn Strom workflow (electricity supplier change)
geli-lieferbeginnmalo_id, lieferbeginn_datumGuided GeLi Gas Lieferbeginn workflow (gas supplier change)
wim-geraetewechselmelo_id, process_date, receiver_mp_id, marktrolleGuided WiM Gerätewechsel workflow (meter device change)
msb-preisanfrage(none)Step-by-step MSB Preisanfrage (REQOTE/QUOTES, PRICAT 27003 dispatch)
wim-device-change(none)Guided WiM MSB-Wechsel, beide Sparten (approve/reject within 3 / 5 / 7 / 1 WT)
gpke-sperrung(none)Guided GPKE Sperrung Strom (LF confirms disconnection to NB)

Each prompt returns a User message that instructs the LLM to call the right tools in the right order, with the correct payload fields and applicable regulatory deadline.

Server instructions

When a client connects, makod returns dynamic server instructions that include:

  • The instance's tenant ID and configured Marktrollen
  • A filtered command list (only commands relevant to the configured roles)
  • A regulatory deadline table (GPKE 24 h, WiM Strom 3/5/7/1 Werktage per PID, GeLi Gas 10 Werktage; MaBiS has no response Frist — see mako_mabis::fristen)
  • Machine-readable error prefix glossary

This means the LLM always has full operational context without additional configuration.

Claude Desktop integration

Add makod as an MCP server in ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "makod": {
      "url": "http://localhost:8080/mcp",
      "headers": {
        "Authorization": "Bearer <your-auth-key-or-oidc-token>"
      }
    }
  }
}

Replace localhost:8080 with your --http-addr and the header value with a valid auth key or OIDC access token. Restart Claude Desktop to activate.

VS Code Copilot integration

Add to your VS Code settings.json or .vscode/mcp.json:

{
  "mcp": {
    "servers": {
      "makod": {
        "type": "http",
        "url": "http://localhost:8080/mcp",
        "headers": {
          "Authorization": "Bearer ${env:MAKOD_AUTH_KEY}"
        }
      }
    }
  }
}

Set MAKOD_AUTH_KEY in your shell environment before opening VS Code.

Kubernetes deployment note

When makod runs inside a cluster, expose the HTTP port to the MCP client via kubectl port-forward or an internal Service. The /mcp path is subject to the same network access controls as the REST API — no additional configuration is needed.


REST API Endpoints

All REST endpoints are mounted on the --http-addr port. The /health probes are also mounted on --as4-addr and --api-webdienste-addr.

OpenAPI spec and Swagger UI

The full machine-readable API contract is served at runtime:

PathDescription
GET /api/v1/openapi.jsonOpenAPI 3.1 JSON spec — suitable for client generation (openapi-generator, oapi-codegen, etc.)
GET /api/v1/docs/Swagger UI — interactive browser-based API explorer

Both paths are public (no bearer token required). The spec is generated from the handler annotations and is always in sync with the running binary — no separate maintenance step needed.

# Download the spec for client generation
curl http://localhost:8080/api/v1/openapi.json -o makod-openapi.json

# Open Swagger UI in the browser
open http://localhost:8080/api/v1/docs/

The bearer token for protected endpoints can be entered directly in Swagger UI via the Authorize button.

ERP command ingest

MethodPathAuthDescription
POST/api/v1/commands✅ BearerSubmit an ERP process-trigger command (GPKE, GeLi Gas, WiM, MABIS)

EDIFACT ingest

MethodPathAuthDescription
POST/edifact✅ BearerSubmit a raw EDIFACT interchange for routing and processing
GET/health/live❌ publicLiveness probe; reports only that the process answers
GET/health/ready❌ publicReadiness probe; pings the SlateDB store and checks worker heartbeats
GET/health❌ publicAlias of /health/ready

See the ERP Commands section below for the full endpoint specification.

POST /edifact request:

POST /edifact HTTP/1.1
Content-Type: text/plain; charset=utf-8
Authorization: Bearer <token>

UNB+UNOC:3+9900357000004:500+4012345000023:500+261001:1200+001++TL
UNH+...

POST /edifact response 200 OK:

{
  "accepted": 1,
  "rejected": 0,
  "messages": [
    {
      "message_type": "UTILMD",
      "pid": 55001,
      "workflow": "GpkeSupplierChange",
      "status": "routed"
    }
  ]
}

Per-message status:

StatusMeaningCounted as
routedPID found and a workflow is registered for itaccepted
unknown_pidPID found, no workflow registered — dead-letteredaccepted
no_pidThe message type carries none by design (CONTRL only)accepted
missing_pidA PID-bearing type arrived without one — dead-letteredrejected
missing_partyNAD+MS or NAD+MR absent — dead-letteredrejected
parse_errorNot parseable at allrejected

Both rejections are for the same reason one step apart: nothing can be routed without a PID, and Allgemeine Festlegungen V6.1d §2.13 identifies the fachliche sender and receiver in NAD+MS/NAD+MR DE 3035 "für alle EDI@Energy EDIFACT Nachrichten und -dateien einheitlich". The sender is the address of the answer and the APERAK; the receiver decides which of the operator's own MP-IDs — hence which Sparte and Marktrolle — was addressed. Substituting a blank produces a process that runs a real Frist and answers nobody.

CONTRL is exempt from both: a UN/EDIFACT syntax acknowledgement carries neither a Prüfidentifikator nor a NAD, so it keeps no_pid. The AS4 door and POST /edifact classify with one shared function and cannot drift apart.

A routed message can still fail to reach a process — the peer answered a closed process, or sent one with nothing to correlate on. That is normal traffic, logged but not recorded. The recorded case is a PID the router resolved with no dispatch arm behind it: the transport already acknowledged the message, so it is dead-lettered with reason = "not_dispatchable" — always a coverage bug on this side.

The PID is read from SG1 RFF+Z13 and BGM DE 1004, in the order the profile's pid_source declares. The AHBs put it in RFF+Z13 and give DE 1004 as a Dokumentennummer, so reading only one location makes a conformant partner's message undetectable. Only a plausible 5-digit code is accepted from either, so a numeric Belegnummer cannot outrank the real PID.

DVGW gas transport

The endpoint accepts both message families. They share the transports and the PidRouter — DVGW allocates Prüfidentifikatoren from 70000–79999 and BDEW does not — but not the parser, and the difference is not optional: a DVGW message rides ORDERS or ORDRSP, so the BDEW parser accepts an ALOCAT as a well-formed ORDRSP and reads 70001 straight out of RFF+Z13. The message then routes correctly and arrives as the wrong type, carrying no document code, no gas day and no positions.

dvgw_edi::sniff reads BGM C002 DE 1001 out of the head of the interchange — the only field that separates the families — and every inbound path tries it first: POST /edifact, AS4 inbound, and the combined-role loopback. A BDEW interchange pays only the sniff.

For a DVGW interchange the response has the same shape, with message_type set to the DVGW family (ALOCAT, NOMINT, NOMRES) and pid to the code from RFF+Z13. Two DVGW-specific outcomes appear in error as skipped: …:

skippedMeaning
no_correlation_keyThe Prüfidentifikator has no Zuordnungstupel published for it (ALOCAT 5.11a §3.3), or a ZO-T* tuple had no gas day to scope it — the message has no defined way to reach a process, so it is not attached to a guessed one
process_not_foundA NOMRES arrived with no nomination to answer. Only the NOMINT initiates; spawning on an answer would hand a fresh process a command it rejects

malo_id is always absent for a DVGW message: it has no MaLo, and its correlation key is the published Zuordnungstupel.

The CONTRL Empfangsbestätigung is owed for a DVGW interchange too — CONTRL AHB 1.0 §2.3.1 keys the obligation on Sparte, and the DVGW formats are the gas transport layer, so it applies unconditionally. The AS4 eb:Receipt is a protocol acknowledgement and does not discharge it; the six-hour EDIFACT-level one is enqueued from NAD+MS and the UNB DE 0020 control reference.

Ingest durability

Three properties hold for every inbound message, whichever transport carried it. They are stated here because each was once not true, and each failure was silent at the time it happened.

A spawn is one atomic write. Events, outbox entries, regulatory deadlines and the correlation-index entries all commit in a single SlateDB transaction. The correlation entry is what makes a process reachable: until the business key resolves to it, the counterparty's reply finds nothing and is skipped, and the next thing to happen is the process's own Frist expiring as a false timeout. Writing that entry after the events left a window where a crash produced a live process that was unreachable for the rest of its life — with the business key itself blocked against a fresh spawn.

One business key, one process. Spawning is a check-then-act — the lookup finds no live process, so one is created — so the lookup→spawn section is serialised per business key. Without that, two initiating messages for the same key arriving together both pass the check and both spawn, and nothing fails at that moment: every later message resolves the key to two processes and returns AmbiguousProcess, while the duplicate runs its own Fristen to expiry and reports them as missed.

The lock is in-process, which is sufficient because makod is a single writer by construction — the exclusive data-directory lock refuses a second instance. A deployment using --allow-multi-instance needs the same external lock that AS4 inbox deduplication already requires.

Self-addressed messages are held to the network's standard. In a combined-role deployment (NB + MSB on one MP-ID) a large share of traffic never leaves the process. That path runs the same pre-send AHB conformance gate as the network path, refuses to skip a message of its own interchange that will not parse back, and visits every message rather than stopping at the first PID with no workflow on this side. An interchange that dispatched nothing is an error, not an acknowledgement: acknowledging it would retire an outbox entry that was never delivered anywhere.

When no PID in a self-addressed interchange has a workflow on this side — a build that hosts the sending role but not the receiving one — the entry is acknowledged, because retrying cannot change which workflows are compiled in. Complete the exchange through the ERP command API.


ERP Commands (POST /api/v1/commands)

This endpoint is the integration point between your ERP system and the MaKo process engine. The ERP names the exact process command to trigger; the engine resolves all EDI-layer details (sender/receiver MP-IDs, PID, message reference) from internal state.

Why not just send EDIFACT?

ERP systems (SAP IS-U, Powercloud, Wilken, Schleupen) model business objects (MaLo, Lieferant, Zähler), not EDIFACT messages. This endpoint accepts those objects and process-specific dates — the engine generates the correct EDIFACT interchange and dispatches it over AS4.

Request envelope

{
  "command": "gpke.lieferbeginn.anmelden",
  "payload": {
    "malo_id":            "10001234558",
    "lieferbeginn_datum": "2026-10-01",
    "bilanzkreis":        "11XBK-STD-----9"
  }
}

bilanzkreis is required on an Anmeldung (55001, 55077); a command without one is refused. UTILMD AHB Strom 2.2 Kap. 5.3 makes the Produktpaket SG8 SEQ+Z79 Muss there and names the reason — „ohne die Angabe eines für den LF gültigen Bilanzkreises [kann] der NB den LF der Marktlokation bzw. Tranche nicht zuordnen". It renders as Produkt-Code 9991000002082 with the value in SG10 CAV+ZV4 and the Umsetzungsgrad in SG8 SEQ+ZH0, never as an FTX+ACB remark. An Abmeldung (55004) and a Kündigung (55016) register nothing and carry none.

GeLi Gas states it differently. The Gas AHB has no Produktpaket: UTILMD AHB Gas 1.2 marks SG10 CCI+Z19 DE 7037 Muss on a 44001, and geli.lieferbeginn.anmelden takes the same bilanzkreis field and renders that segment. Neither shape is sendable on the other Sparte.

For multi-role commands, include "marktrolle" to disambiguate:

{
  "command":    "wim.geraetewechsel.beauftragen",
  "marktrolle": "NB",
  "payload": { "melo_id": "DE00012345678", "process_date": "20261001", "receiver_mp_id": "9900357000004" }
}
FieldRequiredDescription
commandDotted command name: <domain>.<prozess>.<aktion>
marktrolleSee belowRequired only for multi-role commands; inferred for single-role
payloadCommand-specific fields (see payload table below)

Required: every command must carry an Idempotency-Key header. A missing or empty value is rejected with 422 missing_idempotency_key.

The accepted response is stored under the key for 24 hours and replayed verbatim on a retry — same 202, same process_id, no second dispatch. Reusing one key for a different command or payload is refused with 422 idempotency_key_reuse. Underneath it, a business-level guard refuses a second anmelden while a process for the same business key is still active — even under a fresh key — answering 409 duplicate_process with that process's id; treat that as success. See ERP integration for the full contract, including how it differs from 409 invalid_state.

Marktrolle resolution

The engine resolves the effective Marktrolle in two steps:

Step 1 — Infer or require from request

  • Single-role commands (e.g. gpke.lieferbeginn.anmelden → always LF): the Marktrolle is inferred from the command name. Any marktrolle value in the request is silently ignored — this means ERP connectors that always send a fixed role will not break.

  • Multi-role commands (e.g. wim.geraetewechsel.beauftragenNB or MSB): marktrolle must be supplied. The engine cannot infer which EDIFACT qualifier and workflow variant to use without it.

Step 2 — Check against --marktrollen

The resolved effective role must appear in --marktrollen. This prevents an LF-licensed deployment from accidentally issuing NB commands, and vice versa.

Error responses:

HTTPerror fieldCause
422command_rejected / detail unknown_commandCommand name not in registry
422command_rejected / detail marktrolle_requiredMulti-role command, no marktrolle supplied
422command_rejected / detail role_not_permittedAsserted marktrolle is not allowed for this command
422command_rejected / detail role_not_configuredEffective role is not in --marktrollen
422malo_not_foundmalo_id is not in the MaLo cache
422invalid_payloadMissing or malformed required payload field
500engine_errorStorage or engine failure

Success response 202 Accepted:

{
  "idempotency_key": "01924f4e-3b4a-7e12-8c47-0022f4b2d3a1",
  "command":         "gpke.lieferbeginn.anmelden",
  "marktrolle":      "LF",
  "status":          "accepted"
}

Fields the engine owns — never supply these

FieldSource
sender_mp_idAlways our operator MP-ID — the primary [[party]] MP-ID (role-specific entries override per command)
receiver_mp_idResolved from the MaLo cache (data_market_location_network_operators)
pruefidentifikatorDerived from command name (e.g. gpke.lieferbeginn.anmelden → 55001)
message_refGenerated by the engine (UUID); replay-stable across retries
document_dateToday (UTC) at dispatch time

The MaLo cache is populated by the ERP via PUT /admin/malo/{malo_id} using the NB's MaloIdentResultPositive response from the API-Webdienste Strom endpoint. If the MaLo is not in the cache, the engine returns 422 malo_not_found.

Command registry

CommandMarktrolleDomainPIDsNotes
gpke.lieferbeginn.anmeldenLFGPKE55001New supplier registers supply start
gpke.lieferbeginn.bestaetigenNBGPKE55002/55003DSO accepts/rejects supply start
gpke.lieferende.anmeldenLFGPKE55004Old supplier registers supply end (Abmeldung/Lieferende LF → NB)
gpke.lieferende.bestaetigenNBGPKE55005/55006DSO accepts/rejects supply end
gpke.kuendigung.anmeldenLFGPKE55016LFN terminates the old supply contract (Kündigung LFN → LFA; 55017 is the Bestätigung)
gpke.beendigung-zuordnung.anfragenNBGPKE55010Asks the incumbent LFA to release the Marktlokation (SD Lieferbeginn Nr. 3). The LFA answers 55011/55012 by 09:00 Uhr des 1. WT; silence counts as Zustimmung
gpke.zuordnung.informierenNBGPKE55036Tells the LFN die Identität des LFA when the MaLo is already assigned (SD Lieferbeginn Nr. 2, 07:00 Uhr des 1. WT nach dem ÜT)
gpke.zuordnung.beendenNBGPKE55037Ends the LFA's Zuordnung, naming the Grund and the Zuordnungsende (Nr. 10, 12:00 Uhr des 1. WT)
gpke.zuordnung.aufhebenNBGPKE55038Cancels a future LFZ Zuordnung (Nr. 13, 12:00 Uhr des 1. WT)
gpke.msb-zuordnung.beendenNBGPKE55611Tells the MSB its Zuordnung ends (ZC8) or the MSBZ that a future one is cancelled (ZH1) — SD Lieferende von NB an LF Nr. 11 / 13. The one message here that may name a Messlokation
geli.zuordnung.informierenGNBGeLi Gas44036Gas twin — Ablauf des 4. WT nach Eingang der Anmeldung
geli.zuordnung.beendenGNBGeLi Gas44037Gas twin — am selben Tag wie die Antwort, nur bei Bestätigung
geli.zuordnung.aufhebenGNBGeLi Gas44038Gas twin — am selben Tag wie die Antwort
gpke.eog.anmeldenNBGPKE55013NB assigns a contractless MaLo to the Grundversorger (§36/§38 EnWG gap closure)
geli.eog.anmeldenGNBGeLi Gas44013Gas twin of gpke.eog.anmelden — GNB registers a contractless Gas-MaLo into E/G
gpke.eog.bestaetigenLFGPKE55014E/G confirms the EoG Zuordnung (Versorgungsart + Bilanzkreis, SG8 SEQ+Z79)
gpke.eog.ablehnenLFGPKE55015E/G rejects the EoG Zuordnung (EBD E_0615: A02/A04/A05)
gpke.sperrung.beauftragenLFGPKE17115LF orders a disconnection from the NB
gpke.entsperrung.beauftragenLFGPKE17117LF orders a reconnection from the NB
gpke.sperrung.stornierenLFGPKE39000LF cancels a pending Sperrauftrag (ORDCHG)
gpke.sperrung.bestaetigenNBGPKE17115/17117NB reports successful execution → IFTSTA 21039
gpke.sperrung.fehlgeschlagenNBGPKE17115/17117NB reports failed execution + reason → IFTSTA 21039
gpke.abrechnung.annehmenLFGPKE31001/31002The LF settles an inbound Netznutzungsabrechnung → REMADV 33001
gpke.abrechnung.ablehnenLFGPKE31001/31002The LF disputes it → REMADV 33003/33004, AJT code from E_0406
geli.lieferbeginn.anmeldenLFGGeLi Gas44001Gas supplier registers supply start
geli.lieferbeginn.bestaetigenGNBGeLi Gas44002/44003Gas DSO accepts/rejects supply start
geli.lieferende.anmeldenLFGGeLi Gas44004Gas supplier registers supply end (Abmeldung NN)
geli.kuendigung.anmeldenLFGGeLi Gas44016LFN sends the Kündigung to the Altlieferant (GeLi Gas 3.0 § 3.1)
geli.lieferende.bestaetigenGNBGeLi Gas44005/44006Gas DSO accepts/rejects supply end
wim.geraetewechsel.beauftragenNB or MSBWiM55039/55042/55051/55168Commission a meter-device change
wim.geraetewechsel.bestaetigenNB or MSBWiM55040/55043/55052/55169Business Bestätigung — UTILMD with SG4 STS+E01 from the process's EBD
wim.rechnungsabwicklung.beendenLF or MSBWiM17006End the Rechnungsabwicklung MSB über LF (either side may)
wim.rechnungsabwicklung.zustimmenLF or MSBWiM19009Confirm a received Beendigung (ORDRSP)
wim.rechnungsabwicklung.ablehnenLF or MSBWiM19010Reject a received Beendigung (ORDRSP)
wim.steuerungsauftrag.bestaetigenMSBWiMMSB sends final positive control-measure response
wim.steuerungsauftrag.ablehnenMSBWiMMSB sends final negative control-measure response
mabis.abrechnung.einleitenBKVMaBiS13003 · 13020 · 13023Record a version of a Summenzeitreihe; opens the settlement on the first one and resumes it on every later one. Requires zeitreihe (the SG10 CAV code) and version (the Erstellungszeitpunkt)
mabis.abrechnung.daten-einreichenBKVMaBiS21000 · 21001 · 21005Send a Prüfmitteilung for one version. Requires antwortcode — a code published by the Entscheidungsbaum that decides this Summenzeitreihe — plus grund for anything other than that tree's Zustimmung. No Frist — bounded by the § 3.10 clearing window
mabis.abrechnung.begleichenBKV or ÜNBMaBiSClose the clearing window. It does not set a Datenstatus: that is the BIKO's alone (§ 3.8.3) and arrives as IFTSTA 21003/21004
mabis.liste.korrigierenLFN/NB/ÜNBMaBiS55066 · 55196 · 55202 · 55224Answer a Clearingliste with a Korrekturliste — one entry per disputed Marktlokation, { malo, grund }. An empty list is the ordinary „reconciled, nothing to correct" reply and is still sent: silence reads as acceptance of whatever the distributor filed. sender_rolle is required — it selects the Entscheidungsbaum, and E_0047 (NB) and E_0004 (ÜNB) publish different codes for the same Korrekturgrund
mabis.liste.ablehnenLFN/NB/ÜNBMaBiS55066 · 55196 · 55202 · 55224Refuse a Clearingliste entire — the disjoint cluster that names no Marktlokation at all. Each whole-list fact (abonnement_bestellt, zeitraum_plausibel, mabis_zp_passt, version_zugelassen, innerhalb_clearingphase) is tri-state: absent means „cannot answer", which escalates rather than guessing. The tree decides, so a list whose whole-list Prüfschritte all pass is refused here and owed a Korrekturliste instead
mabis.summenzeitreihe.uebermittelnNB or ÜNBMaBiS13003File a Summenzeitreihe for one Bilanzierungsgebiet with the BIKO
gpke.vollzugsmeldung.empfangenNB/LFN/LFAGPKE21024–21033Vollzugsmeldung received via REST (manual replay)
wim.iftsta.empfangenNB/MSBWiM21009–21018WiM IFTSTA status received via REST (manual replay)
mabis.iftsta.empfangenBKV/NB/ÜNBMaBiS21002Abweisung einer Prüfmitteilung (BIKO → NB/ÜNB); requires abweisungsgrund. A rejected Prüfmitteilung is never forwarded, so the check has to be redone (§ 9.8.2 Nr. 2)
mabis.datenstatus.empfangenBKV/NB/ÜNBMaBiS21003 · 21004Datenstatus received via REST. Both PIDs carry one — 21003 addresses the NB/ÜNB, 21004 the BKV — so which one arrives follows from the role. Accepts the STS+Z04 codes A01/A02/A03/A04/A06 or their snake_case names

| gpke.lieferbeginn.ablehnen | NB | GPKE | 55003 | DSO rejects supply start (E_0622/E_0623 code) | | gpke.neuanlage.bestaetigen | NB | GPKE | 55602/55603 | NB confirms a Neuanlage (E_0608; cluster picks the PID) | | gpke.neuanlage.ablehnen | NB | GPKE | 55604/55605 | NB refuses a Neuanlage — admissible only after the 60-WT Prüflauf | | gpke.abrechnungsdaten.bearbeitungsstand | NB | GPKE | 21047 | Bearbeitungsstand on 55156/55220/55673 (E_0595, IFTSTA) | | gpke.lieferbeginn.aktivieren | LF | GPKE | — | LF marks its own Anmeldung active once the NB confirmed | | gpke.lieferende.ablehnen | NB | GPKE | 55006 | DSO rejects supply end (E_0607) | | gpke.kuendigung.bestaetigen | LF | GPKE | 55017 | LFA confirms the Kündigung (E_0614) | | gpke.kuendigung.ablehnen | LF | GPKE | 55018 | LFA rejects the Kündigung (E_0614) | | gpke.nb-lieferende.bestaetigen | LF | GPKE | 55008 | LF confirms the NB's Ankündigung Lieferende (55007) | | gpke.nb-lieferende.ablehnen | LF | GPKE | 55009 | LF rejects the NB's Ankündigung Lieferende | | gpke.beendigung-zuordnung.bestaetigen | LF | GPKE | 55011 | LFA confirms the Beendigung der Zuordnung (55010, E_0624) | | gpke.beendigung-zuordnung.ablehnen | LF | GPKE | 55012 | LFA rejects the Beendigung der Zuordnung | | gpke.zuordnung-lf.bestaetigen | LF | GPKE | 55608 | LFN confirms the Ankündigung Zuordnung LF (55607); bilanzkreis rides SG8 SEQ+Z79, not FTX+ACB | | gpke.zuordnung-lf.ablehnen | LF | GPKE | 55609 | LFN rejects the Ankündigung Zuordnung LF | | maloid.lieferbeginn.fortsetzen | LF | GPKE | 55001 | Resume an Anmeldung once the MaLo-ID arrived from the MaLo-ID-Vergabe | | gpke.abrechnung.selbstausstellen | LF | GPKE | 31006 | LF issues the MMM invoice itself (Gutschriftverfahren) | | invoic.nne-abschlag.stellen | NB or GNB | Sparte-neutral | 31001 | Abschlagsrechnung Netznutzung | | invoic.nne.stellen | NB or GNB | Sparte-neutral | 31002 | Netznutzungsabrechnung — one PID in both Sparten | | invoic.mmm.stellen | NB or GNB | Sparte-neutral | 31005 | Mehr-/Mindermengenrechnung — one PID in both Sparten | | wim.msb-rechnung.stellen | NB or MSB | WiM | 31009 | MSB invoices the NB/LF/ESA for Messstellenbetrieb | | geli.lieferende.ablehnen | GNB | GeLi Gas | 44006 | GNB rejects the Abmeldung (G_0007) | | geli.nb-lieferende.ablehnen | LFG | GeLi Gas | 44009 | LFG rejects the GNB-initiated Lieferende | | geli.beendigung-zuordnung.ablehnen | LFG | GeLi Gas | 44012 | LFA rejects the Abmeldungsanfrage | | geli.kuendigung.ablehnen | LFG | GeLi Gas | 44018 | LFA rejects the Kündigung (G_0001) | | geli.eog.ablehnen | LFG | GeLi Gas | 44015 | E/G rejects the EoG Zuordnung | | geli.stornierung.initiieren | LFG | GeLi Gas | 44022 | LF cancels a running Gas Zuordnungsprozess | | geli.datenabruf.anfragen | LFG | GeLi Gas | 17103 | LF requests Gas Netzzustandsdaten (ORDERS) | | wim.geraetewechsel.ablehnen | NB or MSB | WiM | 55041/55044/55053/55170 | Business Ablehnung — requires an antwortcode from the process's EBD | | wim.geraetewechsel.aperak | NB or MSB | WiM | 55039/55042/55051/55168 | Technical APERAK (45 min, APERAK AHB 1.0 §2.4.1) — not the business answer | | wim.gesamtvorgang.melden | MSB or nMSB | WiM | 21010/21009 | MSBN reports the Gesamtvorgang outcome; the date it names becomes the Zuordnungsbeginn | | wim.zuordnung.bestaetigen | NB | WiM | 21012 | NB makes the Zuordnung from the reported date, 00:00 Uhr | | wim.zuordnung.ablehnen | NB | WiM | 21011 | NB records the MSB-Scheitermeldung; the MSBA stays assigned | | wim.preisanfrage.angebot-senden | MSB | WiM | 15001 | MSB answers a Preisanfrage with a QUOTES Angebot | | wim.weiterverpflichtung.beantworten | MSB | WiM | 19003/19004 | MSBA answers the Weiterverpflichtungsauftrag; Z13/Z14/Z22 is computed against the 3-Monats- resp. 1-Monats-Kappung | | wim.stoerung.bestaetigen | MSB | WiM | 23004 | MSB confirms an inbound Störungsmeldung; opens the Ergebnisfrist | | wim.stoerung.ablehnen | MSB | WiM | 23003 | MSB rejects the Störungsmeldung; the Use-Case ends | | wim.stoerung.ergebnis-melden | MSB | WiM | 23008 | MSB sends the Ergebnisbericht and closes the Use-Case | | wim.rechnung.annehmen | LF, NB, GNB, MSB, NMSB, ESA | WiM | 31009 · 31003 · 31004 | the payer accepts the invoice (REMADV) | | wim.rechnung.ablehnen | LF, NB, GNB, MSB, NMSB, ESA | WiM | 31009 · 31003 · 31004 | the payer disputes the invoice (REMADV; the AJT tree comes from rechnungspruefung(pid, empfaenger, gegenstand)E_0264 toward an ESA, E_0566/E_0273 an NB, E_0210/E_0270 an LF, the second of each pair when IMD+7081 is TEC) | | invoic.stornorechnung.annehmen | the union of every billing family's recipients | INVOIC | 31004 | Accept a Sparte-neutral Stornorechnung (REMADV). 31004 cancels an invoice from any family, so whoever could receive the original can receive its cancellation — the permitted set is derived from the family commands, not restated | | invoic.stornorechnung.ablehnen | the union of every billing family's recipients | INVOIC | 31004 | Dispute a Stornorechnung by the invoice's Zahlungsziel | | invoic.sonstige-leistung.stellen | NB or GNB | Sparte-neutral | 31011 | Rechnung sonstige Leistung (GPKE Teil 2 · AWH Sperrprozesse Gas) | | invoic.sonstige-leistung.annehmen | LF, LFG, LFN, LFA | Sparte-neutral | 31011 | LF accepts the invoice (REMADV) | | invoic.sonstige-leistung.ablehnen | LF, LFG, LFN, LFA | Sparte-neutral | 31011 | LF disputes the invoice (REMADV) | | gabi.rechnung.annehmen | BKV · MGV | GaBi Gas | 31007 | Settles a GaBi Gas invoice — the MGV the aggregated MMM-Rechnung 31007/31008, the BKV the Kapazitätsrechnung 31010 | | gabi.rechnung.ablehnen | BKV · MGV | GaBi Gas | 31007 | Disputes one — same family, same two roles | | geli.lieferbeginn.ablehnen | GNB | GeLi Gas | 44003 | GNB rejects the Anmeldung Netznutzung (G_0011) | | geli.nb-lieferende.bestaetigen | LFG | GeLi Gas | 44008 | LFG confirms the GNB-initiated Lieferende | | geli.beendigung-zuordnung.bestaetigen | LFG | GeLi Gas | 44011 | LFA confirms the Abmeldungsanfrage | | geli.kuendigung.bestaetigen | LFG | GeLi Gas | 44017 | LFA confirms the Kündigung (G_0001) | | geli.eog.bestaetigen | LFG | GeLi Gas | 44014 | E/G confirms the EoG Zuordnung | Commands with a single Marktrolle never need a marktrolle field. Commands listing two Marktrollen (NB/MSB, BKV/ÜNB) always require it.

ERP payload fields per command

Only fields the ERP genuinely owns are listed here. MP-IDs resolved by the engine (sender, receiver) are intentionally absent.

CommandRequired ERP payload fields
gpke.lieferbeginn.anmeldenmalo_id, lieferbeginn_datum, transaktionsgrund¹
gpke.eog.anmeldenmalo_id, gv_mp_id, process_date, transaktionsgrund, haushaltskunde¹
geli.eog.anmeldenmalo_id, gv_mp_id, process_date
gpke.eog.bestaetigenmalo_id, versorgungsart (ZC9/ZD0/ZE3/ZZD), bilanzkreis¹
gpke.eog.ablehnenmalo_id, reason
gpke.lieferende.anmeldenmalo_id, lieferende_datum
gpke.kuendigung.anmeldenmalo_id, kuendigung_datum, alter_lf_mp_id¹
gpke.sperrung.beauftragenmalo_id
gpke.entsperrung.beauftragenmalo_id
gpke.sperrung.stornierenmalo_id
gpke.sperrung.bestaetigenmalo_id, optional note/reason
gpke.sperrung.fehlgeschlagenmalo_id, reason (or note) — required
gpke.abrechnung.annehmenrechnung (BO4E RECHNUNG object)
gpke.abrechnung.ablehnenrechnung (BO4E RECHNUNG object), ablehnungsgrund
geli.lieferbeginn.anmeldenmalo_id (gas MaLo), lieferbeginn_datum
geli.lieferende.anmeldenmalo_id (gas MaLo), lieferende_datum
geli.kuendigung.anmeldenmalo_id (gas MaLo), zaehlpunkt, process_date
wim.geraetewechsel.beauftragenmelo_id², process_date (YYYYMMDD), receiver_mp_id, optional pid (default 55042)
wim.geraetewechsel.bestaetigenmelo_id², optional antwortcode (defaults to the tree's unconditional Zustimmung), optional bemerkung, abweichender_termin (required with Z01)
wim.geraetewechsel.ablehnenmelo_id², antwortcode (from E_0200/E_0201/E_0202/E_0240), optional bemerkung, abweichender_termin (required with Z12)
wim.geraetewechsel.aperakmelo_id², optional positiv (default true), optional reason
wim.gesamtvorgang.meldenmelo_id², optional erfolgreich (default true), zuordnungsbeginn (YYYYMMDD, required on success)
wim.zuordnung.bestaetigen / .ablehnenmelo_id²
mabis.abrechnung.einleitenzeitreihe, mabis_zp_id, bilanzierungsmonat, version, biko_id, absender_mp_id
mabis.abrechnung.daten-einreichenversion, pid, antwortcode, grund (required unless the code is the tree's Zustimmung)

¹ alter_lf_mp_id is required only when the old supplier is a different legal entity. The ERP derives it from contract data; the engine does not know the previous LF.

² For WiM Gerätewechsel the primary key is the melo_id (Messlokation), not the MaLo. The NB and MSB MP-IDs are resolved from the MeLo cache entry.

Integrated operators (NB + MSB, same MP-ID)

A Stadtwerke operating as both NB and MSB has one MP-ID in the BDEW Marktstammdatenregister. Start makod with --marktrollen NB,MSB. For multi-role commands, marktrolle selects the EDIFACT qualifier (DDM for NB, MS for MSB) and the correct workflow variant — it is a dispatch hint, not an identity claim.

In-process loopback for self-addressed outbox messages

Several workflows emit outbox messages addressed to a co-located role's MP-ID as part of their normal process flow:

MessageWorkflowSender → Recipient
ORDERS 17116 (Anfrage Sperrung Strom)gpke-sperrungNB → MSB
ORDERS 17116 (Anfrage Gas-Sperrung)geli-gas-sperrung-nbGNB → gMSB
ORDERS 17134/17135 (Konfiguration)gpke-konfigurationNB → MSB
ORDERS 17001/17009 (Geräteübernahme)wim-geraeteubernahmeNB → MSBA

When NB and MSB (or GNB and gMSB) share the same tenant_party_id — the typical configuration for an integrated Stadtwerke deployment — BdewAs4Sender detects this automatically and delivers the message via an in-process loopback instead of an AS4 round-trip:

  1. Renders the EDIFACT interchange (identical to external delivery).
  2. Re-parses it via Platform::parse_interchange.
  3. Passes each parsed message to EdifactIngestDispatcher::dispatch, which spawns or resumes the correct workflow process with zero network overhead.

No --as4-partner registration is required for own-MP-ID loopback delivery. --marktrollen NB,MSB (or GNB,gMSB) is still required so the Command API accepts multi-role ERP commands.

Runaway guard. Step 3 can make the workflow emit another outbox message, and if that one is self-addressed too the cycle repeats. Nothing else bounds it: a successful loopback acknowledges the message, so what the workflow emits is a fresh outbox entry with attempt_count 0 and the delivery retry budget never applies. An unbounded cycle would spin the outbox worker and grow the event store.

makod therefore counts loopback hops per conversation_id and dead-letters past 32, logging conversation_id and the hop count. Real combined-role exchanges are short — an Anfrage and its answer is two hops, and the longest modelled chain stays in single digits — so the cap is far above legitimate traffic and exists only to break a cycle. The counter is in-memory and per-process: a restart resets it, which is the right trade for a guard that must never be the reason a long conversation stalls.

Dispatch table for loopback-delivered messages:

PID(s) received via loopbackActionWorkflow
17115, 17117 (ORDERS Strom)spawn by MaLogpke-sperrungReceiveSperrauftrag
17115, 17117 (ORDERS Gas)spawn by MaLogeli-gas-sperrung-nbReceiveSperrung
19118, 19119 (ORDRSP)resume by MaLogpke-sperrungReceiveMsbAntwort
19116, 19117 (ORDRSP)resume by MaLogpke-sperrung-lfReceiveOrdrsp
19116, 19117 (ORDRSP Gas)resume by MaLogeli-gas-sperrung-lfReceiveOrdrsp
55001, 55004, 55077spawn by MaLogpke-supplier-changeReceiveUtilmd
55016spawn by MaLogpke-kuendigungReceiveKuendigung (its own workflow: gpke-supplier-change shares the MaLo key with the NB's Anmeldung)
55003–55006, 55017, 55018resume by MaLogpke-lf-anmeldungReceiveAntwort
44001–44021spawn by MaLogeli-gas-supplier-changeReceiveUtilmd
55036–55038 · 44036–44038spawn per Meldunggpke-zuordnungsmeldung / geli-gas-zuordnungsmeldungEmpfangen. Never resumed: three Meldungen ride one MaLo per Lieferbeginn, so each spawns its own process

The table is illustrative, not exhaustive — further combined-role pairs (e.g. the wim-rechnungsabwicklung ORDERS 17005/17006 and ORDRSP 19009/19010 exchange, or the ESA Wertebestellung handshake between esa-wertebestellung and wim-wertebestellung) follow the same spawn/resume pattern.

PIDs without a registered handler — for example, ORDERS 17116 when no autonomous gMSB-side workflow is running — are acknowledged immediately with a warn! log. The outbox entry is not retried. The waiting NB/GNB workflow continues until the APERAK deadline fires or the ERP delivers a confirmation via the Command API:

# NB reports successful physical execution → dispatches IFTSTA 21039 to the LF:
curl -X POST http://localhost:8080/api/v1/commands \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "command": "gpke.sperrung.bestaetigen",
    "marktrolle": "NB",
    "malo_id": "51238696012",
    "payload": { "note": "Zähler gesperrt, Plombe gesetzt" }
  }'

# NB reports that execution failed — `reason` is mandatory, so the LF learns why
# instead of waiting out the 24-hour deadline:
curl -X POST http://localhost:8080/api/v1/commands \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "command": "gpke.sperrung.fehlgeschlagen",
    "marktrolle": "NB",
    "malo_id": "51238696012",
    "payload": { "reason": "Zutritt verweigert" }
  }'

sperrd issues both of these automatically from PUT /api/v1/sperr-orders/{id}/execute and .../fail.


Partner management (/admin/partners/)

MethodPathDescription
GET/admin/partnersList all trading-partner records for this tenant
GET/admin/partners/{mp_id}Retrieve a single partner record
PUT/admin/partners/{mp_id}Create or update a partner record
DELETE/admin/partners/{mp_id}Remove a partner record
POST/admin/partners/importImport from a raw PARTIN EDIFACT interchange

PUT /admin/partners/{mp_id} request body — a flattened PartnerRecord. Only mp_id is required, and it must equal the path parameter; everything else defaults. updated_at is server-owned and ignored if sent.

{
  "mp_id": "9900000000001",
  "display_name": "Stadtwerke Beispiel GmbH",
  "channels": [
    { "qualifier": "AK", "address": "https://partner.example/as4/inbox" },
    { "qualifier": "EM", "address": "edifact@partner.example" }
  ],
  "roles": ["NB"],
  "valid_from": "2025-10-01T00:00:00Z",
  "country_code": "DE"
}

Response 200 OK:

{
  "mp_id": "9900000000001",
  "display_name": "Stadtwerke Beispiel GmbH",
  "updated_at": "2026-06-17T10:00:00Z"
}

Delivery channels must be HTTPS

Four different paths can set a partner's delivery address — the --as4-partner and --maloid-partner flags, this endpoint, a record discovered from the BDEW Verzeichnisdienst, and the COM segments of an inbound PARTIN. All four are held to the same rule: a channel makod delivers to (AK/AS4 for the AS4 inbox, AW for the API-Webdienste callback) must be an https:// URL, with localhost exempt for development.

An AW channel is where a MaLoIdentResultPositive is posted — the Marktlokation, its postal address and its NB/MSB assignment, all personal data of the Anschlussnutzer (DSGVO Art. 32).

PUT and the flags refuse an offending record. A PARTIN import drops the offending channel and keeps the rest — a counterparty telling us where to send their messages is exactly what PARTIN is for, and discarding a whole record over one bad channel would lose legitimate contact updates. Every drop is logged with the MP-ID and the address.

MaLo cache (/admin/malo/)

MethodPathDescription
GET/admin/malo/{malo_id}Retrieve a cached MaLo record
PUT/admin/malo/{malo_id}Upsert a MaLo record
DELETE/admin/malo/{malo_id}Remove a MaLo record
GET/admin/malo/statsPer-tenant statistics

EDIFACT Rendering

Workflow intent becomes wire bytes in orchestrator/edifact_renderer/ (split per message type), which dispatches on the outbox message type and — for MSCONS — on the Prüfidentifikator.

flowchart LR
    cmd["POST /api/v1/commands"] --> wf["Workflow"]
    wf --> ob[("Outbox")]
    ob --> r["edifact_renderer"]
    r -->|"message_type"| mt{"UTILMD · APERAK · CONTRL<br/>ORDERS · ORDCHG · ORDRSP · REQOTE · QUOTES<br/>INVOIC · REMADV · MSCONS · IFTSTA · …"}
    mt -->|"MSCONS"| pid{"Prüfidentifikator"}
    pid --> b["edi-energy builder"]
    b --> as4["AS4 / ebMS3"]

IFTSTA carries WiM Strom Teil 2 UC 4.4 „Beendigung durch MSB" as an MSB → ESA status message. The renderer drives PID 21042 (Umsetzungsstatus „Bestellung WiM") with BGM+Z09, the SG14 CNI Vorgangsnummer, the SG15 STS 9015=Z21 / 4405=105 („beendet"), the SG15 RFF+Z13 Prüfidentifikator, the SG15 RFF+AGI back-reference to the Bestellung and the SG15 DTM+93 Vertragsende.

Dates on the wire

DTM+137 Dokumentendatum is DE 2379 303 (CCYYMMDDHHMMZZZ) in every EDI@Energy AHB, with [931] fixing the zone to +00 and [494] requiring the stamp to be the creation moment or earlier. Inside UTILMD SG4 the Vorgangsdaten 92, 93, 76 and 157 are 303 too; only 154 (Annahmedatum) is 102 and Z10 (Kündigungstermin) 106.

A command may hand the renderer a plain YYYYMMDD date: the builders normalise it to CCYYMMDD0000+00, and a value that already carries a zone passes through untouched. Two edi-energy guards hold the line — no DTM+137 in 102, and no 303 value without its zone.

Inbound is deliberately more forgiving: a counterparty's 102 document date is parsed rather than rejected over a format code whose value is unambiguous.

MSCONS use cases

MSCONS carries many Anwendungsfälle with materially different segment shapes, so the renderer dispatches on the PID. An unimplemented one is refused by name — rendering it in a supported shape would produce a syntactically valid message stating something the sender did not say.

PIDAnwendungsfallBGM DE 1001Shape
13003Summenzeitreihe (MaBiS)BKsummed series over settlement slots
13023Redispatch 2.0 AusfallarbeitssummenzeitreiheZ46same
13015Arbeit + Leistungsmaximum im Kalenderjahr vor LieferbeginnZ27work entry plus one or two monthly maxima
13016Energiemenge und LeistungsmaximumZ28same
13019Energiemenge (Strom)7
13027Werte nach Typ 2 (MSB → ESA)Z83work entry only

BGM DE 1001 names what kind of document the message is and the receiver routes by it, so it is set per Anwendungsfall rather than left at a default.

Summed series (13003, 13023). Carries the identifying 3-tuple — LOC+172 (the polymorphic Meldepunkt qualifier — it accepts either a MaLo or a MeLo; here it carries the MaBiS-Zählpunkt), DTM+492 (Bilanzierungsmonat, CCYYMM) and DTM+293 (Versionsangabe, CCYYMMDDHHMMSSZZZ) — then one QTY per settlement slot, each bounded by DTM+163/DTM+164. A quantity without those bounds has no time reference, so the receiver cannot place it on the grid.

Work and maxima (13015, 13016, 13019). SG9 repeats two to three times for one delivery point: once for the energy from the start of the calendar year to Lieferbeginn, then once or twice for the highest and second-highest monthly power maxima, which the KAV concession-levy band depends on. Each maximum carries the period it fell in as DTM+306 — format 610 (CCYYMM) under a monthly or yearly Leistungspreissystem, 102 (CCYYMMDD) under a daily one. 13019 carries energy alone and refuses a maximum, pointing at 13016.

Quantities use DE 6063 220 (Wahrer Wert) or 67 (Ersatzwert), so a substitute is never reported as a measurement. Units are validated against DE 6411's closed code list — KWH, KWT, D54, MTS (MIG 2.5).

Conformance

services/makod/tests/mscons_conformance.rs renders each use case, parses it back, and validates it against the registered release profile — mandatory segments, segment order, group repeats and code lists — rather than asserting on segment substrings. A substring assertion confirms a segment the author thought of is present; profile validation confirms the message satisfies the rules the receiver applies.

Messages with more than one LIN/QTY cycle are checked on the same terms: the generated DETAIL_GROUP_TRIGGERS table rewinds the cursor on any nested group trigger, not just the outermost one.


Docker Deployment

The workspace ships a production-grade Dockerfile at the repository root. It uses a 4-stage cargo-chef + distroless build:

StageBasePurpose
cheflukemathwalker/cargo-chef:latest-rust-1.94-bookwormRust toolchain + native build deps (libssl-dev, libclang-dev, cmake, nasm)
plannerchefcargo chef prepare — analyses workspace manifests, emits recipe.json
builderchefcargo chef cook (cached dep layer) → cargo build -p makodstrip
runtimegcr.io/distroless/cc-debian12:nonrootMinimal runtime: glibc + libgcc + CA certs + tzdata only; no shell, no package manager

Key build properties:

  • OPENSSL_STATIC=1 — OpenSSL linked statically into the binary; no libssl.so needed at runtime.
  • TZ=Europe/Berlin/usr/share/zoneinfo/Europe/ copied from builder so time::OffsetDateTime resolves CET/CEST correctly for regulatory deadline arithmetic.
  • /var/lib/makod pre-created with uid 65532 (distroless nonroot) so SlateDB can write without a mounted volume (e.g. --check mode and CI).
  • VOLUME ["/var/lib/makod"] declared after the pre-owned directory so Docker does not reset ownership.
  • HEALTHCHECK CMD ["/usr/local/bin/makod", "--check"] — runs the full startup validation; exits 0 on success.

Pre-built image

Every release is automatically built for linux/amd64 and linux/arm64 and pushed to the GitHub Container Registry:

# Pull the latest release
docker pull ghcr.io/hupe1980/makod:latest

# Pin to a specific version
docker pull ghcr.io/hupe1980/makod:latest

# Smoke-test the image
docker run --rm ghcr.io/hupe1980/makod:latest --check

Images are tagged with the release version (e.g. 1.2.3), its major.minor (1.2), and latest. Pin a concrete tag in production; the examples here use latest so they never go stale.

Building locally

docker build \
  --build-arg OCI_REVISION=$(git rev-parse HEAD) \
  --build-arg OCI_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  -t makod:latest \
  .

Pass --build-arg PROFILE=dev for a debug build. The dep layer is cached as long as Cargo.lock and Cargo.toml files are unchanged.

Running the container

# Persistent local storage, signing keys, full port layout
docker run -d \
  -v /srv/makod/data:/var/lib/makod \
  -v /srv/makod/config:/etc/makod:ro \
  -p 4080:4080 \
  -p 8080:8080 \
  -p 8090:8090 \
  -e MAKOD_CONFIG=/etc/makod/makod.toml \
  -e MAKOD_AUTH_KEYS="erp-sap=$(openssl rand -hex 32)" \
  makod:latest

# Validate config without starting any workers (useful in CI pre-flight)
docker run --rm makod:latest --check

The container runs as uid 65532 (nonroot) with no capabilities. Mount signing keys and config as read-only volumes (-v /path:/etc/makod:ro); the data volume must be writable by uid 65532.

For docker-compose, declare the volume as user-scoped:

services:
  makod:
    image: makod:latest
    user: "65532:65532"
    volumes:
      - makod-data:/var/lib/makod
      - ./config:/etc/makod:ro
    ports: ["4080:4080", "8080:8080", "8090:8090"]
    environment:
      MAKOD_CONFIG: /etc/makod/makod.toml

volumes:
  makod-data:

Kubernetes example

apiVersion: apps/v1
kind: Deployment
metadata:
  name: makod
spec:
  replicas: 1          # ← single writer; see Scaling below
  selector:
    matchLabels: { app: makod }
  template:
    metadata:
      labels: { app: makod }
    spec:
      # Worst-case drain: --shutdown-timeout-secs (default 30 s) covers the
      # listener/worker join and the dead-letter flush, and the store close
      # keeps a 10 s floor of its own. Leave headroom above that sum — a
      # SIGKILL during the store close can leave the last writes unflushed.
      terminationGracePeriodSeconds: 60
      containers:
        - name: makod
          image: ghcr.io/hupe1980/makod:latest
          ports:
            - containerPort: 4080    # AS4
            - containerPort: 8080    # HTTP REST
            - containerPort: 8090    # Webdienste
          env:
            - name: MAKOD_CONFIG
              value: /etc/makod/makod.toml
            - name: MAKOD_AUTH_KEYS
              valueFrom:
                secretKeyRef: { name: makod-secrets, key: auth-keys }
          volumeMounts:
            - name: config
              mountPath: /etc/makod
            - name: data
              mountPath: /var/lib/makod
          # Liveness answers only "is the process up?" — it never consults the
          # store or the workers, because a restart would not fix either.
          livenessProbe:
            httpGet: { path: /health/live, port: 8080 }
            initialDelaySeconds: 5
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
          # Readiness carries the dependency state. Failing it only removes the
          # pod from Service endpoints, so it may react quickly.
          readinessProbe:
            httpGet: { path: /health/ready, port: 8080 }
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 3
          # Gate rollouts on the same readiness contract instead of a fixed
          # sleep: a replacement pod replays its event store before it answers.
          startupProbe:
            httpGet: { path: /health/ready, port: 8080 }
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 60       # up to 5 min for a cold replay
          # See terminationGracePeriodSeconds above: it must exceed the drain,
          # or Kubernetes SIGKILLs makod mid-delivery.
      volumes:
        - name: config
          secret: { secretName: makod-config }
        - name: data
          persistentVolumeClaim: { claimName: makod-data }

Scaling

SlateDB uses snapshot-isolation OCC transactions. For local and s3 backends, only one writer at a time is safe — run replicas: 1. makod enforces this for local with an exclusive lock on <data-dir>/.makod.lock; a second instance refuses to start rather than corrupting the write-ahead log.

There is no read-only replica mode: every makod instance opens the store for writing. Scaling out reads would need a separate read path, which is not implemented.

For high availability, use an S3-compatible object store and a leader-election layer (Kubernetes lease, etcd) so that only one instance runs at a time. Note that the object-store backends have no equivalent of the local lock file — pass --allow-multi-instance only when such a lock is genuinely in place, since AS4 inbox deduplication is not shared between instances.


Startup Validation (--check)

makod --check runs every validation the real boot runs, then exits without opening a socket or spawning a worker. Its contract is the one deployment pipelines gate on: exit 0 means this configuration will start.

PhaseWhat it proves
Config fileSchema parses; no unknown key; no secret supplied both inline and by file
[[party]]Marktpartner-ID format, one code per Marktrolle, no mixed Strom+Gas entry (Allgemeine Festlegungen §2.13)
ProfilesEvery registered domain module has an active edi-energy profile for each of its message types
Adapter coverageEvery adapter registry accepts every BDEW format version the compiled profile registry declares
Dispatch completenessEvery workflow the PidRouter reaches has an ingest arm and a deadline arm
StoreThe object store opens and the data directory lock is acquired
AS4 materialSigning key and certificate build a real session; the inbound decryption key is present; every partner endpoint is HTTPS and has an encryption certificate
CedarThe policy set compiles, including operator files from authz.cedar_policy_dir
PortsEvery authenticated port has an API key or an OIDC issuer; an issuer has an audience and uses HTTPS
TransportsAt least one ingest transport is configured, and outbound EDIFACT has somewhere to go

Only the network round-trips are deferred — OIDC discovery and the JWKS fetch — so the check runs on a CI runner with no route to the identity provider. The issuer's arguments are still validated.

The daemon does not re-derive any of this at boot: it consumes the preflight's own output, so a check that passes and a boot that fails cannot disagree.

--check changes no domain state. The process-registry reconciliation — the only startup step that writes events or registry entries — is deliberately sequenced after the check exit, so pointing a pipeline at a live data directory cannot alter what is stored in it.

Two things it does still do, both deliberate: it takes the exclusive data-directory lock, so it will refuse while the daemon is running (proving the lock is available is itself a startup precondition worth checking), and opening the store causes SlateDB to write its own manifest and WAL bookkeeping. Neither touches process state.

makod --check --config /etc/makod/makod.toml && echo "safe to promote"

The container image uses the same command as its HEALTHCHECK.


Health Checks

Three routes are mounted on every enabled port. All are unauthenticated, and all are exempt from the per-peer rate limiter — the limiter keys on the peer address, which behind a proxy or a shared NAT is the same address the orchestrator probes from, and a throttled probe reads as a dead container.

RouteAnswersFails whenProbe
/health/liveIs the process running?never, if it responds at alllivenessProbe
/health/readyCan it serve traffic?store unreachable, or a worker heartbeat is stalereadinessProbe
/healthalias of /health/readyas above
HTTP 200 {"status":"ok","instance_id":"makod-0-1","version":"0.18.0"}
HTTP 503 {"status":"degraded","instance_id":"makod-0-1","version":"0.18.0",
          "reason":"worker_stale:deadline-scheduler"}

reason is a stable category — store_unavailable, or worker_stale:<name>. It never contains filesystem paths or internal SlateDB state, so it is safe to surface in an alert.

Why the split

Kubernetes restarts a container that fails liveness and only removes it from Service endpoints when it fails readiness. Dependency state therefore belongs on readiness: restarting makod does not fix an unreachable object store, and doing it mid-delivery costs an AS4 retry cycle. Liveness reports only that the process is up and its HTTP stack answers.

Pointing both probes at a single endpoint that reports dependency state — the previous behaviour — turns a transient object-store outage into a restart loop.

The one failure readiness cannot fix

A readiness failure removes the pod from the Service; it never restarts it. That is the right answer for a dependency outage and the wrong one for a fenced writer.

SlateDB fences writers: a second process opening the same object-store path bumps the writer epoch and closes the older handle underneath its owner. On a local --data-dir an exclusive directory lock refuses the second start, but the cloud-backed deployment — --object-store=s3|gcs|azure, the production shape — has no such lock, and a rolling update overlaps two replicas by construction. A fenced instance keeps its listeners and fails every write.

makod watches for that and treats it as a shutdown: it drains listeners, workers and the dead-letter buffer as it would on SIGTERM, logs event store closed underneath the daemon, and exits non-zero so the orchestrator recycles the container instead of leaving it Running and inert. If a genuine second writer holds the lease, the restart loop is loud and visible; if the fence was a transient double-start, the restart is the recovery.

Worker liveness

Readiness covers more than the store. Every background worker publishes a heartbeat, and a watch that goes stale flips /health/ready to 503:

WorkerStale afterWhat a stall costs
deadline-scheduler3 × poll intervalRegulatory deadlines expire unnoticed — the most consequential stall
outbox-worker120 sOutbound EDIFACT stops leaving the queue
erp-webhook-worker120 sERP stops receiving CloudEvents
erp-log-worker120 sRegistered instead of the above when --erp-webhook-url is unset; ERP-targeted outbox entries accumulate
projection-worker:gpke-konfiguration5 × checkpoint interval (min 300 s)Read models serve stale data
projection-worker:gpke-supplier-change5 × checkpoint interval (min 300 s)Read models serve stale data
retention-purge-worker26 hAS4 dedup entries and Idempotency-Key records accumulate — storage grows without bound

The purge window is deliberately loose: the loop ticks daily, so a tighter threshold would flap on a slow purge over a large store. A stalled purge is the mildest of the six — deduplication keeps working, entries simply are not reclaimed — but it is the one that degrades silently over weeks.

In Kubernetes, target the --http-addr port with /health/live for liveness and /health/ready for readiness. Target --as4-addr separately if the AS4 server must be healthy before traffic is routed.


Background Workers

startup::spawn_workers launches the background workers as Tokio tasks and returns their handles. Every worker holds a clone of the shared CancellationToken and returns at its next message or tick boundary once that token is cancelled; the shutdown path then joins the handles before closing the event store.

That join is load-bearing rather than tidy. Cancelling a token nobody reads and dropping a JoinHandle — which does not abort a Tokio task — leaves workers running while the store closes underneath them. An outbox acknowledge lost to that race leaves the counterparty holding a message the outbox still shows as pending, and the next start delivers it a second time.

Cancellation is always observed between units of work, never inside one: a delivery in flight runs to its acknowledge, and a deadline being dispatched commits its events and outbox entries together. Work left undone stays durable — a queued message is still queued, and an undispatched deadline is still due — so the next start picks it up.

The two primary event-driven flows — outbox delivery and deadline firing — are:

graph LR
    OS[OutboxStore] -->|pending messages| OW[OutboxWorker]
    OW -->|EDIFACT SOAP| AS4[AS4 sender<br/>asx-rs]
    OW -->|MaLo callbacks| MS[MaloIdentSender]

    DS[DeadlineStore] -->|due_now every 30s| DSch[DeadlineScheduler]
    DSch -->|TimeoutExpired cmd| P[Process::execute_timeout]
    P -->|events + outbox| ES[EventStore + OutboxStore]
WorkerPoll intervalPurpose
OutboxWorkerContinuous, exponential backoffDrains OutboxStore and delivers EDIFACT via AS4 or MaLo callbacks
OutboxErpWorkerContinuous (optional, --erp-webhook-url)POSTs BO4E CloudEvents from the outbox to the ERP webhook
DeadlineSchedulerEvery 30 s (--deadline-poll-interval-secs)Fires overdue process deadlines (APERAK Frist, Zahlungsfrist)
Projection checkpoint--projection-checkpoint-intervalPersists projection checkpoints for crash-safe replay
Inbox purgePeriodicEvicts expired AS4 dedup entries from the inbox store

A JWKS refresh loop also runs when OIDC is enabled (see OIDC).


CONTRL Empfangsbestätigung (Sparte Gas)

Per CONTRL AHB 1.0 §2.3.1, the receiver must return a CONTRL Empfangsbestätigung (UCI DE0083 = 7) within 6 wall-clock hours for every inbound Gas Übertragungsdatei (and every Gas APERAK); in Strom, CONTRL is only sent on syntax error. The obligation is a property of the interchange, keyed purely on Sparte — it is independent of which message types (UTILMD, INVOIC, MSCONS, ORDERS …) it contains.

Which of our MP-IDs signs an outbound ORDERS

NAD+MS on an outbound message must be the MP-ID of the Marktrolle that owns the process — in the VIU configuration §2.13 mandates, that is a different code per role. The sender is resolved in three steps:

  1. payload["sender"], when the emitting workflow names it outright.
  2. The Prüfidentifikator's sending Marktrolle, taken from the Kommunikation columns of the BDEW Anwendungsübersicht Prüfidentifikatoren 4.0, narrowed by payload["sparte"] where the PID is shared between Strom and Gas (17115/17116/17117 name the LF/NB in GPKE and the LFG/GNB in the AWH Sperrprozesse Gas).
  3. The primary MP-ID — which is correct in a single-code deployment and a wrong sender identity in any other, so it logs a warning naming the PID.

orders_sender_coverage pins step 2 against the routed PID set: every ORDERS Prüfidentifikator makod routes either resolves a Marktrolle or is listed as exempt with a reason. Two exemptions stand — 17118, whose sender is the weiterer MSB of GPKE Teil 3 rather than one of the Marktrollen [[party]] accepts, and 17301, which the RB HKN-R sends and makod only receives.

makod determines the interchange Sparte from the recipient MP-ID (UNB DE0010 — the own party the interchange is addressed to). Every [[party]] entry covers exactly one Sparte (BDEW §2.13), so MpIdRegistry::sparte_of(recipient) is authoritative. This is deliberately not inferred from the Prüfidentifikator or the release code: INVOIC/ORDERS/MSCONS release codes carry no Sparte prefix (only UTILMD does, G…/S…), and the NAD DE3055 agency code (293 BDEW) is shared across both sectors — so a Gas NN-Rechnung (31002), MMM (31005) or MSB-Rechnung (31009) is only recognised as Gas via the recipient MP-ID.

When the recipient is a sparte-neutral party or not one of our own MP-IDs, makod falls back to a conservative message-level heuristic: an unambiguous Gas-only PID (UTILMD G 44xxx, INVOIC 31003/31007/31008/31010/31011) or a Gas UTILMD release track. INVOIC 31004 is deliberately absent — the Stornorechnung is the Sparte-neutral universal Storno of any INVOIC (INVOIC AHB §3.1.2), so it resolves by recipient MP-ID and is never forced to Gas.

The CONTRL and its 6 h escalation deadline are written in one transaction (enqueue_outbox_with_deadlines), so a crash cannot queue the acknowledgement without its deadline, and the outbox worker discharges the deadline on delivery — it escalates only when the CONTRL genuinely did not go out. Its sender is the recipient MP-ID, so a combined Strom+Gas deployment answers from the Sparte-correct own MP-ID.


Logging

Structured JSON (production)

[logging]
level  = "info"
format = "json"

Log lines look like:

{"timestamp":"2026-06-17T10:00:00.000Z","level":"INFO","target":"makod","fields":{"addr":"0.0.0.0:8080","authenticated":true,"msg":"HTTP REST API listening"}}

Tracing spans

Enable the tracing feature in edi-energy to get per-message parse/validate spans:

cargo add edi-energy --features tracing

These integrate with OpenTelemetry exporters when a global subscriber is configured. The makod daemon wires a tracing_subscriber::Registry at startup — set RUST_LOG=mako_engine=debug,edi_energy=debug for verbose output.


Secrets Management

Never embed secrets (signing keys, API tokens) in container images or version control. Use:

MethodHow
Kubernetes SecretsMount as volume files; point the *_file config keys at the mount
Secrets Store CSI driverProject AWS Secrets Manager / Azure Key Vault / Vault into a tmpfs mount
Docker Secretsdocker secret create makod-key signing.pem; bind-mount into the container
systemdLoadCredential=signing.key.pem:/path/to/key, then reference $CREDENTIALS_DIRECTORY

Always prefer the *_file variant. Every secret has one — the three AS4 keys, the per-partner encryption certificates, the API keys, the ERP webhook secret, and the marktd API key. A value passed as a CLI flag appears in ps output; a value passed by environment variable is readable by anything that can inspect the process environment or the container spec. The environment forms exist for local development and for orchestrators that inject configuration that way, not for production key material.


Operational Runbook

First-time setup

BDEW AS4-Profil v1.2 §2.2.6.2.1/§2.2.6.2.2 (with BSI TR-03116-3 §9.1) requires EC keys on BrainpoolP256r1, and two separate keypairs: one for signing, one for encryption. RSA material is not conformant, and reusing one key for both purposes is not either.

# 1. Signing keypair — ECDSA-SHA256 over BrainpoolP256r1
openssl ecparam -name brainpoolP256r1 -genkey -noout -out signing.key.pem
openssl req -new -x509 -key signing.key.pem -out signing.cert.pem -days 1095 \
  -subj "/CN=9900357000004/O=Stadtwerke Beispiel/C=DE"

# 2. Encryption keypair — ECDH-ES key agreement, same curve, different key
openssl ecparam -name brainpoolP256r1 -genkey -noout -out decryption.key.pem
openssl req -new -x509 -key decryption.key.pem -out encryption.cert.pem -days 1095 \
  -subj "/CN=9900357000004/O=Stadtwerke Beispiel/C=DE"

# In production both certificates are issued by the BDEW PKI, not self-signed;
# the self-signed pair above is for a test connection to a partner's test MSH.

# 3. Publish the encryption certificate to your trading partners and collect
#    theirs — one per partner, referenced from as4.partner_cert_files. Download
#    the BDEW PKI CA certificate for as4.trust_anchor_pem_file: without it the
#    trust anchor falls back to your own certificate and every counterparty is
#    rejected.

# 4. Validate the configuration before starting it
makod --check --config /etc/makod/makod.toml

# 5. Start makod
makod --config /etc/makod/makod.toml

# 4. Seed partner records (if not already in config)
curl -X PUT http://localhost:8080/admin/partners/9900000000001 \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "mp_id": "9900000000001",
    "channels": [{"qualifier":"AK","address":"https://partner.example/as4/inbox"}]
  }'

Checking the store is healthy

curl -s http://localhost:8080/health | jq .
# → {"status":"ok","store":"open"}

Submitting a test EDIFACT message

curl -X POST http://localhost:8080/edifact \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: text/plain; charset=utf-8" \
  --data-binary @my_message.edi

Listing registered trading partners

curl http://localhost:8080/admin/partners \
  -H "Authorization: Bearer ${TOKEN}" | jq '.partners[].gln'

Observability

{: #observability }

makod exports OpenTelemetry traces and metrics via OTLP (gRPC or HTTP). Every significant operation carries a trace context:

SignalWhat is instrumented
TracesInbound AS4/REST request → parse → route → execute → WriteBatch
TracesOutboxWorker delivery attempts (success / retry / dead-letter)
TracesDeadlineScheduler tick — due_now scan → TimeoutExpired dispatch
Metricsmako.events.appended counter (by workflow, tenant)
Metricsmako.outbox.pending gauge (by tenant)
Metricsmako.deadline.fired counter (by workflow, label)
Metricsmako.process.duration_ms histogram

makod also exports a Prometheus-format counter endpoint at GET /metrics:

CounterLabelsAlert condition
makod_process_initiated_totalfamilyBaseline for process volume
makod_process_completed_totalfamily, resultresult != "accepted" for NB-STP compliance
makod_outbox_delivery_attempts_totalresultresult = "transport_error" spikes
makod_deadline_fired_totalfamilyBaseline for deadline volume
makod_dead_letter_recorded_totalreasonAny dead-letter = regulatory risk. reason = "not_dispatchable" is specifically a mako coverage bug: the router claimed the PID and no arm consumed it; reason = "missing_interchange_party" is a defect in the sender's message
makod_inbound_messages_totalpid, resultresult = "error" for unknown PIDs
makod_validation_failed_totalmessage_type, releaseA counterparty is sending messages that do not conform to the AHB
makod_aperak_missed_totallabelAlert when > 0 — an undelivered APERAK is a regulatory violation (APERAK AHB 1.0 §2.4.1 Strom / §2.3.1 Gas)

family is the domain prefix — gpke, wim, geli-gas, wim-gas, gabi-gas, mabis, redispatch — and carries the same value on the initiated and completed counters, so the two join on one label. result is accepted (terminal success), rejected (negative APERAK), timeout (a regulatory window expired unanswered) or cancelled (permanent failure). Completions are counted as the ERP outbox drains each terminal event, the one point that sees every process ending regardless of family. An accepted APERAK is not a completion — it acknowledges that the interchange parsed, not that the process finished.

makod_validation_failed_total counts inbound messages, once each, at the ingest boundary — not adapter invocations, and not parse failures. Only about half the adapter families act on an AHB verdict; the rest are answer-PID adapters whose messages publish no Anwendungsfall, so counting inside the adapters would report nothing for whole families. A release with no registered profile is not counted either — there was no rule to break. A message that fails to parse becomes a dead letter, having neither a message type nor a release to label.

makod_aperak_missed_total counts APERAK delivery windows that were still registered when they came due. The outbox worker discharges a window the moment the APERAK it was watching is delivered, so a window that survives to its due time is an APERAK that never went out. That discharge is what makes the counter meaningful — the deadline scheduler selects on due_at <= now, so "fired after its due time" is true of every deadline it ever hands out and is not, on its own, evidence of anything.

Inbound rate limiting

Every port carries a GCRA token-bucket rate limiter keyed on the peer address: 100 requests/second sustained, burst 50, answering HTTP 429 Too Many Requests with Retry-After: 1 when a peer's bucket is empty. Health routes are exempt — a throttled probe reads as a dead container.

The AS4 port (:4080) has its own bucket, separate from the one the REST API (:8080) and the API-Webdienste port (:8090) share. Behind a proxy every client arrives from the ingress address, so a single shared bucket would let an ERP batch on :8080 throttle a trading partner's AS4 delivery — and that partner's retry schedule is what stands between the operator and a missed Frist.

On top of the per-IP limit, the AS4 port applies a per-sender limit of 50 req/s (burst 25), keyed on the eb:PartyId inside eb:From. That value is read before signature verification and is therefore spoofable; both limits always apply, so a spoofing sender still burns their own per-IP budget and a spoofed partner can at worst see extra 429s, never extra capacity.

Together these protect the event store from capacity exhaustion by a misconfigured or malicious counterparty (OWASP A05). The keys are socket peer addresses; X-Forwarded-For is deliberately not trusted, so a fronting load balancer must enforce its own per-client limits.

Configuration

[otel]
endpoint     = "http://otel-collector:4317"   # OTLP gRPC
service_name = "makod"

Or via environment, which takes precedence over the config file so telemetry can live entirely in the orchestrator:

OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 \
OTEL_SERVICE_NAME=makod \
makod --config /etc/makod/makod.toml

With an endpoint configured the subscriber switches to the structured JSON layer and [logging] format no longer applies. Omit [otel] entirely to disable telemetry with zero overhead.


Outbox Auto-Integrations

makod emits several CloudEvents from its outbox that downstream services consume automatically — no manual ERP triggering required.

WiM Stammdaten — ZAK+ZE register auto-population

When makod receives a WiM Stammdaten ORDERS response (PIDs 17102–17133) from the MSB, the wim_stammdaten_uebermittlung_registry() adapter automatically:

  1. Parses ZAK+ZE+ZD EDIFACT segments into structured zaehlwerke JSON
  2. Emits a de.mako.process.completed outbox entry carrying melo_id + parsed register data

marktd receives the event and upserts the ZaehlzeitRegister + ZaehlzeitSaison rows. This feeds billingd's §14a Modul 3 tariff-zone resolution (HT/ST/NT) without manual setup.

ZAK/ZE segmentParsed fieldValues
ZAK element 0obis_kennzahlOBIS code (e.g. "1-1:1.8.0")
ZAK element 1zaehlerauspraegungZ01HT, Z02NT, Z03EINZEL
ZAK element 2bezeichnungHuman-readable label
ZE element 0saisonZ01SOMMER, Z02WINTER, Z03GESAMT
ZD element 0tagtypZ01WERKTAG, Z02SAMSTAG, Z03SONNTAG_FEIERTAG
ZD elements 1..Ntime windows"HHMM:RegisterCode" switch-point pairs

No additional configuration is required — the pipeline activates whenever wim-stammdaten is registered in the PID router (role role-msb-strom or role-nb-strom).

WiM Steuerungsauftrag — VPP dispatch auto-billing

When an MSB confirms a Konfiguration command via wim.steuerungsauftrag.bestaetigen (PID 55168 positive Endantwort), makod emits a de.vpp.dispatch.confirmed CloudEvent (CE type de.vpp.dispatch.confirmed) via the DispatchConfirmed outbox message.

The payload carries all data needed for downstream billing:

{
  "tx_id":               "abc123",
  "location_id":         "C0001234567890",
  "location_type":       "sr",
  "execution_time_from": "2026-01-15T10:00:00Z",
  "execution_time_until": "2026-01-15T10:15:00Z",
  "max_power_kw":        "11.0",
  "command_type":        "Konfiguration",
  "sender_mp_id":        "9900123456789",
  "produkt_code":        "TX-MODUL2-HT"
}

billingd subscribes to this CloudEvent at POST /api/v1/webhooks/vpp-dispatch and automatically generates a VPP settlement Rechnung:

flexibility_kwh = max_power_kw × (execution_time_until − execution_time_from) / 3600
netto_eur       = flexibility_kwh × capacity_price_eur_per_kwh   (from vertragd.aggregatorvertraege)

The vpp-billing-agent in agentd monitors settlement completeness and performs Art. 17 RL (EU) 2019/944 audit-field checks.

InitialZustand resets (command_type = "InitialZustand") do not emit a DispatchConfirmed event — only load-reduction Konfiguration commands generate billing.


See Also

Edit this page ↗