marktd Operator Guide

marktd operator guide: Market Data Hub for Marktlokation, Messlokation, VersorgungsStatus (with history + point-in-time queries), NeLo (Redispatch 2.0), MaLo grid topology (NB STP), trading-partner management, price sheets, Cedar ABAC, durable fan-out. PostgreSQL-backed, OIDC-secured, OpenAPI 3.1, CloudEvents 1.0 outbound webhooks.

marktd Operator Guide

marktd is the Market Data Hub — the single source of truth for all market entity state in a MaKo deployment. It stores Marktlokationen (MaLo) with typed rubo4e::current::Marktlokation API responses (schema validated on PUT), Messlokationen (MeLo) with typed rubo4e::current::Messlokation responses, Zaehler + Geraete (device registry) with typed rubo4e::current::Zaehler/Geraet responses, energy contracts, trading partners, network contracts (nb_contracts) with full BO4E Vertrag payload (vertragsart, vertragsstatus as indexed columns for ERP digital LRV exchange), price sheets (PreisblattNetznutzung), VersorgungsStatus per MaLo (with full history and point-in-time queries), MaLo grid topology (malo_grid — sourced from the NB's NIS/GIS system and provisioned via PUT /api/v1/malos/{id}/grid; read by processd for Anmeldung STP decisions), and Netz-Element-Lokationen (NeLo) for Redispatch 2.0.

Beyond data storage, marktd includes:

  • durable fan-out — enriches inbound de.mako.* events with marktrole and fans out to all registered subscribers (ERP, processd, invoicd, obsd) via HMAC-signed webhooks.
  • VersorgungsStatus derivation — five-phase lifecycle: announce_lf_next (55001/55077/44001 process.initiated; the first announcement wins), confirm_supply (55002/55078/44002 process.completed — Bestätigung Anmeldung; a lfa_lieferende earlier than the Zuordnungsbeginn is Fall b and emits de.markt.versorgung.gap-detected for the days between), end_supply (55005/44005 process.completed — Bestätigung Lieferende, recording the contractual Lieferende the process carries; when it leaves an uncovered interval it emits de.markt.versorgung.gap-detected with gap_from/gap_until, the §38 EnWG gap-closure trigger), begin_eog_supply (55013/44013 process.completed — Anmeldung/Zuordnung EOG: the Grundversorger becomes the supplier of record, eog_seit anchors the §38 Abs. 4 3-month maximum), clear_lf_next (55003/55080/44003 process.completed — Ablehnung Anmeldung resets the announced future supplier). Tracks lf_mp_id_next + lf_next_lieferbeginn (pending transition), appends every change to versorgungsstatus_history, and supports ?at=YYYY-MM-DD point-in-time queries.

marktd is a pure data hub. Automated Anmeldung STP decisions are the responsibility of processd's NB module, which subscribes to marktd's fan-out and uses the pure mako-pruefung library for all decisions. This separation keeps marktd free of domain policy and makes processd independently scalable and testable.

graph TB
    makod["makod :8080<br/>EDIFACT ↔ BO4E"]
    marktd["marktd :8180<br/>Market Data Hub<br/>(this service)"]
    processd["processd :8580<br/>Process decisions<br/>(NB STP + LF answers)"]
    erp["ERP<br/>(Powercloud / SAP IS-U)"]
    invoicd["invoicd :8280<br/>Billing"]
    edmd["edmd :8380<br/>Energy Data"]
    obsd["obsd :8480<br/>Observability"]
    pg["PostgreSQL"]

    makod -->|"de.mako.process.*<br/>HMAC-signed POST"| marktd
    marktd -->|"VersorgungsStatus<br/>derivation"| pg
    marktd -->|"de.mako.* + de.markt.*<br/>HMAC webhooks"| erp
    marktd -->|"de.mako.process.initiated"| processd
    marktd -->|"de.mako.process.initiated"| invoicd
    marktd -->|"de.mako.*"| edmd
    marktd -->|"de.mako.*"| obsd
    marktd --- pg
    erp -->|"PUT /api/v1/malos<br/>PUT /api/v1/partners"| marktd
    invoicd -->|"GET /api/v1/preisblaetter<br/>GET /api/v1/nb-contracts"| marktd
    processd -->|"GET /api/v1/versorgung<br/>GET /api/v1/malos/{id}/grid<br/>GET /api/v1/partners"| marktd
    processd -->|"POST /api/v1/commands"| makod

The clean separation of concerns:

ServiceResponsibility
makodEDIFACT parsing, BDEW process rules, AS4 delivery, regulatory deadlines
marktdMarket data, VersorgungsStatus, ERP subscriptions, durable fan-out
processdAutomated STP decisions (NB: mako-pruefung; LF: answers to 55007 / 55010)

Port Layout

┌─────────────────────────────────────────────────────────────────┐
│  marktd  :8180                                                    │
│                                                                 │
│  Axum REST API                                                  │
│   ├─ OIDC/JWT middleware  → JwtClaims { sub, mako_tenant,      │
│   │                                     mako_roles, mako_sparte}│
│   ├─ Cedar ABAC enforcer  → permit / deny                      │
│   └─ Handlers             → PostgreSQL (SQLx)                  │
│                                                                 │
│  POST /api/v1/events  ← makod CloudEvents ingest               │
│   ├─ Verify HMAC signature                                     │
│   ├─ Deduplicate via processed_events table                    │
│   ├─ Fan-out to all fan-out subscribers                       │
│   └─ Derive VersorgungsStatus (55002/55078/55005/55013 + Gas)  │
│                                                                 │
│  GET  /admin/fanout/dlq                       ← DLQ inspection │
│  POST /admin/fanout/dlq/{event}/{sub}/retry   ← re-deliver     │
│  DEL  /admin/fanout/dlq/{event}/{sub}         ← discard entry  │
│  GET  /metrics                                ← Prometheus     │
│                                                                 │
│  Note: Automated STP decisions live in processd :8580          │
│  marktd is a pure data hub — no domain policy.                  │
│                                                                 │
│  GET /health/live   — liveness (no DB check)                   │
│  GET /health/ready  — readiness (bounded PostgreSQL ping)      │
└─────────────────────────────────────────────────────────────────┘

Quick Start

With Docker Compose (full stack)

See demos/nb-stp/docker-compose.yml for the complete 8-service stack (postgres + webhook + marktd + processd + makod + invoicd + edmd + obsd).

Minimal compose snippet for marktd alone:

services:
  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB:       marktd
      POSTGRES_USER:     marktd
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U marktd -d marktd"]
      interval: 5s
      retries: 10

  marktd:
    image: ghcr.io/hupe1980/marktd:0.12.0
    depends_on:
      postgres:
        condition: service_healthy
    volumes:
      - ./marktd.toml:/etc/marktd/marktd.toml:ro
    environment:
      DATABASE_URL:         postgres://marktd:secret@postgres/marktd
      MAKOD_API_KEY:        my-makod-api-key
      MAKOD_WEBHOOK_SECRET: my-webhook-secret
    command: ["--config=/etc/marktd/marktd.toml"]
    ports: ["8180:8180"]

Binary

marktd --config /etc/marktd/marktd.toml
# or: MARKTD_CONFIG=/etc/marktd/marktd.toml marktd

Migrations run automatically at startup via sqlx migrate run.


Configuration

marktd reads its configuration from a TOML file (default: marktd.toml), with secrets deferred to environment variables via "env:VAR_NAME" values.

Full marktd.toml reference

Config is loaded by mako_service::load_config: marktd.toml first (path from MARKTD_CONFIG, default ./marktd.toml), then MARKTD_* environment variables with __ as the section separator, then any *_FILE variable read from a file. The file is optional — a container can be configured entirely from the environment (MARKTD_DATABASE__URL, MARKTD_MARKT__TENANT, MARKTD_MAKOD__API_KEY_FILE, …).

[http]
addr = "0.0.0.0:8180"     # default

[database]
url             = "env:DATABASE_URL"  # required; use env: for secrets
pool_size       = 20
min_connections = 2

[markt]
# This deployment's own operator identity: the `resource_tenant` every Cedar check
# compares the caller's `mako_tenant` claim against, the `tenant` column on
# tenant-scoped rows, and the source URN of every outbound CloudEvent.
tenant = "9900357000004"           # required

[makod]
base_url  = "http://makod:8080"   # required
api_key   = "env:MAKOD_API_KEY"   # required

[webhook]
inbound_path   = "/api/v1/events"             # must match makod's [erp] webhook_url (code default: /api/v1/mako/events)
inbound_secret = "env:MAKOD_WEBHOOK_SECRET"   # required unless allow_insecure_no_auth

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

# marktd is fail-closed: without [oidc] AND webhook.inbound_secret it refuses
# to start. The insecure dev posture must be requested by name:
# allow_insecure_no_auth = true

# [otel]            # omit to disable tracing
# endpoint = "http://otel-collector:4317"

CLI flags

FlagEnv varDefaultDescription
--config / -cMARKTD_CONFIGmarktd.tomlPath to marktd.toml
--log-levelRUST_LOGinfoLog level (info, debug, marktd=trace)
--checkMARKTD_CHECKfalseValidate config + DB connectivity, then exit 0. Used by Dockerfile HEALTHCHECK.

Authentication & JWT Claims

marktd validates every request using a JWT Bearer token. The JWT must contain these custom claims (in addition to standard OIDC claims):

ClaimTypeRequiredDescription
substringyesPrincipal identifier
mako_tenantstringyesGLN of the tenant this principal belongs to
mako_rolesstring[]yesRoles, e.g. ["NB"], ["LF"], ["MSB","NB"]
mako_spartestring[]noOptional commodity scope, e.g. ["Strom","Gas"]

Configure your OIDC provider (Keycloak, Zitadel, Auth0, Entra ID) to populate mako_tenant and mako_roles from your user store or service account attributes.

Supported signing algorithms: RS256, ES256, PS256. HS256/HS512 are rejected — symmetric algorithms are not acceptable for OIDC.


Authorization: Cedar ABAC

marktd uses Cedar (AWS PARC model) for fine-grained Attribute-Based Access Control. The policy file is loaded once at startup.

Default policy (policies/marktd.cedar)

// Any authenticated principal of the same tenant can read all resources.
permit(
    principal,
    action in [
        Action::"read-malo", Action::"read-melo", Action::"read-nb-contract",
        Action::"read-partner", Action::"read-preisblatt"
    ],
    resource
)
when {
    principal.tenant == resource.tenant
};

// Any principal of the same tenant can write malo/melo/nb-contracts/partners.
permit(
    principal,
    action in [
        Action::"write-malo", Action::"write-melo", Action::"write-nb-contract",
        Action::"write-partner"
    ],
    resource
)
when {
    principal.tenant == resource.tenant
};

// Only NB-role principals may write price sheets.
permit(
    principal,
    action == Action::"write-preisblatt",
    resource
)
when {
    principal.tenant == resource.tenant &&
    principal.roles.contains("NB")
};

// Only operator-admin principals may manage the fanout dead-letter queue.
// Grant this action to your on-call / operations service account.
permit(
    principal,
    action == Action::"manage-fanout",
    resource
)
when {
    principal.tenant == resource.tenant &&
    principal.roles.contains("ADMIN")
};

Context fields

FieldValue
principal.tenantmako_tenant JWT claim
principal.rolesmako_roles JWT claim
resource.tenantTenant GLN of the requested resource

Denied response

{
  "error": "Forbidden",
  "detail": "action=write-preisblatt denied for principal=svc@example.com resource_tenant=9900357000004"
}

Custom policies

Replace policies/marktd.cedar and restart marktd; policies are loaded at startup.


REST API

Interactive docs: http://localhost:8180/api/v1/docs/

OpenAPI spec: GET /api/v1/openapi.json

The BO4E gate

Every PUT carrying a BO4E payload runs the same four stages — mako_markt::bo4e::decode, described in The BO4E gate — and every refusal is a 422 naming the stage in code:

{
  "error": "MARKTLOKATION carries 1 out-of-schema enum value(s) at: sparte",
  "code":  "bo4e.unknown_enum",
  "paths": ["sparte"]
}

Two consequences are specific to marktd, which stores what it accepts:

  • The stored data is the canonical round-trip, and the typed columns beside it are derived from the same object, so a column cannot disagree with the document it shadows. That is also why the strict-enum stage matters here: Unknown serialises back as the literal "UNKNOWN", so skipping it would replace a caller's value rather than merely accept it.
  • The envelope never asks for a field the BO declares. sparte on a NeLo, nutzung/verbrauchsart/ist_fernschaltbar/malo_id/melo_id on a TechnischeRessource, konfigurationsprodukte on a SteuerbareRessource and zaehler_typ/eichung_bis on a Zaehler are derived from the payload. Where the BO declares no such field — nelos.nb_mp_id, a MaLo's fallgruppe and fernsteuerbar — the envelope keeps it and an upsert leaves the column alone.

Writes that touch a shadowed column merge into the JSONB in the same statement, so the EDIFACT Stammdatenänderung patch and the konfigurationsprodukte sub-resource cannot leave the column and the document disagreeing.

Endpoints

MethodPathCedar actionDescription
GET/health/liveLiveness (no DB, no auth)
GET/health/readyReadiness (bounded DB ping, no auth). Mounted by mako_service::run
GET/metricsPrometheus (no auth)
PUT/api/v1/malos/{malo_id}write-maloUpsert Marktlokation through the BO4E gate; pushes to makod MaLo cache
GET/api/v1/malos/{malo_id}read-maloGet Marktlokation as typed rubo4e::current::Marktlokation (canonical BO4E camelCase)
GET/api/v1/malos/{malo_id}/lastprofilread-maloLastprofil (SLP/TLP) assigned to the Marktlokation
GET/api/v1/malosread-maloList Marktlokationen (schema-drift records silently filtered)
PUT/api/v1/melos/{melo_id}write-meloUpsert Messlokation through the BO4E gate
GET/api/v1/melos/{melo_id}read-meloGet Messlokation as typed rubo4e::current::Messlokation
GET/api/v1/melos/{melo_id}/standorteigenschaftenread-meloBO4E Standorteigenschaften for the MeLo
PUT/api/v1/partners/{mp_id}write-partnerUpsert trading partner as a Geschaeftspartner through the BO4E gate; the stored form is the canonical camelCase round-trip
GET/api/v1/partners/{mp_id}read-partnerGet trading partner — returns a geschaeftspartner field with the typed rubo4e::current::Geschaeftspartner payload (graceful fallback for legacy records)
GET/api/v1/partnersread-partnerList partners
GET/api/v1/partners/{mp_id}/as4-addressread-partnerAS4 endpoint URL and certificate for a partner
GET/api/v1/partners/{mp_id}/marktteilnehmerread-partnerBO4E Marktteilnehmer view of a partner (typed marktrolle/rollencodetyp, mp_id → rollencodenummer). Note: partner PUTs with the legacy literal role "LFG" are rejected 422 — model gas suppliers as LF + Rollencodetyp DVGW
GET/PUT/api/v1/mmma-preise/gas/{year}/{month}read-mmma-preis / write-mmma-preisGas MMM Abrechnungspreise (Trading Hub Europe / MGV, monthly) — {mehr_ct_kwh, minder_ct_kwh}; queried by netzbilanzd for INVOIC 31007/31008 billing and invoicd check 6 validation
GET/api/v1/mmma-preise/gasread-mmma-preisList all Gas MMM price records (newest first; ?limit=)
GET/PUT/api/v1/mmm-preise/strom/{year}/{month}read-mmma-preis / write-mmma-preisStrom Mehr-/Mindermengenpreise — {mehr_ct_kwh, minder_ct_kwh}. One nationwide series: § 13 Abs. 3 StromNZV requires einheitliche prices computed from monthly market prices, and the BDEW determines and publishes them centrally, so the application month is the entire key. There is no per-VNB and no per-ÜNB variant. Queried by netzbilanzd for MMM INVOIC 31005/31006 and invoicd check 6
PUT/api/v1/preisblaetter/{nb_mp_id}write-preisblattUpsert price sheet + store versioned snapshot + emit de.markt.pricat.published
GET/api/v1/preisblaetter/{nb_mp_id}read-preisblattGet price sheet valid on date
GET/api/v1/pricat/{nb_mp_id}/historyread-pricatList PRICAT version history (newest first)
GET/api/v1/pricat/{nb_mp_id}/dispatch-log/{version_id}read-pricatPRICAT dispatch audit log for a version
POST/api/v1/pricat/{nb_mp_id}/dispatchdispatch-pricatEnqueue (re-)dispatch of latest PRICAT to all active LF partners
GET/api/v1/versorgung/{malo_id}read-versorgungsstatusCurrent VersorgungsStatus; add ?at=YYYY-MM-DD for point-in-time
GET/api/v1/versorgung/{malo_id}/historyread-versorgungsstatusFull supply-state change history (newest first, paged)
PUT/api/v1/versorgung/{malo_id}write-versorgungsstatusUpsert VersorgungsStatus (ERP-driven override)
GET/api/v1/grundversorger/{nb_mp_id}read-grundversorgerGrundversorger Feststellung (§36 Abs. 2 EnWG); ?sparte=STROM|GAS
PUT/api/v1/grundversorger/{nb_mp_id}write-grundversorgerUpsert the Feststellung (NB role) — read by the processd EoG gap closure. Optional default_bilanzkreis deposits the GPKE-Teil-4 default BK applied when an EoG completes without the E/G supplying its own (EoG ohne Antwort).
POST/api/v1/esa/einwilligungenwrite-einwilligungGrant an ESA consent (§49 Abs. 2 Nr. 9 MsbG). Emits de.markt.einwilligung.erteilt. Evidence-agnostic
GET/api/v1/esa/einwilligungenread-einwilligungList active consents (?esa_mp_id=)
GET/api/v1/esa/einwilligungen/{id}read-einwilligungGet a consent
DELETE/api/v1/esa/einwilligungen/{id}write-einwilligungRevoke (GDPR Art. 7(3)) — emits de.markt.einwilligung.widerrufen and fires the 17008 Abbestellung at makod, once per covered location and no Messprodukt: a location may carry several subscriptions and makod stops every one of them
PUT/api/v1/esa/preise/{msb_mp_id}/{esa_mp_id}write-einwilligungRecord the prices of an accepted QUOTES 15003 Angebot (esa_messprodukt_preise). Filed by makod on the ORDRSP 19011 — the moment the offer becomes the agreement
GET/api/v1/esa/preise/{msb_mp_id}/{esa_mp_id}?at=read-einwilligungThe prices in force on at. Read by invoicd to check an INVOIC 31009: an ESA has no Preisblatt (§35 MsbG leaves the Entgelt for a Zusatzleistung to be agreed per request), so the offer it ordered against is the price basis
PUT/GET/api/v1/esa/framework/{msb_mp_id}/{esa_mp_id}Bilateral EDI@Energy framework agreement + AS4 cert state
PUT/api/v1/esa/messprodukte/{msb_mp_id}write-einwilligungRecord which optional Kapitel-4.6 Messprodukte this MSB serves an ESA, and in which Abo mode (E_0252 Prüfschritt 2, E_0256 Prüfschritte 4/5). A code outside Kapitel 4.6 is refused with 422 — the catalogue of orderable products is code, not data
GET/api/v1/esa/messprodukte/{msb_mp_id}/{messprodukt}?at=read-einwilligungDoes this MSB serve the product on at, in which mode? The answer folds in the dated Pflicht rule: a Pflichtprodukt is served whatever the catalogue holds (BNetzA Mitteilung Nr. 3, §34 Abs. 2 S. 2 Nr. 10 MsbG). als_abo/als_einmalig are null where nothing is recorded — „not carried" is a decision, „nothing recorded" is not, and the walks escalate on the difference
GET/api/v1/esa/subscriptions/{bestellung_ref}read-einwilligungWhich Messprodukt an ORDERS 17007 Belegnummer subscribed to. edmd's Typ-2 surveillance is the caller: the Codeliste publishes a delivery cadence per product, but an inbound MSCONS 13027 names only the Belegnummer (SG1 RFF+AGI). 404 → fall back to the configured threshold rather than invent a cadence
GET/api/v1/esa/consent-checkGate an ESA message (?esa_mp_id=&msb_mp_id=&location_id=&perspective=) → {allowed, code, reason}. perspective=msb_inbound (default, lenient: missing record = self-assertion) or esa_outbound (strict: missing record = no lawful basis). makod calls this before running the Wertebestellung workflow
GET/api/v1/mabis-zpread-mabis-zpEvery Bilanzierungsgebiet → MaBiS-Zählpunkt assignment for the tenant
GET/api/v1/bilanzierungsgebiete/{eic}/mabis-zpread-mabis-zpResolve the MaBiS-Zählpunkt (MSCONS SG6 LOC+172) for a territory. 404 is the signal mabis-syncd turns into a refused submission — it must never be read as "use the Bilanzierungsgebiet EIC instead"
PUT/api/v1/bilanzierungsgebiete/{eic}/mabis-zpwrite-mabis-zpAssign the MaBiS-Zählpunkt (NB role). Rejects a Meldepunkt equal to the EIC and one that is not a 33-character Zählpunktbezeichnung, with 400. The length check catches another territory's 16-character EIC, which the inequality alone lets through and which would read as valid master data until a submission run refused it. Both are enforced at the API and by table CHECKs — the substitution is invisible once on the wire
PUT/api/v1/netzzugang/antraegewrite-netzzugangUpsert a §20b EnWG Netzzugangsplattform request (makod netzzugang adapter projection). Emits de.markt.netzzugang.antrag.updated
GET/api/v1/netzzugang/antraegeread-netzzugangList §20b requests (?status=&netzanschluss_id=)
GET/api/v1/netzzugang/antraege/{id}read-netzzugangGet a §20b request
PATCH/api/v1/netzzugang/antraege/{id}/statuswrite-netzzugangAdvance lifecycle (erfasst → uebermittelt → bestaetigt/abgelehnt, optional platform_ref; optional expected_version → 412 on mismatch). Used by the makod sender and by the operator recording the platform's answer
PUT/api/v1/msb-rahmenvertraege-gaswrite-msb-rv-gasUpsert a Gas MSB-Rahmenvertrag conclusion (GeLi Gas 3.0 Tenor 13–16: KoV XV Anlage 8 from 01.10.2026; status=anpassung_erforderlich tracks the BK7-17-026 migration duty). Idempotent on the natural key (tenant, gnb_mp_id, msb_mp_id, valid_from); optimistic version (mismatch → 412); rejects valid_to < valid_from. Emits de.markt.msb-rahmenvertrag-gas.updated (incl. version, valid_from, valid_to, signed_at)
GET/api/v1/msb-rahmenvertraege-gasread-msb-rv-gasList Gas MSB framework contracts (?msb_mp_id=&status=)
GET/api/v1/msb-rahmenvertraege-gas/{id}read-msb-rv-gasGet one Gas MSB framework contract
GET/api/v1/nelosread-neloList NeLos (?nb_mp_id= filters by Netzbetreiber)
GET/api/v1/nelos/{id}read-neloGet a NeLo by EIC / BDEW Codenummer
PUT/api/v1/nelos/{id}write-nelo (NB role)Insert or update a NeLo through the BO4E gate. sparte is derived from the payload and required by this endpoint's profile (STROM/GAS only — MaKo is a two-commodity market where BO4E's Sparte has seven values); only nb_mp_id, which Netzlokation declares no field for, rides in the envelope
GET/api/v1/tranchenread-trancheList Tranchen (?malo_id= filters by parent MaLo)
GET/api/v1/tranchen/{id}read-trancheGet a Tranche
PUT/api/v1/tranchen/{id}write-tranche (NB role)Insert or update a Tranche (GPKE Teil 4 „Daten der Tranche")
GET/api/v1/malos/{malo_id}/gridread-malo-gridMaLo grid topology (Netzgebiet, Bilanzierungsgebiet)
PUT/api/v1/malos/{malo_id}/gridwrite-malo-grid (NB role)Upsert grid record from NIS/GIS
GET/api/v1/preisblaetter-messung/{msb_mp_id}read-preisblattPreisblattMessung valid on date (MSB metering tariffs); includes auf_abschlaege
PUT/api/v1/preisblaetter-messung/{msb_mp_id}write-preisblattUpsert MSB metering price sheet
GET/PUT/api/v1/preisblaetter-ka/{nb_mp_id}read-preisblatt / write-preisblattPreisblattKonzessionsabgabe valid on date
GET/PUT/api/v1/preisblaetter-dienstleistung/{msb_mp_id}read-preisblatt / write-preisblattPreisblattDienstleistung valid on date (MSB services)
GET/PUT/api/v1/preisblaetter-hardware/{msb_mp_id}read-preisblatt / write-preisblattPreisblattHardware valid on date (MSB devices)
GET/api/v1/steuerbare-ressourcen/{sr_id}read-srGet a SteuerbareRessource by SR-ID
PUT/api/v1/steuerbare-ressourcen/{sr_id}write-srUpsert a SteuerbareRessource
GET/api/v1/steuerbare-ressourcen/{sr_id}/konfigurationsprodukteread-srList Konfigurationsprodukte (§14a steuerbare Verbrauchseinrichtungen)
GET/api/v1/technische-ressourcen/{tr_id}read-deviceGet a TechnischeRessource by TrId
GET/PUT/api/v1/steuerbare-ressourcen/{sr_id}/konfigurationsprodukteread-sr / write-sr§14a Konfigurationsprodukte on a SteuerbareRessource
DELETE/api/v1/steuerbare-ressourcen/{sr_id}/konfigurationsprodukte/{produktcode}write-srRemove one Konfigurationsprodukt
PUT/api/v1/technische-ressourcen/{tr_id}write-deviceUpsert a TechnischeRessource (E-mobility, generation, storage)
GET/api/v1/malos/{malo_id}/technische-ressourcenread-deviceList TechnischeRessource for a MaLo
GET/api/v1/malos/{id}/lokationenread-maloRecursive Lokationszuordnung graph from a MaLo (?at=YYYY-MM-DD)
GET/api/v1/malos/{id}/buendelread-maloFirst-class Lokationsbündel rooted at a MaLo — the bundle projected from the typed graph plus its structural-integrity status (valid + validation_error; ≥1 MeLo required)
GET/api/v1/melos/{id}/lokationenread-meloRecursive Lokationszuordnung graph from a MeLo
PUT/api/v1/lokationszuordnungenwrite-lokationszuordnungUpsert a directed location graph edge (lokationsbuendelcode extracted into a typed column). Note the single-write-path invariant: a MeLo PUT reconciles the melo→malo graph edge in the same transaction (previous edges closed with valid_to, never deleted), so the melo.malo_id FK and the graph cannot drift
DELETE/api/v1/lokationszuordnungen/{von_id}/{nach_id}write-lokationszuordnungHard-delete an edge pair (all temporal variants)
GET/api/v1/melos/{melo_id}/zaehlerread-deviceList Zaehler for a MeLo (typed Vec<ZaehlerResponse> with data: rubo4e::current::Zaehler)
GET/api/v1/melos/{melo_id}/msbread-melo-msbThe MSB responsible for the MeLo on ?at=YYYY-MM-DD (default today) — WiM Teil 2 UC 4.1.1 historical Werteanfrage routing
PUT/api/v1/melos/{melo_id}/msbwrite-melo-msbRecord a dated MSB assignment ({ msb_mp_id, valid_from }); closes the previously-open assignment atomically
GET/api/v1/melos/{melo_id}/msb/historyread-melo-msbFull dated MSB timeline for the MeLo (newest first)
PUT/api/v1/malos/{malo_id}/bilanzierungwrite-bilanzierungUpsert a BO4E Bilanzierung (BO #3) through the BO4E gate, keyed on (malo, bilanzierungsbeginn); typed columns (Bilanzkreis/Aggregationsverantwortung/Prognosegrundlage/Fallgruppe) extracted, full BO stored as JSONB
GET/api/v1/malos/{malo_id}/bilanzierungread-bilanzierungThe Bilanzierung effective at ?at=<RFC3339|YYYY-MM-DD> (default now) — point-in-time by validity window
GET/api/v1/malos/{malo_id}/bilanzierung/historyread-bilanzierungFull Bilanzierung history for the MaLo (newest validity-start first)
GET/api/v1/melos/{melo_id}/sharing-eligibilityread-sharing-eligibility§42c EnWG metering capability — qualifies via Zählerstandsgangmessung (§2 Satz 1 Nr. 27 MsbG) or viertelstündliche RLM. Returns capability, basis, required_action, reasons, bilanzierungsgebiet, and the master-data evidence it decided from.
GET/api/v1/zaehler/{zaehler_id}/zaehlwerkeread-deviceList Zaehlwerk registers for a Zaehler (typed Vec<Zaehlwerk> from JSONB)
PUT/api/v1/zaehler/{zaehler_id}write-deviceUpsert a Zaehler through the BO4E gate. zaehler_typ and eichung_bis are derived from the BO, never taken beside it
GET/api/v1/zaehler/{zaehler_id}/geraeteread-deviceList Geraete for a Zaehler (typed Vec<GeraetResponse> with data: rubo4e::current::Geraet + konfigurationen: Vec<GeraetKonfiguration>)
GET/api/v1/zaehler/{zaehler_id}/geraete/{geraet_id}read-deviceGet a single Geraet — full BO4E payload + konfigurationen; 404 when not found
GET/PUT/api/v1/zaehler/{zaehler_id}/geraete/{geraet_id}/konfigurationenread-device / write-deviceGet or atomically replace typed GeraetKonfiguration entries (MsbG §23); PUT emits de.markt.geraet.konfiguration.updated
GET/PUT/api/v1/zaehler/{zaehler_id}/registerread-device / write-deviceList/upsert iMSys TOU registers (ZaehlzeitRegister)
GET/PUT/api/v1/zaehler-register/{register_id}/saisonsread-device / write-deviceList/upsert seasonal TOU windows (ZaehlzeitSaison)
GET/api/v1/zaehler/{zaehler_id}/tariff-zoneread-deviceResolve HT/NT/EINZEL tariff zone for a given local datetime
GET/api/v1/zaehler/{zaehler_id}/zaehlzeitdefinitionenread-deviceReturn typed rubo4e::current::Zaehlzeitdefinition assembled from zaehler_register + zaehler_saisons; ?valid_only=true filters to current registers
PUT/api/v1/geraete/{geraet_id}write-deviceUpsert a Geraet through the BO4E gate
GET/api/v1/nb-contracts/{id}read-nb-contractGet NB network contract with typed BO4E Vertrag payload
PUT/api/v1/nb-contracts/{id}write-nb-contractUpsert NB network contract as a Vertrag through the BO4E gate; emits de.markt.nb-contract.updated
GET/api/v1/nb-contractsread-nb-contractList NB contracts (?nb_mp_id=... required)
GET/api/v1/nb-contracts/by-malo/{malo_id}read-nb-contractContract in force for a MaLo on ?on= (default today) — the Netznutzer and its type
GET/PUT/api/v1/energiemix/{nb_mp_id}read-energiemix / write-energiemix§42 EnWG Energiemix for a Netzbetreiber (?year=)
GET/api/v1/energiemix/{nb_mp_id}/historyread-energiemixEvery published Energiemix year for the NB
GET/api/v1/subscriptionsmanage-subscriptionList durable fan-out subscriptions
GET/PUT/DELETE/api/v1/subscriptions/{id}manage-subscriptionRead, register or remove one subscriber
POST/api/v1/subscriptions/{id}/testmanage-subscriptionSend a probe CloudEvent to the subscriber's webhook
GET/api/v1/correlationsread-correlationList process correlations (?malo_id=, ?workflow=)
GET/api/v1/correlations/{id}read-correlationOne correlation by process_id or erp_order_id
POST[webhook] inbound_pathInbound CloudEvent from makod (HMAC-verified); appended to event_log before fan-out. Default /api/v1/mako/events; it must match makod's [erp] webhook_url
GET/admin/fanout/dlqmanage-fanoutList unresolved DLQ entries
POST/admin/fanout/dlq/{event_id}/{subscriber_id}/retrymanage-fanoutRe-deliver a dead-lettered delivery
DELETE/admin/fanout/dlq/{event_id}/{subscriber_id}manage-fanoutDiscard a dead-lettered delivery
GET/admin/eventsmanage-fanoutCloudEvent replay log — ?from=RFC3339&to=RFC3339&type=&limit=
GET/metricsPrometheus metrics (no auth, internal only)

A consent stops being a lawful basis two ways — Widerruf (GDPR Art. 7(3)) and expiry — and E_0256 Prüfschritt 8 names both in one code: A08 is „widerrufen oder ihre Gültigkeit ist abgelaufen". Both owe the same message, because the only protocol-level stop is the ORDERS 17008 the ESA sends.

DELETE covers the Widerruf; an hourly sweep closes every consent whose valid_to has passed and stops the deliveries it authorised, through the same code path. Idempotent by construction: revoked_at is stamped in the statement that selects, so a second sweep — or a DELETE racing it — returns nothing.

Both paths emit de.markt.einwilligung.widerrufen. The payload's grund (einwilligung_widerrufen / einwilligung_abgelaufen) is what lets an audit tell them apart.


Price Sheets — PreisblattNetznutzung

Price sheets record the Netznutzungspreise for a Netzbetreiber. Validity is derived from the BO4E gueltigkeit.startdatum / gueltigkeit.enddatum fields inside the JSON payload.

PUT request body

{
  "data": {
    "_typ": "PREISBLATT_NETZNUTZUNG",
    "bezeichnung": "Netznutzungspreise 2025 — 9900357000004",
    "gueltigkeit": { "startdatum": "2025-10-01", "enddatum": "2026-09-30" },
    "marktteilnehmer": {
      "_typ": "MARKTTEILNEHMER",
      "marktrolle": "NB",
      "rollencodenummer": "9900357000004",
      "rollencodetyp": "BDEW"
    },
    "preispositionen": [ ... ]
  },
  "bo4e_version": "202607.1.0"
}

GET response

{
  "data":         { "_typ": "PREISBLATT_NETZNUTZUNG", ... },
  "source":       "api",
  "bo4e_version": "202607.1.0",
  "updated_at":   "2025-10-01T08:15:00Z",
  "zeitvariable_preispositionen": [
    {
      "bezeichnung": "HT-Arbeitspreis",
      "einheit": "CT_PRO_KWH",
      "bezugsgroesse": "STUNDE",
      "zeitfenster": { "startzeit": "07:00", "endzeit": "21:00" },
      "preis": "8.35"
    }
  ]
}

zeitvariable_preispositionen contains the ZeitvariablePreisposition array from the BO4E payload. If the price sheet has no ToU tariffs the field is omitted (serialized with #[serde(skip_serializing_if = "Vec::is_empty")]). This array is consumed by netzbilanzd for §14a Modul 2 ToU billing (BNetzA BK6-22-300).

Query parameter: ?date=YYYY-MM-DD (defaults to today in CET/CEST).

Source field

Every price sheet row carries a source field:

SourceSet bySemantics
apiREST PUT /api/v1/preisblaetter/{nb_mp_id}Operator-supplied via REST API or ERP export
makoPRICAT ingest path (invoicd/makod)Received as EDIFACT from the NB

Operator-override rule: an api entry always supersedes a mako entry for the same NB GLN and validity period. A price sheet uploaded via the REST API cannot be silently overwritten by an incoming EDIFACT PRICAT.

Enforced in SQL:

ON CONFLICT (nb_mp_id, valid_from)
DO UPDATE SET data = EXCLUDED.data, ...
WHERE preisblaetter.source <> 'api' OR EXCLUDED.source = 'api';

PRICAT 27003 dispatch pipeline

preisblaetter vs pricat_versions — why both

The two tables hold the same JSON and are not redundant. They answer different questions, so they carry opposite constraints:

preisblaetterpricat_versions
QuestionWhich Preisblatt is valid on date X?Which document did we transmit, to whom, and when?
KindCurrent stateAudit trail
OverlapForbidden — EXCLUDE USING gist guarantees one answer per party per dayAllowed by design
CorrectionReplaces the rowAdds a version; earlier ones stay queryable
Read byinvoic-checker, netzbilanzd, invoicd for tariff resolutionOperators and auditors, via /pricat/{nb}/history

pricat_versions.data is a snapshot, not a reference. A PRICAT sent last quarter remains a fact after the sheet behind it is corrected, and pointing at the live row would let that correction retroactively rewrite what was transmitted.

Only the NNE sheet feeds this ledger: PRICAT 27003 is the NB→LF Preisblatt Netznutzung transmission, so preisblaetter_messung (MSB) and preisblaetter_konzessionsabgabe have no PRICAT of their own.

The write path

Every PUT /api/v1/preisblaetter/{nb_mp_id} call, in one transaction:

  1. Writes or updates the current price sheet in preisblaetter
  2. Inserts a versioned snapshot in pricat_versions keyed on (nb_mp_id, tenant, valid_from)
  3. Enqueues de.markt.pricat.published on the outbox → fan-out to ERP webhook subscribers

A background task then dispatches PRICAT 27003 per active LF partner via MakodClient. The three writes share one transaction so the sheet, the snapshot and the dispatch trigger cannot diverge.

The dispatch audit log (pricat_dispatch_log) records every outbound dispatch attempt (NB × LF pair) with outcome and makod process ID.

sequenceDiagram
    participant ERP
    participant marktd
    participant pricat_versions
    participant MakodClient
    participant AS4

    ERP->>marktd: PUT /api/v1/preisblaetter/{nb_mp_id}
    marktd->>preisblaetter: UPSERT (existing)
    marktd->>pricat_versions: UPSERT versioned snapshot
    marktd-->>ERP: 204 No Content
    marktd-)fanout: de.markt.pricat.published
    Note over marktd: background task
    marktd->>pricat_versions: mark_queued(version_id)
    loop per active LF partner
        marktd->>MakodClient: POST /api/v1/commands dispatch-pricat-27003
        MakodClient->>AS4: PRICAT 27003
        marktd->>pricat_dispatch_log: log(lf_mp_id, outcome)
    end
    marktd->>pricat_versions: mark_done(version_id)

Auto-dispatch on LF partner registration: when PUT /api/v1/partners/{mp_id} registers a partner with marktrolle = "LF", the latest PRICAT version for the operator's NB GLN is automatically re-queued for dispatch to the new partner.

Manual re-dispatch: POST /api/v1/pricat/{nb_mp_id}/dispatch resets dispatch state to queued so the background task picks it up again. Use this after AS4 outages or to force distribution to newly on-boarded partners.

Dispatch states:

StateMeaning
pendingVersion stored; no dispatch started yet
queuedDispatch task picked this version up
doneAll active LF partners successfully reached
errorLast dispatch attempt failed; will be retried on next poll

Trading Partners — Geschaeftspartner BO4E

marktd stores trading partners in the partners table, keyed by mp_id (BDEW-Codenummer or DVGW-Codenummer). Every PUT validates and normalises the partner payload as rubo4e::current::Geschaeftspartner.

Schema validation on PUT

The record has two halves, validated differently:

PartValidation
marktrolle, rollencodetyp, sparte — top-level record fieldsTyped enums; 422 when serde's lenient decode falls through to Unknown (a typo, or the legacy EDIFACT LFG — BO4E models a gas supplier as LF + rollencodetyp: DVGW)
channels — the BO4E payloadDecoded as rubo4e::current::Geschaeftspartner: _typ injected when absent, 422 when it names another type, every declared enum checked, re-serialised to canonical camelCase before storage

marktrolle, rollencodetyp and marktteilnehmerstatus are Marktteilnehmer fields — BO4E does not define them on Geschaeftspartner. Putting them inside channels does not fail: the decode absorbs them as extension fields, stores them unvalidated and reads them back as nothing. The role fields belong at the top level, where they are typed.

# Register a trading partner (LF). The role fields are top-level; `channels`
# carries the Geschaeftspartner.
curl -s -X PUT "http://marktd:8180/api/v1/partners/9904234560001" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Muster Energieversorgung GmbH",
    "marktrolle":    "LF",
    "rollencodetyp": "BDEW",
    "sparte":        "STROM",
    "makoadresse":   ["https://as4.muster-ev.de/as4/in"],
    "channels": {
      "_typ": "GESCHAEFTSPARTNER",
      "anrede": "FRAU",
      "adresse": {
        "_typ": "ADRESSE",
        "strasse": "Musterstraße",
        "hausnummer": "1",
        "postleitzahl": "10115",
        "ort": "Berlin",
        "landescode": "DE"
      }
    }
  }'

Typed GET response

GET /api/v1/partners/{mp_id} returns a structured response with a geschaeftspartner field containing the typed rubo4e::current::Geschaeftspartner payload:

{
  "mp_id":   "9904234560001",
  "display_name": "Muster Energieversorgung GmbH",
  "marktrolle": "LF",
  "rollencodetyp": "BDEW",
  "makoadresse": ["https://as4.muster-ev.de/as4/in"],
  "geschaeftspartner": {
    "_typ": "GESCHAEFTSPARTNER",
    "anrede": "FRAU",
    "adresse": { "_typ": "ADRESSE", "strasse": "Musterstraße", "hausnummer": "1", ... }
  },
  "version": 3,
  "updated_at": "2026-07-11T09:15:00Z"
}

Legacy partner records written before schema validation was introduced are returned with the raw channels JSONB in the geschaeftspartner field (graceful fallback).


Database Schema

marktd uses a single SQL schema file (migrations/0001_initial.sql). Migrations run automatically at startup via sqlx migrate run.

Tables

TablePurpose
maloMarktlokationen — JSONB payload, bo4e_version, GIN index
rollenzuordnungenTemporal NB/LF/MSB role assignments per MaLo
melo_msb_zuordnungenPer-MeLo dated MSB timeline(tenant, melo_id, msb_mp_id, valid_from, valid_to); point-in-time MSB resolution for WiM Teil 2 UC 4.1.1 (a MaLo can bundle MeLos with divergent MSB history). Derived from IFTSTA 21012 (derive_msb_zuordnung), never from the vorläufige Anmeldebestätigung 55043
bilanzierungenBO4E Bilanzierung (BO #3) — first-class temporal balancing resource per MaLo: bilanzierungsbeginn/ende validity, typed bilanzkreis/aggregationsverantwortung/prognosegrundlage/fallgruppenzuordnung, full BO in data JSONB. Authoritative home for the Bilanzierung-model fields. Writing a currently-effective Bilanzierung derives malo.fallgruppe. Note: malo.bilanzierungsmethode/bilanzierungsgebiet are Marktlokation fields (BO #12), correctly on malo — not Bilanzierung
lokationszuordnungenLocation graph edges — (tenant, von_id, von_typ, nach_id, nach_typ, valid_from, valid_to); von_typ/nach_typ are the canonical BO4E Lokationstyp codes (MALO/MELO/NELO/SR/TR); recursive-CTE BFS traversal
meloMesslokationen — JSONB payload, bo4e_version
partnersTrading partners (GLN → channels) — JSONB
subscriptionsERP webhook registrations
process_correlationRunning/completed MaKo process tracking per MaLo
processed_eventsInbound event idempotency log
preisblaetterNB price sheets — source CHECK ('api','mako'), GIN index
preisblaetter_messungMSB metering price sheets — same source-override protection
versorgungsstatusVersorgungsStatus per MaLo — LieferStatus CHECK, optimistic concurrency version BIGINT
versorgungsstatus_historyAppend-only audit log of every supply-state transition — powers ?at= and /history
nb_contractsNB network contracts — typed SQL columns (netzebene, bilanzierungsmethode, billing_schedule, netznutzer_mp_id, netznutzer_typ) + full BO4E Vertrag JSONB (data) for ERP digital LRV exchange
pricat_versionsVersioned PRICAT snapshots — (nb_mp_id, tenant, valid_from) unique, dispatch state
pricat_dispatch_logDispatch audit log — one row per NB × LF dispatch attempt
neloNetz-Element-Lokationen (Redispatch 2.0) — EIC or BDEW Codenummer, owner NB GLN, JSONB data
trancheTranchen der Marktlokation (GPKE Teil 4 „Daten der Tranche") — keyed by (tranche_id, tenant), parent malo_id; bilanzierungsgebiet/netzebene/energierichtung typed columns + BO4E Tranche JSONB
malo_gridMaLo grid topology — Netzgebiet, Bilanzierungsgebiet, sourced from NIS/GIS
steuerbare_ressourcenWiM iMS controllable resources — keyed by SR-ID (C[A-Z0-9]{9}[0-9]), linked to MaLo; konfigurationsprodukte JSONB for contracted iMS control products
technische_ressourcenE-mobility, generation, storage resources — keyed by TrId; BO4E-aligned nutzung (TechnischeRessourceNutzung) + verbrauchsart (TechnischeRessourceVerbrauchsart) + ist_fernschaltbar typed columns; linked to MaLo/MeLo
zaehlerMeter registry — linked to MeLo; zaehler_typ (CHECK-constrained to BO4E Zaehlertyp) and eichung_bis are derived from the BO4E payload, which also carries the zaehlwerke array
geraeteDevice registry — linked to Zaehler, stores geraet_typ, BO4E payload, and geraet_konfigurationen JSONB (typed GeraetKonfiguration[] per MsbG §23; GIN-indexed for cert-expiry queries)
event_logDurable CloudEvent replay log — keyed by event_id (unique); indexed by ce_type + received_at

All columns listed below are part of the single migrations/0001_initial.sql file. There are no incremental migration files — the initial schema is the authoritative source.

Key typed columns on malo

ColumnTypeDescription
netzebeneTEXTNetzebene (voltage/pressure level) — drives NNE tariff selection
bilanzierungsgebietTEXTBilanzierungsgebiet code for EEG/KWK allocation
energierichtungTEXTAUSSP (consumption) / EINSP (generation)
gasqualitaetTEXTGas quality (H/L)
bilanzierungsmethodeTEXTRLM | SLP | IMS | TLP_*; drives netzbilanzd Leistungspreis routing
regelzoneTEXTRegelzone EIC code — maps to ÜNB for Redispatch 2.0 + MABIS
fallgruppeTEXTGaBi Gas RLM category (e.g. LNF, LF, TK)
fernsteuerbarBOOLEAN§14a EnWG „Status der Fernsteuerbarkeit" — true = technisch fernsteuerbar, false = nicht (UTILMD CCI+7037 Z97/Z96)

Key typed columns on melo

ColumnTypeDescription
netzebene_messungTEXTNetzebene where the meter is installed
regelzoneTEXTRegelzone EIC code — extracted from standorteigenschaften.eigenschaftenStrom[0].regelzone
standorteigenschaftenJSONBFull StandortEigenschaften object (GIN indexed) for WiM Stammdaten enrichment

NB Network Contracts — Vertrag BO4E

NB network contracts are stored in nb_contracts as both fast-query typed SQL columns (netzebene, bilanzierungsmethode, billing_schedule, valid_from, valid_to) and a full BO4E Vertrag JSON payload for ERP digital LRV exchange.

vertragsart and vertragsstatus are extracted from the payload as indexed columns, enabling SQL-level filtering. A de.markt.nb-contract.updated CloudEvent is emitted on every successful upsert so ERP subscribers can rebuild Vertrag caches without polling.

PUT /api/v1/nb-contracts/{contract_id}

{
  "malo_id":             "51238696012",
  "nb_mp_id":            "9900357000004",
  "sparte":              "STROM",
  "netzebene":           "NS",
  "bilanzierungsmethode": "SLP",
  "billing_schedule":    "MONTHLY",
  "netznutzer_mp_id":    "9905555550003",
  "netznutzer_typ":      "LIEFERANT",
  "valid_from":          "2026-10-01",
  "valid_to":            null,
  "data": {
    "_typ":            "VERTRAG",
    "vertragsart":     "NETZNUTZUNGSVERTRAG",
    "vertragsstatus":  "AKTIV",
    "sparte":          "STROM",
    "vertragsbeginn":  "2026-10-01T00:00:00+00:00"
  }
}

data is optional — if omitted, a minimal Vertrag is auto-constructed from the other fields (vertragsart = NETZNUTZUNGSVERTRAG, vertragsstatus = AKTIV).

Validation: _typ must be "VERTRAG" (422 if wrong). All enum fields (vertragsart, vertragsstatus) are validated against rubo4e::current::Vertrag.

GET /api/v1/nb-contracts/{contract_id} response

{
  "contract_id":         "nv-9900357000004-51238696012",
  "malo_id":             "51238696012",
  "nb_mp_id":            "9900357000004",
  "sparte":              "STROM",
  "netzebene":           "NS",
  "bilanzierungsmethode": "SLP",
  "billing_schedule":    "MONTHLY",
  "netznutzer_mp_id":    "9905555550003",
  "netznutzer_typ":      "LIEFERANT",
  "valid_from":          "2026-10-01",
  "valid_to":            null,
  "data": {
    "_typ":           "VERTRAG",
    "vertragsart":    "NETZNUTZUNGSVERTRAG",
    "vertragsstatus": "AKTIV",
    "sparte":         "STROM",
    "vertragsbeginn": "2026-10-01T00:00:00+00:00"
  },
  "vertragsart":    "NETZNUTZUNGSVERTRAG",
  "vertragsstatus": "AKTIV",
  "version":        1,
  "tenant":         "9900357000004"
}

netzebene accepts all Strom voltage levels (NS/MS/MSP/HSP/HS/HöS/HöS/HS) and Gas pressure levels (GND/GMT/GHD). bilanzierungsmethode accepts RLM, SLP, IMS, and TLP variants.

The Netznutzer, and the Selbstzahler

netznutzer_mp_id is the counterparty — the party that owes the Netznutzungsentgelt. netznutzer_typ says what kind of party it is:

ValueMeaning
LIEFERANT (default)The ordinary case: an all-inclusive supply contract, the LF is Netznutzer
LETZTVERBRAUCHERSelbstzahler — „Netznutzer ohne All-Inklusiv-Vertrag". The Letztverbraucher pays the Netznutzung itself

A Selbstzahler takes the LF role in GPKE (Teil 1, Vorbemerkung) and is an ordinary LF on the wire — nothing routes differently. Registered as a Marktpartner with the LF role, he already receives the PRICAT Preisblatt and the „sonstige Leistung" invoice Teil 2 Kap. 3.4.4 / 3.4.5 owe him. The flag exists for the one carve-out, the LF's Lieferantenwechsel-Meldungen, where processd holds a Wechsel (E03) for the operator rather than answering it automatically.

GET /api/v1/nb-contracts/by-malo/{malo_id}?on=YYYY-MM-DD

The contract in force for a MaLo on a date (default today), or 404. This is the read processd uses before it decides an Anmeldung.


Inbound Events from makod

marktd receives process lifecycle events from makod via POST /api/v1/events.

Enable push in makod config

# makod.toml
[erp]
webhook_url    = "http://marktd:8180/api/v1/events"
webhook_secret = "shared-hmac-secret"

Inbound delivery is idempotent — duplicates are detected by event_id and silently acknowledged without re-processing.

Automatic malo.bilanzierungsmethode + malo.fallgruppe update

When marktd receives de.mako.process.initiated for PIDs 55001/55077 (GPKE) or 44001 (GeLi Gas), it calls MaloRepository::patch_typenmerkmal() to update the malo table:

Payload fieldColumnSource
bilanzierungsmethodemalo.bilanzierungsmethodeUTILMD TM+EM segment (Z01→SLP, Z02→RLM, Z04→IMS) extracted by makod adapter
fallgruppemalo.fallgruppeUTILMD TM+Z10 segment (Gas GaBi RLM category) extracted by makod adapter

This keeps the MaLo's billing mode and GaBi Gas Fallgruppe in sync with the UTILMD without requiring a separate ERP PUT /api/v1/malos call. The update is best-effort: if the MaLo row does not yet exist, the patch silently no-ops (the values will be set on the first PUT /api/v1/malos).

# After a 55001 Anmeldung, verify the update:
curl -s "http://marktd:8180/api/v1/malos/10001234558" \
  -H "Authorization: Bearer <token>" | jq '.bilanzierungsmethode, .fallgruppe'
# → "SLP", null     (for a Strom SLP point)
# → "RLM", "Z01"   (for a Gas RLM point with GaBi category Z01)

PUT /api/v1/malos — MaLo Typed Columns & Schema Validation

Every PUT /api/v1/malos/{malo_id} call:

  1. Validates the incoming data payload as rubo4e::current::Marktlokation:
    • Auto-injects _typ: "MARKTLOKATION" if absent
    • Returns 422 if _typ is present but not MARKTLOKATION
    • Returns 422 if any typed field contains an unknown enum value (e.g. "bilanzierungsmethode": "UNKNOWN")
  2. Normalises to canonical camelCase BO4E form before storage. The repository serialises the typed Marktlokation, so the canonical form is the only shape that reaches the data JSONB column; keys the schema does not define round-trip losslessly through the _additional extension map.
  3. Derives typed columns from that same typed object — never from string lookups on its JSON:
ColumnMarktlokation fieldVocabularyPurpose
netzebenenetzebeneNSP | MSP | HSP | HSS | MSP_NSP_UMSP | HSP_MSP_UMSP | HSS_HSP_UMSP | HD | MD | NDVoltage/pressure level for the NNE billing tier
bilanzierungsgebietbilanzierungsgebietEIC (object type Y, Area)Drives processd NB check 4
gasqualitaetgasqualitaetH_GAS | L_GASGas tariff routing
energierichtungenergierichtungEINSP | AUSSPEINSP (Einspeisung) feeds the grid — a generating MaLo; AUSSP (Ausspeisung) draws from it — a consuming one. The direction is named from the grid's point of view
bilanzierungsmethodebilanzierungsmethodeRLM | SLP | IMS | TLP_GEMEINSAM | TLP_GETRENNT | PAUSCHALDrives netzbilanzd Leistungspreis routing — RLM requires spitzenleistung_kw
regelzoneregelzoneEICMaps the MaLo to its ÜNB for MABIS IFTSTA 21000 routing and Redispatch 2.0 Stammdaten forwarding

Every enum column holds a BO4E wire value and nothing else: the value comes from the enum's own as_wire(), and a SQL CHECK constraint listing that enum's VARIANTS refuses anything else. A test in mako-markt compares the CHECK lists against the schema, so a rubo4e bump that adds a variant fails the build instead of rejecting valid data at run time.

All columns are NULL when the BO4E payload does not carry the field.

Two columns are deliberately not in that table. fallgruppe (the GaBi RLM Fallgruppe) is a Bilanzierung field and fernsteuerbar (§14a EnWG) has no BO4E field at all — neither is on Marktlokation, so a PUT /malos/{id} leaves both alone. They are written by the Bilanzierung resource and by the UTILMD Stammdatenänderung path (TM+Z10, CCI+Z24++Z96/Z97) respectively.

The call also automatically pushes the NB and MSB GLNs to makod's MaLo cache via PUT /admin/malo/{malo_id} — fire-and-forget; makod failure does not fail the API call.

Fields forwarded to makod:

FieldSource
nb_mp_idrollenzuordnung[] entry with zuordnungstyp == "NB" or "GNB"
msb_mp_idrollenzuordnung[] entry with zuordnungstyp == "MSB" or "GMSB"
bilanzierungsgebietMarktlokation.bilanzierungsgebiet
netzgebietMarktlokation.netzgebietsnr
spartesparte field

MaloResponse (GET) exposes the typed columns as top-level fields alongside the validated data payload, so a caller can filter without parsing the BO:

{
  "malo_id": "10001234558",
  "sparte": "STROM",
  "version": 3,
  "netzebene": "NSP",
  "bilanzierungsgebiet": "11YDE-RWE-NETZ-1",
  "gasqualitaet": null,
  "energierichtung": "AUSSP",
  "bilanzierungsmethode": "SLP",
  "regelzone": "10YDE-EON------1",
  "rollenzuordnung": [...],
  "data": { "_typ": "MARKTLOKATION", ... }
}

ERP Subscriptions & Fan-Out

marktd delivers CloudEvents 1.0 to every matching ERP subscriber when master data changes or when makod lifecycle events arrive. The fan-out worker runs in a dedicated Tokio task and delivers independently per subscriber — a slow or unavailable ERP does not block other subscribers.

Ordering is per aggregate. A delivery is held back while an earlier event about the same Marktlokation (event_log.seq, event_delivery.ordering_key) is still outstanding to the same subscriber; events about different MaLos never wait for each other. A dead-lettered delivery stops blocking its key, so head-of-line blocking is bounded by max_retry_attempts.

roles and sparten filter on CloudEvents extensions. An empty array matches everything; otherwise the event's marktrole / marktsparte must appear in it. An event with no marktsparte is not Sparte-scoped (a Marktpartner, a subscription test) and matches every sparten filter.

Event types

SourceEvent typeTrigger
marktd master datade.markt.malo.updatedPUT /api/v1/malos/{malo_id}
marktd master datade.markt.malo.stammdaten-geaendertUTILMD Stammdatenänderung applied to a MaLo (GPKE Teil 4 / GeLi Gas) — carries the applied patch
marktd master datade.markt.stammdaten.geaendertUTILMD Stammdatenänderung applied to a non-MaLo object (MeLo/NeLo/Tranche) — carries objekt + the applied patch
marktd master datade.markt.partner.updatedPUT /api/v1/partners/{mp_id}
marktd NB contractde.markt.nb-contract.updatedPUT /api/v1/nb-contracts/{id} — carries vertragsart, version, tenant in data
marktd PRICATde.markt.pricat.publishedPUT /api/v1/preisblaetter/{nb_mp_id}
marktd supplyde.markt.versorgung.changedevery VersorgungsStatus transition — announce (55001/55077/44001), confirm (55002/55078/44002), reject (55003/55080/44003), end (55005/44005), EoG (55013/44013) — and the REST upsert. Carries the resulting lieferstatus, lf_mp_id, lf_mp_id_next, lieferbeginn, lieferende, eog_seit, sparte, version
marktd supplyde.markt.versorgung.gap-detectedAn interval no supplier covers — a Lieferende (55005/44005) the announced successor does not follow on, or a Fall-b Bestätigung (55002/55078/44002) whose Altlieferant released earlier. §38 EnWG gap-closure trigger (consumer: processd)
marktd supplyde.markt.versorgung.eog-begonnen55013/44013 completed → begin_eog_supply (Ersatz-/Grundversorgung active; consumer: processd)
makod process relayde.mako.process.initiatedforwarded from makod ingest
makod process relayde.mako.aperak.acceptedforwarded from makod ingest
makod process relayde.mako.aperak.rejectedforwarded from makod ingest
makod process relayde.mako.aperak.timeoutforwarded from makod ingest
makod process relayde.mako.process.completedforwarded from makod ingest
makod process relayde.mako.process.failedforwarded from makod ingest

de.mako.* events carry the CloudEvents extensions makoconvid, makopid, makoworkflow, and marktrole (role of the counterparty: NB, LF, MSB, BIKO). Downstream services (invoicd, edmd, obsd) filter on makopid to select only the event types they care about.

Register a subscription

curl -X PUT http://localhost:8180/api/v1/subscriptions/erp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint_url": "https://erp.example.com/mdm/events",
    "secret":       "mysecret64hexchars",
    "event_types":  ["de.markt.malo.updated", "de.markt.pricat.published",
                     "de.mako.process.completed"]
  }'

event_types entries are matched with the canonical shared matcher (mako_events::matches — the same one agentd trigger patterns use): exact types, trailing-* prefixes (de.markt.*), full mid-pattern globs (de.*.rechnung.erstellt) and ? single-character wildcards all work; an empty list subscribes to everything.

Webhook payload

POST https://erp.example.com/mdm/events
Content-Type: application/cloudevents+json
webhook-signature: v1,<base64>
{
  "specversion":     "1.0",
  "id":              "01932a4f-7b3e-4c5d-8f6a-9e0b1c2d3e4f",
  "source":          "urn:mako:marktd:tenant:9900357000004",
  "type":            "de.mako.process.completed",
  "time":            "2025-10-01T08:15:00+02:00",
  "subject":         "018f3a2b-7c4e-7d5f-8a9b-0c1d2e3f4a5b",
  "datacontenttype": "application/json",
  "makoconvid":      "018f3a2b-7c4e-7d5f-8a9b-0c1d2e3f4a5b",
  "makopid":         55001,
  "makoworkflow":    "gpke-lieferbeginn",
  "marktrole":       "LF",
  "data": { "_typ": "MARKTLOKATION", "marktlokationsId": "51238696012", ... }
}

Signature verification

webhook-signature carries an HMAC-SHA256 hex digest over the raw request body, prefixed with sha256=, computed with the secret registered in the subscription (the workspace-wide format emitted by mako_service::webhook::sign).

The subscription secret is an integrity key stored in plaintext in subscriptions.webhook_secret — protect it with least-privilege database grants and storage-level encryption; it never protects confidentiality of customer data.

import hmac, hashlib

def verify(body: bytes, secret: str, header: str) -> bool:
    received = header.removeprefix("sha256=")          # strip the algorithm prefix
    expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)

Return 200 OK for duplicates — fan-out retries on non-2xx.

Durability & retry behaviour

Fan-out is persist-before-fan-out. Every produced event is written to the durable event_log outbox (the full CloudEvent envelope) before any delivery is attempted, so a marktd crash never loses an in-flight event. A two-phase worker drains it:

  1. Fan-out — claims pending event_log rows, resolves the matching subscribers, and snapshots one event_delivery row per subscriber (in one transaction with stamping fanned_out_at).
  2. Deliver — claims due event_delivery rows with a lease (FOR UPDATE SKIP LOCKED), signs + POSTs each, and on failure backs off (30 s → 5 m → 30 m → 2 h) or, after the attempt cap, marks dead_lettered_at.

A crash at any boundary is recoverable from the two tables; subscribers receive at-least-once and dedup on the CloudEvent id. This durability is required by § 147 AO / GoBD / §41 EnWG: a silent drop of a de.mako.process.initiated event to invoicd would mean the INVOIC plausibility check never runs.

Dead-letter queue (DLQ)

A delivery that exhausts its attempts is flagged dead_lettered_at on its event_delivery row (a status-column DLQ — no separate table). Operators inspect and remediate via the admin endpoints, keyed by (event_id, subscriber_id):

MethodPathDescription
GET/admin/fanout/dlqList dead-lettered deliveries (newest first, paged)
POST/admin/fanout/dlq/{event_id}/{subscriber_id}/retryRequeue for immediate redelivery
DELETE/admin/fanout/dlq/{event_id}/{subscriber_id}Discard without retry
# Inspect dead-lettered deliveries
curl http://localhost:8180/admin/fanout/dlq \
  -H "Authorization: Bearer $TOKEN" | jq '.[] | {event_id, subscriber_id, attempts, last_error}'

# Requeue a specific (event, subscriber) delivery
curl -X POST http://localhost:8180/admin/fanout/dlq/$EVENT_ID/$SUBSCRIBER_ID/retry \
  -H "Authorization: Bearer $TOKEN" | jq .

# Discard after manual ERP re-import
curl -X DELETE http://localhost:8180/admin/fanout/dlq/$EVENT_ID/$SUBSCRIBER_ID \
  -H "Authorization: Bearer $TOKEN"

Requeuing redelivers with the subscriber's current webhook secret.

Prometheus metrics (/metrics)

GET /metrics exposes operational counters in Prometheus text format:

MetricDescription
marktd_fanout_dlq_depthDead-lettered deliveries in event_delivery
marktd_active_subscriptionsRegistered fan-out subscribers
marktd_processed_events_totalEvents ingested from makod (all time)
marktd_db_pool_sizeCurrent PostgreSQL connection pool size
marktd_db_pool_idleIdle connections in the pool

Scrape via Prometheus static_configs or a ServiceMonitor in Kubernetes.


Process Correlations

Track which MaKo processes are currently running against a given MaLo:

curl "http://localhost:8180/api/v1/correlations/51238696012" \
  -H "Authorization: Bearer $TOKEN"
[
  {
    "malo_id":      "51238696012",
    "pid":          55001,
    "conv_id":      "018f3a2b-...",
    "initiated_at": "2026-07-01T08:00:00Z",
    "status":       "RUNNING"
  }
]

Docker Deployment

docker pull ghcr.io/hupe1980/marktd:0.12.0

docker run -d \
  --name marktd \
  -p 8180:8180 \
  -v /etc/marktd/marktd.toml:/etc/marktd/marktd.toml:ro \
  -e DATABASE_URL=postgres://marktd:secret@postgres/marktd \
  -e MAKOD_API_KEY=my-api-key \
  -e MAKOD_WEBHOOK_SECRET=my-webhook-secret \
  ghcr.io/hupe1980/marktd:0.12.0 \
  --config=/etc/marktd/marktd.toml

Health Checks

EndpointDB checkUse for
GET /health/livenoKubernetes livenessProbe
GET /health/readyyes (ping)Kubernetes readinessProbe

Common Issues

401 Unauthorized JWT validation failed. Check: correct [oidc] issuer, token not expired, mako_tenant claim present.

403 Forbidden Cedar denied the request. Check: mako_tenant matches tenant GLN in URL, mako_roles contains required role (NB for write-preisblatt).

404 Not Found on GET preisblatt No price sheet valid on the requested date. Upload one first with PUT /api/v1/preisblaetter/{nb_mp_id}.

Price sheet not updating (mako source rejected) Intentional. An existing source=api row cannot be overwritten by source=mako — operator-override protection is working. Use the REST API to update operator-controlled price sheets.

Auto-responder dispatching ablehnen for all requests Check rule 3 (NB in grid): your operator MP-ID (tenant in marktd.toml) must appear in the MaLo's rollenzuordnung as zuordnungstyp = "NB". Upload the MaLo with PUT /api/v1/malos/{malo_id} including the NB entry.

Auto-responder deferring all requests (no commands dispatched) Check rule 1 (MaLo exists): the MaLo referenced in the UTILMD has not been pre-loaded into marktd. Use PUT /api/v1/malos/{malo_id} to register it.

Auto-responder rejecting with Z0C (Preisblatt missing) Only triggered when auto_accept = true. Upload a price sheet covering the process_date with PUT /api/v1/preisblaetter/{nb_mp_id}. Check that gueltigkeit.startdatum and gueltigkeit.enddatum in the BO4E payload bracket the process_date from the UTILMD.

relation "malo" does not exist Migrations have not run. Check DATABASE_URL and PostgreSQL connectivity. marktd runs sqlx migrate run automatically on startup.


MCP Server

marktd exposes an MCP (Model Context Protocol) Streamable HTTP server at POST /mcp / GET /mcp. The same OIDC + Cedar authorization layer applies.

Tools (18) — plus get_info for a self-describing capability summary:

ToolDescription
get_malo / list_maloFetch one MaLo / list MaLos (Sparte, Netzgebiet, NB)
get_melo / get_melo_standorteigenschaftenMeLo record / site properties
get_partner / list_partnersMarket-partner registry (MP-ID, name, roles)
get_preisblattPrice sheet for an NB MP-ID and date (Netznutzung)
list_pricat_versionsPRICAT version history. Dispatch is REST-only (POST /api/v1/pricat/{nb_mp_id}/dispatch, Cedar dispatch-pricat, NB role) — use-mcp is one blanket gate over the whole MCP surface and cannot tell a read tool from a destructive one
get_versorgungsstatus / get_versorgungsstatus_history / get_versorgung_atSupply state — current, history, point-in-time
get_rollenzuordnungTemporal NB/MSB/LF role assignments for a MaLo
get_nb_contractNB contract record for a MaLo
get_nb_energiemix§42 EnWG grid-area energy mix
get_correlationProcess correlation for a MaLo
get_technische_ressource / get_steuerbare_ressource§14a device registry

Prompts (4): lookup_malo, versorgungswechsel, investigate_supply_gap, grid_topology — guided multi-tool investigations for LLM clients.


VersorgungsStatus

marktd maintains one VersorgungsStatus record per MaLo, derived automatically from inbound CloudEvents. Every write appends a row to versorgungsstatus_history in the same transaction, enabling full audit trails and ?at=YYYY-MM-DD point-in-time queries.

Supplier-transition lifecycle

A Lieferantenwechsel spans three distinct phases, each triggering a targeted partial update:

PhaseCloudEventPIDOperationEffect
Announceprocess.initiated55001 / 55077 / 44001announce_lf_nextSets lf_mp_id_next (WHO) + lf_next_lieferbeginn (WHEN). Does not change lieferstatus, and does not displace an announcement another supplier already holds.
Confirmprocess.completed55002 / 55078 / 44002confirm_supplyAtomic SQL: lf_mp_id ← lf_mp_id_next, lieferbeginn ← lf_next_lieferbeginn, lieferstatus = Beliefert, clears lf_mp_id_next.
Endprocess.completed55005 / 44005end_supplylieferstatus = Unbeliefert, clears lf_mp_id/lieferbeginn/eog_seit — preserves lf_mp_id_next if another transition is already announced. An uncovered interval → emits de.markt.versorgung.gap-detected.
EoGprocess.completed55013 / 44013begin_eog_supplylieferstatus = Ersatzversorgung/Grundversorgung (per data.eog_art), lf_mp_id = E/G, eog_seit = Zuordnungsbeginn (may be retroactive — anchors §38 Abs. 4). Resolves the Bilanzkreis from the completion payload, else the NB's deposited default_bilanzkreis (EoG ohne Antwort). Emits de.markt.versorgung.eog-begonnen (incl. bilanzkreis).
Stammdatenänderungprocess.completedGPKE Teil 4 / GeLi Gas Änderung PIDspatch_stammdatenObject-generic apply. Dispatches by the data.objekt marker to the matching typed-column patch — MARKTLOKATIONmalo (incl. §14a fernsteuerbar), MESSLOKATIONmelo + the MSB-Zuordnung (zugeordneter MSB CAV+7111=Z91) recorded on the dated melo_msb_zuordnungen timeline via assign_msb effective the Änderungsdatum, NETZLOKATIONnelo (incl. §14a steuerkanal), TECHNISCHE_RESSOURCEtechnische_ressourcen (nutzung CCI+7059 Z17/Z50/Z56, verbrauchsart CAV+7111 Z64/Z65/ZE5/ZA8, ist_fernschaltbar), STEUERBARE_RESSOURCEsteuerbare_ressourcen (Konfigurationsprodukte — each SG8 SEQ+Z79 product group → a BO4E Konfigurationsprodukt with produktcode PIA+5 DE7140, zugeordneter Marktpartner CAV+Z91/ZF0, and leistungskurvendefinition from CCI+Z66; the full contracted array is replaced, not merged), TRANCHEtranche. Each Some field overwrites its column via COALESCE; JSONB payload and version untouched; no-op when the object is unknown locally. Emits de.markt.malo.stammdaten-geaendert (MaLo) / de.markt.stammdaten.geaendert (other objects). Deep MeLo standorteigenschaften are acknowledged without a typed apply (structural-MIG level).
Clearprocess.completed55003 / 55080 / 44003clear_lf_nextLieferbeginn rejected (Ablehnung Anmeldung): resets lf_mp_id_next + lf_next_lieferbeginn so no consumer acts on a switch that will not happen. Idempotent — a no-op when nothing is announced.

All operations are idempotent under at-least-once fan-out delivery, and each emits de.markt.versorgung.changed carrying the state it produced.

Schema

VersorgungsStatusRecord
├── malo_id              — 11-digit Marktlokations-ID
├── lieferstatus         — Beliefert | Unbeliefert | Grundversorgung | Ersatzversorgung | Ruhend | Stillgelegt
├── lf_mp_id             — active Lieferant MP-ID (set when Beliefert)
├── lf_mp_id_next        — announced future Lieferant MP-ID (WHO; set on 55001/55077/44001)
├── lf_next_lieferbeginn — announced Lieferbeginn date (WHEN; paired with lf_mp_id_next)
├── lieferbeginn         — current supply start date
├── lieferende           — announced supply end date
├── msb_mp_id            — active Messstellenbetreiber MP-ID
├── nb_mp_id             — Netzbetreiber MP-ID (partition key)
├── last_process_id      — UUID of the last process that triggered a state change
├── updated_at           — UTC timestamp of last write
└── version              — optimistic-concurrency counter (OCC)

State machine

stateDiagram-v2
    [*] --> Unbeliefert : MaLo registered

    Unbeliefert --> Unbeliefert : 55001/55077/44001 process.initiated<br/>→ lf_mp_id_next + lf_next_lieferbeginn set
    Unbeliefert --> Beliefert   : 55002/55078/44002 process.completed<br/>→ confirm_supply (lf_mp_id_next → lf_mp_id)

    Beliefert --> Beliefert     : 55001/55077/44001 process.initiated<br/>→ next LF announced; current LF still active
    Beliefert --> Unbeliefert   : 55005/44005 process.completed<br/>→ end_supply (lf_mp_id_next preserved if set)

    Unbeliefert --> Ersatzversorgung : 55013/44013 process.completed<br/>→ begin_eog_supply (§38 EnWG, eog_seit set)
    Unbeliefert --> Grundversorgung  : 55013/44013 process.completed<br/>→ begin_eog_supply (§36 EnWG, Haushaltskunde)
    Ersatzversorgung --> Grundversorgung : 3 Monate um, Haushaltskunde<br/>(automatic by law, no market message)
    Ersatzversorgung --> Beliefert   : new supply confirmed<br/>→ confirm_supply (clears eog_seit)
    Grundversorgung --> Beliefert    : new supply confirmed<br/>→ confirm_supply

    Beliefert --> Ruhend    : MaKo suspended
    Ruhend --> Beliefert    : Reactivated
    Beliefert --> Stillgelegt : Decommissioned
    Stillgelegt --> [*]

NB gap-closure (§38 EnWG). A gap is an interval no supplier covers, and two routes lead to one. end_supply reaches it when the Lieferende leaves days before the announced successor starts — or has no successor at all. A Bestätigung Anmeldung reaches it through Fall b: the Altlieferant answered the Abmeldeanfrage with its own, earlier Lieferendedatum (E_0624 A34), which rides the completion payload as lfa_lieferende while the confirmation stands at the Zuordnungsbeginn the new supplier asked for. Neither route's message states both ends; this projection holds them together.

Both emit the same de.markt.versorgung.gap-detected with gap_from/gap_until, so the processd EoG module handles them identically: it resolves the Grundversorger (GET /api/v1/grundversorger/{nb_mp_id}) and dispatches gpke.eog.anmelden (UTILMD 55013).

GPKE rule A06. processd reads lf_mp_id_next before accepting a new Anmeldung and compares it against the requesting supplier. marktd writes the marker while ingesting the process.initiated, before fanning the event out, so the Anmeldung under evaluation has already written its own MP-ID by the time the check runs — a bare IS NOT NULL test would reject every first-time Anmeldung against itself.

The comparison only means anything because announce_lf_next keeps the first announcement; a competing supplier does not displace it. The holder of the announcement may still correct its own date.

Optimistic concurrency. Every write uses WHERE malo_id = $1 AND tenant = $2 AND version = $3. Conflict → 412 Precondition Failed → retry after re-read.

REST API

# Current state
GET  /api/v1/versorgung/{malo_id}

# Point-in-time state (as of end-of-day on that German calendar date, CET/CEST)
GET  /api/v1/versorgung/{malo_id}?at=2025-10-01

# Full state-change history (newest first, paged)
GET  /api/v1/versorgung/{malo_id}/history?page=0&size=50

# Admin override or ERP-driven upsert; supply If-Match: "<version>" for OCC
PUT  /api/v1/versorgung/{malo_id}

Response shape (GET /api/v1/versorgung/{malo_id}):

{
  "malo_id": "51238696012",
  "lieferstatus": "Beliefert",
  "lf_mp_id": "4012345000023",
  "lf_mp_id_next": null,
  "lf_next_lieferbeginn": null,
  "lieferbeginn": "2026-10-01",
  "lieferende": null,
  "msb_mp_id": "9900000000002",
  "nb_mp_id": "9900357000004",
  "last_process_id": "...",
  "updated_at": "2026-07-10T08:23:41Z",
  "version": 5
}

Point-in-time query (?at=YYYY-MM-DD): Returns the supply state as it was at end-of-day on the given date in German local time (CET/CEST). Returns 404 when no history exists on or before that date.

sequenceDiagram
    participant ERP
    participant marktd
    participant PostgreSQL

    ERP->>marktd: GET /api/v1/versorgung/51238696012?at=2025-07-01
    marktd->>PostgreSQL: SELECT * FROM versorgungsstatus_history<br/>WHERE malo_id=$1 AND tenant=$2<br/>AND (valid_from AT TIME ZONE 'Europe/Berlin')::date <= '2025-07-01'<br/>ORDER BY valid_from DESC LIMIT 1
    PostgreSQL-->>marktd: snapshot (LieferStatus, LF, NB, …)
    marktd-->>ERP: 200 OK + VersorgungsStatusResponse

processd reads GET /api/v1/versorgung/{malo_id} to drive the LF's automated responses without ERP involvement (GPKE Teil 1 §5).


MSB Price Sheets — PreisblattMessung

marktd stores MSB metering price sheets (PreisblattMessung) in the preisblaetter_messung table. These cover Messentgelte per Messpreistyp and form the tariff basis for REQOTE/QUOTES (PIDs 35001/35002/35004/35005) and for invoicd plausibility checks on INVOIC 31009 (MSB-Rechnung).

The API mirrors PreisblattNetznutzung exactly but is keyed by msb_mp_id (the MSB's BDEW-Codenummer) instead of nb_mp_id.

# Upload an MSB price sheet (operator or ERP)
curl -s -X PUT "http://marktd:8180/api/v1/preisblaetter-messung/9900012345678" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "_typ": "PREISBLATT_MESSUNG",
      "bezeichnung": "Messentgelte 2026",
      "gueltigkeit": { "startdatum": "2026-10-01", "enddatum": "2027-09-30" },
      "preispositionen": [],
      "zeitvariablePreispositionen": [
        { "zaehlzeitregister": "HT", "preis": { "wert": "12.50", "einheit": "EUR_PRO_KWH" } },
        { "zaehlzeitregister": "NT", "preis": { "wert": "8.75",  "einheit": "EUR_PRO_KWH" } }
      ]
    },
    "bo4e_version": "202607.1.0"
  }'

# Retrieve for a billing date — response includes typed zeitvariable_preispositionen
curl -s "http://marktd:8180/api/v1/preisblaetter-messung/9900012345678?date=2026-01-15" \
  -H "Authorization: Bearer <token>"
# → {
#     "data": { ... },
#     "zeitvariable_preispositionen": [
#       { "zaehlzeitregister": "HT", "preis": { "wert": "12.50", ... } },
#       { "zaehlzeitregister": "NT", "preis": { "wert": "8.75",  ... } }
#     ],
#     "auf_abschlaege": [],
#     "schema_drift_count": 0
#   }

§14a Modul 2 — zeitvariablePreispositionen

For MSBs that operate under §14a Modul 2 (time-of-use pricing for controllable loads), each ZeitvariablePreisposition element in the price sheet must carry a non-empty zaehlzeitregister band code (e.g. "HT", "NT", "ST"). The PUT endpoint validates this:

ValidationError
Missing zaehlzeitregister422 — mandatory per §14a Modul 2 (BK6-22-300)
bandNummer field present422 — does not exist in BO4E v202607
Invalid BO4E schema422 — serde_json schema error

invoic-checker check 4 uses the zaehlzeitregister codes to route INVOIC 31009 positions against the correct ToU band price, rather than guessing from positionstext keywords.

Source-override protection. Same as preisblaetter: an operator REST upload (source = 'api') is never silently overwritten by an engine ingest (source = 'mako').


MMM Settlement Prices — Gas MMMA + Strom Ausgleichsenergie

marktd stores monthly settlement reference prices for Mehr-/Mindermengenabrechnungen (MMM). These are B2B settlement prices — not B2C retail tariffs — and must therefore live in marktd, not in a retail tariff service. Both netzbilanzd (NB, generates MMM invoices) and invoicd (LF, validates inbound MMM invoices) need them, and they cannot share a database directly.

Gas MMM Abrechnungspreise — Trading Hub Europe (THE)

Published monthly by Trading Hub Europe (THE, the German gas market area operator since 2021). netzbilanzd auto-fetches these when mehr_preis_ct_per_kwh / minder_preis_ct_per_kwh are not supplied in the POST /api/v1/billing/run request. invoicd uses them for check 6 on inbound INVOIC 31007/31008.

# Import THE Gas MMMA prices for a billing month (operator monthly task)
curl -s -X PUT "http://marktd:8180/api/v1/mmma-preise/gas/2026/7" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "marktgebiet": "THE",
    "mehr_ct_kwh": "1.25",
    "minder_ct_kwh": "0.87",
    "source": "manual"
  }'

# Query (used by netzbilanzd and invoicd)
curl -s "http://marktd:8180/api/v1/mmma-preise/gas/2026/7" \
  -H "Authorization: Bearer <token>"
# → { "price_month": "2026-07-01", "marktgebiet": "THE", "mehr_ct_kwh": "1.25", ... }

# List all imported months
curl -s "http://marktd:8180/api/v1/mmma-preise/gas?limit=12" \
  -H "Authorization: Bearer <token>"

Strom MMM Ausgleichsenergie — ÜNB (GPKE (BK6-24-174) Teil 1 Kap. 8.4)

Published monthly per ÜNB (50Hertz, TenneT, Amprion, TransnetBW). Used by netzbilanzd for MMM INVOIC 31005/31006 and invoicd check 6 on inbound Strom MMM invoices.

# Import Strom MMM prices for TenneT (example)
curl -s -X PUT "http://marktd:8180/api/v1/mmm-preise/strom/2026/7" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "mehr_ct_kwh": "2.10",
    "minder_ct_kwh": "1.45",
    "source": "manual"
  }'

# Query
curl -s "http://marktd:8180/api/v1/mmm-preise/strom/2026/7" \
  -H "Authorization: Bearer <token>"

Operator task: import these prices monthly before running MMM billing. A missed monthly import causes netzbilanzd to require manual ERP input and invoicd to skip check 6 (logged at debug level, not a hard error).

Automated monthly import

marktd includes a background worker that automatically fetches and imports Gas MMMA and Strom MMM prices on the 1st of each month. Configure it in marktd.toml:

[mmma_import]
enabled       = true
gas_url       = "https://www.the-group.de/gas/mmma/export.csv"  # THE CSV endpoint
strom_url     = "https://www.netztransparenz.de/mmm/strom.json" # ÜNB JSON endpoint
check_hour_utc = 6   # import at 06:00 UTC (after THE typically publishes ~05:00 UTC)

The worker wakes every hour; on the 1st of the month at or after check_hour_utc it fetches, parses (CSV or JSON), and upserts — idempotent if prices already exist for the current month.

Supported formats:

# CSV (5-column)
year,month,marktgebiet,mehr_ct_kwh,minder_ct_kwh
2026,7,THE,1.25,0.87
// JSON (single object or array)
{ "mehr_ct_kwh": "1.25", "minder_ct_kwh": "0.87", "marktgebiet": "THE" }

Manual trigger (catch-up after downtime or testing):

curl -s -X POST "http://marktd:8180/api/v1/mmma-preise/import-trigger?year=2026&month=7" \
  -H "Authorization: Bearer <token>"
# → { "year": 2026, "month": 7, "import_enabled": true, "results": [...] }

CloudEvents emitted:

Event typeTrigger
de.markt.mmma.import.successSuccessful monthly import
de.markt.mmma.import.failedFetch or parse failure (requires operator action)

Device Registry — Zaehler + Geraete

marktd maintains a device registry for meters (Zähler) and their associated devices (Geräte). This is populated by WiM MSB/NB device-handover processes (ORDERS PIDs 17001, 17002, 17009) and by operator REST uploads.

Hierarchy

MeLo ──► Zaehler ──► Geraete
         (1..n)       (0..n)

              └──► Zaehlwerk (0..n)
                   (OBIS registers)

A Zaehler carries:

  • data — the full BO4E Zaehler payload. _typ is auto-injected if absent, and every enum in the tree is strict-decoded on write (Bo4eStrict::ensure_known_enums), so an unrecognised value is a 422 naming the field rather than a row that reads UNKNOWN.
  • zaehler_typderived from data.zaehlertyp, not supplied beside it. A BO4E Zaehlertyp wire value, CHECK-constrained to the 13 v202607 variants (DREHSTROMZAEHLER, INTELLIGENTES_MESSSYSTEM, MODERNE_MESSEINRICHTUNG, …). GASZAEHLER is not one of them, and neither is UNKNOWN. §42c Energy-Sharing eligibility reads this column; mako-markt's bo4e_check_constraints_match_the_schema test pins the list to rubo4e::current::Zaehlertyp::VARIANTS. Watch the spelling: Zaehlertyp uses INTELLIGENTES_MESSSYSTEM (three s), while Geraetetyp uses INTELLIGENTES_MESSYSTEM (two). That is a BO4E quirk.
  • eichung_bis — calibration valid-until date (Eichgültigkeitsdatum), derived from data.eichungBis. Neither it nor zaehler_typ is a request field: the column drives the replacement workflow, so it must not be able to disagree with the meter record it shadows.
  • data.zaehlwerke — list of Zaehlwerk OBIS registers; exposed via GET /api/v1/zaehler/{id}/zaehlwerke as typed Vec<Zaehlwerk>

A Geraet carries:

  • data — full BO4E Geraet payload (_typ auto-injected if absent, strict-decoded on write)
  • geraet_typ — derived from data.geraetetyp; a BO4E Geraetetyp wire value such as STROMWANDLER, MODEM_GSM or MULTIPLEXANLAGE. Deliberately not CHECK-constrained: the enum has 48 variants and turns over between BO4E versions, so an inline list would be the next thing to drift.
  • konfigurationen — typed Vec<GeraetKonfiguration> for MSB device management (see below)

BO4E _typ discriminator. All four PUT device endpoints (zaehler, geraete, steuerbare-ressourcen, technische-ressourcen) automatically inject the correct _typ discriminator into the data JSONB blob if the caller omits it. Callers that include _typ in the request body have their value preserved.

Zaehlwerk registers

Each Zaehler stores 0..n Zaehlwerk objects in data["zaehlwerke"] (BO4E v202607). A Zaehlwerk is an individual measurement register on the meter, identified by its OBIS code. iMSyS (intelligent metering systems) expose multiple registers simultaneously — demand, reactive energy, export, time-of-use tariff splits.

GET /api/v1/zaehler/{zaehler_id}/zaehlwerke extracts the zaehlwerke array from data and returns it as typed Vec<Zaehlwerk>:

curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/zaehlwerke" \
  -H "Authorization: Bearer <token>" | jq '.[] | {
    obisKennzahl,
    richtung,
    verbrauchsart,
    anzahlAblesungen
  }'

Response shape:

[
  {
    "_typ": "ZAEHLWERK",
    "obisKennzahl": "1-0:1.8.0",
    "richtung":     "EINSP",
    "verbrauchsart": "WIRKARBEIT",
    "anzahlAblesungen": 1
  },
  {
    "_typ": "ZAEHLWERK",
    "obisKennzahl": "1-0:2.8.0",
    "richtung":     "AUSSP",
    "verbrauchsart": "WIRKARBEIT",
    "anzahlAblesungen": 1
  }
]

Returns [] (not 404) when no registers are stored. Cedar action: read-device.

Use cases:

  • TOU (time-of-use) billing: identify HT/NT registers before computing Arbeitspreis split
  • iMSyS demand management: enumerate active demand registers for wim.steuerungsauftrag.bestaetigen
  • MSB tariff selection: PreisblattMessung Preisstaffel matching uses richtung + OBIS

Endpoints

# List meters for a MeLo
curl -s "http://marktd:8180/api/v1/melos/DE00056789000000000000000012345678/zaehler" \
  -H "Authorization: Bearer <token>" | jq '.[] | {zaehler_id, zaehler_typ, eichung_bis}'

# Register or update a meter (include zaehlwerke in data for structured register access)
curl -s -X PUT "http://marktd:8180/api/v1/zaehler/Z001234567" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "melo_id": "DE00056789000000000000000012345678",
    "zaehler_typ": "DREHSTROMZAEHLER",
    "eichung_bis": "2030-12-31",
    "data": {
      "zaehlwerke": [
        { "_typ": "ZAEHLWERK", "obisKennzahl": "1-0:1.8.0", "richtung": "EINSP" }
      ]
    },
    "bo4e_version": "202607.1.0"
  }'

# List Zaehlwerk registers for a meter (typed Vec<Zaehlwerk>)
curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/zaehlwerke" \
  -H "Authorization: Bearer <token>" | jq .

# List devices for a meter
curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/geraete" \
  -H "Authorization: Bearer <token>" | jq '.[] | {geraet_id, geraet_typ}'

# Get a single Geraet — full BO4E payload + typed konfigurationen
curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/geraete/SMGW-2026-001" \
  -H "Authorization: Bearer <token>" | jq '{geraet_id, geraet_typ, konfigurationen}'

Geraet Konfigurationen — device configuration records (MsbG §23)

marktd maintains typed device-configuration entries for each Geraet, stored in the geraet_konfigurationen JSONB column (separate from the BO4E data payload so they can be updated atomically without rewriting the full Geraet).

The geraet_konfigurationen column has a GIN index, enabling fast SQL queries such as "find all devices with SMGW cert expiring within 30 days":

SELECT malo_id FROM geraete
WHERE geraet_konfigurationen @> '[{"parameter":"SMGW_CERT_ABLAUFDATUM"}]'
  AND tenant = '...'

Konfigurationsparameter enum

ValueTypeLegal basisPurpose
FIRMWARE_VERSIONstringBSI TR-03109-1 §4.3Current firmware version (e.g. "3.1.2")
HARDWARE_REVISIONstringMsbG §23Hardware revision string
KOMMUNIKATIONenum stringCommunication technology: "GPRS" / "PLC" / "ETHERNET" / "FUNK" / "FESTNETZ" / "GSM"
FERN_UPDATE_FAEHIGbool stringBSI TR-03109-4Supports OTA firmware update ("true" / "false")
CLS_FAEHIGbool string§14a EnWG BK6-22-300CLS channel capable ("true" / "false") — checked by processd §14a auto-acknowledge
SMGW_TLS_CERT_FINGERPRINThex stringBSI TR-03109-3SHA-256 fingerprint (64 hex chars) of the SMGW TLS cert
SMGW_CERT_ABLAUFDATUMISO dateBSI TR-03109-4 §6.3TLS cert expiry date — monitored by edmd cert-expiry worker
CLS_KANAL_IDstringGPKE Teil 3 Kap. 2CLS channel ID for §14a Steuerungsauftrag routing
GWA_CODENUMMER13-digitBDEWGWA (Gateway-Administrator) BDEW-Codenummer
HERSTELLERstringMsbG §23Manufacturer name
INBETRIEBNAHMEDATUMISO date§ 13 StromNZVCommissioning date
LETZTE_WARTUNGISO date§ 13 StromNZVLast maintenance date
NAECHSTE_WARTUNGISO date§ 13 StromNZVNext scheduled maintenance date
AUSLESEE_PROTOKOLLenum stringReadout protocol: "SML" / "DLMS" / "IEC62056"
MSB_VERTRAGSNUMMERstringMsbG §23MSB contract number for this device
SONSTIGESstringCustom parameter — use notiz for the actual key name

GeraetKonfiguration entry shape

{
  "parameter":  "SMGW_CERT_ABLAUFDATUM",
  "wert":       "2027-06-30",
  "updated_at": "2026-07-18T08:00:00Z",
  "notiz":      null
}

updated_at is always set server-side on write — callers must not include it in PUT requests. Duplicate parameter values in a single PUT body are deduplicated (last entry wins) before storage.

Endpoints

# Get current configuration entries for a device
curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/geraete/SMGW-2026-001/konfigurationen" \
  -H "Authorization: Bearer <token>" | jq '.[] | {parameter, wert, updated_at}'

# Set SMGW configuration after BSI TR-03109-4 Admin session (§14a fleet rollout)
curl -s -X PUT "http://marktd:8180/api/v1/zaehler/Z001234567/geraete/SMGW-2026-001/konfigurationen" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "konfigurationen": [
      { "parameter": "FIRMWARE_VERSION",       "wert": "3.1.2"            },
      { "parameter": "HARDWARE_REVISION",      "wert": "Rev. C"           },
      { "parameter": "KOMMUNIKATION",          "wert": "GPRS"             },
      { "parameter": "CLS_FAEHIG",             "wert": "true"             },
      { "parameter": "CLS_KANAL_ID",           "wert": "CLS-00042"        },
      { "parameter": "SMGW_TLS_CERT_FINGERPRINT", "wert": "a1b2c3d4..."   },
      { "parameter": "SMGW_CERT_ABLAUFDATUM",  "wert": "2027-06-30"       },
      { "parameter": "GWA_CODENUMMER",         "wert": "9900000000099"    },
      { "parameter": "HERSTELLER",             "wert": "Sagemcom"         },
      { "parameter": "INBETRIEBNAHMEDATUM",    "wert": "2024-03-15"       }
    ]
  }'
# → 204 No Content + emits de.markt.geraet.konfiguration.updated CloudEvent
# If CLS_FAEHIG is set, processd auto-acknowledges §14a Steuerungsauftrag for this device.
# If SMGW_CERT_ABLAUFDATUM is set, edmd cert-expiry worker starts monitoring.

Integration with processd (§14a Steuerungsauftrag)

When CLS_FAEHIG = "true" is stored, processd auto-acknowledges §14a WimSteuerungsauftrag requests for this device (GPKE Teil 3 Kap. 2). When CLS_FAEHIG = "false" or absent, processd rejects the Steuerungsauftrag (GPKE Teil 3 Kap. 2 — device not remotely controllable).

Integration with edmd (SMGW cert-expiry monitoring)

Setting SMGW_CERT_ABLAUFDATUM triggers the edmd daily compliance worker to monitor the device. When the expiry date is ≤ 30 days away, edmd emits de.messwert.cls.compliance-issue (severity WARNING); after expiry, severity CRITICAL.


ZaehlzeitDefinition — typed TOU definition for ERP and portals

GET /api/v1/zaehler/{zaehler_id}/zaehlzeitdefinitionen assembles a complete rubo4e::current::Zaehlzeitdefinition BO4E object from zaehler_register + zaehler_saisons rows and returns it in canonical JSON. This is the endpoint ERP systems and customer portals use to display ToU register schedules to end customers without custom ETL.

curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/zaehlzeitdefinitionen" \
  -H "Authorization: Bearer <token>" | jq .

Response shape (BO4E Zaehlzeitdefinition):

{
  "_typ": "ZAEHLZEITDEFINITION",
  "_id": "Z001234567",
  "saisons": [
    {
      "_typ": "ZAEHLZEITSAISON",
      "bezeichnung": "WINTER",
      "tagtypen": [
        {
          "_typ": "ZAEHLZEITTAGTYP",
          "tagtyp": "WERKTAGS",
          "umschaltzeiten": [
            { "_typ": "UMSCHALTZEIT", "registercode": "HT", "umschaltzeit": "07:00" },
            { "_typ": "UMSCHALTZEIT", "registercode": "NT", "umschaltzeit": "22:00" }
          ]
        },
        {
          "_typ": "ZAEHLZEITTAGTYP",
          "tagtyp": "WOCHENENDE",
          "umschaltzeiten": [
            { "_typ": "UMSCHALTZEIT", "registercode": "NT", "umschaltzeit": "00:00" }
          ]
        }
      ]
    }
  ]
}

The ?valid_only=true query parameter restricts the response to currently valid registers (valid_to IS NULL OR valid_to >= today).

Why this endpoint? ERP systems (Schleupen, SAP IS-U, powercloud) need the nested Zaehlzeitdefinition shape for customer portal display. Without it, clients must query two endpoints and assemble the hierarchy themselves. The endpoint returns canonical BO4E that can be schema-validated client-side.

§14a Modul 2 context. Under BK6-22-300, the NB assigns HT/NT registers to controllable loads at specific switching times communicated via WiM Stammdaten (ORDERS 17102–17133 ZAK+ZE segments). marktd auto-populates the underlying data from those events; this endpoint exposes it in BO4E form. See also billingd §14a Modul 2 billing.


ZaehlzeitRegister — iMSys TOU register definitions

ZaehlzeitRegister and ZaehlzeitSaison provide structured Time-of-Use (TOU) register definitions for iMSys (intelligent metering systems). They underpin §14a Modul 2 billing by enabling automatic classification of 15-min Lastgang intervals into HT/NT tariff bands without per-meter manual configuration.

Data model

Zaehler (1) ──► ZaehlzeitRegister (N)
                   zaehlerauspraegung: HT | NT | EINZEL
                   obis_kennzahl: "1-1:1.29.0" (HT), "1-1:1.49.0" (NT)
                   valid_from / valid_to  (seasonal changeover supported)

ZaehlzeitRegister (1) ──► ZaehlzeitSaison (N)
                               saison: SOMMER | WINTER | GESAMT
                               wochentage: [1,2,3,4,5] (Mon–Fri)
                               zeit_von: "07:00"   (inclusive, local German time CET/CEST)
                               zeit_bis: "22:00"   (exclusive)

A typical residential iMSys meter has two registers (HT + NT) each with two seasons (SOMMER, WINTER). Weekdays differ from weekends. marktd stores all combinations and resolves them efficiently via a single PostgreSQL JOIN with JSONB @> containment.

Endpoints

MethodPathDescription
GET/api/v1/zaehler/{id}/registerList all TOU registers for a Zaehler
PUT/api/v1/zaehler/{id}/registerUpsert a ZaehlzeitRegister
GET/api/v1/zaehler-register/{id}/saisonsList seasonal windows for a register
PUT/api/v1/zaehler-register/{id}/saisonsUpsert a ZaehlzeitSaison
GET/api/v1/zaehler/{id}/tariff-zoneResolve HT/NT/EINZEL at a given datetime

Setting up TOU registers

# 1. Create an HT register
REGISTER_ID=$(uuidgen)
curl -s -X PUT "http://marktd:8180/api/v1/zaehler/Z001234567/register" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d "{
    \"id\": \"${REGISTER_ID}\",
    \"bezeichnung\": \"HT\",
    \"zaehlerauspraegung\": \"HT\",
    \"obis_kennzahl\": \"1-1:1.29.0\",
    \"einheit\": \"KWH\",
    \"valid_from\": \"2025-01-01\"
  }"

# 2. Add winter season: Mon–Fri 07:00–22:00
curl -s -X PUT "http://marktd:8180/api/v1/zaehler-register/${REGISTER_ID}/saisons" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d "{
    \"id\": \"$(uuidgen)\",
    \"saison\": \"WINTER\",
    \"wochentage\": [1,2,3,4,5],
    \"zeit_von\": \"07:00\",
    \"zeit_bis\": \"22:00\"
  }"

# 3. Resolve tariff zone at a specific time
curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/tariff-zone?datetime=2026-01-15T14:30:00" \
  -H "Authorization: Bearer <token>" | jq .
# → { "zaehler_id": "Z001234567", "local_datetime": "2026-01-15T14:30:00", "tariff_zone": "HT" }

The tariff-zone endpoint performs a single JOIN between zaehler_register and zaehler_saisons, filtering by JSONB array containment (wochentage @> $3::jsonb) and time range. No application-level iteration is needed on the consumer side.

Integration with billingd (§14a Modul 2): billingd calls GET /api/v1/zaehler/{id}/tariff-zone?datetime=<slot-start> for each 15-min slot in the billing month and aggregates kWh by zone. This eliminates the need for operators to maintain manual HT/NT time-window configuration per meter in the billing engine.

Automatic population from WiM Stammdaten (ORDERS 17102–17133)

marktd populates ZaehlzeitRegister and ZaehlzeitSaison automatically when makod receives an inbound WiM Stammdaten response (ORDERS PIDs 17102–17133) from the MSB.

The adapter extract_zak_ze_zaehlwerke() in makod parses the EDIFACT ZAK+ZE+ZD segments:

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

After parsing, makod emits a ProcessCompleted outbox entry (CloudEvent type de.mako.process.completed, PID 17102–17133) carrying melo_id and zaehlwerke. When marktd's event_ingest handler receives this event, it:

  1. Looks up the Zaehler associated with the MeLo via list_zaehler_by_melo()
  2. For each zaehlwerk, calls upsert_register() (idempotent on zaehler_id + bezeichnung + valid_from)
  3. For each season window (ZE→ZD), calls upsert_saison() with a deterministic UUID derived from (register_id, saison, tagtyp, zeit_von) — safe for at-least-once delivery

This means operators do not need to manually provision ZaehlzeitRegister entries for meters where the MSB sends WiM ORDERS Stammdaten responses — marktd and makod handle it automatically.

Data flow:

sequenceDiagram
    participant MSB
    participant makod
    participant marktd
    participant billingd

    MSB->>makod: ORDERS PID 17102–17133<br/>ZAK+ZE+ZD segments
    makod->>makod: extract_zak_ze_zaehlwerke()<br/>parse OBIS / saisons / fenster
    makod--)marktd: de.mako.process.completed<br/>{melo_id, zaehlwerke:[...]}
    marktd->>marktd: list_zaehler_by_melo(melo_id)
    marktd->>marktd: upsert_register() + upsert_saison()<br/>(idempotent, deterministic UUID)
    billingd->>marktd: GET /zaehler/{id}/tariff-zone?datetime=...
    marktd-->>billingd: { "tariff_zone": "HT" }

SteuerbareRessource Registry

marktd stores steuerbare Ressourcen (SR) — iMS controllable resources per GPKE Teil 3 Kap. 2. An SR-ID has the format C[A-Z0-9]{9}[0-9] (Codetyp C + 9 alphanumeric chars + ASCII-Verfahren check digit).

Populated by WiM iMS Steuerungsauftrag processes (PID 55168) and by operator uploads. Linked optionally to a MaLo and/or MeLo.

The konfigurationsprodukte field stores the contracted iMS control products — used for pre-dispatch eligibility checks in wim.steuerungsauftrag.bestaetigen. The value is preserved across PUT calls unless explicitly replaced via the sub-resource endpoint.

Konfigurationsprodukte — typed API

The konfigurationsprodukte sub-resource has its own endpoints with full BO4E validation per GPKE Teil 3 Kap. 1.3:

# Retrieve typed Konfigurationsprodukte (returns Vec<ZeitvariablePreisposition> deserialized)
curl -s "http://marktd:8180/api/v1/steuerbare-ressourcen/C0001234567890/konfigurationsprodukte" \
  -H "Authorization: Bearer <token>"
# → {
#     "sr_id": "C0001234567890",
#     "konfigurationsprodukte": [{ "produktcode": "FLEX-001", ... }],
#     "count": 1,
#     "schema_drift": 0
#   }

# Replace all contracted products (validates each element + enforces non-empty produktcode)
curl -s -X PUT "http://marktd:8180/api/v1/steuerbare-ressourcen/C0001234567890/konfigurationsprodukte" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '[
    { "produktcode": "FLEX-PRODUCT-001", "zaehlzeitregister": "HT" }
  ]'
# → 204 No Content + emits de.markt.sr.konfigurationsprodukt.updated CloudEvent

# Remove a single product by produktcode
curl -s -X DELETE "http://marktd:8180/api/v1/steuerbare-ressourcen/C0001234567890/konfigurationsprodukte/FLEX-PRODUCT-001" \
  -H "Authorization: Bearer <token>"
# → 204 No Content

Validation rules (GPKE Teil 3 Kap. 1.3):

  • Each element must deserialize as rubo4e::current::Konfigurationsprodukt
  • produktcode must not be empty — every contracted product requires a unique code
  • bandNummer is rejected with 422 — it does not exist in BO4E v202607; use zaehlzeitregister

processd checks this list before auto-confirming a Steuerungsauftrag. An uncontracted produktcode triggers wim.steuerungsauftrag.ablehnen automatically.


PUT /api/v1/melos — MeLo Typed Columns & Schema Validation

Every PUT /api/v1/melos/{melo_id} call:

  1. Validates the incoming data as rubo4e::current::Messlokation: auto-injects _typ: "MESSLOKATION", rejects wrong _typ or invalid enum values with 422.
  2. Normalises to canonical camelCase BO4E form before storage.
  3. Extracts typed columns for efficient SQL queries:
ColumnSource fieldPurpose
netzebene_messungdata.netzebeneMessungVoltage/pressure level at the metering point (e.g. "NS", "MS")
regelzonedata.standorteigenschaften.eigenschaftenStrom[0].regelzoneRegelzone EIC code → maps MeLo to the ÜNB for Redispatch 2.0 Stammdaten forwarding and MABIS IFTSTA 21000 routing

MeloResponse (GET) returns data: rubo4e::current::Messlokation (typed):

{
  "melo_id": "DE00056789000000000000000012345678",
  "malo_id": "10001234558",
  "version": 2,
  "netzebene_messung": "NS",
  "regelzone": "10YDE-EON------1",
  "data": { "_typ": "MESSLOKATION", "netzebeneMessung": "NS", ... }
}

To populate regelzone from a NIS export, include the BO4E path in the PUT body:

{
  "malo_id": "10001234558",
  "data": {
    "_typ": "MESSLOKATION",
    "standorteigenschaften": {
      "eigenschaftenStrom": [
        { "regelzone": "10YDE-EON------1" }
      ]
    }
  }
}

Netz-Element-Lokationen (NeLo) — Redispatch 2.0

marktd maintains a registry of Netz-Element-Lokationen (NeLo) for BDEW Redispatch 2.0. A NeLo is a network element location identified by a 16-char EIC code (ENTSO-E, NAD DE3055 = ZEW) or a 13-digit BDEW Codenummer.

NeLos are owned by the Netzbetreiber (NB role) responsible for the network element. They carry structural metadata (Sparte, Netzebene) and an open-ended JSONB data payload for additional Redispatch 2.0 attributes.

REST API:

# List all NeLos for this tenant (optionally filter by Netzbetreiber GLN)
GET  /api/v1/nelos
GET  /api/v1/nelos?nb_mp_id=9900357000004&page=0&size=50

# Get a single NeLo by EIC or BDEW Codenummer
GET  /api/v1/nelos/{nelo_id}

# Insert or update a NeLo (NB role required; supply If-Match for OCC)
PUT  /api/v1/nelos/{nelo_id}

Request body for PUT (includes typed NeLo columns):

{
  "sparte": "STROM",
  "name": "Umspannwerk Musterstadt 110/20 kV",
  "netzebene": "HS",
  "nb_mp_id": "9900357000004",
  "steuerkanal": true,
  "eigenschaft_msb_lokation": "NB",
  "grundzustaendiger_msb_codenr": "9900357000004",
  "data": {
    "eic": "10XDE-EON-NETZ--G",
    "regelzone": "10YDE-EON------1"
  }
}

The steuerkanal, eigenschaft_msb_lokation, and grundzustaendiger_msb_codenr fields are extracted into typed SQL columns at write time for efficient Redispatch 2.0 queries (e.g. "find all controllable NeLos for a given NB").

Authorization: read-nelo is open to all authenticated users in the tenant. write-nelo requires the NB role (Cedar policy write-nelo).


Location Graph — Lokationszuordnung

The lokationszuordnungen table stores the full MaKo location graph as directed edges with temporal validity (valid_from, valid_to). Each edge connects two location nodes typed by the BO4E LokationstypMALO, MELO, NELO, SR (SteuerbareRessource), or TR (TechnischeRessource). Node types are the typed rubo4e::current::Lokationstyp end to end (request body, storage, and traversal results), not free strings.

graph LR
    MaLo["MaLo (51238696012)"] --> MeLo["MeLo (DE-MEL-001)"]
    MeLo --> NeLo["NeLo (EIC 10XDE-…)"]
    MeLo --> SR["SR (C0001234567)"]
    MeLo --> TR["TR (T0001234567)"]

Graph traversal

GET /api/v1/malos/{id}/lokationen runs a recursive-CTE BFS query (max depth 8) and returns all reachable edges from the given MaLo, ordered by depth.

# Full graph from a MaLo (all edges regardless of validity)
GET /api/v1/malos/51238696012/lokationen

# Graph valid on a specific date (temporal filter)
GET /api/v1/malos/51238696012/lokationen?at=2025-01-15

# Graph from a MeLo
GET /api/v1/melos/DE-MEL-001/lokationen?at=2025-01-15

Response: Vec<LokationszuordnungEdge> ordered by depth (0 = direct edges from root).

[
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "tenant": "9900357000004",
    "von_id": "51238696012",
    "von_typ": "MALO",
    "nach_id": "DE-MEL-001",
    "nach_typ": "MELO",
    "valid_from": null,
    "valid_to": null,
    "data": {},
    "depth": 0
  }
]

Lokationsbündel

GET /api/v1/malos/{id}/buendel returns the Lokationsbündel (UTILMD Lokationsbündelstruktur) as a first-class aggregate — the set of MeLos, NeLos, SRs, and TRs bundled under a MaLo, projected from the typed graph — together with its structural-integrity status. The aggregate (mako_markt::repository::Lokationsbuendel) enforces the bundle invariants at the domain boundary: a consuming MaLo must carry at least one MeLo, and all MeLos of the bundle must share one MSB (validate / validate_msb_consistency). A bundle can be transiently incomplete mid-Einzug, so the endpoint reports valid: false with a validation_error rather than failing the request.

{
  "malo_id": "51238696012",
  "lokationsbuendelcode": "1S",
  "messlokationen": ["DE-MEL-001"],
  "netzlokationen": [],
  "steuerbare_ressourcen": [],
  "technische_ressourcen": [],
  "valid": true
}

Upsert and delete

# Upsert an edge (idempotent)
PUT /api/v1/lokationszuordnungen
Content-Type: application/json

{
  "von_id":    "51238696012",
  "von_typ":   "MALO",
  "nach_id":   "DE-MEL-001",
  "nach_typ":  "MELO",
  "valid_from": null,
  "valid_to":   null,
  "data":       {}
}

# Hard-delete an edge pair (all temporal variants)
DELETE /api/v1/lokationszuordnungen/51238696012/DE-MEL-001

Temporal succession: Multiple edges between the same (von_id, nach_id) pair are allowed when valid_from differs. One open-ended edge (valid_from IS NULL) is permitted per pair. Dated edges allow modelling supplier-switch driven reassignments.


TechnischeRessource (E-mobility, Generation, Storage)

TechnischeRessource records link E-mobility charging points, generation units, and storage to MaLos/MeLos. Required for WiM iMS Steuerungsauftrag routing and Redispatch 2.0 flexibility registration.

# Get a TechnischeRessource by TrId
GET  /api/v1/technische-ressourcen/{tr_id}

# Upsert
PUT  /api/v1/technische-ressourcen/{tr_id}

# List all TechnischeRessourcen linked to a MaLo
GET  /api/v1/malos/{malo_id}/technische-ressourcen

Request body for PUT:

{
  "data":              { "_typ": "TechnischeRessource", ... },
  "malo_id":           "51238696012",
  "melo_id":           "DE-MEL-001",
  "nutzung":           "STROMVERBRAUCHSART",
  "verbrauchsart":     "E_MOBILITAET",
  "ist_fernschaltbar": true,
  "bo4e_version":      "202607.1.0"
}

BO4E-aligned classification (both fall back to the typed data payload when omitted; invalid values are rejected with 400 Bad Request): nutzung = TechnischeRessourceNutzung ("STROMVERBRAUCHSART" | "STROMERZEUGUNGSART" | "SPEICHER"); verbrauchsart = TechnischeRessourceVerbrauchsart, only for STROMVERBRAUCHSART ("KRAFT_LICHT" | "WAERME" | "E_MOBILITAET" | "STRASSENBELEUCHTUNG"). ist_fernschaltbar: true marks the resource as remotely controllable for Redispatch 2.0.


CloudEvent Replay Log

Every inbound CloudEvent is appended to the durable event_log table before fan-out, enabling full replay without data loss.

# Query the event log (all parameters optional)
GET /admin/events?from=2025-01-01T00:00:00Z&to=2025-02-01T00:00:00Z&type=de.mako.process.initiated&limit=500

Response: Vec<EventLogRow> ordered by received_at ASC (oldest first = deterministic replay).

Use cases:

  • New subscriber onboarding: replay all de.mako.process.initiated events since go-live
  • Bug fix replay: re-deliver specific event types after a invoicd fix
  • Post-incident forensics: trace which events were delivered to which subscriber

CloudEvents — outbound event catalog

marktd emits CloudEvents to all registered ERP webhook subscribers for the following domain events. Each event carries the markt* extension attributes listed below the table.

typesubjectTriggerConsumers
de.markt.malo.updatedmalo_idMaLo PUTedmd, processd, ERP
de.markt.malo.stammdaten-geaendertmalo_idUTILMD Stammdatenänderung appliedERP audit
de.markt.melo.updatedmelo_idMeLo PUTedmd, processd, ERP
de.markt.pricat.publishednb_mp_idPRICAT 27003 dispatchnetzbilanzd, invoicd, ERP
de.markt.nb-contract.updatedcontract_idNB contract PUTERP
de.markt.sr.konfigurationsprodukt.updatedsr_idSR Konfigurationsprodukt replaceprocessd (§14a eligibility check), ERP
de.markt.geraet.konfiguration.updatedgeraet_idGeraet konfigurationen PUTedmd cert-expiry worker, processd §14a auto-ack check, ERP
de.markt.partner.updatedmp_idMarktpartner PUTmakod partner sync, ERP
de.markt.versorgung.changedmalo_idVersorgungsStatus transitionvertragd, billingd, ERP
de.markt.versorgung.gap-detectedmalo_idAn interval no supplier covers: a Lieferende (55005/44005) with a gap before the successor, or a Fall-b Bestätigung (55002/55078/44002) — §38 EnWGprocessd EoG module
de.markt.versorgung.eog-begonnenmalo_id55013/44013 completed → begin_eog_supplyprocessd EoG module, ERP
de.markt.mmma.import.successyear-month, commodityMonthly MMMA/MMM price import (Gas or Strom)netzbilanzd, invoicd
de.markt.mmma.import.failedyear-month, commodityMonthly import fetch/parse/store failureoperator

de.markt.geraet.konfiguration.updated data payload

{
  "specversion": "1.0",
  "type":        "de.markt.geraet.konfiguration.updated",
  "source":      "urn:mako:marktd:tenant:9900000000003",
  "subject":     "SMGW-2026-001",
  "id":          "a1b2c3d4-...",
  "time":        "2026-07-18T08:01:00Z",
  "data": {
    "geraet_id":  "SMGW-2026-001",
    "zaehler_id": "Z001234567",
    "count":      10
  }
}

edmd subscribes to this event type and, upon receipt, updates the smgw_sessions table for the corresponding MaLo if an existing session exists — ensuring the cert-expiry compliance worker always has fresh SMGW_CERT_ABLAUFDATUM data without polling marktd.

CloudEvents Extensions

All outbound events carry the markt* extension namespace (CloudEvents §3.3 — lowercase alphanumeric only):

ExtensionTypeDescription
marktmaloidstringResolved Marktlokations-ID
marktmeloidstringResolved Messlokations-ID
marktrolestringMarktrolle: NB, LF, MSB, BIKO, UNB
markterprefstringERP-supplied idempotency key
makopidu32Forwarded BDEW Prüfidentifikator
makoworkflowstringWorkflow family name
makoercstringBDEW ERC error code (on aperak.rejected)
traceparentstringW3C Trace Context — 00-<trace-id>-<parent-id>-<flags>
tracestatestringW3C Trace Context vendor bag — forwarded unchanged

marktrole derivation from makoworkflow:

PatternRole
ends with -lf or contains -lf-LF
starts with wim-MSB
starts with mabis-BIKO
everything elseNB

Event source: "urn:mako:marktd:tenant:{tenant}"


See Also

Edit this page ↗