processd Operator Guide

processd operator guide: Process decision engine — NB Anmeldung STP, LF answers to every process the market asks a supplier about (Strom and Gas), MSB REQOTE and §14a ORDRSP, EoG gap closure. Role-gated binaries for §7 EnWG separation, Cedar ABAC on every route, MCP tools, PostgreSQL audit log.

processd Operator Guide

processd is the process decision engine — the service that automates regulatory decisions within mandatory deadlines.

graph TB
    marktd["marktd :8180<br/>fan-out"]
    processd["processd :8580<br/>(this service)"]
    makod["makod :8080"]
    pg["PostgreSQL<br/>anmeldung_decisions<br/>abmeldeanfragen<br/>approval_queue"]

    marktd -->|"de.mako.process.initiated<br/>de.markt.versorgung.gap-detected<br/>HMAC POST /webhook"| processd

    subgraph NB ["NB module (--features nb-only)"]
        NC["mako-pruefung<br/>NB: E_0622 · E_0623 · E_0607 · E_0608 · G_0011 · G_0007<br/>LF: E_0609 · E_0624 · E_0614 · E_0615<br/>STP target ≥ 95%"]
        EOG["EoG gap closure<br/>§36/§38 EnWG · §38 timer"]
        NC --> pg
        EOG --> pg
    end

    subgraph LF ["LF module (--features lf-only)"]
        LFA["LF answers · Strom 55007 / 55010 / 55016<br/>Gas 44007 / 44010 / 44016"]
        LFA --> pg
    end

    processd --> NB
    processd --> LF
    NB -->|"gpke.lieferbeginn.*<br/>gpke.lieferende.*<br/>gpke.eog.anmelden<br/>POST /api/v1/commands"| makod
    LF -->|"gpke.nb-lieferende.bestaetigen/ablehnen<br/>geli.lieferbeginn.anmelden<br/>POST /api/v1/commands"| makod
    NB & LF -->|"GET /api/v1/versorgung<br/>GET /api/v1/malos/{id}/grid"| marktd

Port layout

┌────────────────────────────────────────────────────────────────────┐
│  processd  :8580                                                  │
│                                                                  │
│  POST /webhook              ← marktd CloudEvents (HMAC)          │
│  GET  /api/v1/decisions     ← NB STP audit log (OIDC+Cedar)     │
│  GET  /api/v1/queue         ← approval queue (every role)       │
│  POST /api/v1/queue/{id}/approve|reject  ← operator action       │
│  POST /api/v1/start-supply              ← LFN Strom bootstrap    │
│  POST /api/v1/start-supply-gas          ← LFN Gas 44001 bootstrap│
│  POST /api/v1/end-supply[-gas]          ← LF Lieferende bootstrap│
│  GET  /api/v1/eog           ← EoG gap-closure case log (§36/§38) │
│  GET  /api/v1/neuanlage     ← E_0608 case log                    │
│  PUT  /api/v1/neuanlage/{id}/identifikation                      │
│  GET  /health/live  /health/ready                                │
│  POST|GET /mcp       ← MCP Streamable HTTP (2025-11-25)          │
└────────────────────────────────────────────────────────────────────┘

Role isolation

processd is compiled with feature flags that gate which modules are included. This ensures §7 EnWG separation: an nb-only binary provably contains no LF PIDs.

[features]
role-lf-strom  = ["mako-pruefung/role-lf"]  # LF answers 55007/55010/55013/55016/55607
role-lf-gas    = ["mako-pruefung/role-lf"]  # LF Gas 44007 / 44010 / 44013 / 44016
role-nb-strom  = ["mako-pruefung/role-nb"]  # GPKE STP (55001, 55077, 55004, 55600/55601), EoG closure
role-nb-gas    = ["mako-pruefung/role-nb"]  # GeLi Gas An-/Abmeldung STP (44001, 44004)
role-msb       = ["mako-pruefung/role-msb"]   # REQOTE→QUOTES, §14a ORDRSP, MSB-answered MSB-Wechsel,
                                             # ESA Wertebestellung (35003/17007/17008/39002)

lf-only    = ["role-lf-strom", "role-lf-gas"]
nb-only    = ["role-nb-strom", "role-nb-gas"]
msb-only   = ["role-msb"]
integrated = ["role-lf-strom", "role-lf-gas", "role-nb-strom", "role-nb-gas", "role-msb"]

PID 55016 „Kündigung" is not an NB process. The Anwendungsübersicht der Prüfidentifikatoren 4.0 (lfd. Nr. 20030) has it going LFN → LFA, answered 55017/55018 by the Altlieferant under EBD E_0614 — so it belongs to role-lf-strom. Routing it into the NB module would make an nb-only binary answer a supplier-role message, exactly what these feature flags exist to prevent.

For §7 EnWG deployments (≥ 100k Netzkunden): BNetzA inspects the binary SHA to confirm no cross-contamination. Use separate container images compiled with nb-only and lf-only respectively.


NB module — Anmeldung STP

Decision pipeline

de.mako.process.initiated
  ├─ Anmeldung (55001 verb. MaLo / 55077 erz. MaLo / 44001 Gas)
  │    → GET marktd /api/v1/versorgung/{malo_id}       → VersorgungsStatus
  │    → GET marktd /api/v1/malos/{malo_id}/grid       → MaloGridRecord
  │    → GET marktd /api/v1/partners/{lf_mp_id}        → partner_known
  │    → mako_pruefung::evaluate(…)             ← E_0622 / E_3005, the Vorprüfung
  │    → mako_pruefung::evaluate_lieferbeginn(…) ← E_0623 / E_3007, what is answered
  └─ Abmeldung (55004 Strom / 44004 Gas)
       → GET marktd /api/v1/versorgung/{malo_id}       → VersorgungsStatus
       → mako_pruefung::evaluate_abmeldung(…)

  Accept   → anmeldung_decisions(Accept, Zustimmungscode)
             [if auto_accept] → makod …bestaetigen (antwort_code, antwort_ebd)
             else             → approval_queue
  Reject   → anmeldung_decisions(Reject, Antwortcode)
             → makod …ablehnen (antwort_code, antwort_ebd, bemerkung)
  Escalate → anmeldung_decisions(Escalate) → approval_queue
  AnfrageErforderlich
           → INSERT abmeldeanfragen (the waiting AnmeldungAnfrage)
           → makod gpke.beendigung-zuordnung.anfragen  → UTILMD 55010 to the LFA
             …09:00 Uhr des 1. WT, or the LFA answers 55011/55012…
de.mako.abmeldeanfrage.beantwortet
           → take abmeldeanfragen → mako_pruefung::evaluate_lieferbeginn(…)
           → the Accept / Reject / Escalate paths above

The Anmeldung is two trees, and on an assigned Marktlokation two phases. E_0622 is a Vorprüfung: surviving it means only that the Anmeldung is not directly refusable. GPKE Teil 2 § 2.1.2 SD Lieferbeginn Nr. 1 Prüfschritt 4 then asks whether the Marktlokation is already assigned at the Zuordnungsbeginn — and if it is, the NB owes the incumbent LFA an Anfrage zur Beendigung der Zuordnung (55010, Nr. 3) before it may answer the LFN at all, because E_0623 Prüfschritte 20–50 read that answer.

The waiting decision is persisted in abmeldeanfragen as the serialised AnmeldungAnfrage, so phase two replays the same pure evaluation with one more fact. The row is written before the Anfrage goes out — a loopback answer can arrive in milliseconds, and one that finds no waiting row would leave the Anmeldung unanswered past its own 11:00 Frist. Resolving it is a single UPDATE … WHERE resolved_at IS NULL, because the LFA's answer and the 09:00 lapse race by design and exactly one of them may answer the LFN.

Silence releases the Marktlokation. „Verstreicht die Frist, ohne dass eine Antwort beim NB eingeht, gilt dies als Bestätigung nach Fall a). Nach Ablauf der Frist eingehende Antworten sind für den Fortlauf dieses Prozesses unerheblich." A Widerspruch that is not A30 / A41 („bereits abgemeldet") refuses the Anmeldung with E_0623 A50 / A57Z35 in Gas — outcomes an NB that never sends an Anfrage cannot reach at all. Those two codes oblige the Ablehnung to carry a second SG4 STS: STS+Z35 restates the LFA's own E_0624 code, which is GPKE Teil 2 § 2.1.2 Nr. 6's „der NB gibt zusätzlich den Grund der Ablehnung des LFA an". processd puts it on the ablehnen command and makod refuses to render the message without it.

Geschäftsvorfall 3 is not decided automatically: Prüfschritte 500–600 read a Tranchen-Zuordnung the versorgung projection cannot express, so the tree escalates rather than choosing between two Ablehnungen and two Zustimmungen.

Three trees, three alphabets. E_0622 Prüfschritt 10 splits Strom into two branches that share no Antwortcode, and Gas answers from a different Codeliste:

AnwendungsfallTree„andere Anmeldung in Bearbeitung"FristüberschreitungZustimmung
Strom, verbrauchende / ruhende MaLoE_0622 15–70A06A07A51 (E_0623)
Strom, erzeugende MaLo / TrancheE_0622 220–830A45A34/A28/A29/A30/A32/A35/A44A58 (E_0623)
GasE_3005 / G_0011ZC5E17E15 (G_0012)
Abmeldung StromE_0607A02A11
Abmeldung GasE_3019 / G_0007E17E15

Putting A06 on a 44003 is not a wrong reason — it is a code the Gas Codeliste does not define. Every code is resolved through mako_pruefung::codes against the tree that publishes it, RejectReason can only be built from a resolved entry, and makod re-checks it at the command boundary where the published Cluster decides the response PID.

A Bestätigung carries a code too. SG4 STS+E01 is Muss on every Antwortnachricht: E_0622 is a Vorprüfung whose codes are all Ablehnungen, and a message that survives it is confirmed out of E_0623. NbEntscheidung::Accept therefore carries A51 / A58 / E15.

mako_pruefung::nb::evaluate — the Anmeldung

Strom, verbrauchende / ruhende Marktlokation (E_0622 Prüfschritte 15–70):

PrüfschrittRuleOn failure
MaloGridRecord exists for the MaLoEscalate (a mako data gap, not a ground to refuse)
15Vorlauffrist — one full Werktag between receipt and Zuordnungsbeginn (LFW24, all Transaktionsgründe)Reject A07
30MaLo participates in MaKoReject A02
60Zuordnungsermächtigung: Bilanzierungsgebiet matches, LF in the partner directoryReject A05
70No other Anmeldung in BearbeitungReject A06

A ruhende Marktlokation is not refused: Prüfschritt 30's own Hinweis names only stillgelegte Marktlokationen and the Modell-2-Zuordnung, and Prüfschritte 16–28 exist to check a ruhende one.

Strom, erzeugende Marktlokation (Prüfschritte 220–830) uses A45 / A25 for the same two questions and then picks between the six Vorlauffristen GPKE Teil 2 § 2.1.1 publishes, keyed on (Geschäftsvorfall, bestehende, angemeldete Veräußerungsform). The angemeldete one is SG10 CCI+Z22 on the wire; the bestehende one and the Ausfallvergütung flag come from einsd (GET /api/v1/anlagen/by-malo/{malo_id}/veraeusserungsform, configured as [nb] einsd_url) — wire code Z90 covers both the uneingeschränkte Einspeisevergütung and the Ausfallvergütung, whose Fristen differ by a month versus five Werktage. A missing fact escalates and is named; the statutory anchor for the Monatserster rule is § 21b Abs. 1 EEG 2023, not § 10c.

Gas (G_0011) runs the A03/A04/A16/A17 identification checks first, as the AHB requires, then E17 for a Fristüberschreitung, E13 for a Bilanzierungsproblem and ZC5 / Z08 for a conflicting or duplicate Anmeldung.

mako_pruefung::nb::evaluate_neuanlage — the Neuanlage, E_0608

Inbound 55600 / 55601: a Lieferant registers a Marktlokation being commissioned for the first time. The tree has a third outcome — Prüfschritte 110 / 590 loop, so a Marktlokation the NB cannot yet identify is re-checked daily for 60 Werktage and may only then be refused A07 / A16.

processd therefore keeps a case log (neuanlage_faelle) and a daily worker rather than deciding once:

EndpointPurpose
GET /api/v1/neuanlage?status=offenThe Prüflauf view — each row carries the letzter_pruefungstag a refusal only becomes admissible after, and the pruefungen count that evidences the daily attempts
PUT /api/v1/neuanlage/{id}/identifikationThe MaLo-ID the NB matched from its NIS/GIS. A Neuanlage carries address and device data, not an ID, so this is where the identification arrives — the next pass then walks the tree with it

The two branches share no code (A01A09 verbrauchend, A10A19 erzeugend), and the Vorlauffrist is measured against the Übertragungstag, so a case re-evaluated on day 40 reaches the same verdict it did on day one.

mako_pruefung::nb::evaluate_abmeldung — the Abmeldung, E_0607 / E_3019

Inbound 55004 (Strom) / 44004 (Gas): the supplier ends the assignment and the NB answers 55005/55006 (44005/44006).

#PrüfschrittOn failure
1The MaLo is known to this NBEscalate
2The requesting LF is the assigned Lieferant (Prüfschritt 110)Escalate
3Vorlauffrist eingehalten (Prüfschritt 50) — Strom: one full Werktag between receipt and Zuordnungsende, or Monatserster + 1 Monat for an EEG-MaLo (§ 21b Abs. 1 EEG 2023); Gas: the GeLi Gas Kap. 3.2.1 retroactivity rulesReject A02 (Strom) / E17 (Gas)
4Kein bereits bestätigtes Lieferende zum selben Datum (Prüfschritt 120)Reject A09 (Strom) / Z08 (Gas)
Prüfschritt 130 — did the already confirmed Abmeldung name an Auszugsgrund?Escalate — the projection does not keep the earlier message's Transaktionsgrund, and A10 and „confirm" are both live outcomes there

Prüfschritte 10–30 (Kundenanlagen-Herauslösung) and 60–90 (ESV-Ende, Aufhebung einer zukünftigen Zuordnung) need Transaktionsgründe and prior process history the projection does not carry; they escalate rather than guess. Escalation is the § 20 EnWG-safe direction — an unfounded Ablehnung keeps a customer bound to a supplier they have left.

STP rate targets

processd targets ≥ 95 % straight-through processing on a verbrauchende Anmeldung. The malo_grid record is a prerequisite — a missing one escalates — so STP improves markedly once it is provisioned. An erzeugende Marktlokation additionally needs [nb] einsd_url: E_0622 chooses between six published Vorlauffristen from the bestehende Veräußerungsform, which is register data and not on the wire. A deployment without it escalates every 55077 — the § 20 EnWG-safe outcome, since none of the six is a defensible default.

Grid records are the NB’s own grid topology — not from MaStR. Provision them via marktd’s NB-role PUT /api/v1/malos/{malo_id}/grid endpoint (manual / ERP provisioning).

Monitor via GET /api/v1/decisions or the get_stp_rate MCP tool.

[nb] auto_accept

Set auto_accept = false (the default; PROCESSD_NB__AUTO_ACCEPT in the environment) until you have verified:

  1. Grid record coverage for your MaLo portfolio (GET /api/v1/malos/{id}/grid)
  2. Partner directory populated for all expected LF MP-IDs
  3. At least one manual review cycle confirmed correct ERC codes

§20 EnWG — affiliate guard

When processd is deployed in an integrated NB+LF utility (§6b EnWG), auto-acceptance is always blocked for Anmeldungen where the requesting LF is an affiliate of the NB operator. This implements the §20 EnWG Diskriminierungsfreiheitspflicht non-discrimination obligation.

It applies to Abmeldungen as well as Anmeldungen — an affiliate must not get a faster automatic path in either direction.

Detection logic:

initiating LF MP-ID == [identity] own_mp_id  →  initiator_is_affiliate = true
                                                auto_accept overridden to false
                                                → approval_queue (operator review)

The operator's own MP-ID comes from processd.toml:

[identity]
own_mp_id = "9900357000004"   # BDEW 99… / DVGW 98…; must match makod's primary party

Startup logs the coding authority derived from it (293 = BDEW, 332 = DVGW) and warns on a GS1 GLN, because a mismatched prefix makes every parity comparison fail silently.

obsd records initiator_is_affiliate = true on the resulting ProcessProjection and the KPI report exposes the parity delta for BNetzA audit evidence. See obsd §20 EnWG parity for query examples.

Selbstzahler — the Lieferantenwechsel carve-out

GPKE Teil 1 (BK6-24-174 Anlage 1a), Vorbemerkung: „Ist der Letztverbraucher selbst Netznutzer, so tritt er in die Rolle des Lieferanten i.S. dieser Prozessbeschreibung […]. Eine Ausnahme bilden die Meldungen des Lieferanten im Rahmen des Lieferantenwechsels."

A Selbstzahler („Netznutzer ohne All-Inklusiv-Vertrag") is an ordinary LF in every other GPKE process, so nothing routes differently. But when a Wechsel displaces one, the incumbent is not acting in the LF role the automation assumes. processd reads the Netznutzungsvertrag in force the day before the requested Zuordnungsbeginn (GET /api/v1/nb-contracts/by-malo/{malo_id}?on=) and, when its netznutzer_typ is LETZTVERBRAUCHER, holds the decision for the operator instead of dispatching a Bestätigung:

transaktionsgrund == "E03" (Wechsel)
  ∧ incumbent netznutzer_typ == LETZTVERBRAUCHER
  → approval_queue (operator review), Antwortfrist attached

Only the Wechsel Transaktionsgrund triggers it. An Einzug (E01) or Einzug in Neuanlage (E02) on the same MaLo stays on the automated path, and the lookup does not run at all for those — widening the hold would take an industrial customer's whole MaLo portfolio off automation for no regulatory reason.


NB module — EoG gap closure (§36/§38 EnWG)

Every consuming Marktlokation must be assigned to a Bilanzkreis at all times (GPKE Teil 2 Kap. 2.3). The EoG module closes supply gaps automatically:

de.markt.versorgung.gap-detected           (marktd: an interval no supplier
  │                                         covers — a Lieferende the successor
  │                                         does not follow on, or a Fall-b
  │                                         Bestätigung 55002/55078/44002)
  ├─ record case in eog_activations         (idempotent per MaLo)
  ├─ [eog.auto_activate] GET /api/v1/grundversorger/{nb_mp_id}?sparte=…
  │    └─ found → Strom: gpke.eog.anmelden → UTILMD 55013;
  │             Gas:   geli.eog.anmelden → UTILMD G 44013 → makod → E/G
  │       (Zuordnungsbeginn = day after Lieferende — retroactive allowed)
  │    └─ missing → case stays `detected`; operator provisions the
  │       §36 Abs. 2 Feststellung and re-triggers
  └─ de.markt.versorgung.eog-begonnen → case `active`
       (eog_art = Ersatz-/Grundversorgung as classified by the E/G in 55014;
        eog_seit = Zuordnungsbeginn)

The case lifecycle — the states exposed by GET /api/v1/eog?status=…:

stateDiagram-v2
    [*] --> detected: gap-detected (uncovered interval)
    detected --> detected: Grundversorger missing (operator provisions §36 Abs. 2)
    detected --> angemeldet: eog.anmelden → UTILMD 55013 / G 44013
    angemeldet --> active: E/G Bestätigung 55014 (eog-begonnen)
    angemeldet --> detected: E/G Ablehnung 55015 (EBD E_0615)
    active --> expiring: §38 timer, warn_days_before_expiry ahead
    expiring --> expired: three-month maximum reached (§38 Abs. 4 S. 1)
    active --> [*]: Grundversorgung (no statutory maximum)
    expired --> [*]: operator follow-up (Grundversorgung regime / vertragliche Ersatzbelieferung / Anschlussunterbrechung)

§38 timer. A daily worker enforces the three-month maximum (§38 Abs. 4 S. 1 EnWG, calendar months from eog_seit — not from detection): warn_days_before_expiry days ahead the case turns expiring, at expiry expired; both emit de.markt.versorgung.ersatz-auslaufend to eog.notify_webhook_url. Grundversorgung cases have no statutory maximum. After expiry the follow-up is operator-driven: Haushaltskunden transition into Grundversorgung automatically without a market message (the E/G's billing switches regime); otherwise the NB secures the Bilanzkreis (vertragliche Ersatzbelieferung, STS E06) or interrupts the Anschlussnutzung.

Operator surface. GET /api/v1/eog?status=detected|angemeldet|active|expiring|expired.

[eog]
auto_activate            = true       # default false — record-only
default_transaktionsgrund = "ZT6"     # SG4 STS DE9013 for automatic Anmeldungen
warn_days_before_expiry  = 14
notify_webhook_url       = "https://erp.example/hooks/eog"

LF module — answering what the market asks a supplier

Nine inbound processes, each with its own Entscheidungsbaum, its own Codeliste and its own Antwortfrist:

SparteInboundProcessEBDAnswersFrist
Strom55007Lieferende von NB an LFE_060955008 / 5500905:00 Uhr des 1. WT nach dem ÜT
Strom55010Beendigung der ZuordnungE_062455011 / 5501209:00 Uhr des 1. WT nach dem ÜT
Strom55013Anmeldung E/G (§ 36 / § 38 EnWG)E_061555014 / 5501515:00 Uhr am ÜT
Strom55016Kündigung (LFN → LFA)E_061455017 / 55018Ablauf des 1. WT nach dem ÜT
Strom55607Ankündigung Zuordnung LF (erz. MaLo / Tranche)E_0603E_060655608 / 5560915:00 Uhr am ÜT
Gas44007Lieferende von NB an LFE_300244008 / 44009Ablauf des 3. WT
Gas44010Beendigung der ZuordnungE_302044011 / 44012Ablauf des 3. WT
Gas44013Anmeldung E/GE_300844014 / 44015Ablauf des 2. WT
Gas44016Kündigung beim AltlieferantenE_300144017 / 44018Ablauf des 3. WT

The same business process carries the same command name in both Sparten — {gpke,geli}.nb-lieferende.*, {gpke,geli}.beendigung-zuordnung.*, {gpke,geli}.kuendigung.* — and one walk decides both, from one de.mako.process.initiated contract.

55607 is the one where silence is not a lapsed Frist. GPKE Teil 2 § 2.4.2.2 Nr. 3 has the NB assign the supplier to the erzeugende Marktlokation „aufgrund fehlender Antwort" anyway, using whichever Bilanzkreis it has on file. The substance of the answer is that Bilanzkreis, not the code: A01 and A99 are the only two the four trees publish. Which BK is admissible is the BKV's grant — MaBiS § 10.2.1 issues the Zuordnungsermächtigung „je ZRT, BG, BK und LF" — so [[lf.bilanzkreise]] is keyed on the Bilanzierungsgebiet, and a regime with several authorised BKs is a choice the supplier makes, not a default.

Which of the four Anwendungsfälle a 55607 belongs to is on the wire, not inferred: SG4 STS+7 DE 9013 element 3 carries ZW8 (Fall 1) to ZX1 (Fall 4), and UTILMD AHB Strom 2.2 Bedingungen [161]–[164] map the four onto E_0603E_0606 in DE 1131 of the answer. That is a different code space from the ZW3/ZW4/ZW5/ZAP Lokationsart every other LF-answered Vorgang puts in the same element.

The Bilanzkreis itself rides the Produktpaket — SG8 SEQ+Z79 with Produkt-Code 9991000002082, Merkmalswert in SG10 CAV+ZV4 — because the UTILMD AHB admits SG4 FTX+ACB on the 55609 Ablehnung only (Bedingung [48]).

Two of the questions the trees ask decide the answer before any contract data is consulted, and both come from the message itself:

  • Which object is the Vorgang about? SG4 STS+7 DE 9013 element 3 — ZW3 erzeugende, ZW4 verbrauchende, ZW5 Tranche, ZAP ruhende Marktlokation. The two halves of E_0609 and E_0624 answer from different code ranges, so a missing Ergänzung is an escalation rather than a default.
  • Was the Vorlauffrist kept? (E_0609 Prüfschritt 40, E_3002 E17.) Arithmetic on the Übertragungstag and the Zuordnungsende, resolved by mako_fristen::abmeldung against GPKE Teil 2 § 2.5.2 Nr. 1. The window is a calendar month for EEG-Marktlokationen and the day before the last Werktag for everything else, so an erzeugende Marktlokation escalates unless the deployment can say which it is.

How a decision is made

mako-pruefung walks the published Prüfschritte. processd assembles the facts they ask about and routes the outcome:

de.mako.process.initiated (an answerable PID)
  → LfAnfrage from the CloudEvent — Transaktionsgrund and its Ergänzung
    (ZW3/ZW4/ZW5/ZAP, which selects the tree's branch), Termin, ÜT
  → LfVertragslage from marktd (supply state) + vertragd (contract state)
  → pruefe_*(…)
      Antwort    → makod command carrying the Antwortcode and its EBD
      Eskalation → approval_queue, naming the Prüfschritt it stopped at

The Antwortcode's published Cluster selects the answer PID, not the command name: A36 „Vertragsverhältnis wurde beendet" rides 55011, A35 „Es besteht eine Vertragsbindung" rides 55012.

Contract facts

Half of what the trees ask is about a contract, not about supply state:

PrüfschrittQuestionSource
E_0624 50Ist der Kunde aus der Anfrage identisch mit dem Kunden beim LFA?vertragd
E_0624 60Hat der LFA Informationen, dass sein Kunde nicht ausgezogen ist?vertragd
E_0624 90 / 220Bleibt das Vertragsverhältnis zum Folgetag bestehen?vertragdkuendigung_zum and vertragsende, whichever cuts first
E_0614 40 / 50 / 80Wurde der Vertrag bereits gekündigt, und zu welchem Datum?vertragdkuendigung_zum only
E_0614 70 / 580Ist der Vertrag zum Kündigungstermin kündbar?vertragdnaechstmoeglicher_kuendigungstermin ≤ Termin
E_0614 500Liegt zu dem genannten Objekt ein Vertrag vor?vertragd — a contract row, not its absence
E_0609 40Wurde die Vorlauffrist eingehalten?mako-fristen
E_0624 5Ging die Anfrage bis 07:00 Uhr des nächsten WT ein?SG4 DTM+154 on the message
E_0624 50Ist der Kunde aus der Anfrage identisch mit dem Kunden beim LFA?SG12 NAD+Z09 on the message, compared by vertragd
E_0624 20Besteht zum Folgetag noch eine Zuordnung?marktd
E_0615 20Liegt die MaLo im Grundversorgungsgebiet des Empfängers?[lf] grundversorgungs_netzgebiete

Prüfschritt 50 compares names, not strings. processd forwards the SG12 NAD+Z09 „Kunde des LF" as ?kunde= and vertragd matches it against the contract holder on the set of normalised tokens. A shared Nachname and nothing else — a family member moving in — is Unklar and escalates, because A32 refuses the Einzug and A34 releases the Marktlokation. See vertragd for the matching rule.

vertragd's three dates are three different facts. kuendigung_zum records that somebody has terminated; vertragsende is the agreed Laufzeitende, which nobody terminated; naechstmoeglicher_kuendigungstermin is when notice could next take effect. The table above says which Prüfschritt reads which.

Set [lf] vertragd_url to answer them. Without it those facts are Bekannt::Unbekannt and any decision reaching one escalates to an operator — deliberately: a supplier with no contract database cannot claim a Vertragsbindung, and must not agree to release the customer instead.

auto_respond = false means an operator decides, not nobody answers: the walk still runs and its outcome is queued with the Antwortfrist attached.

The E/G Anmeldung has its own switch

E_0615 / E_3008 is the only Anmeldung a supplier is asked to check, and the only one whose Zustimmung accepts a statutory duty — § 36 / § 38 EnWG — for a customer this supplier has no contract with. Prüfschritt 20 asks whether the Marktlokation lies in the recipient's Grundversorgungsgebiet, which no BDEW process transports: [lf] grundversorgungs_netzgebiete lists the Netzbetreiber MP-IDs whose area this supplier serves, and an empty list escalates every 55013 with its 15:00-Uhr Frist attached rather than answering from an absent record.

Dispatch needs a second opt-in, [lf] eog_auto_respond (default false), on top of auto_respond. The walk runs either way, so the Frist is always visible.

A Zustimmung additionally needs two facts no tree produces and the AHB marks Muss on a 55014: the Versorgungsart (SG10 CCI+Z36ZC9, ZD0, ZE3 and no more, since ZZD Übergangsversorgung is a Transaktionsgrund) from [lf] eog_versorgungsart, and the Bilanzkreis (SG8 SEQ+Z79) from [[lf.bilanzkreise]]. Missing either, the answer is queued with the missing field named.

Approval queue

The queue is shared by every compiled role: the NB queues escalated and held-back Anmeldungen, the MSB escalated MSB-Wechsel and § 14a Steuerungsaufträge, the LF its GPKE answers. Each entry stores the makod command to dispatch on approve and on reject, resolved from the trigger PID at enqueue time.

expires_at is the business answer Frist of that process, less headroom:

TriggerFristSource
GPKE Anmeldung 55001 / 5507711:00 Uhr des 1. WT nach dem ÜTBK6-24-174 Teil 2, SD Lieferbeginn 5/6
GPKE Abmeldung 5500406:00 Uhr des 1. WT nach dem ÜTBK6-24-174 Teil 2, SD Lieferende von LF an NB 2/3
GPKE LF answer 5500705:00 Uhr des 1. WT nach dem ÜTBK6-24-174 Teil 2, SD Lieferende von NB an LF 2
GPKE LFA answer 5501009:00 Uhr des 1. WT nach dem ÜTBK6-24-174 Teil 2, SD Lieferbeginn 4
GPKE LFA answer 55016Ablauf des 1. WT nach dem ÜTBK6-24-174 Teil 2, SD Kündigung 2
GeLi Gas LF answer 44007Ablauf des 3. WTAWH GeLi Gas 2.0 Kap. 2.3.2 Nr. 2
GeLi Gas LFA answer 44010Ablauf des 3. WTAWH GeLi Gas 2.0 Kap. 2.5.2 Nr. 4
GeLi Gas LFA answer 44016Ablauf des 3. WTGeLi Gas 3.0 Kap. 3.1
GeLi Gas Anmeldung 44001Ablauf des 4. WT nach EingangBK7-24-01-009 Kap. 3.2.3
GeLi Gas Abmeldung 44004Ablauf des 3. WT nach EingangBK7-24-01-009 Kap. 3.2.2
WiM MSB-Wechsel 55039 / 55042 / 55051 / 551683 / 5 / 7 / 1 WTWiM Strom Teil 1
GPKE Neuanlage 55600 / 5560100:00 Uhr des 61. WT nach dem ÜTBK6-24-174 Teil 2 § 2.2.2 (60 WT täglicher Prüflauf, E_0608)
GPKE Sperr-/Entsperrauftrag 17115 / 17117 / 39000spätester ÜT ist der 1. WT nach dem ÜTBK6-24-174 Teil 2 §§ 3.5.1.2 / 3.5.2.2 / 3.5.3.2 Nr. 2
GPKE Anfrage Sperrung an den MSB 171163. WT nach dem ÜT — Fristverstreichen gilt als ZustimmungBK6-24-174 Teil 2 § 3.5.1.2 Nr. 4
GPKE Teil 4 Stammdaten-Rückmeldung 55109 / 55557 / 55639–55643 / 556932. WT nach dem ÜTBK6-24-174 Teil 4 §§ 1.4.3 / 1.4.4 Nr. 2
GPKE Teil 2 Bearbeitungsstand Abrechnungsdaten 55156 / 55220 / 556732. WT nach dem ÜTBK6-24-174 Teil 2 §§ 3.1.1.2 / 3.1.2.2 / 3.1.3.2

The 45-minute APERAK window on the same message is a separate clock and is makod's to answer.

A background task runs every 60 s and sets status = Expired for stale entries. It is deliberately not role-gated, since every role build can enqueue.

decided_by records the sub of the principal who approved or rejected (§ 20 EnWG parity evidence and the GoBD trail both have to say who decided).

Operator workflow:

GET /api/v1/queue                     → list Pending entries (review before expires_at)
POST /api/v1/queue/{id}/approve       → dispatch consent command via makod AND mark Approved
POST /api/v1/queue/{id}/reject        → dispatch reject command via makod AND mark Rejected

Regulatory deadline: expires_at is the per-PID business Frist less an hour of headroom (OPERATOR_HEADROOM), read from mako_fristen::antwort — the table makod also registers the process deadline from.

The approve/reject handlers claim the entry (status = 'Pending' guard) before dispatching to makod, releasing the claim if the dispatch fails so the operator can retry. Claiming first is what stops a terminal entry re-sending its market message, and two operators deciding at once from sending both an einwilligung and an ablehnen. Expired entries log a WARN and must be reconciled manually.


LF module — LFN bootstrap

Strom: POST /api/v1/start-supply

Initiates a GPKE Lieferbeginn (UTILMD 55001) with LFW24 Vorlauffrist validation (BK6-22-024, effective 2025-06-06).

curl -X POST http://processd:8580/api/v1/start-supply \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"malo_id": "10001234558", "lieferbeginn_datum": "2026-10-01",
       "bilanzkreis": "11XBK-STD-----9"}'
FieldRequiredNotes
malo_id11-digit Strom Marktlokations-ID
lieferbeginn_datumISO-8601 date (YYYY-MM-DD)
bilanzkreis✓*SG8 SEQ+Z79 Produktpaket, Produkt-Code 9991000002082

* Optional only where [[lf.bilanzkreise]] declares exactly one standard entry, which is then used. UTILMD AHB Strom 2.2 Kap. 5.3 makes the Produktpaket Muss on a 55001 — „ohne die Angabe eines für den LF gültigen Bilanzkreises [kann] der NB den LF der Marktlokation bzw. Tranche nicht zuordnen" — so a request that resolves to none is 422 MISSING_BILANZKREIS rather than a message the NB must reject.

Vorlauffrist rules (LFW24, BK6-24-174 GPKE Teil 2, SD Lieferbeginn Prozessschritt 1):

"Spätester ÜT ist der Tag vor dem letzten WT vor dem Zuordnungsbeginn" — the Frist is day-granular (ÜT = calendar day of the AS4 receipt); there is no time-of-day cutoff.

Submission (Berlin date)Earliest allowed Lieferbeginn
TodayCalendar day after the next Werktag after today
Retroactive date (< today_berlin)Rejected with RETROACTIVE_DATE

Response includes earliest_lieferbeginn and berlin_date_at_submission for operator transparency.

Gas: POST /api/v1/start-supply-gas

Initiates a GeLi Gas Lieferbeginn (UTILMD 44001). Both malo_id and zaehlpunkt are mandatory per BK7-24-01-009 AHB rules.

curl -X POST http://processd:8580/api/v1/start-supply-gas \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "malo_id":    "10001234558",
    "zaehlpunkt": "DE00123456789012345678901234567890",
    "process_date": "20261001",
    "bilanzkreis": "9870000000006"
  }'
FieldRequiredNotes
malo_idGas-MaLo-ID — rendered into SG5 LOC+Z16
zaehlpunktZählpunktbezeichnung (RFF+Z13)
process_dateLieferbeginn date (YYYYMMDD, CET/CEST)
bilanzkreis✓*SG10 CCI+Z19 DE 7037 — not the Strom Produktpaket
transaktionsgrundE03 Wechsel (default), E01/E02 Einzug

* Same fallback as the Strom endpoint. GeLi Gas has no Produktpaket at all: UTILMD AHB Gas 1.2 marks SG10 CCI+Z19 Muss on a 44001, and the renderer emits that segment rather than SG8 SEQ+Z79. Sending either shape on the other Sparte is a segment the receiving AHB does not define.

Mindestvorlauffrist: 10 WT for a Lieferantenwechsel (AWH GeLi Gas 2.0 Kap. 2.5.2 Nr. 1), enforced here. An Einzug passes with a vorlauffrist_hinweis: the NB corrects the date to the second Werktag after confirmation rather than rejecting (Kap. 4).

The GNB responds with PID 44002 (Bestätigung) or 44003 (Ablehnung) by the Ablauf des 4. WT (Kap. 3.2.3) — a different clock from the supplier's own 10-WT lead time.

No API-Webdienste equivalent for Gas. The ERP must supply the Gas-MaLo-ID (malo_id) upfront from the customer contract, MaStR, or DVGW Codevergabe.

Gas Datenabruf: geli.datenabruf.anfragen

Request Abrechnungsbrennwert and Zustandszahl on-demand (ORDERS 17103):

curl -X POST http://makod:8080/api/v1/commands \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"command": "geli.datenabruf.anfragen", "payload": {"malo_id": "10001234558"}}'

The GNB responds with MSCONS 13007 (data delivery) or ORDRSP 19103 (rejection). Successful delivery automatically updates edmd meter_billing_periods via the existing update_gas_quality path.


§20 EnWG parity

Every anmeldung_decisions row includes:

initiator_is_affiliate BOOLEAN  -- TRUE when lf_mp_id == own_mp_id (integrated deployment)

This field is the BNetzA audit evidence for §20 EnWG parity compliance. A systematically faster decision time for initiator_is_affiliate = true is a §20 EnWG violation in integrated §6b EnWG deployments.

Use obsd's parity report or query directly:

SELECT
    initiator_is_affiliate,
    COUNT(*) AS total,
    AVG(EXTRACT(EPOCH FROM (decided_at - created_at))) AS avg_response_secs
FROM anmeldung_decisions
WHERE tenant = $1 AND decided_at >= now() - interval '90 days'
GROUP BY initiator_is_affiliate;

Configuration reference

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

# The config-file path defaults to ./processd.toml; override with PROCESSD_CONFIG.
PROCESSD_CONFIG=/etc/processd/processd.toml processd

Full processd.toml reference

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

[database]
url       = "env:DATABASE_URL"  # required; use env: for secrets
pool_size = 10                  # default

[identity]
own_mp_id = "9900357000004"     # required — must match makod.toml [[party]] primary
tenant    = ""                  # optional; defaults to own_mp_id

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

[marktd]
url     = "http://marktd:8180"  # required
api_key = "env:MARKTD_API_KEY"  # required

[webhook]
inbound_secret = "env:INBOUND_WEBHOOK_SECRET"   # optional; omit for dev

[subscription]
# Self-register this subscription with marktd on startup.
# No manual curl required — topology is fully config-driven.
webhook_url   = "http://processd:8580/webhook"  # optional; omit to skip registration
subscriber_id = "processd"                       # default
# default: de.mako.process.initiated + de.markt.versorgung.{gap-detected,eog-begonnen,changed}
event_types   = "de.mako.process.initiated"

[nb]
auto_accept              = false  # true → dispatch bestaetigen automatically on Accept
gas_bearbeitungsfrist_wt = 3      # AWH GeLi Gas 2.0 Kap. 2.2
einsd_url                = ""     # EEG-/KWKG-Register; without it every 55077 escalates
einsd_api_key            = ""

[lf]
auto_respond = true   # false → every inbound LF process routed to approval_queue

# The Netzgebiete this supplier is Grund-/Ersatzversorger in, by NB MP-ID.
# `E_0615` Prüfschritt 20 asks for exactly this and no market message carries
# it. Empty → every 55013 / 44013 escalates.
grundversorgungs_netzgebiete = ["9900000000001"]
# A resolved E_0615 / E_3008 answer accepts or declines a statutory supply duty,
# so it needs its own opt-in on top of `auto_respond`.
eog_auto_respond = false
# SG10 CCI+Z36 — which fallback supply an automatic Zustimmung states:
# ZC9 §38 Ersatzversorgung, ZD0 §36 Grundversorgung, ZE3 vertragliche
# Ersatzbelieferung — the three codes DE 7037 publishes. The AHB marks it Muss on a
# 55014 and no Entscheidungsbaum produces it; without it an automatic Zustimmung
# is queued rather than dispatched.
eog_versorgungsart = "ZD0"

# The Bilanzkreise a 55607 Zustimmung may name, by Bilanzierungsgebiet and
# regime. MaBiS § 10.2.1 grants the Zuordnungsermächtigung „je ZRT, BG, BK und
# LF", so one BK per regime answers automatically and several is an operator
# choice. Omit `bilanzierungsgebiet` for the fallback row; omit the table
# entirely and every 55607 escalates with its 15:00-Uhr Frist attached.
[[lf.bilanzkreise]]
bilanzierungsgebiet = "11YN-BG-EON---X"
eeg      = ["11XBK-EEG-----1"]
kwkg     = ["11XBK-KWKG----5"]
standard = ["11XBK-STD-----9"]

[msb]
auto_accept       = false   # true → dispatch the MSB-Wechsel Bestätigung
auto_preisanfrage = true    # false → the REQOTE goes to the approval queue

[esa]                                 # WiM Teil 2 Kap. 4 — the MSB's answers to an ESA
auto_accept                = false    # true → dispatch the E_0256/E_0257/E_0254 Zustimmungscode
auto_reject                = false    # true → dispatch a deterministic Ablehnungscode
accept_after_bindungsfrist = false    # E_0256 Prüfschritt 2 — a commercial decision

[eog]                                     # §36/§38 EnWG gap closure (NB role)
auto_activate             = false         # true → dispatch gpke.eog.anmelden on gap-detected
default_transaktionsgrund = "ZT6"         # SG4 STS DE9013 for automatic Anmeldungen
warn_days_before_expiry   = 14            # §38 Abs. 4 3-month warning lead
# notify_webhook_url      = "https://erp.example/hooks/eog"   # ersatz-auslaufend CloudEvents

# [oidc]                # omit to disable auth (dev only — never omit in production)
# issuer   = "https://login.microsoftonline.com/{tenant-id}/v2.0"
# audience = "api://mako-processd"
# jwks_refresh_secs = 300

# Tracing/OTel is configured from the environment (see the table below), not
# from a [otel] block.

CLI flags & environment

The daemon lifecycle is owned by the shared mako_service runner. There is a single CLI flag; everything else is environment-driven.

Flag / Env varDefaultDescription
--checkProbe the running instance's /health/ready on loopback and exit 0/non-zero (container HEALTHCHECK)
PROCESSD_CONFIGprocessd.tomlPath to the config file
RUST_LOG / LOG_LEVELinfoLog level (info, debug, processd=trace)
OTEL_EXPORTER_OTLP_ENDPOINTOTLP endpoint; unset disables tracing export

Any config key may also be overridden via a PROCESSD_<SECTION>__<KEY> env var (e.g. PROCESSD_DATABASE__URL).


marktd subscription — self-registration

processd self-registers its subscription with marktd on startup. Set [subscription] webhook_url in processd.toml to the URL marktd should POST events to, and processd calls PUT /api/v1/subscriptions/{subscriber_id} automatically with exponential-backoff retry (up to 30 s).

This makes subscription topology configuration-driven (TOML / Helm values.yaml) rather than an imperative bootstrap step.

For Helm charts, map [subscription] to values.yaml under processd.subscription.*.


MCP tools

ToolRoleDescription
list_decisionsNBLast N Anmeldung decisions with ERC codes and affiliate flag
get_decisionNBSingle Anmeldung decision by UUID
get_stp_rateNBSTP rate over last N days vs. 95 % target
get_stp_breakdown_by_ercNBRejection breakdown by ERC code
list_affiliate_decisionsNBDecisions involving affiliated suppliers (§20 EnWG parity)
list_pending_approvalsLFPending approval queue entries (most urgent first)
get_queue_entryLFSingle queue entry by UUID
approve_queue_entryLFApprove a queue entry (dispatches the response)
reject_queue_entryLFReject a queue entry with a reason code

Monitoring

Domain metrics register on the shared /metrics served by mako_service::run:

MetricTypeDescription
processd_decisions_total{decision,pid}counterNB STP decisions by outcome (Accept/Reject/Escalate) and PID (55001/55077/55004/44001/44004)
processd_approval_queue_pendinggaugeProcesses waiting for an operator decision
processd_approval_queue_overduegaugePending entries past expires_at — the answer deadline has been missed. Alert on > 0
processd_eog_opengaugeErsatz-/Grundversorgung cases not yet closed (§ 36/§ 38 EnWG)

Alert rules

Metric / QueryTarget
processd_decisions_total{decision="Accept"} / processd_decisions_total≥ 95 % (STP rate)
processd_approval_queue_overdue0
processd_decisions_total{decision="Escalate"} / total< 5 % (grid coverage indicator)

Alert when:

  • STP rate drops below 90 % (grid record coverage degraded — provision missing malo_grid records via marktd’s NB-role PUT /api/v1/malos/{malo_id}/grid endpoint)
  • processd_approval_queue_overdue > 0 (a business answer Frist has been missed)
  • Decision latency > 10 s (marktd connectivity issue)

MSB module — WiM MSB-Wechsel STP

processd evaluates inbound WiM MSB-Wechsel requests automatically: role-nb-strom answers 55042 (Anmeldung MSB) and 55051 (Ende MSB), role-msb answers 55039/44039 (Kündigung MSB) and 55168/44168 (Verpflichtungsanfrage) — beide Sparten under one feature, because AWH WiM Gas 2.0 restates WiM Strom Teil 1 use-case for use-case and one handler serves both. STP target: ≥ 80 %.

The Prüfschritte are mako_pruefung::msb, the executable form of the published Entscheidungsbäume; msb_module.rs is the plumbing around them.

Decision pipeline (msb_module.rs)

de.mako.process.initiated (PID 55039 / 55042 / 55051 / 55168)
  → GET marktd /api/v1/melos/{melo_id}          ← MeLo known?
  → GET marktd /api/v1/partners/{msb_mp_id}     ← Rahmenvertrag § 9 Abs. 1 Nr. 3 MsbG?
  → mako_pruefung::msb::pruefe_{anmeldung,abmeldung,kuendigung}
      Accept   → wim.geraetewechsel.bestaetigen (antwortcode) [if auto_accept]
                 else approval_queue with the WiM Frist
      Reject   → wim.geraetewechsel.ablehnen (antwortcode from the process's EBD)
      Escalate → approval_queue with the WiM Frist

The Frist runs from the Übertragungstag the CloudEvent carries in time, so a redelivery does not restart it.

Anmeldung MSB (55042) — E_0201, answered by the NB

WiM Strom Teil 1 Kap. 2.3.2 Nr. 2 names three checks, and no others:

CheckOutcome on failure
Messlokation known to this NBZC9
Versicherung über die Beauftragung durch den AN vorhandenZB6
Mindestvorlaufzeit eingehalten — 15 WT, 7 WT bei erstmaliger EinrichtungE17, naming the earliest reachable Zuordnungsbeginn
Vertrag nach § 9 Abs. 1 Nr. 3 MsbG mit dem MSBNEscalate — E_0201 publishes no code

The metering technology at the Messlokation is not a ground: §5 MsbG gives the Anschlussnutzer a free choice of MSB and §14 MsbG the right to switch, and E_0201 publishes no code for it.

Ende MSB (55051) — E_0202, answered by the NB

A Zuordnungsende inside the 20-Werktage Mindestvorlauffrist is not refused: Kap. 2.4.2 Nr. 2 has the NB set it to the nächstmögliches Zuordnungsende and confirm with Z01. An Außerbetriebnahme carries no lead time at all — it is reported after the Geräteausbau.

Kündigung MSB (55039) — E_0200, answered by the MSBA

The Kündigung runs on the contract layer between the two MSB (Kap. 2.1.3); the Netzbetreiber is not a party. Every Prüfschritt is a question about the MSBA's own Messstellenbetriebsvertrag, which vertragd holds (GET /api/v1/messstellenvertraege/{melo_id}/{msb_mp_id}) — the same split the LF module uses: supply and market state from marktd, contract state from vertragd:

Contract stateOutcome
vertragd unreachable or unconfiguredEscalate
404 — this MSB holds no contract hereZC9
beendet_am setZ29
gekuendigt_zum setthe Kap. 2.2.3 table — E15, Z01 to an earlier date, or Z34
Live, requested date on or after naechstmoeglichE15
Live, requested date inside the bindingZ12, naming naechstmoeglich
Live, no naechstmoeglich recordedEscalate
„Nächstmöglicher Termin" requested (DTM+471)Z01, naming the date

vertragd derives naechstmoeglich from the contract's notice period, capped by § 309 Nr. 9 lit. c BGB. Its absence on a live contract escalates rather than confirming: „no Kündigungsfrist recorded" and „terminable at any time" look identical, and only one of them makes every requested date admissible.

Verpflichtungsanfrage (55168) — E_0240

Kap. 2.4.2 Nr. 4 leaves the answer to the gMSB's own commercial judgement („nach eigenem Ermessen"), so it escalates with its 1-Werktag window attached.

All escalated decisions still generate an anmeldung_decisions row for the §20 EnWG audit trail.


MSB module — REQOTE auto-response

When processd receives de.mako.process.initiated for PIDs 35001, 35002, 35004 or 35005 (REQOTE Preisanfrage from an nMSB), it automatically dispatches a QUOTES response sourced from the active PreisblattMessung in marktd. Dispatching from master data rather than from a manual ERP trigger is what keeps the response inside the REQOTE answer window.

Decision pipeline

de.mako.process.initiated (PIDs 35001/35002/35004/35005, REQOTE)
  → GET marktd /api/v1/preisblaetter-messung/{own_mp_id}  ← PreisblattMessung current?
      Found   → wim.preisanfrage.angebot-senden
                 (includes preisblatt_gueltigkeit in payload for makod QUOTES build)
      Not found → approval_queue with the REQOTE Frist — an operator quotes

Enable in processd.toml:

[msb]
auto_preisanfrage = true   # default: true

Set auto_preisanfrage = false to route every REQOTE to the approval queue instead (e.g. during PreisblattMessung update windows); it lands there with its Frist either way.


MSB module — ESA Wertebestellung (WiM Teil 2 Kap. 4)

Serving an Energieserviceanbieter is a mandatory Zusatzleistung (§34 Abs. 2 S. 2 Nr. 10 MsbG), so role-msb always carries these four obligations:

Inbound PIDProcessAnswered withFristEBD
35003Werteanfrage (REQOTE)QUOTES 150035 WTE_0252
17007Bestellung von WertenORDRSP 19011/190122 WTE_0256
39002Stornierung der BestellungORDRSP 19013/190142 WTE_0257
17008Abbestellung von WertenORDRSP 19011/190122 WTE_0254

The Prüfschritte are mako_pruefung::esa::wertebestellung; esa_module.rs is the plumbing.

Decision pipeline (esa_module.rs)

de.mako.process.initiated (PID 35003 / 17007 / 17008 / 39002)
  → GET marktd /api/v1/esa/framework/{msb}/{esa}   ← Rahmenvertrag established?   (E_0256 Nr. 6)
  → GET marktd /api/v1/esa/consent-check           ← Einwilligung still valid?    (E_0256 Nr. 8)
  → GET marktd /api/v1/melos/{melo}/msb?at=        ← MSB assigned for the period? (E_0256 Nr. 7)
  → GET marktd /api/v1/malos/{malo}/buendel?at=    ← one MSB across the bundle?  (E_0256 Nr. 11)
  → mako_pruefung::esa::wertebestellung::pruefe_{anfrage,bestellung,stornierung,beendigung}
      Accept   → wim.wertebestellung.*-beantworten (Zustimmungscode) [if auto_accept]
                 else approval_queue with the WiM Frist
      Reject   → the same command with the tree's Ablehnungscode      [if auto_reject]
      Escalate → approval_queue with the WiM Frist

The answer command is the same for both clusters: the code's Cluster picks the PID, so there is no separate „ablehnen" command to route to.

The Werteanfrage: E_0252, not E_0253

Two trees answer the Werteanfrage and only one is the MSB's. E_0252 „Anfrage prüfen" is the MSB's check of an inbound 35003 — eight Prüfschritte, refusals A02A07 (EBD 4.3 Kap. 8.25.1). Published without a tree are E_0253 „Angebot zur Anfrage prüfen", the ESA's look at the offer that comes back, and E_0258, its look at the ORDRSP. One letter apart, opposite sides of the relationship.

A refused Anfrage is dispatched like any other answer: the code's own wording rides FTX+ACB on the QUOTES via wim.wertebestellung.anfrage-ablehnen, since the 15003 has no AJT for a code to sit in.

A surviving Anfrage reaches an operator: E_0252's two positive exits both read „Angebot zur Anfrage erstellen", and the Angebot's Bindungsfrist, earliest start, per-Artikel-ID prices and OBIS registers are commercial terms the Festlegung does not specify. It queues with its 5-Werktage window and both candidate commands (anbieten / anfrage-ablehnen) attached.

A02A07 mean different things in E_0252 than the same letters do in E_0256: the two ask six of the same questions at different moments — the Anfrage against today, the Bestellung against the Zeitraum der Messwertermittlung.

The Messprodukt-Katalog answers the commercial question

Whether this MSB offers an optional Kapitel-4.6 product is commercial, and marktd's esa_messprodukt_katalog is where an operator states it — PUT /api/v1/esa/messprodukte/{msb_mp_id}, dated, with separate flags for the two Abo modes because E_0256 refuses them with different codes (A04 for a declined Abo, A05 for a declined one-shot).

Two rules keep the catalogue from deciding things it may not:

  • Nothing recorded is not a refusal. An absent row escalates; only a catalogue that exists and omits the product yields A02/A04/A05. Those codes are statements the MSB made, and a table nobody filled in has made none.
  • The Pflichtprodukte are outside the operator's gift. BNetzA Mitteilung Nr. 3 removed the MSB's discretion over the seven and §34 Abs. 2 S. 2 Nr. 10 MsbG makes serving an ESA a mandatory Zusatzleistung, so one is served whatever the catalogue holds. „Pflicht" is dated — the Codeliste lists 9991 00000 077 1 and 078 9 as „Optional ab 01.10.2023, Pflicht ab 06.08.2024" — and a Vergangenheitswerte-Bestellung may reach back before the cut-over, where the MSB's discretion still stood and the catalogue is the whole answer again.

Gerätetechnik — a device fact, read from the Zähler-Stammdaten

E_0252 Nr. 6 and E_0256 Nr. 9 ask whether the installed equipment can produce the values the Messprodukt names. Three rules answer it from the Messlokation's own meters, and each is a fact about the device:

RuleSource
A Kapitel-4.6.2 product without an iMS cannot be servedUC 4.1.1 Vorbedingung: „Bei Übermittlung von Werten aus dem iMS: Alle … benötigten Messlokationen sind mit einem iMS ausgestattet."
A direction with no registerZaehlwerk.richtungAUSSP Verbrauch, EINSP Erzeugung
Blindarbeit with no Blindarbeit registerthe OBIS C group: 1/2 Wirkarbeit, 38 Blindarbeit

Several meters at one Messlokation are judged on the union of their registers: the values come from the location, not from one device.

What still escalates on the order PIDs

  • A ¼h Lastgang on a non-iMS meter. It plainly needs registrierende Leistungsmessung, but mako holds the Zaehlertyp and not the RLM capability, and a refusal is a binding statement. An operator confirms it.
  • No meter, or no registers, on record. „Not established" is not „established as impossible", and refusing there denies a §34-mandated Zusatzleistung on a missing record.
  • An unknown Abo start. E_0254 Prüfschritt 2 compares the requested end against it, and it is the DTM+203 of the Bestellung — carried on the ProcessInitiated payload as abo_beginn. Never the Bindungsfrist of the MSB's own Angebot, and never the Abbestellung's ausfuehrungsdatum: that field is the requested end, so comparing the two can only ever refuse.

Enable in processd.toml:

[esa]
auto_accept = false                 # true → dispatch the Zustimmungscode directly
auto_reject = false                 # true → dispatch a deterministic Ablehnungscode
accept_after_bindungsfrist = false  # E_0256 Prüfschritt 2 — a commercial decision

Both automation flags default to false and are deliberately separate: a wrong Bestätigung commits the MSB to a delivery it may not be able to make, a wrong Ablehnung denies a §34-mandated Zusatzleistung.


MSB module — §14a Steuerungsauftrag auto-ORDRSP

When an MSB receives a WiM Steuerungsauftrag (iMS ORDERS, makoworkflow = wim-steuerungsauftrag), processd auto-confirms if:

  1. The SteuerbareRessource.istFernschaltbar = true (remote-switchable), and
  2. The dispatched produktcode is in the contracted konfigurationsprodukte list (GPKE Teil 3 Kap. 1.3).

If the produktcode is not contracted, processd dispatches wim.steuerungsauftrag.ablehnen immediately — preventing unauthorized control of customer assets.

Decision pipeline

de.mako.process.initiated (wim-steuerungsauftrag)
  → [parallel]
      GET marktd /api/v1/steuerbare-ressourcen/{sr_id}                 ← istFernschaltbar?
      GET marktd /api/v1/steuerbare-ressourcen/{sr_id}/konfigurationsprodukte  ← contracted?
  → istFernschaltbar=true + produktcode contracted  → bestaetigen
  → istFernschaltbar=true + produktcode NOT contracted  → ablehnen (GPKE Teil 3 Kap. 1.3)
  → istFernschaltbar=false  → Escalate (manual ORDRSP required)
  → SR not found  → Escalate

Register contracted products via PUT /api/v1/steuerbare-ressourcen/{sr_id}/konfigurationsprodukte in marktd. Each entry requires a non-empty zaehlzeitregister-linked produktcode.

Edit this page ↗