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)               │
│                                                               │
│  GET /health — available 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

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.

cargo run -p makod -- \
  --config makod.toml \
  --allow-volatile \
  --http-addr 127.0.0.1:8080

Without --allow-volatile, makod refuses to start in volatile mode and prints an error directing you to either set --data-dir or pass the flag explicitly. This prevents accidental production deployments without persistent storage.

The flag can also be set via the environment variable MAKOD_ALLOW_VOLATILE=1 or via the config file (storage.allow_volatile = true).

Persistent local storage

cargo run -p makod -- \
  --config makod.toml \
  --data-dir /var/lib/makod \
  --http-addr 0.0.0.0:8080 \
  --auth-key erp-prod=$(openssl rand -hex 32)

Full production deployment

makod \
  --config /etc/makod/makod.toml \
  --data-dir /var/lib/makod \
  --http-addr 0.0.0.0:8080 \
  --auth-key erp-sap=$(openssl rand -hex 32) \
  --auth-key ops-grafana=$(openssl rand -hex 32) \
  --api-webdienste-addr 0.0.0.0:8090 \
  --as4-addr 0.0.0.0:4080 \
  --as4-party-id 9900357000004 \
  --as4-signing-key-pem-file /etc/makod/signing.key.pem \
  --as4-signing-cert-pem-file /etc/makod/signing.cert.pem \
  --as4-partner 9900000000001=https://partner-a.example/as4/inbox \
  --as4-partner 9900000000002=https://partner-b.example/as4/inbox

TOML Configuration File

All CLI flags can be placed in a TOML file and loaded with --config <FILE> (or MAKOD_CONFIG=<FILE>). CLI flags and environment variables take precedence over the config file.

# /etc/makod/makod.toml

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

[storage]
backend  = "s3"     # local | s3 | gcs | azure

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

[[party]]
mp_id   = "9900357000004"       # your 13-digit GLN
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)
# Note: auth_keys, marktrollen, and cedar_policy_dir are CLI flags / env vars only.

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

[as4]
addr     = "0.0.0.0:4080"
party_id = "9900357000004"
# Inline PEM (alternative: use *_pem_file to reference disk files)
signing_key_pem_file  = "/etc/makod/signing.key.pem"
signing_cert_pem_file = "/etc/makod/signing.cert.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",
]

[webdienste]
addr = "0.0.0.0:8090"

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_ESA_PARTNER_MP_IDS--esa-partner-mp-ids(empty)Market-partner IDs of counterparties acting as an Energieserviceanbieter — see below
(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 counterparties

REQOTE 35002 is shared: an ESA Werteanfrage (WiM Teil 2 Kap. 4 UC 4.1 Nr. 1) and a Preisanfrage arrive under the same Prüfidentifikator, because no ESA-specific REQOTE PID exists in any published format version. WiM Teil 2 resolves this at content level, and the sender's registered role — an ESA is registered via PARTIN 37006 — is the decisive signal.

A NAD segment carries only the party code, not the role, so list the ESA counterparties in --esa-partner-mp-ids. Without them the classifier falls back to the PIA Messprodukt marker alone, and a Werteanfrage that omits it is routed to wim-preisanfrage.

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 35002 Werteanfrage (LOC = MaLo)
    MSB-->>ESA: QUOTES 15003 Angebot · DTM+273 Bindungsfrist
    Note over ESA,MSB: 5 WT · no Bindungsfrist ⇒ Ablehnung der Anfrage
    ESA->>MSB: ORDERS 17007 Bestellung (within Bindungsfrist)
    MSB-->>ESA: ORDRSP 19011 Bestätigung / 19012 Ablehnung
    Note over ESA,MSB: 2 WT · ORDRSP has no LOC ⇒ correlate by RFF+ACW
    loop §60 Abs. 1 MsbG · daily by 09:30
        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 = Bestellung
        MSB-->>ESA: ORDRSP 19013 Bestätigung / 19014 Ablehnung
    else Abbestellung during delivery (UC 4.3)
        ESA->>MSB: ORDERS 17008 Abbestellung
        MSB-->>ESA: ORDRSP 19011 Bestätigung
    end

Only the REQOTE, QUOTES and ORDERS carry a LOC (the MaLo). The conformant ORDRSP and ORDCHG carry none — they are correlated to the running process by the order reference each echoes (RFF+ACW on an answer, RFF+ON on the Storno), the Belegnummer of the order it responds to. The 39002 Stornierung is part of the same subscription lifecycle; it is not a standalone process.

When --marktd-url is set, an inbound ESA Werteanfrage (REQOTE 35002) 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 35002, 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 35002 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 now 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.

The MSB's answers come back inbound and resume the process. The QUOTES 15003 Angebot still carries a LOC, so it correlates by MaLo. The ORDRSP 19011/19012/19013/19014 answers carry no LOC in their MIG-conformant form, so they correlate by the order reference the answer echoes in RFF+ACW (the Belegnummer of the ORDERS/ORDCHG the ESA sent). The ESA indexes its process under each outbound order Belegnummer for exactly this lookup; symmetrically, the MSB indexes its process under each inbound ORDERS Belegnummer so the LOC-less ORDCHG 39002 Stornierung (which references the original Bestellung in RFF+ON) resumes it. 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.

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 (daily, by 09:30). 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.

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 wire
wim.wertebestellung.anbietenQUOTES 15003 Angebot (carries the Bindungsfrist in DTM+273)
wim.wertebestellung.anfrage-ablehnenQUOTES 15003 Ablehnung (reason in FTX+ACB, no Bindungsfrist)
wim.wertebestellung.bestellung-beantwortenORDRSP 19011 (accept:true) / 19012 (accept:false, needs reason)
wim.wertebestellung.stornierung-beantwortenORDRSP 19013 / 19014
wim.wertebestellung.abbestellung-bestaetigenORDRSP 19011

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). So the ESA's esa-wertebestellung process resumes into AngebotErhalten or Abgelehnt correctly without a second PID.

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-sperrung, gpke-konfiguration, gpke-konfiguration-aenderung, gpke-neuanlage, gpke-partin, mako-wim (NB side), mako-redispatch (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-sperrung-nb, geli-gas-stornierung, geli-gas-datenabruf, geli-gas-partin, geli-gas-sperrprozesse-invoic
role-msb-strommako-wim: wim-device-change, wim-geraeteubernahme, wim-stammdaten, wim-preisanfrage, wim-preisliste, wim-rechnung, wim-insrpt, wim-wertebestellung
role-msb-gasmako-wim-gas: all WiM Gas workflows

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

Default (no flags)

When no role feature flags are set, all modules register — this is the backward-compatible default. Use this for development and combined multi-role deployments. The makod binary in the container image ships with all roles compiled in; use feature flags to produce smaller operator-specific images.

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

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
(CLI/env only)MAKOD_AUTH_KEYS--auth-key(none)Named API keys NAME=TOKEN. Repeatable. At least one --auth-key or --oidc-issuer is required when --http-addr is set.
(CLI/env only)MAKOD_CEDAR_POLICY_DIR--cedar-policy-dir(none)Directory of extra .cedar policy files appended to the built-in policy

makod refuses to start when --http-addr is set and neither --auth-key nor --oidc-issuer is provided. GET /health is 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 (sub-only IdP tokens supported — mako_tenant is optional at the verifier level and enforced by the Claims extractor where services need it).

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. Replace it with stricter policies for multi-tenant or multi-system deployments.

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). 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 (denials answer as not_found to avoid an existence oracle). 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 ...

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.

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 GLN (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)
partnersMAKOD_AS4_PARTNER--as4-partnerTrading-partner MP-ID=URL pairs
MAKOD_AS4_PARTNER_CERT--as4-partner-certTrading-partner encryption certificates, MP-ID=<PEM> pairs (see [docs/as4-bdew.md])
MAKOD_AS4_DECRYPTION_KEY_PEM--as4-decryption-key-pemOperator's own EC (BrainpoolP256r1) private key for inbound decryption
MAKOD_ALLOW_UNENCRYPTED_AS4--allow-unencrypted-as4Dev/test only: downgrade missing-encryption-material startup refusals to warnings

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.

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.

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 (0.11) verifies the counterparty's synchronous signal: it parses the SOAP namespace-correctly, checks the receipt signature covers the acted-on eb:SignalMessage, confirms RefToMessageId, verifies every NonRepudiationInformation digest against what the sent message was signed over, and enforces a replay window — the Non-Repudiation-of-Receipt guarantee, proven rather than assumed. A returned eb:Error is surfaced as a typed rejection (with its ebMS3 code) so retry-vs-dead-letter routing keys on the real reason; an unverifiable receipt is a retryable failure that backs off and eventually dead-letters. --as4-lenient-receipts drops to asx-rs's relaxed() policy (accepts 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
(CLI/env only)MAKOD_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."

§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 X-Mako-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 11 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)

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": "10001234567", "lieferbeginn_datum": "2026-10-01"}
marktrollestringMarktrolle override (LF, NB, MSB, …); required for multi-role commands
idempotency_keystringStable UUID for retry safety; a random UUID is generated when omitted

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://10001234567 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-gas-anmeldung(none)Guided WiM Gas MSB-Wechsel Anmeldung (GNB approve/reject within 10 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 5 Werktage, GeLi Gas 10 Werktage, MABIS 1 Werktag)
  • 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. GET /health is 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❌ publicLiveness/readiness probe; pings the SlateDB store

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"
    }
  ]
}

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 GLNs, 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":            "10001234567",
    "lieferbeginn_datum": "2026-10-01"
  }
}

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)

Recommended: send an Idempotency-Key: <uuid> header to prevent double-execution on network retries.

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 GLN — 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.anmeldenLFGPKE55002Old supplier registers supply end
gpke.lieferende.bestaetigenNBGPKE55005/55006DSO accepts/rejects supply end
gpke.kuendigung.anmeldenLFGPKE55017LF cancels a Lieferbeginn Anmeldung
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)
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.annehmenNBGPKE31001/31002DSO settles a Netznutzungsabrechnung
gpke.abrechnung.ablehnenNBGPKE31001/31002DSO disputes a Netznutzungsabrechnung
geli.lieferbeginn.anmeldenLFGGeLi Gas44001Gas supplier registers supply start
geli.lieferbeginn.bestaetigenGNBGeLi Gas44002/44003Gas DSO accepts/rejects supply start
geli.lieferende.anmeldenLFGGeLi Gas44002Gas supplier registers supply end
geli.lieferende.bestaetigenGNBGeLi Gas44005/44006Gas DSO accepts/rejects supply end
wim.geraetewechsel.beauftragenNB or MSBWiM55039/55042/55051/55168Commission a meter-device change
wim.geraetewechsel.bestaetigenMSBWiM55039/55042/55051/55168MSB confirms physical device swap
wim.steuerungsauftrag.bestaetigenMSBWiMMSB sends final positive control-measure response
wim.steuerungsauftrag.ablehnenMSBWiMMSB sends final negative control-measure response
mabis.abrechnung.einleitenBKVMABIS13003Open a balancing-zone billing period
mabis.abrechnung.daten-einreichenBKVMABIS13003BKV answers the Abrechnungssummenzeitreihe with a Prüfmitteilung (positive, or negative with reason)
mabis.abrechnung.begleichenBKV or ÜNBMABIS13003Mark billing period settled
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)
wim.gas.anmeldung.bestaetigenNB/GNBWiM Gas44042–44053GNB accepts GMSB Anmeldung (positive APERAK within 10 WT)
wim.gas.anmeldung.ablehnenNB/GNBWiM Gas44042–44053GNB rejects GMSB Anmeldung (negative APERAK within 10 WT)
wim.gas.kuendigung.bestaetigenNB/GNBWiM Gas44039–44041GNB accepts GMSB Kündigung
wim.gas.kuendigung.ablehnenNB/GNBWiM Gas44039–44041GNB rejects GMSB Kündigung
wim.gas.stornierung.bestaetigenNB/GNBWiM Gas44022–44024GNB sends positive APERAK 44023 to LF Stornierung
wim.gas.stornierung.ablehnenNB/GNBWiM Gas44022–44024GNB sends negative APERAK 44024 to LF Stornierung
mabis.iftsta.empfangenBKV/NB/ÜNB/BIKOMABIS21000–21003, 21005, 21007MABIS IFTSTA informational status received via REST
mabis.datenstatus.empfangenBKV/NB/BIKOMABIS21004MABIS Datenstatus received via REST (BIKO → BKV/NB)

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. GLNs 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
wim.geraetewechsel.beauftragenmelo_id², process_date (YYYYMMDD), receiver_mp_id, optional pid (default 55042)
wim.gas.anmeldung.bestaetigenmalo_id (gas MaLo)
wim.gas.anmeldung.ablehnenmalo_id (gas MaLo), reason (ERC code + text)
wim.gas.kuendigung.bestaetigenmalo_id (gas MaLo)
wim.gas.kuendigung.ablehnenmalo_id (gas MaLo), reason
wim.gas.stornierung.bestaetigenvorgang_id (Vorgangsnummer from PID 44022 IDE+24)
wim.gas.stornierung.ablehnenvorgang_id, reason
mabis.abrechnung.einleitenbilanzierungsgebiet, abrechnungszeitraum_von, abrechnungszeitraum_bis

¹ 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 GLNs are resolved from the MeLo cache entry.

Integrated operators (NB + MSB, same GLN)

A Stadtwerke operating as both NB and MSB has one GLN 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 GLN 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.

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, 55002, 55016spawn by MaLogpke-supplier-changeReceiveUtilmd
55003–55006, 55017, 55018resume by MaLogpke-lf-anmeldungReceiveAntwort
44001–44021spawn by MaLogeli-gas-supplier-changeReceiveUtilmd

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": "51238696780",
    "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": "51238696780",
    "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:

{
  "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:

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

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.

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)7work 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 (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 covered by #[ignore]d cases: the MSCONS profile models the AHB's SG5 (NAD+DP) and SG6 (LOC) as one group triggered by LOC, so a repeated cycle reads as out of order even when it conforms. Run them with --include-ignored to see it.


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"] — validates all adapters and profiles; 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:0.14.0

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

Images are tagged with the semver version (0.14.0), major.minor (0.14), and latest.

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:
      containers:
        - name: makod
          image: ghcr.io/hupe1980/makod:0.14.0
          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
          livenessProbe:
            httpGet: { path: /health, port: 8080 }
            initialDelaySeconds: 5
          readinessProbe:
            httpGet: { path: /health, port: 8080 }
      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. Multiple readers can share the same store via read-only SlateDbStore::open_read_only().

For high-availability, use an S3-compatible object store and implement a leader election layer (e.g. Kubernetes leader election, etcd) to ensure only one makod instance writes at a time.


Health Checks

GET /health is mounted on every enabled port.

HTTP 200 {"status":"ok","store":"open"}      ← store is healthy
HTTP 503 {"status":"degraded","store":"err"} ← store closed or unreachable

In Kubernetes, target the --http-addr port for both liveness and readiness probes. 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, all cancelled on graceful shutdown. 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.

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 such as UTILMD G 44xxx / INVOIC 31003/31004/31007/31008/31010/31011, or a Gas UTILMD release track). The CONTRL and its 6h escalation deadline are written in one transaction (enqueue_outbox_with_deadlines), so a crash can never queue the acknowledgement without its deadline. The CONTRL sender is the recipient MP-ID — the Sparte-correct own GLN, even in a combined Strom+Gas deployment.


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:

edi-energy = { version = "0.14", 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; use signing_key_pem_file config key
Docker Secretsdocker secret create makod-key signing.pem; bind-mount into container
Environment variablesMAKOD_AS4_SIGNING_KEY_PEM (inline PEM); MAKOD_AUTH_KEYS
AWS Secrets ManagerFetch at startup via init container; write to tmpfs volume

For the signing key and cert, always prefer the *_pem_file variant over inline PEM — it avoids the key appearing in process environment listings or container inspect output.


Operational Runbook

First-time setup

# 1. Generate a signing keypair (RSA 2048 minimum; RSA 4096 recommended for production)
openssl genrsa -out signing.key.pem 4096
openssl req -new -x509 -key signing.key.pem -out signing.cert.pem -days 3650 \
  -subj "/CN=9900357000004/O=Stadtwerke Beispiel/C=DE"

# 2. Register the certificate with your trading partners (out-of-band via BDEW)

# 3. 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 '{
    "gln": "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_totalfamilyMissed APERAK windows
makod_dead_letter_recorded_totalreasonAny dead-letter = regulatory risk
makod_inbound_messages_totalpid, resultresult = "error" for unknown PIDs
makod_aperak_missed_totallabelAlert when > 0 — late APERAK = regulatory violation (APERAK AHB 1.0 §2.4.1 Strom / §2.3.1 Gas)

AS4 inbound rate limiting

The AS4 inbound endpoint (/as4/inbox) is protected by a GCRA token-bucket rate limiter:

  • Sustained: 100 requests/second
  • Burst: 50 requests
  • Response on exhaustion: HTTP 429 Too Many Requests + Retry-After: 1

Protects the event store from capacity exhaustion by misconfigured or malicious counterparties (OWASP A05).

Configuration

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

Or via environment:

OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 \
OTEL_SERVICE_NAME=makod \
makod --data-dir /var/lib/makod ...

Omit the [otel] section entirely to disable telemetry with zero overhead — the instrumentation compiles to a no-op when the feature is off.


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 2 HT/NT tariff-zone resolution 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 ↗