makod Operator Guide

makod operator guide: ports, CLI flags, the config file, persistent and volatile storage, AS4 inbound, the REST API and Kubernetes deployment.

On this page 19 sections

makod is the production daemon for the Mako process engine. It assembles the seven domain modules startup::production_modules() registers — GPKE, WiM, GeLi Gas, GaBi Gas, MaBiS, NZR-EMob and Redispatch — on a durable SlateDB event store, and serves three independent ports: AS4 inbound, HTTP REST, and BDEW API-Webdienste Strom.

It parses an inbound EDIFACT message, routes it by Prüfidentifikator (PID) to a workflow, runs the Frist as a durable deadline, and renders the answer back onto the wire.

The terms — LF, NB, MSB, BKV, AHB and MIG, the message types — are defined in the glossary; which PID belongs to which process is the process map.


Port Layout

PortFlagServes
:4080--as4-addrAS4/ebMS3 inbound (EDIFACT + Redispatch XML)
:8080--http-addrREST API (POST /edifact, /api/v1/commands, admin), /mcp, /metrics, OpenAPI
:8090--api-webdienste-addrAPI-Webdienste Strom (iMS REST/JSON)

Each port is optional and enabled independently. /health, /health/live and /health/ready are mounted on every enabled port.

Daemons makod exchanges commands and events with most directly:

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
processd:8580Walks the Entscheidungsbäume and posts the answer command back
netzbilanzd:8680NNE/MMM/MSB INVOIC generation

The service index lists every daemon.


Quick Start

Development

just dev makod     # cargo run with dev/config/makod.toml

dev/config/makod.toml runs an in-memory event store, the permit-all Cedar baseline and one synthetic NB/LF/MSB identity. Its load-bearing keys:

[authz]
permit_all = true              # dev only: every authenticated caller is admin

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

[http]
addr      = "127.0.0.1:8080"
auth_keys = ["dev=dev-makod-key"]

[[party]]
mp_id   = "9900357000004"
roles   = ["NB", "LF", "MSB"]
primary = true

[as4]
allow_no_signing      = true   # no AS4 credentials: log outbound EDIFACT
allow_no_trust_anchor = true

Each of those is a startup refusal without its opt-in:

KeyRefusal it waives
allow_volatile (MAKOD_ALLOW_VOLATILE, --allow-volatile)No data_dir and no object store
auth_keys or an [oidc] issuer[http] addr set with no authentication
permit_all or cedar_policy_dirDefault-deny authorization with no policy — every request would be 403
allow_no_signingNo AS4 signing material and no [erp] edifact_outbox_webhook_url

⚠ Volatile mode is development and CI only. All event streams, outbox messages, snapshots, registry entries and deadlines live in RAM. Any exit, crash, restart or reschedule loses every in-flight process, undelivered APERAK and CONTRL, and the § 147 AO / GoBD audit trail. makod_volatile_mode_active is 1 while it is on.

Production

A production deployment carries key material, so it belongs in the config file: a secret passed as a flag is visible in ps.

makod --check --config /etc/makod/makod.toml   # gate the pipeline on this
makod --config /etc/makod/makod.toml

Configuration file

Every CLI flag has a config-file key, and every secret has a *_file companion read at startup. Unknown keys are rejected, and supplying both an inline secret and its *_file companion is an error. Precedence:

CLI flags  >  Environment variables  >  Config file  >  Built-in defaults
# /etc/makod/makod.toml

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

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

[storage]
backend = "s3"      # local | s3 | gcs | azure
# data_dir          = "/var/lib/makod"   # required for backend = "local"
# max_stream_events = 10000              # per-stream quota; 0 disables

[storage.s3]
bucket = "my-makod-events"
prefix = "makod"
# endpoint = "http://minio:9000"

# One entry per Marktpartner-ID. Allgemeine Festlegungen §2.13 requires a
# separate code per Energieart and Marktrolle.
[[party]]
mp_id   = "9900357000004"
roles   = ["NB"]
primary = true

[http]
addr           = "0.0.0.0:8080"
auth_keys_file = "/etc/makod/auth-keys"         # NAME=TOKEN per line

[authz]
cedar_policy_dir = "/etc/makod/cedar"           # start from conservative.cedar

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

[webdienste]
addr = "0.0.0.0:8090"

[engine]
# deadline_poll_interval_secs = 30
# marktrollen                 = ["NB"]   # defaults to the [[party]] union

[as4]
addr                    = "0.0.0.0:4080"
party_id                = "9900357000004"
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"
partners = [
  "9900000000001=https://partner-a.example/as4/inbox",
]
partner_cert_files = [
  "9900000000001=/etc/makod/partners/9900000000001.enc.pem",
]
partner_signing_cert_files = [
  "9900000000001=/etc/makod/partners/9900000000001.sig.pem",
]

[erp]
webhook_url         = "https://erp.example.com/mako/events"
webhook_secret_file = "/etc/makod/erp-webhook.secret"

[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

Use the *_file form for every secret; the file forms compose with Kubernetes Secrets, the Secrets Store CSI driver, a vault-agent tmpfs sink or systemd LoadCredential=. Environment variables are readable from the process environment and the container spec.

InlineFile companion
as4.signing_key_pem · signing_cert_pem · decryption_key_pem · trust_anchor_pemthe same key with _file
as4.partner_certs · as4.partner_signing_certsas4.partner_cert_files · as4.partner_signing_cert_files
as4.client_tls_cert_pem · client_tls_key_pemthe same key with _file
webdienste.client_cert_pem · client_key_pem · signing_key_pemthe same key with _file
http.auth_keyshttp.auth_keys_file
erp.webhook_secreterp.webhook_secret_file
marktd.api_keymarktd.api_key_file

All Configuration Options

[logging] / [otel]

TOML keyEnv varCLI flagDefault
logging.levelMAKOD_LOG_LEVEL--log-levelinfo
logging.formatMAKOD_LOG_FORMAT--log-formatpretty (compact, json)
otel.endpointMAKOD_OTEL_ENDPOINT--otel-endpoint(unset — no export)
otel.service_nameMAKOD_OTEL_SERVICE_NAME--otel-service-namemakod

OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_SERVICE_NAME are honoured as well and take precedence over the file. With an endpoint set, spans export via OTLP/gRPC with W3C propagation and the subscriber is the JSON layer (logging.format does not apply).

[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 path (backend = "local")
allow_volatileMAKOD_ALLOW_VOLATILE--allow-volatilefalseRequired to run without data_dir; never production
max_stream_eventsMAKOD_MAX_STREAM_EVENTS--max-stream-events10000Per-stream event quota; 0 disables
allow_multi_instanceMAKOD_ALLOW_MULTI_INSTANCE--allow-multi-instancefalseOnly behind an external lock — see Scaling
SectionKeys (env MAKOD_<SECTION>_<KEY>, flag --<section>-<key>)Credentials
[storage.s3]bucket (required), prefix (makod), endpoint (MinIO/compatible)AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
[storage.gcs]bucket (required), prefix (makod)GOOGLE_SERVICE_ACCOUNT_KEY or GOOGLE_APPLICATION_CREDENTIALS
[storage.azure]container, account (both required), prefix (makod)AZURE_STORAGE_ACCOUNT_KEY, or 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
deadline_poll_interval_secsMAKOD_DEADLINE_POLL_INTERVAL_SECS--deadline-poll-interval-secs30Deadline scheduler poll (min 1 s). Keep well under the Redispatch 2.0 AcknowledgementDocument Frist of 3 minutes
snapshot_intervalMAKOD_SNAPSHOT_INTERVAL--snapshot-interval100Events between snapshots; 0 disables
projection_checkpoint_intervalMAKOD_PROJECTION_CHECKPOINT_INTERVAL--projection-checkpoint-interval60Seconds between projection cursor writes; 0 stops the projection workers
worker_threadsMAKOD_WORKER_THREADS--worker-threadslogical CPUsTokio worker threads
marktrollenMAKOD_MARKTROLLEN--marktrollen[[party]] role unionRoles the command API accepts commands for
deployment_rolesMAKOD_DEPLOYMENT_ROLES--deployment-roles[[party]] role unionRoles that gate PID registration: NB, LF, MSB, NMSB, AMSB, BKV, UENB/ÜNB/UNB/FNB, BIKO, ESA; GNB/ANB/VNB → NB, LFG → LF, GMSB → MSB. Unrecognised values are dropped with a WARN

A command for a role outside marktrollen is refused with 422 role_not_configured. Typical values: LF (supplier), LF,LFG (dual-fuel), NB, NB,MSB (Stadtwerke), BKV.

[[party]] — operator identities (required)

At least one entry; one per Marktpartner-ID.

TOML keyRequiredDescription
mp_idyes13-digit BDEW- (99…) or DVGW-Codenummer (98…), GS1 GLN, or 16-char EIC
rolesyesCase-insensitive. Strom: NB, LF, MSB, ANB, VNB, NMSB, AMSB, BKV, UNB, BIKO, ESA, DP, EIV. Gas: GNB, LFG, GMSB, MGV, FNB, KN. Both: RB. MGV, DP, EIV, KN, RB route no PIDs
primarynoStorage partition key (derives the TenantId) and default sender MP-ID; the first entry when none is marked
agencynoNAD DE 3055 override; derived from the prefix otherwise (99… → 293, 98… → 332, EIC → ZEW, else 9)

[http] / [authz] — REST API

TOML keyEnv varCLI flagDefaultDescription
http.addrMAKOD_HTTP_ADDR--http-addr(disabled)Listen address
http.max_body_bytesMAKOD_HTTP_MAX_BODY_BYTES--http-max-body-bytes10485760Max POST /edifact body
http.auth_keysMAKOD_AUTH_KEYS (comma-separated)--auth-key (repeatable)—NAME=TOKEN API keys
http.auth_keys_file———File of NAME=TOKEN lines
authz.cedar_policy_dirMAKOD_CEDAR_POLICY_DIR--cedar-policy-dir—*.cedar files merged into one policy set
authz.permit_allMAKOD_CEDAR_PERMIT_ALL--cedar-permit-allfalseDevelopment only — every authenticated principal is admin

makod refuses to start when http.addr is set with neither an API key nor an OIDC issuer, and when neither a policy directory nor permit_all is set.


Role Feature Flags

Cargo features decide which domain modules are compiled in; startup::production_modules() is the list.

Feature flagModules compiled in
role-lf-stromGpkeModule (LF side) · EmobModule (the LF answers 55240)
role-nb-stromGpkeModule · WimModule · MabisModule · EmobModule · RedispatchModule
role-lf-gasGeliGasModule (LF side)
role-nb-gasGeliGasModule (GNB side) · WimModule · GaBiGasModule
role-msb-strom / role-msb-gasWimModule
role-esa-stromWimModule (esa-wertebestellung and its wim-wertebestellung counterpart)
role-lf · role-nb · role-msbthe Strom + Gas pair of each

WimModule covers both Sparten. RedispatchModule is NB Strom only — LF, MSB and gas-only deployments have no §§ 13/13a obligations (BK6-20-059/060/061 as consolidated by BK6-23-241).

default enables every role. A role-scoped build turns it off:

RUN cargo build -p makod --release --no-default-features --features role-lf

Selecting no role is refused at startup. Each role build registers a strict subset of the default's 71 workflows over 469 Prüfidentifikatoren, and the startup log records both counts. Feature flags remove code; --marktrollen only refuses commands at runtime.


Authorization

{: #authorization }

Every endpoint except /health* and the OpenAPI document is authorized by Cedar ABAC. The shared mechanics (named keys with constant-time matching, Bearer/JWT routing, schema-validated policy loading) live in mako_service::cedar_schema; makod adds the MaKo:: actions, entities and schema. Policies are validated in strict mode at startup, and a type error prevents startup.

Authorization is default-deny. --cedar-policy-dir is the only source of access; services/makod/src/cedar/conservative.cedar is the least-privilege starting point and is written for § 6a EnWG role separation. --cedar-permit-all installs the permit-all baseline, under which additional policies can only remove access via forbid.

Identity model

MaKo namespace
├── Principal          — caller identity (API-key name or JWT sub)
├── Command            — attrs: name, marktrolle, pid, tenant
├── EdifactIngest      — attrs: tenant
├── AdminMaloRecord    — attrs: tenant, malo_id (optional)
├── AdminPartnerRecord — attrs: tenant, gln (optional)
├── MetricsEndpoint · McpEndpoint · RechnungEndpoint
├── MigrationEndpoint · WebdiensteEndpoint
└── ProcessRecord      — attrs: tenant, workflow

Actions
├── SubmitCommand · IngestEdifact
├── ReadMetrics · UseMcp · ReadRechnung · AdminMigrations · UseWebdienste
├── ReadProcess        — evaluated per workflow (§ 6a EnWG role separation)
├── AdminMalo (group)    → AdminMaloRead / Write / Delete / Stats
└── AdminPartner (group) → AdminPartnerRead / Write / Delete / Import
ActionGuards
SubmitCommandPOST /api/v1/commands, MCP submit_command
IngestEdifactPOST /edifact
AdminMalo* / AdminPartner*/admin/malo/*, /admin/partners/*
ReadMetricsGET /metrics
UseMcp/mcp (transport only — see MCP authorization)
ReadRechnungGET /api/v1/invoic/{process_id}/rechnung
AdminMigrationsPOST /admin/migrations — grant to a break-glass principal for the FV cutover only; conservative.cedar grants it to nobody
UseWebdiensteevery :8090 route
ReadProcessMCP get_process, list_overdue_deadlines, list_dead_letters; carries the workflow name, so an NB principal can be kept off LF process state

Policies can name the AdminMalo / AdminPartner groups:

// /etc/makod/cedar/read_only_grafana.cedar — under --cedar-permit-all
forbid(
  principal == MaKo::Principal::"ops-grafana",
  action in [MaKo::Action::"AdminMalo", MaKo::Action::"AdminPartner"],
  resource
)
unless {
  action == MaKo::Action::"AdminMaloStats"
  || action == MaKo::Action::"AdminPartnerRead"
};

OIDC / JWT authentication

TOML keyEnv varCLI flagDefault
oidc.issuerMAKOD_OIDC_ISSUER--oidc-issuer—
oidc.audienceMAKOD_OIDC_AUDIENCE--oidc-audience—
oidc.jwks_refresh_secsMAKOD_OIDC_JWKS_REFRESH_SECS--oidc-jwks-refresh-secs300

At startup makod fetches <issuer>/.well-known/openid-configuration and the JWKS, verifies tokens locally and refreshes the keys in the background. Any standards-compliant provider works (Entra ID, Keycloak, Okta, Cognito, Kubernetes workload identity).

  • Only asymmetric algorithms (RS256/384/512, ES256/384, PS256/384/512); HS* is rejected.
  • iss, aud and exp are checked on every token.
  • mako_tenant must equal the primary MP-ID, and a missing claim is rejected. In a shared realm another operator's token is otherwise cryptographically valid here. The check sits in BearerAuthenticator, which every Cedar-gated endpoint uses.
  • The JWT sub becomes the Cedar principal ID (for a Kubernetes service account, system:serviceaccount:<namespace>:<name>).

API keys and OIDC can be active at the same time.


[as4] — AS4/ebMS3 inbound and outbound

Four kinds of certificate material, each with a different job:

MaterialKeysUsed for
Own identitysigning_key_pem / signing_cert_pem, decryption_key_pemSigning outbound, decrypting inbound
Trust anchortrust_anchor_pemProves a signer is a BDEW/DVGW market participant
Partner encryption certspartner_certsEncrypting what is sent to each partner
Partner signing certspartner_signing_certsAuthenticating what each partner sends
WIRK TLS client certclient_tls_cert_pem / client_tls_key_pemTransport credential to a partner MSH (optional; an egress proxy may present it)

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 separate keypairs for signing and encryption.

TOML keyEnv varCLI flagDescription
addrMAKOD_AS4_ADDR--as4-addrListen address
party_idMAKOD_AS4_PARTY_ID--as4-party-idOperator MP-ID (default: primary [[party]])
signing_key_pem / _fileMAKOD_AS4_SIGNING_KEY_PEM--as4-signing-key-pemOwn signing key
signing_cert_pem / _fileMAKOD_AS4_SIGNING_CERT_PEM--as4-signing-cert-pemOwn signing certificate
decryption_key_pem / _fileMAKOD_AS4_DECRYPTION_KEY_PEM--as4-decryption-key-pemOwn inbound decryption key
trust_anchor_pem / _fileMAKOD_AS4_TRUST_ANCHOR_PEM--as4-trust-anchor-pemBDEW/BNetzA PKI CA certificate
partnersMAKOD_AS4_PARTNER--as4-partnerMP-ID=HTTPS-URL pairs (repeatable; comma-separated in the env var)
partner_certs / partner_cert_filesMAKOD_AS4_PARTNER_CERT--as4-partner-certPartner encryption certs, MP-ID=<PEM or path>
partner_signing_certs / partner_signing_cert_filesMAKOD_AS4_PARTNER_SIGNING_CERT--as4-partner-signing-certPartner signing certs
client_tls_cert_pem / _fileMAKOD_AS4_CLIENT_TLS_CERT_PEM--as4-client-tls-cert-pemWIRK TLS client chain
client_tls_key_pem / _fileMAKOD_AS4_CLIENT_TLS_KEY_PEM--as4-client-tls-key-pemIts key
allow_unencryptedMAKOD_ALLOW_UNENCRYPTED_AS4--allow-unencrypted-as4Dev/test: downgrade the encryption and partner-signing-cert refusals to warnings
allow_no_signingMAKOD_ALLOW_NO_AS4_SIGNING--allow-no-as4-signingDev/test: start without signing material or outbox webhook; outbound EDIFACT is logged
allow_no_trust_anchorMAKOD_ALLOW_NO_AS4_TRUST_ANCHOR--allow-no-as4-trust-anchorDev/test: run with no trust anchor (every partner signature is rejected)
lenient_receiptsMAKOD_AS4_LENIENT_RECEIPTS--as4-lenient-receiptsInterop bring-up: tolerate an unsigned or non-NRR eb:Receipt

Partners are bootstrapped into the durable PartnerStore; runtime changes via PUT /admin/partners/{mp_id} or inbound PARTIN survive restarts.

Startup refusals (all also run under --check):

ConditionWhy
AS4 active, no partner_signing_certsThe listener would accept nothing
No trust anchorThe anchor falls back to the own certificate and every inbound message is rejected
No decryption key, or a partner without an encryption certificate§2.2.6.2.2 requires encryption; the send would fail anyway
Signing key and certificate do not build a session, or a partner endpoint is http://Caught at boot rather than per message

The BDEW profile stack declares a sign-and-encrypt security floor; asx-rs rejects a relaxing override layer with ProfileValidationCode::SecurityFloorViolation.

Receipts and retries.

  • Inbound messages get a signed eb:Receipt with NonRepudiationInformation.
  • An outbound delivery counts only after verify_sync_response proves the receipt signature covers the signal, RefToMessageId and every NRI digest match, and the receipt is inside the replay window.
  • An eb:Error or unverifiable receipt backs off and retries — safe, because the MessageId is the stable outbox id and the receiver deduplicates. P-Mode, partner certificate and rendering are resolved per attempt, so a configuration fix heals delivery without re-enqueueing.
  • The retry budget is 72 hours from creation. outbox_delivery_attempted logs counterparty_error, receipt_unverified or transport_error.

Connectivity pings carrying both ebMS3 Test Service URIs (Core §5.2.2) are acknowledged and not delivered.

Outbound wire format. A complete Übertragungsdatei UNB … UNZ. UNB sender/receiver equal NAD+MS/NAD+MR (Allgemeine Festlegungen 6.1d Kap. 2); DE 0007 follows the MP-ID (500 BDEW, 502 DVGW, 14 GS1); the DE 0020 Datenaustauschreferenz — also in UNZ and the §2.12 filename — derives from the outbox id, so retries reuse it.

Tracing. An inbound traceparent is captured into every outbox message the request creates and re-injected on the ERP webhook, the CloudEvent and the outbound AS4 request.

Webhook transport substitute. With [erp] edifact_outbox_webhook_url set, outbound interchanges are POSTed as CloudEvents of type de.mako.edifact.outbound (message_type, recipient, edifact), the outbox id as CloudEvent id and X-Idempotency-Key, signed with Standard Webhooks under [erp] webhook_secret — which is then required.

It is a development and ERP-integration path; MaloIdentCallback messages still go to the MaLo-ID sender.

Who owns an outbox message. Two consumers share one outbox: the wire transport (BdewAs4Sender / WebhookEdifactSender) and the ERP notifier (OutboxErpWorker). core::erp_adapter::is_erp_notification decides — types mapping to an ErpEventType and the ERP_ prefix belong to the ERP notifier, everything else to the transport. A worker leaves the other's messages untouched.

AS4 security tests (sign+encrypt round trip, tamper rejection, replay dedup, P-Mode defaults) run without WIRK certificates:

cargo test -p makod --test it -- as4_security

[webdienste] — BDEW API-Webdienste Strom

addr and the trust keys describe the inbound :8090 port; the client_* and signing_key_* keys describe the identity used when calling another party's API-Webdienste.

TOML keyEnv varCLI flagDescription
addrMAKOD_API_WEBDIENSTE_ADDR--api-webdienste-addrListen address
allow_unauthenticatedMAKOD_WEBDIENSTE_ALLOW_UNAUTHENTICATED--webdienste-allow-unauthenticatedDrop the bearer/OIDC + Cedar layer — only behind an mTLS-terminating proxy; logs a WARN
trust_client_mp_id_headerMAKOD_WEBDIENSTE_TRUST_CLIENT_MP_ID_HEADER--webdienste-trust-client-mp-id-headerRead the caller from X-Mako-Client-MP-ID; implied by allow_unauthenticated
client_cert_pem / _fileMAKOD_WEBDIENSTE_CLIENT_CERT_PEM--webdienste-client-cert-pemOutbound mTLS chain (EMT.API, BSI SM-PKI)
client_key_pem / _fileMAKOD_WEBDIENSTE_CLIENT_KEY_PEM--webdienste-client-key-pemIts PKCS#8 key
client_root_ca_pem_filesMAKOD_WEBDIENSTE_ROOT_CA_PEM--webdienste-root-ca-pemSM-PKI roots to trust when calling out
signing_key_pem / _fileMAKOD_WEBDIENSTE_SIGNING_KEY_PEM--webdienste-signing-key-pemEMT.API key for TR-03116-3 content signing
allow_unauthenticated_clientMAKOD_ALLOW_UNAUTHENTICATED_WEBDIENSTE_CLIENT--allow-unauthenticated-webdienste-clientDev/test: call out without identity or signing key
  • Outbound identity is required. The API-Webdienste authenticate callers by mTLS with an EMT.API certificate; without one the call fails at the counterparty. makod refuses to start when --verzeichnisdienst-url or --maloid-partner is set without a client identity.
  • Content signing is a second layer. TR-03116-3 signs the URI, the RFC 8785 canonical body, creationDateTime and transactionId into DIGEST / SIGNATURE headers (Regelungen zum Übertragungsweg für API-Webdienste 1.2, Kap. 3.3: „Jede API-Interaktion und der übertragene Payload wird signiert."). It applies to Control Measures and MaLo Identification; the Verzeichnisdienst uses JWS. Without a signing key, makod refuses to start when addr, --maloid-partner or --verzeichnisdienst-url is set.
  • Inbound signatures are verified. A request carrying X-BDEW-SIGNATURE / X-BDEW-DIGEST / X-BDEW-CERT (Kap. 3.3; certificate in RFC 9440 encoding) is checked against the named certificate's key over https://{Host}{path?query}, the canonical body and the creationDateTime / transactionId headers before any handler runs. A mismatch, a partial or repeated header set, or an initialTransactionId header is 401; an unsigned request passes with a warning. The proxy, not makod, validates the SM-PKI chain.
  • mTLS is the proxy's job. makod does not terminate TLS; front :8090 with a proxy (Nginx, Envoy, ALB) enforcing mTLS against the BDEW PKI CA. By default every :8090 route also requires bearer/OIDC and Cedar UseWebdienste.

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

The Control Measures request names no sender, so the proxy forwards the client certificate's Marktpartner-ID and must strip any client-supplied copy:

proxy_set_header X-Mako-Client-MP-ID $ssl_client_s_dn_cn;

The header is read only with trust_client_mp_id_header. A request with no establishable caller is 400 — the §14a Endantwort and the WiM Bestätigung are addressed to the caller. The value must be a 13-digit MP-ID or a 16-character EIC; a repeated header is 400. A WiM Order body whose netzbetreiber_id differs from the caller is 403, checked before the transactionId idempotency guard.

§20b EnWG Netzzugangsplattform adapter

§20b EnWG Abs. 2 requires a joint Netzbetreiber platform for Zählpunktanordnungen (Nr. 1), Verrechnungskonzepte (Nr. 2) and Energy-Sharing registration under §42c (Nr. 3); interfaces are left to a BNetzA Festlegung (Abs. 3), and none has been published. makod ships the client side:

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

Payload: netzanschluss_id, nb_mp_id, antragsteller_ref (opaque, no PII), optional details. Each command records an erfasst entry in marktd's netzzugang_antraege and enqueues a NetzzugangAntrag outbox message, which is delivered:

  1. to --netzzugang-endpoint-url (MAKOD_NETZZUGANG_ENDPOINT_URL) when set — the entry becomes uebermittelt, with a platform_ref if returned;
  2. else to the ERP webhook as de.mako.netzzugang.uebermittlungsbedarf, for submission via the NB's Webportal (signed when --erp-webhook-secret is set);
  3. else, or on an undeserialisable payload, marked fehlgeschlagen and acked.

The answer is recorded via marktd PATCH /api/v1/netzzugang/antraege/{id}/status, which emits de.markt.netzzugang.antrag.updated.


MCP Server

/mcp on the --http-addr port speaks the MCP Streamable HTTP transport (spec 2025-11-25): POST for JSON-RPC, GET for SSE. Sessions are in memory. Every request needs Authorization: Bearer <token> (API key or OIDC) and the Cedar UseMcp action; unauthenticated requests are 401.

Tools

makod ships 12 MCP tools:

ToolAnnotationsDescription
list_commandsread_onlyCommands for this instance's Marktrollen, with role, primary PID and whether marktrolle is required — call first
submit_commanddestructiveSame as POST /api/v1/commands, with progress notifications
get_maloread_onlyCached Marktlokation by 11-digit ID
list_partnersread_onlyRegistered trading partners
get_partnerread_onlyPartner by 13-digit MP-ID
get_healthread_onlyVersion, tenant, Marktrollen, MaLo cache stats
get_processread_onlyBusiness key (malo_id/melo_id/vorgang) → active process
list_overdue_deadlinesread_onlyOverdue APERAK/response deadlines — alert if non-empty
list_active_processesread_onlyCount of registered process instances
get_outbox_statusread_onlyPending outbox count and oldest age — alert when stuck > 5 min
list_dead_lettersread_only20 most recent dead letters (§ 147 AO / GoBD)
get_format_version_coverageread_onlyPer message type: stable or transition, and which releases are in force today

tool_inventory_tests in mcp_server.rs pins this list.

Authorization

UseMcp opens the transport only. Each tool that touches tenant data evaluates the action its REST twin enforces:

ToolAdditional Cedar action
submit_commandSubmitCommand (command, Marktrolle, PID)
get_maloAdminMaloRead
list_partners, get_partnerAdminPartnerRead
get_process, list_overdue_deadlinesReadProcess, per workflow (get_process denies as not_found)
list_dead_lettersReadProcess across workflows — a workflow-scoped principal is denied outright
list_commands, get_health, get_format_version_coverage, list_active_processes, get_outbox_statusnone — no tenant data

submit_command parameters

FieldTypeRequiredDescription
commandstring✓<domain>.<prozess>.<aktion>, e.g. gpke.lieferbeginn.anmelden
payloadobject✓Command payload, e.g. {"malo_id": "51238696012", "lieferbeginn_datum": "2026-10-01", "bilanzkreis": "11XBK-STD-----9"}
marktrollestring–Required for multi-role commands
idempotency_keystring–Supplied: a repeat replays the recorded response (24 h), reuse for another request is refused. Omitted: a random UUID, no replay record

get_malo takes malo_id; get_partner takes mp_id.

Resources and prompts

ResourceContent
malo://{malo_id}MaloIdentResultPositive record from the MaLo cache
partner://{mp_id}Partner record with AS4 URL, roles and channels
PromptArgumentsGuides
gpke-lieferbeginnmalo_id, lieferbeginn_datumGPKE Lieferbeginn Strom
geli-lieferbeginnmalo_id, lieferbeginn_datumGeLi Gas Lieferbeginn
wim-geraetewechselmelo_id, process_date, receiver_mp_id, marktrolleWiM Gerätewechsel
msb-preisanfrage—MSB Preisanfrage (REQOTE/QUOTES, PRICAT 27003)
wim-gas-anmeldung—GNB answers a gMSB Anmeldung (UTILMD G 44042–44053)
gpke-sperrung—GPKE Sperrung Strom

The server instructions returned on connect carry the tenant, the Marktrollen, the filtered command list, a per-PID deadline table from mako_fristen::antwort (MaBiS has none — see mako_mabis::fristen) and the error-prefix glossary.

Client configuration

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

That is Claude Desktop's claude_desktop_config.json; VS Code's .vscode/mcp.json takes the same entry under "mcp": {"servers": …} with "type": "http". In a cluster, reach /mcp through kubectl port-forward or an internal Service.


REST API Endpoints

All on the --http-addr port.

MethodPathAuthDescription
POST/api/v1/commandsBearer · SubmitCommandERP command
POST/edifactBearer · IngestEdifactRaw EDIFACT interchange
GET/api/v1/invoic/{process_id}/rechnungBearer · ReadRechnungBO4E billing data of an INVOIC process
GET PUT DELETE/admin/partners/{mp_id} · GET /admin/partners · POST /admin/partners/importBearer · AdminPartner*Partner registry
GET PUT DELETE/admin/malo/{malo_id} · GET /admin/malo/statsBearer · AdminMalo*MaLo cache
POST/admin/migrationsBearer · AdminMigrationsMigrate in-flight process streams between format versions while live ({"from":"FV2025-10-01","to":"FV2026-10-01"})
GET/metricsBearer · ReadMetricsPrometheus
POST GET/mcpBearer · UseMcpMCP server
GET/api/v1/openapi.json · /api/v1/docs/publicOpenAPI 3.1 spec · Swagger UI
GET/health/live · /health/ready · /healthpublicProbes

The OpenAPI document is generated from the handler annotations.

POST /edifact

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

UNB+UNOC:3+9900357000004:500+4012345000023:14+261001:1200+001++TL
UNH+...
{
  "accepted": 1,
  "rejected": 0,
  "messages": [
    { "message_type": "UTILMD", "pid": 55001, "workflow": "gpke-supplier-change",
      "status": "routed", "process_id": "3181967a-02d1-4d0e-9105-0cc46f3b25c9",
      "malo_id": "51238696012" }
  ]
}

200 when at least one message was accepted, 422 when none was, 400 for an empty body, 415 for a Content-Type other than text/plain, application/edifact or application/octet-stream.

statusMeaningCounted as
routedPID found and a workflow registeredaccepted
unknown_pidPID found, no workflow — dead-letteredaccepted
no_pidCONTRL, which carries none by designaccepted
missing_pidA PID-bearing type without one — dead-letteredrejected
missing_partyNAD+MS or NAD+MR absent — dead-lettered (Allgemeine Festlegungen V6.1d §2.13)rejected
parse_errorNot parseablerejected
dispatch_failedRouted, but the workflow command failed; dead-lettered, error names itrejected
  • A partly broken interchange is rejected whole on both POST /edifact and AS4: none of its messages is dispatched, each is dead-lettered, and the sender gets the CONTRL Syntaxfehlermeldung (CONTRL AHB 1.0 Kap. 1.2; see CONTRL).
  • dispatch_failed is a rejection so the gateway redelivers. A routed PID with no dispatch arm is dead-lettered as not_dispatchable — a coverage bug on this side. A reply to a closed process, or one with nothing to correlate on, is logged but not recorded.
  • The PID is read from SG1 RFF+Z13 and BGM DE 1004 in the order the profile's pid_source declares; only a plausible 5-digit code is accepted.
  • The AS4 door and POST /edifact classify through one shared function.

DVGW gas transport

DVGW messages (ALOCAT, NOMINT, NOMRES, SSQNOT; PIDs 70000–79999) ride ORDERS or ORDRSP, so the BDEW parser would accept them as the wrong type. dvgw_edi::sniff reads BGM C002 DE 1001 first on every inbound path (POST /edifact, AS4, loopback). The response has the same shape with the DVGW message_type, no malo_id, and two extra outcomes in error:

skippedMeaning
no_correlation_keyNo Zuordnungstupel is published for the PID (ALOCAT 5.11a §3.3), or a ZO-T* tuple had no gas day
process_not_foundA NOMRES with no nomination to answer — only NOMINT initiates

The CONTRL Empfangsbestätigung is owed for a DVGW interchange too (CONTRL AHB 1.0 §2.3.1); the AS4 eb:Receipt does not discharge it.

Ingest durability

PropertyMechanism
A spawn is one atomic write, on every doorEvents, outbox entries, deadlines and correlation-index entries commit in one SlateDB transaction — inbound message, ERP command or API-Webdienste request alike; a resumed process gains keys in its step's write. cargo xtask check-atomic-spawn refuses a key registration outside that batch
A settled process yields its keyThe correlation index is append-only, so each initiating arm asks its crate's occupancy verdict (OccupiesBusinessKey): settled yields, live keeps. An INVOIC is keyed on its own message reference. cargo xtask check-occupancy holds the count of arms still deciding on presence
One business key, one processLookup→spawn is serialised per business key on one lock map shared by ingest and the command door, so two initiating messages or commands for one MaLo take turns instead of producing AmbiguousProcess later
Self-addressed messages meet the network's standardThe loopback runs the same pre-send AHB conformance gate, refuses an interchange that will not parse back, and treats "dispatched nothing" as an error — except when no workflow for the receiving side is compiled in, where it acknowledges

The business-key lock is in-process; that suffices because the data-directory lock makes makod a single writer. --allow-multi-instance needs an external lock.


ERP Commands (POST /api/v1/commands)

The ERP names the process command and supplies business data; the engine resolves sender/receiver MP-IDs, PID and message reference, renders the EDIFACT and dispatches it. See ERP integration for the full contract.

Request envelope

{
  "command": "gpke.lieferbeginn.anmelden",
  "payload": {
    "malo_id":            "51238696012",
    "lieferbeginn_datum": "2026-10-01",
    "bilanzkreis":        "11XBK-STD-----9"
  }
}
FieldRequiredDescription
command✓<domain>.<prozess>.<aktion>
marktrollemulti-role commandsSelects the EDIFACT qualifier and workflow variant; ignored for single-role commands
payload✓Command fields (see payload fields)

Idempotency-Key header is required. Missing or empty: 422 missing_idempotency_key; sent twice: 400 duplicate_idempotency_key_header. The accepted response is replayed verbatim for 24 hours. A business-level guard also refuses a second initiating command while a process for the same business key is live — even under a fresh key — with 409 duplicate_process and that process's id.

Treat 409 duplicate_process as success (details).

Bilanzkreis on an Anmeldung. bilanzkreis is required on 55001 and 55077: UTILMD AHB Strom 2.2 Kap. 5.3 makes the Produktpaket SG8 SEQ+Z79 Muss — „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.

Gas has no Produktpaket: geli.lieferbeginn.anmelden renders the same field as SG10 CCI+Z19 (UTILMD AHB Gas 1.2, Muss on 44001).

The erzeugende Anmeldung (55077) requires transaktionsgrund_ergaenzung: ZW0 (100 %ige Zuordnung), ZW1 (bestehende Tranche) or ZW2 (neu zu bildende Tranche); there is no default. ZW2 also requires tranchengroesse (Produkt-Code 9991000002090, „zwingend" per Codeliste der Konfigurationen 1.4 Kap. 6.1.1): a bare string is the prozentuale Aufteilung (> 0, < 100, at most two decimals), or {"art": "aufteilungsfaktor" | "technische_ressourcen", "wert": …}.

Error responses

HTTPerrorCause
400duplicate_idempotency_key_headerIdempotency-Key sent twice
401 / 403—No valid bearer token / Cedar denied SubmitCommand
404process_not_foundNo active process for the business key (answer commands)
409duplicate_processA live process holds the business key — treat as success
409ambiguous_processMore than one live process for the key — a data-integrity issue
409invalid_stateThe process refuses the command in its current state
422missing_idempotency_keyNo Idempotency-Key
422command_rejected — detail unknown_command · marktrolle_required · role_not_permitted · role_not_configuredName not registered · multi-role without marktrolle · role not allowed for this command · role not in --marktrollen
422idempotency_key_reuseKey used for a different command or payload
422malo_not_foundmalo_id not in the MaLo cache — seed it via PUT /admin/malo/{malo_id}
422invalid_payloadMissing or malformed payload field
500engine_errorStorage or engine failure
501not_implementedRegistered but not dispatchable in this build — do not retry automatically
503idempotency_unavailableThe idempotency record could not be read — retry

Success 202 Accepted:

{
  "idempotency_key": "01924f4e-3b4a-7e12-8c47-0022f4b2d3a1",
  "command":         "gpke.lieferbeginn.anmelden",
  "marktrolle":      "LF",
  "status":          "accepted",
  "process_id":      "3181967a-02d1-4d0e-9105-0cc46f3b25c9"
}

mabis.summenzeitreihe.uebermitteln also returns message_ref — the UNH DE 0062 the MSCONS 13003 goes out with, which the BIKO's Datenstatus and Prüfmitteilung name.

Fields the engine owns — never supply these

FieldSource
sender_mp_idThe [[party]] MP-ID of the role (primary as fallback)
receiver_mp_idMaLo cache (data_market_location_network_operators)
pruefidentifikatorThe command name
message_refGenerated, replay-stable
document_dateDispatch time

What a GPKE Bestätigung has to say

mako_gpke::AntwortForm holds the answer shapes and the workflow gates on it:

550025500355005550065507855080
BGM DE 1001E01E01E02E02E01E01
STS+7 ErgänzungZW6/ZW7/ZAPZW4——ZW0/ZW1/ZW2ZW3
SG4 DTM92—93—92—
SG5 LOC + SG8 Lokationsdaten✓———✓—
SG6 RFF+Z60 geplantes Produktpaket✓———✓—

The NB supplies the facts only it holds:

Payload fieldSegmentContent
malo_artSG4 STS+7 DE 9013 el. 3ZW6 pauschale, ZW7 gemessene, ZAP ruhende Marktlokation; ZW4 is not admitted. Unset: derived from whether messlokationen is non-empty
geplantes_produktpaketSG6 RFF+Z60Defaults to the Produktpaket the Anmeldung offered
messlokationen / malo_msbSG5 LOC+Z17, SG8 SEQ+Z98/SEQ+ZF3Every Messlokation (Bedingung [623]) with its MSB: CAV+Z91:<MP-ID>::<Z39|Z40|Z41>:<Z19|Z20>, CAV+ZF0 for the gMSB
{
  "antwort_code": "A51",
  "antwort_codeliste": "E_0623",
  "malo_art": "ZW7",
  "messlokationen": [{
    "melo_id": "DE00056266802AO6G56M11SN51G21M24S",
    "msb": { "mp_id": "9903456000009", "rolle": "Z39",
             "grundlage": "Z19", "gmsb_mp_id": "9903456000009" }
  }],
  "malo_msb": { "mp_id": "9903456000009", "rolle": "Z39",
                "grundlage": "Z19", "gmsb_mp_id": "9903456000009" }
}

A ZW7 answer with no Messlokation, or a Messlokation with no MSB, is refused at render time (tests/it/e2e_gpke_antwort_conformance.rs).

Command registry

mako_markt::commands holds a constant per command another service posts; cargo xtask check-answer-commands refuses a bare literal there.

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 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 (SD Lieferbeginn Nr. 2, 07:00 Uhr des 1. WT nach dem ÜT)
gpke.zuordnung.beendenNBGPKE55037Ends the LFA's Zuordnung, naming Grund and 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
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)
geli.eog.anmeldenGNBGeLi Gas44013Gas twin of gpke.eog.anmelden
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.bestaetigenMSBWiM—MSB sends final positive control-measure response
wim.steuerungsauftrag.ablehnenMSBWiM—MSB sends final negative control-measure response
mabis.abrechnung.einleitenBKVMaBiS13003 · 13020 · 13023Record a version of a Summenzeitreihe; opens the settlement on the first one. Requires zeitreihe (SG10 CAV code) and version (Erstellungszeitpunkt)
mabis.abrechnung.daten-einreichenBKVMaBiS21000 · 21001 · 21005Send a Prüfmitteilung for one version. Requires antwortcode from the Entscheidungsbaum of this Summenzeitreihe, plus grund except for its Zustimmung. No Frist — bounded by the § 3.10 clearing window
mabis.abrechnung.begleichenBKV or ÜNBMaBiS—Close the clearing window. Sets no Datenstatus: that is the BIKO's (§ 3.8.3), via IFTSTA 21003/21004
mabis.liste.korrigierenLFN/NB/ÜNBMaBiS55066 · 55196 · 55202 · 55224Answer a Clearingliste with a Korrekturliste, { malo, grund } per disputed MaLo; an empty list is still sent. sender_rolle selects the Entscheidungsbaum (E_0047 NB / E_0004 ÜNB)
mabis.liste.ablehnenLFN/NB/ÜNBMaBiS55066 · 55196 · 55202 · 55224Refuse a Clearingliste entire. Whole-list facts (abonnement_bestellt, zeitraum_plausibel, mabis_zp_passt, version_zugelassen, innerhalb_clearingphase) are tri-state; absent escalates. A list whose whole-list checks all pass is 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/ÜNB/BIKOMaBiS21002Abweisung einer Prüfmitteilung (BIKO → NB/ÜNB); requires abweisungsgrund. A rejected Prüfmitteilung is never forwarded (§ 9.8.2 Nr. 2)
mabis.datenstatus.empfangenBKV/NB/BIKOMaBiS21003 · 21004Datenstatus received via REST — 21003 to the NB/ÜNB, 21004 to the BKV. Accepts STS+Z04 codes A01/A02/A03/A04/A06 or their snake_case names
gpke.lieferbeginn.ablehnenNBGPKE55003DSO rejects supply start (E_0622/E_0623 code)
gpke.neuanlage.bestaetigenNBGPKE55602/55603NB confirms a Neuanlage (E_0608; cluster picks the PID)
gpke.neuanlage.ablehnenNBGPKE55604/55605NB refuses a Neuanlage — admissible only after the 60-WT Prüflauf
gpke.abrechnungsdaten.bearbeitungsstandNBGPKE21047Bearbeitungsstand on 55156/55220/55673 (E_0595, IFTSTA)
gpke.lieferbeginn.aktivierenLFGPKE—LF marks its own Anmeldung active once the NB confirmed
gpke.lieferende.ablehnenNBGPKE55006DSO rejects supply end (E_0607)
gpke.kuendigung.bestaetigenLFGPKE55017LFA confirms the Kündigung (E_0614)
gpke.kuendigung.ablehnenLFGPKE55018LFA rejects the Kündigung (E_0614)
gpke.nb-lieferende.bestaetigenLFGPKE55008LF confirms the NB's Ankündigung Lieferende (55007)
gpke.nb-lieferende.ablehnenLFGPKE55009LF rejects the NB's Ankündigung Lieferende
gpke.beendigung-zuordnung.bestaetigenLFGPKE55011LFA confirms the Beendigung der Zuordnung (55010, E_0624)
gpke.beendigung-zuordnung.ablehnenLFGPKE55012LFA rejects the Beendigung der Zuordnung
gpke.zuordnung-lf.bestaetigenLFGPKE55608LFN confirms the Ankündigung Zuordnung LF (55607); bilanzkreis rides SG8 SEQ+Z79
gpke.zuordnung-lf.ablehnenLFGPKE55609LFN rejects the Ankündigung Zuordnung LF
maloid.lieferbeginn.fortsetzenLFGPKE55001Resume an Anmeldung once the MaLo-ID arrived from the MaLo-ID-Vergabe
gpke.abrechnung.selbstausstellenLFGPKE31006LF issues the MMM invoice itself (Gutschriftverfahren)
invoic.nne-abschlag.stellenNB or GNBSparte-neutral31001Abschlagsrechnung Netznutzung
invoic.nne.stellenNB or GNBSparte-neutral31002Netznutzungsabrechnung — one PID in both Sparten
invoic.mmm.stellenNB or GNBSparte-neutral31005Mehr-/Mindermengenrechnung — one PID in both Sparten
wim.msb-rechnung.stellenNB or MSBWiM31009MSB invoices the NB/LF/ESA for Messstellenbetrieb
geli.lieferende.ablehnenGNBGeLi Gas44006GNB rejects the Abmeldung (G_0007)
geli.nb-lieferende.ablehnenLFGGeLi Gas44009LFG rejects the GNB-initiated Lieferende
geli.beendigung-zuordnung.ablehnenLFGGeLi Gas44012LFA rejects the Abmeldungsanfrage
geli.kuendigung.ablehnenLFGGeLi Gas44018LFA rejects the Kündigung (G_0005)
geli.eog.ablehnenLFGGeLi Gas44015E/G rejects the EoG Zuordnung
geli.stornierung.initiierenLFGGeLi Gas44022LF cancels a running Gas Zuordnungsprozess; the GNB answers within the cancelled message's own Antwortfrist; refused with none running (GeLi Gas 3.0 Kap. 2.7)
geli.datenabruf.anfragenLFGGeLi Gas17103LF requests Abrechnungsbrennwert and Zustandszahl (ORDERS); zeitspanne_bis required — answer due 10 WT after the Anfrage, not before the 10. WT of the following month (GeLi Gas 3.0 Kap. 4.2)
wim.geraetewechsel.ablehnenNB or MSBWiM55041/55044/55053/55170Business Ablehnung — requires an antwortcode from the process's EBD
wim.geraetewechsel.aperakNB or MSBWiM55039/55042/55051/55168Technical APERAK (45 min, APERAK AHB 1.1 §2.4.1) — not the business answer
wim.gesamtvorgang.meldenMSB or nMSBWiM21010/21009MSBN reports the Gesamtvorgang outcome; its date becomes the Zuordnungsbeginn
wim.zuordnung.bestaetigenNBWiM21012NB makes the Zuordnung from the reported date, 00:00 Uhr
wim.zuordnung.ablehnenNBWiM21011NB records the MSB-Scheitermeldung; the MSBA stays assigned
wim.preisanfrage.angebot-sendenMSBWiM15001MSB answers a Preisanfrage with a QUOTES Angebot
wim.weiterverpflichtung.beantwortenMSBWiM19003/19004MSBA answers the Weiterverpflichtungsauftrag; Z13/Z14/Z22 against the 3-Monats- resp. 1-Monats-Kappung
wim.stoerung.bestaetigenMSBWiM23004MSB confirms an inbound Störungsmeldung; opens the Ergebnisfrist
wim.stoerung.ablehnenMSBWiM23003MSB rejects the Störungsmeldung; the Use-Case ends
wim.stoerung.ergebnis-meldenMSBWiM23008MSB sends the Ergebnisbericht and closes the Use-Case
wim.rechnung.annehmenLF, NB, GNB, MSB, NMSB, ESAWiM31009 · 31003 · 31004The payer accepts the invoice (REMADV)
wim.rechnung.ablehnenLF, NB, GNB, MSB, NMSB, ESAWiM31009 · 31003 · 31004The payer disputes the invoice (REMADV; AJT tree from rechnungspruefung(pid, empfaenger, gegenstand) — E_0264 ESA, E_0566/E_0273 NB, E_0210/E_0270 LF, the second when IMD+7081 is TEC)
invoic.stornorechnung.annehmenevery billing family's recipientsINVOIC31004Accept a Sparte-neutral Stornorechnung (REMADV); the permitted set is derived from the family commands
invoic.stornorechnung.ablehnenevery billing family's recipientsINVOIC31004Dispute a Stornorechnung by the invoice's Zahlungsziel
invoic.sonstige-leistung.stellenNB or GNBSparte-neutral31011Rechnung sonstige Leistung (GPKE Teil 2 · AWH Sperrprozesse Gas)
invoic.sonstige-leistung.annehmenLF, LFG, LFN, LFASparte-neutral31011LF accepts the invoice (REMADV)
invoic.sonstige-leistung.ablehnenLF, LFG, LFN, LFASparte-neutral31011LF disputes the invoice (REMADV)
gabi.rechnung.annehmenBKV · MGVGaBi Gas31007Settles a GaBi Gas invoice — the MGV the aggregated MMM-Rechnung 31007/31008, the BKV the Kapazitätsrechnung 31010
gabi.rechnung.ablehnenBKV · MGVGaBi Gas31007Disputes one — same family, same two roles
gabi.nominierung.sendenBKVGaBi Gas (DVGW)70030Nominates a gas day: positionen state point, direction and kwh_pro_h per period
gabi.nominierung.beantwortenNB · MGVGaBi Gas (DVGW)70036Answers a nomination — entscheidung bestaetigt / teilweise / abgelehnt, with bestaetigte_positionen when curtailed
gabi.mehrmindermengen.meldenNBGaBi Gas (DVGW)70095Reports a Netzkonto's Mehr-/Mindermenge (SSQNOT); verfahren slp or rlm
geli.lieferbeginn.ablehnenGNBGeLi Gas44003GNB rejects the Anmeldung Netznutzung (G_0011)
geli.nb-lieferende.bestaetigenLFGGeLi Gas44008LFG confirms the GNB-initiated Lieferende
geli.beendigung-zuordnung.bestaetigenLFGGeLi Gas44011LFA confirms the Abmeldungsanfrage
geli.kuendigung.bestaetigenLFGGeLi Gas44017LFA confirms the Kündigung (G_0006)
geli.eog.bestaetigenLFGGeLi Gas44014E/G confirms the EoG Zuordnung

The ESA commands are listed under ESA messages and the §20b commands under Netzzugangsplattform. Commands listing two Marktrollen always require marktrolle.

ERP payload fields per command

Only fields the ERP owns; MP-IDs are resolved by the engine.

CommandRequired ERP payload fields
gpke.lieferbeginn.anmeldenmalo_id, lieferbeginn_datum, bilanzkreis, 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.beauftragen · gpke.entsperrung.beauftragen · gpke.sperrung.stornierenmalo_id
gpke.sperrung.bestaetigenmalo_id, optional note/reason
gpke.sperrung.fehlgeschlagenmalo_id, reason (or note)
gpke.abrechnung.annehmenrechnung (BO4E RECHNUNG)
gpke.abrechnung.ablehnenrechnung, ablehnungsgrund
geli.lieferbeginn.anmeldenmalo_id, lieferbeginn_datum, bilanzkreis
geli.lieferende.anmeldenmalo_id, lieferende_datum
geli.kuendigung.anmeldenmalo_id, zaehlpunkt, process_date
wim.geraetewechsel.beauftragenmelo_id², process_date (YYYYMMDD), receiver_mp_id, optional pid (default 55042)
wim.geraetewechsel.bestaetigenmelo_id², optional antwortcode (default: the tree's unconditional Zustimmung), bemerkung, abweichender_termin (required with Z01)
wim.geraetewechsel.ablehnenmelo_id², antwortcode (E_0200/E_0201/E_0202/E_0240), optional bemerkung, abweichender_termin (required with Z12)
wim.geraetewechsel.aperakmelo_id², positiv (true = BGM+312, false = BGM+313), optional reason
wim.gesamtvorgang.meldenmelo_id², erfolgreich, zuordnungsbeginn (YYYYMMDD, 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 (unless the code is the tree's Zustimmung)

¹ Conditional: alter_lf_mp_id only when the old supplier is a different legal entity — the ERP derives it from contract data. ² WiM Gerätewechsel is keyed by melo_id; NB and MSB MP-IDs come from the MeLo cache entry.

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

A Stadtwerke operating as NB and MSB under one MP-ID lists both roles on its [[party]]. For multi-role commands marktrolle selects the qualifier (DDM NB, MS MSB) and the workflow variant — a dispatch hint, not an identity claim.

In-process loopback for self-addressed outbox messages

When the recipient is one of this deployment's own MP-IDs, BdewAs4Sender renders the interchange as usual, re-parses it via Platform::parse_interchange and hands each message to EdifactIngestDispatcher::dispatch — no AS4 round trip and no --as4-partner entry. Self-addressed by design:

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
PID(s) received via loopbackActionWorkflow
17115, 17117 (ORDERS Strom / Gas)spawn by MaLogpke-sperrung / geli-gas-sperrung-nb — ReceiveSperrung
19118, 19119 (ORDRSP)resume by MaLogpke-sperrung — ReceiveMsbAntwort
19116, 19117 (ORDRSP Strom / Gas)resume by MaLogpke-sperrung-lf / geli-gas-sperrung-lf — ReceiveOrdrsp
55001, 55004, 55077spawn by MaLogpke-supplier-change — ReceiveUtilmd
55016spawn by MaLogpke-kuendigung — ReceiveKuendigung
55003–55006, 55017, 55018resume by MaLogpke-lf-anmeldung — ReceiveAntwort
44001–44021spawn by MaLogeli-gas-supplier-change — ReceiveUtilmd
55036–55038 · 44036–44038spawn per Meldunggpke-zuordnungsmeldung / geli-gas-zuordnungsmeldung — Empfangen

Other combined-role pairs (wim-rechnungsabwicklung 17005/17006 ↔ 19009/19010, the ESA Wertebestellung) follow the same pattern. Runaway guard: loopback hops are counted per conversation_id and the delivery is dead-lettered past 32 (in-memory, reset on restart).

A self-addressed PID with no handler on this side — e.g. ORDERS 17116 with no gMSB workflow running — is acknowledged with a warn!; the waiting NB workflow continues until its deadline or until the ERP reports the outcome:

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

# NB reports failure — `reason` is mandatory
curl -X POST http://localhost:8080/api/v1/commands \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sperrung-51238696012-fehlgeschlagen" \
  -d '{
    "command": "gpke.sperrung.fehlgeschlagen",
    "marktrolle": "NB",
    "payload": { "malo_id": "51238696012", "reason": "Zutritt verweigert" }
  }'

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

A Prüfidentifikator two Sparten share

A PID two Sparten claim is registered Sparte-qualified, and resolve_workflow prefers that entry by the recipient MP-ID's Sparte; the unqualified table is last-wins across modules.

PIDStromGas
ORDERS 17115 / 17117 Sperrauftraggpke-sperrunggeli-gas-sperrung-nb
INSRPT 23001wim-insrptwim-insrpt (Sparte in the command)
COMDIS 29001 Ablehnung REMADVgpke-abrechnung · wim-invoicgabi-gas-invoic

Between the two Strom claimants of 29001, conversation-ID correlation decides.


ESA messages

REQOTE 35003 „Anfrage von Werten" is ESA → MSB (REQOTE AHB 1.2 §4.3) and routes to wim-wertebestellung on the PID alone. 35002 is a different process (LF → MSB, WiM Teil 1, §4.2).

deployment-roles ESA registers the ESA side's inbound answers (QUOTES 15003, ORDRSP 19011–19014). An MSB serving an ESA registers ORDERS 17007 under MSB. The sets are disjoint, so an integrated deployment may hold both.

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

One correlated process per side: esa-wertebestellung (ESA), wim-wertebestellung (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
RuleDetail
MessproduktFrom Codeliste der Konfigurationen 1.4 Kap. 4.6 (SG27 PIA+5 … :Z11); validated before rendering. A code outside Kap. 4.6, one for another Lokationsebene, or a Kap. 4.6.2 (SM-PKI) product without target address and certificates is 422
LokationsebeneDecided by the Messprodukt, not the ID ([950] serves MaLo and Tranche) — mako_wim::esa::ebene_fuer_messprodukt
AngebotA priced offer (QUOTES AHB 1.1a §4.3): SG4 CUX, 1–3 PIA+Z02 Artikel-IDs each with PRI+CAL (Z01 Einrichtung, Z02 Transaktion, Z03 Betrieb), 1–23 PIA+5 … :SRW OBIS, DTM+469, DTM+273 (a duration: count + 802/803/804). Prices tell an Angebot from an Ablehnung
AusführungsdatumORDERS DTM+203 = max(Wunschtermin, DTM+469)
SubscriptionThe (Meldepunkt, Messprodukt) pair; a second product at one location is a new subscription. Follow-up commands take messprodukt, optional while only one exists
AnswersSG2 AJT is Muss on all four ORDRSP PIDs and no free text is published (only FTX+Z27/Z28, the MSB's IP address); the code is resolved against mako_pruefung's catalogue into mako_wim::esa::Antwort. A code whose Cluster contradicts its PID is resolved by the PID
NotificationAn MSB deployment emits de.mako.process.initiated on 35003/17007/39002/17008 — the input to processd's ESA module

Correlation. Only the REQOTE carries a location; every later message is matched by the Belegnummer it echoes, per the Zuordnungsschlüssel of the Anwendungsübersicht der Prüfidentifikatoren 4.0. Renderer and ingest read one table (mako_wim::esa::korrelation):

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

MSCONS 13027 carries the same reference as SG1 RFF+AGI (ZG-T42); it closes the ESA-side Stornierung window and lets edmd name the subscription a Typ-2 gap belongs to.

With --marktd-url set, an inbound 35003 or 17007 is checked against marktd (GET /api/v1/esa/consent-check, perspective=msb_inbound) before the workflow runs:

Consent stateResult
Revoked (GDPR Art. 7(3)), nothing supersedingAblehnung (QUOTES 15003 / ORDRSP 19012)
Framework agreement without EDI agreement or with a negative cert stateAblehnung (UC 4.1.1 Vorbedingung)
Active, or no record at allProceeds — BNetzA Mitteilung Nr. 3 (07.02.2024) forbids rejecting over a consent that deviates from the BDEW template

The inbound gate fails open (marktd unreachable → proceeds with a warning); the durable stop is the 17008 Abbestellung marktd fires on revocation. Without --marktd-url the gate is off.

An ESA deployment originating requests uses perspective=esa_outbound, which also blocks a missing record (no_consent — no lawful basis, GDPR Art. 7) and fails closed.

ESA-outbound origination workflow

CommandMessageConsent gate
esa.werteanfrage.stellenREQOTE 35003 Werteanfrageesa_outbound
esa.bestellung.beauftragenORDERS 17007 Bestellungesa_outbound (re-checked)
esa.stornierung.beauftragenORDCHG 39002 Stornierung— (before delivery)
esa.abbestellung.beauftragenORDERS 17008 Abbestellungnone — stopping is always possible

esa.werteanfrage.stellen payload:

FieldNotes
messproduktRequired, Kap.-4.6 code, spaced or bare; uncatalogued codes are refused
wunschtermin (or zeitraum_von)YYYY-MM-DD
abonnementZ01 (default, running) or Z03 (single transmission)
msb_mp_idOptional. Omitted: resolved from marktd's dated MSB timeline (GET /melos/{id}/msb?at=) using melo_id and zeitraum_von — the MSB of the requested period (UC 4.1.1). A zeitraum_bis spanning an MSB change is 422
smgwKap. 4.6.2 products: IPv4/IPv6 target URIs, certificate issuer and subject, Schwellwerte
contact, contact_commSG14 CTA+IC / COM, verbatim

Revocation loop. marktd's DELETE /api/v1/esa/einwilligungen/{id} emits de.markt.einwilligung.widerrufen and posts esa.abbestellung.beauftragen per covered location. Without messprodukt the command stops every subscription there — ORDERS 17008 for an Abo, ORDCHG 39002 for a one-shot (a one-shot is not abbestellbar: E_0254 answers A01). beendigung_zum (alias ausfuehrungsdatum) sets DTM+203; default today.

MSB → ESA value delivery (UC 4.2)

wim.wertebestellung.liefern (role MSB) sends MSCONS 13027 „Werte nach Typ 2" to the ESA (the §60 Abs. 1 MsbG delivery duty). It runs only while the process is in lieferung_erlaubt; 404 process_not_found means no confirmed subscription. Cadence follows the Messprodukt: Rohdaten „unverzüglich, jedoch spätestens bis 9:30 Uhr", aufbereitete Daten per WiM Teil 2 Kapitel 2.5.5.

{ "command": "wim.wertebestellung.liefern",
  "payload": { "malo_id": "51238696012",
               "reads": [ { "dtm_from": "2026-06-01T00:00:00+00:00",
                            "dtm_to":   "2026-06-01T00:15:00+00:00",
                            "quantity_kwh": "0.250", "obis_code": "1-0:1.29.0" } ] } }

Inbound 13027 readings land on ProcessCompleted as reads beside malo_id (always present, so edmd records a receipt even without decodable intervals). quality maps SG10 QTY DE 6063: 220 → MEASURED, 67 → SUBSTITUTED, Z18 → PRELIMINARY. Periods are read from DTM+163/+164 format 303; any other format is skipped and counted. The values land in edmd.esa_typ2_reads, never a billing path.

MSB-side answer commands

CommandAnswer on the wireEBD
wim.wertebestellung.anbietenQUOTES 15003 Angebot — takes preise ({artikel_id, betrag}), obis, waehrung (default EUR)E_0252
wim.wertebestellung.anfrage-ablehnenQUOTES 15003 Ablehnung — no priced position, grounds in FTX+ACBE_0252
wim.wertebestellung.bestellung-beantwortenORDRSP 19011 / 19012E_0256
wim.wertebestellung.stornierung-beantwortenORDRSP 19013 / 19014E_0257
wim.wertebestellung.abbestellung-beantwortenORDRSP 19011 / 19012E_0254

The three ORDRSP commands take an antwort_code: its Zustimmungs- or Ablehnungs-Cluster selects the PID (ORDRSP AHB 1.1b §4.15). Resolve it with mako_pruefung::esa::wertebestellung; an unpublished code is 422. E_0257 refuses a Stornierung after delivery with A02 (running Abo) or A03 (one-shot already sent); E_0254 publishes four refusals, and a refused Beendigung leaves delivery running.


Partner management (/admin/partners/)

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

The PUT body is a PartnerRecord: only mp_id is required and must equal the path; unknown fields are 400 (the field is mp_id, not gln). This is makod's PARTIN registry (channels, roles, contacts), distinct from marktd's PUT /api/v1/partners/{mp_id} (BO4E Geschaeftspartner + makoadresse).

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

Delivery channels must be HTTPS. A channel makod delivers to (AK/AS4, AW for the API-Webdienste callback, which carries personal data — DSGVO Art. 32) must be https:// (localhost exempt), whichever of the four paths sets it: --as4-partner / --maloid-partner, this endpoint, the Verzeichnisdienst, or PARTIN COM segments.

PUT and the flags refuse an offending record; a PARTIN import drops the offending channel, keeps the rest, and logs the MP-ID and address.

MaLo cache (/admin/malo/)

MethodPathDescription
GET/admin/malo/{malo_id}Cached MaLo record
PUT/admin/malo/{malo_id}Upsert — the NB's MaloIdentResultPositive
DELETE/admin/malo/{malo_id}Remove
GET/admin/malo/statsPer-tenant statistics

A command whose malo_id is not cached is 422 malo_not_found.


EDIFACT Rendering

orchestrator/edifact_renderer/ turns outbox messages into wire bytes, dispatching on the message type and, for MSCONS, the PID. BDEW families go through the edi-energy builders; ALOCAT, NOMINT, NOMRES and SSQNOT through dvgw-edi, checked against the Nachrichtenbeschreibung — an unfillable Muss row is a render error, never a message on the wire.

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 · …"}
    r -->|"ALOCAT · NOMINT · NOMRES · SSQNOT"| d["dvgw-edi builder"]
    mt -->|"MSCONS"| pid{"Prüfidentifikator"}
    pid --> b["edi-energy builder"]
    b --> as4["AS4 / ebMS3"]
    d --> as4

IFTSTA 21042 carries WiM Strom Teil 2 UC 4.4 „Beendigung durch MSB" (MSB → ESA): BGM+Z09, SG14 CNI, SG15 STS 9015=Z21 / 4405=105, RFF+Z13, RFF+AGI to the Bestellung, DTM+93 Vertragsende.

Dates on the wire

DTM+137 is DE 2379 303 (CCYYMMDDHHMMZZZ) in every EDI@Energy AHB, [931] fixes the zone to +00. In UTILMD SG4, 92, 93, 76 and 157 are 303; 154 (Annahmedatum) is 102 and Z10 (Kündigungstermin) 106. A command may pass YYYYMMDD; builders normalise it to CCYYMMDD0000+00. Inbound, a counterparty's 102 document date is accepted.

MSCONS use cases

An unimplemented MSCONS Anwendungsfall is refused by name.

PIDAnwendungsfallBGM DE 1001Shape
13003Summenzeitreihe (MaBiS)BKLOC+172 MaBiS-Zählpunkt, DTM+492 Bilanzierungsmonat, DTM+293 Version, one QTY per slot bounded by DTM+163/164
13023Redispatch 2.0 AusfallarbeitssummenzeitreiheZ46same
13015Arbeit + Leistungsmaximum im Kalenderjahr vor LieferbeginnZ27work entry plus one or two monthly maxima (DTM+306, 610 or 102)
13016Energiemenge und LeistungsmaximumZ28same
13019Energiemenge (Strom)7work entry only; a maximum is refused (use 13016)
13027Werte nach Typ 2 (MSB → ESA)Z83work entry only

Quantities carry DE 6063 220 (Wahrer Wert) or 67 (Ersatzwert); units are checked against DE 6411 (KWH, KWT, D54, MTS, MIG 2.5). tests/it/mscons_conformance.rs renders each, parses it back and validates it against the release profile.

Which of our MP-IDs signs an outbound message

NAD+MS must be the MP-ID of the role owning the process. Resolution:

  1. payload["sender"] when the workflow names it.
  2. The PID's sending Marktrolle from the Anwendungsübersicht Prüfidentifikatoren 4.0, narrowed by payload["sparte"] for shared PIDs (17115–17117).
  3. The primary MP-ID, with a warning naming the PID.

orders_sender_coverage pins step 2 for every routed ORDERS PID; 17118 (sender: weiterer MSB, GPKE Teil 3) and 17301 (RB HKN-R, receive-only) are exempt.

Sparte of an interchange comes from the recipient MP-ID (UNB DE 0010): every [[party]] is one Sparte (§2.13), so MpIdRegistry::sparte_of decides. Release codes and NAD agency codes cannot — only UTILMD releases carry a Sparte prefix. For a recipient that is not an own MP-ID, a PID counts as Gas only when every row of the PID overview 4.0 for it is Gas. Five look Gas-only and are not, so they resolve by recipient:

PIDAlso
INVOIC 31004StornorechnungSparte-neutral Storno of any INVOIC (INVOIC AHB §3.1.2)
INVOIC 31003WiM-RechnungWiM Strom Teil 1, MSBA → MSBN
INVOIC 31011Rechnung sonstige LeistungGPKE Teil 2, NB → LF
INSRPT 23005 / 23009InformationsmeldungWiM Strom Teil 2

INVOIC 31009 is Strom in all seven rows; IFTSTA 21028 is a GeLi Gas Informationsmeldung inside a Strom range.


CONTRL — Empfangsbestätigung und Syntaxfehlermeldung

(CONTRL AHB 1.0 Kap. 2)

AusprägungUCI DE 0083SparteWhen
Empfangsbestätigung7Gas onlyEvery inbound Gas Übertragungsdatei and every Gas APERAK (§2.3.1)
Syntaxfehlermeldung in der Übertragungsdatei4 + DE 0085BothThe fault cannot be placed on a message; the file „wird nicht weiterbearbeitet" (§2.3.2 / §2.4.2)
Syntaxfehlermeldung in der Nachricht4, no DE 0085BothOne SG1 UCM per broken message, with SG2 UCS / UCD naming segment and element — or UCM DE 0085 + DE 0013 for a UNH/UNT fault (Kap. 3; CONTRL MIG 2.0b)

One CONTRL answers one Übertragungsdatei and „entweder … bestätigt oder die gesamte Übertragungsdatei zurückgewiesen" (Kap. 1.3), so a broken message rejects the readable ones beside it. In Strom the CONTRL is used „ausschließlich als Syntaxfehlermeldung" (§2.4): the Sparte decides whether a clean interchange is acknowledged, never whether a broken one is reported.

What arrivedWindowFundstelle
A Strom UTILMD or ORDERS15 minutes; 6 hours when it arrived on a Saturday§2.4.1
A GABi-Gas ALOCAT45 minutes, either Ausprägung§2.3.1
Anything else, either Sparte6 hours§2.3.1, §2.4.1

Saturday is a Berlin Saturday (mako_fristen::contrl_due_at). A CONTRL falling due inside a Formatumstellung window — 31.3. 18:00 to 2.4. 00:00 or 30.9. 18:00 to 2.10. 00:00 gesetzlicher deutscher Zeit — is due at its end (§2.3.1, §2.4.1). A Formatumstellung the BNetzA dates elsewhere is not modelled.

CaseAnswer
Every message failed to parseSyntaxfehlermeldung in der Übertragungsdatei; DE 0085 from the parser error (12 „Ungültiger Wert" by default)
Some parsed, some did notNone is processed; every message is dead-lettered; Syntaxfehlermeldung in der Nachricht naming each broken message (or in der Übertragungsdatei when a fault cannot be located)
UNB DE 0035 = 1 on the production endpointSyntaxfehlermeldung, DE 0085 25 „Test-Kennzeichen nicht unterstützt"
UNB unreadableNo CONTRL (§2.2.2.1) — dead-lettered
The interchange is a CONTRLNo CONTRL (§2.2.2.2)

DE 0085 codes come from edifact_rs::contrl::SyntaxError (ISO 9735-4 Annex A) and are tested against CONTRL AHB 1.0 Kap. 3. The CONTRL and its escalation deadline are written in one transaction (enqueue_outbox_with_deadlines); the outbox worker discharges the deadline on delivery. The sender is the recipient MP-ID, so a Strom+Gas deployment answers from the Sparte-correct identity.


Docker Deployment

The root Dockerfile is a cargo-chef + distroless build with one runtime stage per service; runtime is makod's.

  • OpenSSL linked statically (OPENSSL_STATIC=1).
  • TZ=Europe/Berlin with the zone data copied in, for deadline arithmetic.
  • Runs as uid 65532 (nonroot); /var/lib/makod is pre-owned and declared a VOLUME.
  • Defaults MAKOD_LOG_FORMAT=json, MAKOD_DATA_DIR=/var/lib/makod, MAKOD_HTTP_ADDR=0.0.0.0:8080; exposes 8080, 4080, 8090.
  • HEALTHCHECK runs makod --check.

Release images are multi-arch (linux/amd64, linux/arm64) on GHCR, tagged with the version, its major.minor, and latest; pin a version in production.

docker pull ghcr.io/hupe1980/mako-makod:latest
docker run --rm ghcr.io/hupe1980/mako-makod:latest --check

# Local build (--build-arg PROFILE=dev for a debug build)
docker build --target runtime \
  --build-arg OCI_REVISION=$(git rev-parse HEAD) \
  --build-arg OCI_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  -t makod:latest .

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 \
  makod:latest

Mount keys and config read-only; the data volume must be writable by uid 65532 (user: "65532:65532" under docker-compose).

Kubernetes example

apiVersion: apps/v1
kind: Deployment
metadata:
  name: makod
spec:
  replicas: 1          # single writer; see Scaling
  selector:
    matchLabels: { app: makod }
  template:
    metadata:
      labels: { app: makod }
    spec:
      # Above --shutdown-timeout-secs (30 s) plus the store close's 10 s floor;
      # a SIGKILL during the close can leave the last writes unflushed.
      terminationGracePeriodSeconds: 60
      containers:
        - name: makod
          image: ghcr.io/hupe1980/mako-makod:latest
          ports:
            - containerPort: 4080    # AS4
            - containerPort: 8080    # HTTP REST
            - containerPort: 8090    # Webdienste
          env:
            - name: MAKOD_CONFIG
              value: /etc/makod/makod.toml
          volumeMounts:
            - { name: config, mountPath: /etc/makod }
            - { name: data,   mountPath: /var/lib/makod }
          livenessProbe:
            httpGet: { path: /health/live, port: 8080 }
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet: { path: /health/ready, port: 8080 }
            periodSeconds: 5
          startupProbe:              # a new pod replays its event store first
            httpGet: { path: /health/ready, port: 8080 }
            periodSeconds: 5
            failureThreshold: 60     # up to 5 min
      volumes:
        - name: config
          secret: { secretName: makod-config }
        - name: data
          persistentVolumeClaim: { claimName: makod-data }

Scaling

Run one writer. On local, makod holds an exclusive lock on <data-dir>/.makod.lock and a second instance refuses to start. Object-store backends have no such lock: use a leader-election layer (Kubernetes lease, etcd) and pass --allow-multi-instance only with that lock in place — AS4 inbox deduplication and the business-key lock are per instance. There is no read-only replica mode.


Startup Validation (--check)

makod --check runs every validation the boot runs and exits without opening a socket or spawning a worker. Exit 0 means this configuration will start; the boot consumes the same preflight output.

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

OIDC discovery and the JWKS fetch are deferred, so --check needs no route to the identity provider. It changes no domain state — registry reconciliation runs after the check exit — but it takes the data-directory lock (so it refuses while the daemon runs) and SlateDB writes its own manifest bookkeeping.


Health Checks

Mounted on every enabled port, unauthenticated, exempt from rate limiting.

RouteFails whenProbe
/health/livenever, if the process answerslivenessProbe
/health/readystore unreachable, or a worker heartbeat is stalereadinessProbe
/healthalias of /health/ready—
HTTP 200 {"status":"ok","instance_id":"makod-0-1","version":"<version>"}
HTTP 503 {"status":"degraded","instance_id":"makod-0-1","version":"<version>",
          "reason":"worker_stale:deadline-scheduler"}

reason is store_unavailable or worker_stale:<name> — no paths, safe for alerts. Dependency state belongs on readiness: a restart fixes neither an unreachable store nor a stalled worker.

Fenced writer. A second process opening the same object-store path fences the first, which keeps its listeners and fails every write. makod detects this, drains as on SIGTERM, logs event store closed underneath the daemon and exits non-zero so the orchestrator recycles it.

Worker heartbeatStale afterWhat a stall costs
deadline-scheduler3 × poll intervalRegulatory deadlines expire unnoticed
outbox-worker120 sOutbound EDIFACT stops
erp-webhook-worker120 sERP stops receiving CloudEvents
erp-log-worker120 sReplaces the above without --erp-webhook-url; ERP outbox entries accumulate
projection-worker:mabis-zp-register5 × checkpoint interval (min 300 s)The MaBiS-ZP register goes stale
retention-purge-worker26 hAS4 dedup and Idempotency-Key records accumulate

Background Workers

startup::spawn_workers starts the workers on one CancellationToken; shutdown cancels it and joins every handle before closing the store. Cancellation is observed between units of work — a delivery runs to its acknowledge, a deadline commits its events and outbox entries together — and anything undone stays durable for the next start.

WorkerIntervalPurpose
OutboxWorkercontinuous, exponential backoffDelivers EDIFACT via AS4 (or the webhook substitute) and MaLo callbacks
OutboxErpWorkercontinuous (with --erp-webhook-url)POSTs CloudEvents to the ERP webhook
DeadlineScheduler--deadline-poll-interval-secs (30 s)Dispatches due deadlines
MabisZpRegister projection--projection-checkpoint-intervalFolds the MaBiS-ZP lifecycle into the set of activated Zählpunkte
retention-purge-workerdailyEvicts AS4 dedup entries (72 h + 24 h) and spent Idempotency-Key records
JWKS refreshoidc.jwks_refresh_secsWith OIDC enabled

Deadline dispatch

Deadlines are never cancelled when a process moves on, so every one fires — the healthy path included. orchestrator::deadline_dispatch decides each firing in one place; the workflow's on_deadline (via its WorkflowDriver) owns the outcome. See Compensation for the sourced outcomes.

Fired deadlineWhat happensWhat the operator sees
A delivery window (APERAK, CONTRL)No workflow is consulted; the outbox worker discharges it on delivery, so firing means a missERROR REGULATORY ALERT: delivery window expired …, makod_aperak_missed_total
A label not in the workflow's DEADLINE_LABELS, or a workflow with no driver in this buildNothing actsERROR deadline fired under a label its workflow does not own
An owned label whose window is closed (the answer went out or came in)Nothing actsnothing
An owned label whose window is openThe Festlegung's outcome for the silence, or FristAbgelaufen where it gives noneWARN Frist lapsed with its window still open; the workflow's de.mako.aperak.timeout where it carries one

FristAbgelaufen is a breach to act on, not an outcome: the process keeps waiting, and a late answer still goes out or still lands.

The MaBiS-ZP register reports a repealed series

BK6-23-241 Tenorziffer 5 repeals MaBiS Kap. 17.2 with the end of 30.09.2026, and the tägliche Ausfallarbeitsüberführungszeitreihe (55197/55198) is not republished in the Anlage zur BilAReM. ReceiveAnfrage refuses an Aktivierung whose Abrechnungszeitraum starts after the series ends.

Already-activated Zählpunkte stay activated; the MabisZpRegister projection logs each one whose series has ended (Zählpunkt, series, end date, Abrechnungszeitraum) after every tick. makod reports and does not send a Deaktivierung — that is the operator's call. A family with no Antwort counts as activated on the recorded Anfrage.


Observability

{: #observability }

Logging and traces

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

format = "json" in production. OTLP traces cover inbound AS4/REST (parse → route → execute → write), outbox delivery attempts and deadline scheduler ticks. RUST_LOG=mako_engine=debug,edi_energy=debug for verbose output.

Prometheus (GET /metrics, Cedar ReadMetrics)

CounterLabelsAlert condition
makod_process_initiated_totalfamilyBaseline
makod_process_completed_totalfamily, resultresult != "accepted" for NB-STP
makod_outbox_delivery_attempts_totalresulttransport_error spikes
makod_deadline_fired_totalfamilyBaseline
makod_dead_letter_recorded_totalreasonAny. not_dispatchable is a mako coverage bug; missing_interchange_party a defect in the sender's message
makod_inbound_messages_totalpid, resultresult = "error"
makod_validation_failed_totalmessage_type, releaseA counterparty sends non-conformant messages
makod_aperak_missed_totallabel> 0 — an undelivered APERAK (APERAK AHB 1.1 §2.4.1 Strom / §2.3.1 Gas)
Gauge (unlabelled)ReadsAlert condition
makod_outbox_pending_totalUndelivered outbound messagesA level that stops falling
makod_deadline_pending_totalRegistered, not firedBaseline
makod_overdue_deadlines_totalPast due_at, not dispatched> 0 sustained — scheduler lag
makod_registry_totalLive process instancesCapacity
makod_dead_letter_recent_totalDLQ records in the last 1000 scannedNon-zero (§ 147 AO)
makod_volatile_mode_active1 when storage is in memory1 in production
makod_build_info1, labelled versionRollout verification
  • family is gpke, wim, esa, geli-gas, gabi-gas, mabis, emob, redispatch, netzzugang or other, all from orchestrator::process_family. An invoic.* command carries the family of the workflow it enters; netzzugang is counted as initiated, never completed.
  • result is accepted, rejected (negative APERAK), timeout or cancelled. Completions are counted as the ERP outbox drains terminal events; an accepted APERAK is not a completion.
  • makod_validation_failed_total counts inbound messages once each at the ingest boundary; parse failures are dead letters instead.
  • makod_aperak_missed_total counts APERAK windows still registered at their due time — the outbox worker discharges a window on delivery.

Inbound rate limiting

GCRA per peer address, 100 req/s sustained, burst 50, answering 429 with Retry-After: 1; health routes are exempt. The AS4 port has its own bucket, separate from the one :8080 and :8090 share, plus a per-sender limit of 50 req/s (burst 25) keyed on eb:From PartyId (read before verification; both limits always apply, so spoofing gains no capacity). X-Forwarded-For is not trusted — a load balancer must enforce its own per-client limits.


Operational Runbook

First-time setup

# 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, 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"
# Production certificates come from the BDEW PKI; self-signed is for a partner test MSH.

# 3. Exchange certificates: publish yours; reference each partner's encryption
#    and signing certificate from as4.partner_cert_files /
#    as4.partner_signing_cert_files; install the BDEW PKI CA as
#    as4.trust_anchor_pem_file.

# 4. Validate, then start
makod --check --config /etc/makod/makod.toml
makod --config /etc/makod/makod.toml

# 5. Seed partner records not in the 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"}]
  }'

Everyday checks

curl -s http://localhost:8080/health/ready | jq .

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

curl -s http://localhost:8080/admin/partners \
  -H "Authorization: Bearer ${TOKEN}" | jq .
SymptomLook at
/health/ready 503 worker_stale:deadline-schedulerScheduler stalled — Fristen are not being dispatched
makod_outbox_pending_total not fallingoutbox_delivery_attempted logs: counterparty_error / receipt_unverified / transport_error; partner URL and certificates
Every inbound AS4 message rejectedTrust anchor and partner_signing_certs
422 malo_not_found on commandsSeed PUT /admin/malo/{malo_id}
409 duplicate_processA live process holds the key — treat as success
makod_dead_letter_recorded_total{reason="not_dispatchable"}A routed PID without a dispatch arm — a mako bug
Exit with event store closed underneath the daemonA second writer on the same object-store path

Outbox Auto-Integrations

WiM Stammdaten — register auto-population

An inbound WiM Stammdaten ORDERS from the MSB (PIDs 17102–17133) is parsed by wim_stammdaten_uebermittlung_registry(); the ZAK/ZE/ZD segments become zaehlwerke on the de.mako.process.completed event, which marktd upserts into ZaehlzeitRegister / ZaehlzeitSaison — the input to billingd's §14a Modul 3 tariff zones (HT/ST/NT).

SegmentFieldValues
ZAK el. 0 / 1 / 2obis_kennzahl / zaehlerauspraegung / bezeichnungOBIS · Z01 HT, Z02 NT, Z03 EINZEL · label
ZE el. 0saisonZ01 SOMMER, Z02 WINTER, Z03 GESAMT
ZD el. 0 / 1..Ntagtyp / time windowsZ01 WERKTAG, Z02 SAMSTAG, Z03 SONNTAG_FEIERTAG · "HHMM:RegisterCode"

Active whenever wim-stammdaten is registered (role-msb-strom or role-nb-strom).

WiM Steuerungsauftrag — VPP dispatch billing

wim.steuerungsauftrag.bestaetigen on a Konfiguration (PID 55168 positive Endantwort) enqueues a DispatchConfirmed outbox message, delivered as de.vpp.dispatch.confirmed:

{
  "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 receives it at POST /api/v1/webhooks/vpp-dispatch and settles it (see the billingd guide). InitialZustand resets emit no event.


See Also

Edit this page ↗