processd Operator Guide

Operator guide for processd, the process decision engine: NB Anmeldung straight-through processing, LF and MSB answers, the process read model with its KPIs and § 7a Abs. 5 EnWG evidence, the NB Sperrauftrag queue, role-gated binaries and Cedar ABAC.

On this page 15 sections

processd is the process decision engine. It receives the market messages its operator must answer, walks the published EBD (Entscheidungsbaum) for the message's PID, dispatches the answer through makod before the Frist, and queues whatever the tree cannot decide for an operator. Beside the decisions it keeps the process read model — one row per process, its Antwortfrist, the per-PID KPIs and the § 7a Abs. 5 EnWG Gleichbehandlung evidence — and, in an NB build, the Sperr-/Entsperrauftrag queue. Terms are defined in the glossary; which PID belongs to which process is the process map.

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

    marktd -->|"de.mako.* · de.markt.versorgung.*<br/>HMAC POST /webhook"| processd

    subgraph OBS ["process read model (every build)"]
        PROJ["obs::project<br/>process_projections · KPIs<br/>§ 7a Abs. 5 EnWG evidence"]
        PROJ --> pg
    end
    processd --> OBS
    OBS -->|"de.obs.deadline.approaching<br/>de.obs.stp.parity.alert"| marktd

    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"]
        SPERR["Sperrauftrag queue<br/>sperr_orders · IFTSTA 21039"]
        NC --> pg
        EOG --> pg
        SPERR --> pg
    end

    subgraph LF ["LF module (--features lf-only)"]
        LFA["LF answers · Strom 55007 / 55010 / 55013 / 55016 / 55607<br/>Gas 44007 / 44010 / 44013 / 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

Role isolation

Feature flags select which decision paths compile in, so a deployment carries only the answers its Marktrolle gives and an nb-only binary cannot answer as an LF:

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

The NB queue routes stay mounted in a build without an NB role, so the Cedar decision is still the first answer; past it they answer 501 Not Implemented — the build does not serve them, and a retry against it cannot succeed.

The flags gate handlers, not linked code: mako-gpke, mako-geli-gas and mako-wim are unconditional dependencies. A role build cannot answer as a role it was not built for; the §§ 6a/7/7a EnWG separation is a matter of the legal entity and of access control, not of the binary.

PID 55016 „Kündigung" is an LF process: LFN → LFA, answered 55017/55018 under E_0614 (Anwendungsübersicht der Prüfidentifikatoren 4.0, lfd. Nr. 20030).


Routes and authorization

Every REST route takes the Claims extractor and evaluates one Cedar action against services/processd/policies/processd.cedar. There is no global auth middleware; tests/it/authorization_guard.rs checks every mounted route for a Claims extractor and an authorization call, and holds the action sets of code and policy to each other in both directions.

RouteCedar actionGranted to
POST /webhook— (marktd HMAC signature + timestamp)marktd
GET /api/v1/decisionsread-decisionsany caller of the tenant
GET /api/v1/queueread-queueany caller of the tenant
GET /api/v1/eogread-eogany caller of the tenant
GET /api/v1/neuanlageread-neuanlageany caller of the tenant
POST /api/v1/queue/{id}/approve · /rejectdecide-queueNB, LF or MSB
PUT /api/v1/neuanlage/{id}/identifikationidentify-neuanlageNB
POST /api/v1/start-supply[-gas] · end-supply[-gas]initiate-supplyLF
GET /obs/processes · /obs/processes/{id}read-processany caller of the tenant
GET /obs/kpis · GET /api/v1/audit/gleichbehandlungread-kpiany caller of the tenant
GET /obs/overdueread-overdueany caller of the tenant
POST /api/v1/obs/rebuildrebuild-projectionADMIN
GET /obs/metrics— (restrict at the ingress)scraper
GET /api/v1/sperr-orders · /{id} · /statsread-sperr-orderNB
POST /api/v1/sperr-orderscreate-sperr-orderNB
PUT /api/v1/sperr-orders/{id}/execute · /failexecute-sperr-orderNB
PUT /api/v1/sperr-orders/{id}/cancelcancel-sperr-orderNB
POST/GET /mcpuse-mcp, then a per-tool actionany caller of the tenant

Roles come from the mako_roles JWT claim, the tenant from mako_tenant, which must equal the deployment's own. The runner adds /health/live, /health/ready and /metrics.

processd refuses to start without [oidc] and without [webhook] inbound_secret, and with a [sperr] erp_webhook_url but no erp_hmac_secret; the refusal names each missing one. allow_insecure_no_auth = true accepts both, for local development (just dev processd reads dev/config/processd.toml).


NB module — Anmeldung STP

Decision pipeline (nb_module.rs)

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

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. If the Marktlokation is assigned at the Zuordnungsbeginn, the NB owes the incumbent LFA an Anfrage zur Beendigung der Zuordnung (55010) before answering the LFN, because E_0623 Prüfschritte 20–50 read that answer (GPKE Teil 2 § 2.1.2 SD Lieferbeginn Nr. 1 Prüfschritt 4, Nr. 3).

The waiting AnmeldungAnfrage is written to abmeldeanfragen before the Anfrage goes out, so a fast loopback answer always finds it; phase two replays the same pure evaluation with one more fact. Resolution is a single UPDATE … WHERE resolved_at IS NULL, so of the LFA's answer and the 09:00 lapse exactly one answers the LFN.

Silence releases the Marktlokation. „Verstreicht die Frist, ohne dass eine Antwort beim NB eingeht, gilt dies als Bestätigung nach Fall a)." A Widerspruch other than A30 / A41 refuses the Anmeldung with E_0623 A50 / A57 (Z35 in Gas). Those two codes need a second SG4 STS+Z35 restating the LFA's E_0624 code (GPKE Teil 2 § 2.1.2 Nr. 6); processd puts it on the ablehnen command and makod refuses to render the message without it.

Geschäftsvorfall. On 55077/55078 SG4 STS+7 DE 9013 element 3 carries ZW0 (ganze Marktlokation), ZW1 (bestehende Tranche) or ZW2 (neu zu bildende Tranche); E_0622 Prüfschritte 300/310 branch on it, and a message carrying none escalates. Geschäftsvorfall 3 asks all Tranchen-LFA and decides on arithmetic (Prüfschritte 500–540): at least one release (A53), enough percentage (A54), and whether a residual share would stay in the NB's own Bilanzkreis on a direktvermarktungspflichtige Marktlokation (A55 / A56).

Geschäftsvorfall-3 detailBehaviour
Direktvermarktungspflicht (Prüfschritt 540)From einsd — installed capacity against the 100-kW ceiling of §21 Abs. 1 Satz 1 Nr. 1 EEG; §20 Satz 1 Nr. 3 EEG pays the Marktprämie only from a pure EE-Bilanzkreis. No register answer → escalate
The registered shareSG8 Produkt-Code 9991000002090, Produkteigenschaft „prozentuale Aufteilung" (Muss on GV 3). An Aufteilungsfaktor or Aufteilung auf Technische Ressourcen escalates
AnswersOne per Tranchen-LFA before deciding; a lapsed 09:00 Frist resolves whatever is outstanding as Zustimmung; an LFA code E_0624 does not publish escalates

Three trees, three alphabets. Strom splits 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 Strom, verbrauchende / ruhende MaLoE_0607 10–140—A02A11
Abmeldung Strom, erzeugende MaLo / TrancheE_0607 500–620—A22 (A21 Datum)A27
Abmeldung GasE_3019 / G_0007—E17E15

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 code's Cluster decides the response PID. A Bestätigung carries a code too (SG4 STS+E01 is Muss), so NbEntscheidung::Accept 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 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 Hinweis names only stillgelegte Marktlokationen and the Modell-2-Zuordnung.

Strom, erzeugende Marktlokation (Prüfschritte 220–830) uses A45 / A25 for the same two questions, then picks one of the six Vorlauffristen of GPKE Teil 2 § 2.1.1, keyed on (Geschäftsvorfall, bestehende, angemeldete Veräußerungsform). The angemeldete one is SG10 CCI+Z22; the bestehende one and the Ausfallvergütung flag come from einsd (GET /api/v1/anlagen/by-malo/{malo_id}/veraeusserungsform, [nb] einsd_url), because wire code Z90 covers both Einspeisevergütung and Ausfallvergütung, whose Fristen differ. A missing fact escalates; a CCI+Z22 code outside the published four is dropped at ingest with a warning. The Monatserster rule rests on § 21b Abs. 1 Satz 2 EEG 2023.

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

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

Inbound 55600 / 55601 register a Marktlokation commissioned for the first time. Prüfschritte 110 / 590 loop: a Marktlokation the NB cannot yet identify is re-checked daily for 60 Werktage and only then refused A07 / A16. processd keeps the case log neuanlage_faelle and a daily worker:

EndpointPurpose
GET /api/v1/neuanlage?status=offenThe Prüflauf view, with letzter_pruefungstag (a refusal is admissible only after it) and the pruefungen count
PUT /api/v1/neuanlage/{id}/identifikationBody {"malo_id": "…"} (nothing else, not empty): the MaLo-ID the NB matched from its NIS/GIS; the next pass walks the tree with it

The branches share no code (A01–A09 verbrauchend, A10–A19 erzeugend). The Vorlauffrist is measured against the Übertragungstag (GPKE Teil 1 Kap. 7 „Fristenberechnung", S. 27) — processd reads it as the Berlin day of the CloudEvent time, which makod stamps when it records the process in the request that received the message — and never against the moment processd evaluates. An Anmeldung received at 23:40 and evaluated after midnight keeps its Werktag, the Abmeldung (E_0607) is judged the same way, and a re-evaluation on day 40 reaches the verdict of day one.

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

Inbound 55004 / 44004, answered 55005/55006 (44005/44006). Strom splits into two branches that share no Antwortcode:

PrüfschrittRuleverbrauchend / ruhenderzeugend
—MaLo known to this NB, and the LF holds an assignment or a Lieferende at this date is already settledEscalateEscalate
50 / 500+520Vorlauffrist — verbrauchend: one full Werktag; erzeugend: Monatserster (A21) one month ahead (A22), § 21b Abs. 1 EEG 2023A02A21 / A22
90 / 570Aufhebung einer zukünftigen Zuordnung (ZH2) nennt den bestätigten ZeitpunktA06A23
80Z41 „Ende der ESV ohne Folgelieferung" setzt eine E/G voraus, die innerhalb von 3 Monaten vor dem Endezeitpunkt begannA05—
100–130 / 580–610Kein bereits bestätigtes Lieferende zum selben DatumA09A25
140 / 620—Zustimmung A11Zustimmung A27

Gas (G_0007) has one code space: E17 Vorlauffrist, Z08 bereits bestätigt, E15 Zustimmung.

Not decided automatically: Prüfschritte 10–30 (Kundenanlagen-Herauslösung, § 20 Abs. 1d EnWG / § 10c EEG) need a fact the projection does not record, so A01 is unreachable; Prüfschritt 130 / 610 asks about an earlier message's Transaktionsgrund and escalates. Escalation is the § 20 Abs. 1 Satz 1 EnWG-safe direction: an unfounded Ablehnung binds a customer to a supplier they have left.

STP rate and [nb] auto_accept

The target is ≥ 95 % straight-through processing on a verbrauchende Anmeldung. A missing malo_grid record escalates; provision grid records (the NB's own topology, not MaStR) via marktd's NB-role PUT /api/v1/malos/{malo_id}/grid. An erzeugende Marktlokation additionally needs [nb] einsd_url; without it every 55077 escalates. Monitor via GET /api/v1/decisions or get_stp_rate.

Keep auto_accept = false (the default; PROCESSD_NB__AUTO_ACCEPT) until grid records cover the portfolio, the partner directory holds every expected LF MP-ID, and one manual review cycle confirmed the codes.

Affiliate guard — § 20 Abs. 1 Satz 1 EnWG

In a vertically integrated NB+LF utility, auto-acceptance is always blocked for An- and Abmeldungen from the operator's own LF:

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

[identity] own_mp_id must match makod's primary party. Startup logs the coding authority derived from it (293 = BDEW, 332 = DVGW) and warns on a GS1 GLN. The flag lands on every anmeldung_decisions row and on the read model's ProcessProjection; see the Gleichbehandlung parity report.

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."

processd reads the Netznutzungsvertrag in force the day before the requested Zuordnungsbeginn (GET /api/v1/nb-contracts/by-malo/{malo_id}?on=) and holds the decision for the operator when it displaces a Selbstzahler:

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

An Einzug (E01, E02) stays automated and skips the lookup.


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:

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)
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)

A daily worker enforces the three-month maximum (§38 Abs. 4 S. 1 EnWG, calendar months from eog_seit): warn_days_before_expiry ahead the case turns expiring, at expiry expired; both emit de.markt.versorgung.ersatz-auslaufend to [eog] notify_webhook_url. After expiry, Haushaltskunden pass into Grundversorgung without a market message; otherwise the NB secures the Bilanzkreis (vertragliche Ersatzbelieferung, STS E06) or interrupts the Anschlussnutzung. Cases are listed by GET /api/v1/eog?status=detected|angemeldet|active|expiring|expired.


LF module — answering what the market asks a supplier

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, sonst 15:00 Uhr des 1. WT
Strom55016Kündigung (LFN → LFA)E_061455017 / 55018Ablauf des 1. WT nach dem ÜT
Strom55607Ankündigung Zuordnung LF (erz. MaLo / Tranche)E_0603–E_060655608 / 5560915:00 Uhr am ÜT, sonst 15:00 Uhr des 1. WT
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

For the two „am ÜT" rows GPKE Teil 2 (§ 2.3.2.2 resp. § 2.4.2.2) states two windows depending on whether the Zuordnungsbeginn lies in the future. mako-fristen publishes the same-day instant, rolling to 15:00 on the next Werktag when that instant is already past, after the cut-off or on a non-Werktag.

The same process carries the same command name in both Sparten — {gpke,geli}.nb-lieferende.*, {gpke,geli}.beendigung-zuordnung.*, {gpke,geli}.kuendigung.*.

How a decision is made

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 Cluster selects the answer PID: A36 „Vertragsverhältnis wurde beendet" rides 55011, A35 „Es besteht eine Vertragsbindung" 55012.

Two facts come from the message itself:

  • Object of the Vorgang — SG4 STS+7 DE 9013 element 3: ZW3 erzeugende, ZW4 verbrauchende, ZW5 Tranche, ZAP ruhende Marktlokation. The halves of E_0609 and E_0624 answer from different code ranges, so a missing Ergänzung escalates. The same element carries ZW0–ZW2 on a 55077 and ZW8–ZX1 on a 55607; the PID decides which code space applies.
  • Vorlauffrist (E_0609 Prüfschritt 40, E_3002 E17) — mako_fristen::abmeldung per GPKE Teil 2 § 2.5.2 Nr. 1: a calendar month for EEG-Marktlokationen, the day before the last Werktag otherwise. An erzeugende Marktlokation escalates unless the deployment can say which applies.

55607: silence is not a lapse. GPKE Teil 2 § 2.4.2.2 Nr. 3 has the NB assign the supplier „aufgrund fehlender Antwort" with the Bilanzkreis on file. The substance of the answer is that Bilanzkreis (A01 / A99 are the only codes). UTILMD AHB Strom 2.2 Bedingungen [161]–[164] map ZW8–ZX1 onto E_0603–E_0606. The BK rides SG8 SEQ+Z79, Produkt-Code 9991000002082, Merkmalswert in SG10 CAV+ZV4 (the AHB admits SG4 FTX+ACB on the 55609 only, Bedingung [48]). [[lf.bilanzkreise]] is keyed on the Bilanzierungsgebiet because MaBiS § 10.2.1 grants the Zuordnungsermächtigung „je ZRT, BG, BK und LF"; several BKs for one regime are an operator choice.

Contract facts

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

Prüfschritt 50 compares names, not strings: processd forwards NAD+Z09 as ?kunde= and vertragd matches token sets; a shared Nachname alone is Unklar and escalates (see vertragd). Without [vertragd] url every contract fact is Bekannt::Unbekannt and a decision reaching one escalates. auto_respond = false means an operator decides: the walk still runs and its outcome is queued with the Antwortfrist.

The E/G Anmeldung has its own switch

E_0615 / E_3008 is the only Anmeldung whose Zustimmung accepts a statutory duty (§ 36 / § 38 EnWG), so dispatch needs [lf] eog_auto_respond = true on top of auto_respond. An empty grundversorgungs_netzgebiete escalates every 55013 / 44013. A Zustimmung also needs the Versorgungsart (SG10 CCI+Z36: ZC9, ZD0, ZE3) from [lf] eog_versorgungsart and the Bilanzkreis from [[lf.bilanzkreise]]; missing either, the answer is queued with the field named.

Approval queue

The queue is shared by every compiled role. Each entry stores the makod command for approve and for reject, resolved at enqueue, and the bodies:

EntryApprove sendsReject sends
A held decision (auto_accept / auto_respond off, a § 20 EnWG affiliate, a Selbstzahler)the answer the automatic path would have sent, frozen at enqueue (answer_payload)the other command, with the operator's Antwortcode
An escalationthe operator's answerthe operator's answer

What an answer is addressed by (the MeLo of a WiM order, the tx_id of a § 14a Steuerungsauftrag, the location and Messprodukt of an ESA order) is frozen on both (answer_payload, reject_payload). The operator states the rest:

{ "antwort_code": "A01", "antwort_tree": "E_0622", "antwort_codeliste": "E_0622", "bemerkung": "…" }

The body is optional and refuses unknown keys. A field the frozen answer already holds cannot be replaced (422): an approval sends the answer that was shown. Every queueable command has an answer contract (ANSWER_CONTRACTS in src/pg/approval.rs) naming the fields makod needs; a decision missing one is 422 with the claim released, before anything is sent. cargo xtask check-approval-commands refuses an enqueue site naming a command without a contract.

Each entry records the Entscheidungsbaum its module decided with (ablehnung_ebd) — the NB the tree its walk stopped in, the LF and ESA the tree of the process, the MSB mako_pruefung::msb::baum — and none where the answer carries no code (ESA Werteanfrage refusal, REQOTE, § 14a Steuerungsauftrag).

A stated antwort_code must come from that tree, in the cluster the answer sends: Zustimmung for an escalated approval, Ablehnung for a refusal, the other cluster when rejecting overrides a held answer. Anything else is 422 with the claim released. The Freigabe view offers codes by the same rule. The ESA Werteanfrage dispatches its refusal only; its Angebot goes through the makod command API.

expires_at is the business Frist from mako_fristen::antwort less one hour:

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 / 55230 / 55557 / 55639–55643 / 556932. WT nach dem ÜTBK6-22-024 Anlage 1d (GPKE 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 is a separate clock, owned by makod.

GET  /api/v1/queue                → list entries, the earliest expires_at first
POST /api/v1/queue/{id}/approve   → dispatch the approve command via makod, mark Approved
POST /api/v1/queue/{id}/reject    → dispatch the reject command via makod, mark Rejected

The handlers claim the entry (status = 'Pending' guard) before dispatching and release the claim if the body is incomplete or the dispatch fails, so a decided entry never re-sends and two operators cannot send both answers. decided_by records the deciding principal's sub. A task running every 60 s marks stale entries Expired; expired entries log a WARN and are reconciled manually.


LF module — LFN bootstrap

Strom: POST /api/v1/start-supply

Initiates a GPKE Lieferbeginn (UTILMD 55001) with LFW24 Vorlauffrist validation.

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_id✓11-digit Strom Marktlokations-ID
lieferbeginn_datum✓ISO-8601 date (YYYY-MM-DD)
bilanzkreis✓*SG8 SEQ+Z79 Produktpaket, Produkt-Code 9991000002082

* Optional only where [[lf.bilanzkreise]] declares exactly one standard entry. UTILMD AHB Strom 2.2 Kap. 5.3 makes the Produktpaket Muss on a 55001, so a request resolving to none is a 422 with code MISSING_BILANZKREIS.

Every refusal is the shared problem body — error, detail (the sentence) and code (the machine-readable reason: MISSING_MALO_ID, MISSING_LIEFERBEGINN, INVALID_DATE, RETROACTIVE_DATE, VORLAUFFRIST_VIOLATION, and for Gas MISSING_ZAEHLPUNKT, MISSING_PROCESS_DATE). Each supply body is a fixed struct: a key it does not declare is a 422 with code unknown_field and its paths, a mandatory key left out is a 422 whose detail names the missing field, and a mandatory key sent as an empty string is the MISSING_* refusal. A command makod refuses in the process's current state is a 409; makod failing or unreachable is a 502 whose body names upstream and an error_id, never makod's own text.

Vorlauffrist (BK6-24-174 GPKE Teil 2, SD Lieferbeginn Prozessschritt 1): „Spätester ÜT ist der Tag vor dem letzten WT vor dem Zuordnungsbeginn" — day-granular, no time-of-day cutoff. The earliest Lieferbeginn is the calendar day after the next Werktag after today (Berlin date); a date before today is RETROACTIVE_DATE, a date before the earliest VORLAUFFRIST_VIOLATION, which carries earliest_lieferbeginn and berlin_date. The 202 carries vorlauffrist.earliest_allowed and vorlauffrist.berlin_date_at_submission.

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

Initiates a GeLi Gas Lieferbeginn (UTILMD 44001).

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_id✓Gas-MaLo-ID — rendered into SG5 LOC+Z16; supplied by the ERP
zaehlpunkt✓Zählpunktbezeichnung (RFF+Z13)
process_date✓Lieferbeginn date (YYYYMMDD, CET/CEST)
bilanzkreis✓*SG10 CCI+Z19 DE 7037 (UTILMD AHB Gas 1.2, Muss on 44001) — Gas has no Produktpaket
transaktionsgrund—E03 Wechsel (default), E01/E02 Einzug

* Same fallback as Strom. A process_date that is not a YYYYMMDD date is INVALID_DATE, and the forwarded payload is exactly the fields above. A Lieferantenwechsel needs 10 WT Mindestvorlauf (AWH GeLi Gas 2.0 Kap. 2.5.2 Nr. 1), enforced here; an Einzug passes with a vorlauffrist_hinweis, because the NB corrects the date rather than rejecting (Kap. 4). The GNB answers 44002 / 44003 by Ablauf des 4. WT (Kap. 3.2.3).

POST /api/v1/end-supply takes malo_id and lieferende_datum (ISO-8601) and forwards gpke.lieferende.anmelden as LF. POST /api/v1/end-supply-gas takes malo_id, zaehlpunkt and process_date (YYYYMMDD, INVALID_DATE otherwise) — the fields geli.lieferende.anmelden reads — and forwards it as LFG, the only role that command is licensed to. All fields are mandatory; the notice period is vertragd's to enforce.

Gas Datenabruf

Abrechnungsbrennwert and Zustandszahl are requested through makod's command API (geli.datenabruf.anfragen, ORDERS 17103, payload malo_id and the required zeitspanne_bis): the GNB answers within ten Werktage, but not before the 10. Werktag of the month after the Zeitspanne ends (GeLi Gas 3.0 Kap. 4.2). The answer is MSCONS 13007 or ORDRSP 19103; a delivery updates edmd meter_billing_periods via record_gas_quality.


Process read model

The read model is a module of every role build. Every de.mako.* lifecycle event the one POST /webhook receives is projected into one process_projections row per business process before any decision module sees it: the Antwortfrist at risk, the per-PID KPIs and the affiliate parity are read from that row. A projection write that fails is not acknowledged, so the fan-out redelivers; every module behind it is idempotent against a redelivery. The STP rate of the read model is completions over the processes that ended in the window.

Two deadline clocks

Every MaKo process carries two independent deadlines. The read model measures the second; the first never reaches it.

ClockWindowA breach meansWhere it shows
APERAK FristStrom UTILMD/ORDERS 45 min (Samstag: Sonntag 12:00), other Strom next Werktag 12:00; Gas Folgeprozess next Werktag 12:00, Initialprozess end of the 3. Werktag (APERAK AHB 1.1 §§ 2.3.1 / 2.4.1)the counterparty's acknowledgement did not arrive — a transport or validation faultnot in the read model: makod raises the delivery-window alert and keeps the message in its outbox
Antwortfristper PID — see belowthe business answer is owed and has not been sentdeadline_at, computed on process.initiated

state = aperak_timeout is set by de.mako.aperak.timeout, which gpke-supplier-change and geli-gas-supplier-change emit when their Antwortfrist lapses unanswered — an Antwortfrist signal raised by the workflow. total_aperak_timeout and total_frist_breached are separate fields on every report; aperak_timeout is not terminal, so those processes stay in the Antwortfrist sweep.

ProcessProjection

FieldDescription
process_idUUID — the CloudEvent subject
pidBDEW Prüfidentifikator (e.g. 55001)
familygpke, wim, geli-gas, wim-gas, gabi-gas, mabis, emob, redispatch, invoic-storno, unknown — the Festlegung of the PID's Antwortfrist; a two-Sparten WiM PID by data.sparte, else the counterparty's coding authority (293 / 332)
workflow_nameFrom the makoworkflow CE extension
stateinitiated | running | aperak_timeout | completed | rejected | failed
malo_id11-digit Marktlokations-ID
partner_mp_idMP-ID of the counterparty
mdm_roleCanonical Marktrolle (LF, NB, …)
started_at / last_event_atFirst and most recent event seen for this process
deadline_atThe business Antwortfrist. null = no published window for this PID
deadline_sourceThe Festlegung deadline_at came from
deadline_riskunknown | green | amber (< 24 h) | red (past)
erc_codeAPERAK ERC code when an APERAK refused the message — not the Antwortcode of a business refusal
initiator_is_affiliateThe Lieferant belongs to this operator's own undertaking

completed_at (not on the wire) is the time the first terminal event carries. It feeds cycle time and caps the breach test (deadline_at < COALESCE(completed_at, now())), so a process that closed late counts as breached.

The Antwortfrist

The read model computes no deadline of its own: mako-fristen does, from the same per-PID table makod registers deadlines from and the approval queue is sized by.

FamilyWindowSource
GPKE Stroma clock time on the 1. Werktag after the ÜT: 11:00 Anmeldung (55001/55077), 06:00 Abmeldung (55004), 05:00 Lieferende NB→LF (55007), 09:00 Beendigung der Zuordnung (55010); Kündigung 55016 to the end of the 1. WTBK6-24-174 GPKE Teil 2
GeLi GasAblauf des 4. WT Anmeldung (44001), 3. WT Abmeldung (44004), 2. WT Ersatz-/Grundversorgung (44013), 3. WT Kündigung (44016)BK7-24-01-009 Kap. 3.1–3.3
WiM (Strom + Gas)3 / 5 / 7 / 1 Werktage (55039/55042/55051/55168 resp. 44039/44042/44051/44168), to the end of that Werktag in Berlin; REQOTE Preisanfrage 4/5/10 WT je PID; Rechnungsabwicklung 8 WTBK6-24-174 Anlage 2a Kap. 2.2.2–2.5.2, 3.2.2, 3.3.1.2, 3.6.3 · AWH WiM Gas 2.0
Everything elsenull — unknown, never unbounded—

A PID with no published window carries no deadline, is absent from every breach sweep, and has deadline_risk = unknown — never green.

§ 7a Abs. 5 EnWG Gleichbehandlung parity

A vertically integrated undertaking's Gleichbehandlungsbeauftragte files a report with the Bundesnetzagentur by 31 March for the preceding calendar year (§ 7a Abs. 5 EnWG); the read model produces its Lieferantenwechsel evidence. The underlying duties are § 6a EnWG and § 20 Abs. 1 Satz 1 EnWG.

A process belongs to the calendar year in which it started, in Europe/Berlin: one initiated on 1 January at 00:30 belongs to the new year, although the stored instant is 31 December 23:30 UTC. The GET /obs/kpis month is bounded by Berlin dates the same way, and grouping by start makes a re-run of a closed year reproduce it.

The comparison covers the processes the network arm answers for a Lieferant, derived from the Antwortfrist table (answered_by == "NB" in GPKE and GeLi Gas): 55001, 55077, 55004, 44001 and 44004. initiator_is_affiliate is set when the Lieferant's MP-ID (new_supplier, else the counterparty) is in [identity] own_mp_ids; list every MP-ID the operator uses, or its affiliate processes count as third-party.

gap_pp = affiliate − third_party, in percentage points; positive means the affiliate fared better. gap_pp covers the completion rate, frist_gap_pp the share answered inside the Antwortfrist; both are null when either group has fewer than 10 processes. The Bundesnetzagentur publishes no numeric parity limit; [obs] parity_threshold_pp is the operator's own escalation policy.

# JSON, for the current year
curl -s "http://processd:8580/api/v1/audit/gleichbehandlung?year=2026" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '.by_pid[] | {pid, process, gap_pp, frist_gap_pp, favours}'

# CSV, for the filing: 15 columns; an unstatable rate is an empty cell
curl -s "http://processd:8580/api/v1/audit/gleichbehandlung?year=2025&format=csv" \
  -H "Authorization: Bearer $TOKEN" > gleichbehandlung-2025.csv

Queries

curl -s "http://processd:8580/obs/processes?family=gpke&state=initiated&pid=55001" \
  -H "Authorization: Bearer $TOKEN" | jq '.[] | {process_id, started_at, deadline_at}'
curl -s "http://processd:8580/obs/kpis?pid=55001&period=2026-07" -H "Authorization: Bearer $TOKEN"
curl -s "http://processd:8580/obs/overdue" -H "Authorization: Bearer $TOKEN"

state accepts exactly the six stored spellings; anything else is a 400 naming them. In a KPI report, check total_with_frist before quoting frist_compliance_rate: a small denominator means the bucket is mostly unmeasured. A null rate means nothing measurable in the bucket — not 0, and not perfect performance.

Rebuilding from marktd's replay log

The projection is a function of the events marktd fanned out, and marktd keeps every envelope in event_log. POST /api/v1/obs/rebuild (Cedar rebuild-projection, ADMIN) pages marktd GET /admin/events oldest first, from the beginning of the log to the instant the rebuild started, and hands each envelope to the projection function the webhook calls — a truncated table rebuilt this way equals the original row for row. The projection upserts on process_id, so a rebuild over a populated table repairs it, and an interrupted rebuild is finished by sending it again.

marktd serves the log only to a principal holding its manage-fanout action, so the [marktd] api_key processd uses must belong to one. A replay log holding at least a page (5000) of events received at one instant cannot be paged by arrival time, and the rebuild refuses rather than loops.

Events produced

Two sweeps produce de.obs.* CloudEvents when [obs] outbound_url is set (in production marktd's event ingest), HMAC-signed with [obs] outbound_secret:

EventWhenConsumed by
de.obs.deadline.approachingan open process has deadline_at within deadline_warn_hours, or already past it, and has not been alerted (deadline_alerted_at)agentd deadline-alert-agent
de.obs.stp.parity.alerteither gap — completion rate or Frist compliance — passes parity_threshold_pp, with both groups ≥ 10 processesagentd compliance-agent

Only a delivered alert is stamped, so a downed target cannot consume a warning; a sweep returning a full batch reports saturated.


Sperr-/Entsperrauftrag queue (NB role)

The Sperrauftrag queue is a module of an NB build (role-nb-strom / role-nb-gas): the Netzbetreiber's work queue for the physical acts GPKE orders it to perform. An ORDERS 17115 Sperrauftrag or 17117 Entsperrauftrag from a Lieferant becomes a job for the field team; the outcome goes back as IFTSTA 21039 (Auftragsstatus Sperren/Entsperren). Without the IFTSTA the Lieferant's gpke-sperrung-lf process never reaches a terminal state, so the queue retries and reports its dispatch.

flowchart LR
    LF["Lieferant"] -->|"ORDERS 17115 / 17117"| MAKOD["makod<br/>gpke-sperrung"]
    MAKOD -->|"de.mako.process.initiated"| WH["processd<br/>POST /webhook"]
    WH --> Q[("sperr_orders<br/>pending")]
    OP["Operator"] -->|"POST /api/v1/sperr-orders"| Q
    Q --> FIELD["Field team"]
    FIELD -->|"PUT /execute or /fail"| Q
    Q -->|"gpke.sperrung.bestaetigen<br/>/ .fehlgeschlagen"| MAKOD
    MAKOD -->|"IFTSTA 21039"| LF

The market inbox queues PIDs 17115 and 17117, keyed on the makod process so an AS4 redelivery does not queue a second disconnection; an operator order has no process_id and owes no IFTSTA. PID 17116 (Anfrage Sperrung, NB → MSB) is not queued: it is not an order to execute.

ColumnEDIFACTMeaning
order_typeBGM+Z51 / Z52Sperrung / Entsperrung
ausfuehrung_amDTM+203A fixed date the LF requires (hint [533]: a Gerichtsvollzieher may have set it)
fruehestens_amDTM+469Execute at the next opportunity, but not before this date
arbeitszeitIMD+7081 Z53/Z54Entsperrauftrag: within working hours, or also outside
treffpunkt_*SG2 NAD+Z24Where the technician goes
hinweisSG29 FTX+ACBThe LF's free-text hints

ausfuehrung_am and fruehestens_am are alternatives (AHB conditions [55]/[56]), enforced in the API and by a CHECK.

Timing — three clocks, all published

BK6-24-174 GPKE Teil 2 §§ 3.5.1.2 / 3.5.2.2 state three deadlines, counted in Werktagen on the BDEW-MaKo calendar:

ClockFristTracked by
ORDRSP 19116 / 19117 answering the order„spätester ÜT ist der 1. WT nach dem ÜT" (Prozessschritt 2)makod, from mako_fristen::antwort
The physical act„…spätestens innerhalb von 6 WT nach dem frühestmöglichen Sperrtermin" (Prozessschritt 1)ausfuehrung_faellig_am
IFTSTA 21039„spätester ÜT ist der 1. WT nach dem Abschluss des Sperrauftrags" (Prozessschritt 5)iftsta_faellig_am

/stats reports overdue_pending for the Lieferant's own DTM+203 / DTM+469 and frist_ueberschritten for the regulatory window; a pending order past its 6 WT is announced once as de.sperr.ausfuehrung.ueberfaellig. The Vorlauffristen the Lieferant owes — 6 WT before the frühestmöglicher Sperrtermin, 12 WT before a termingebundener one (Prozessschritt 1) — are recorded as vorlauffrist_eingehalten and counted as vorlauffrist_verletzt, never refused: Prozessschritt 2's checks do not include them.

„Der NB führt bis zu zwei Sperrversuche innerhalb eines Sperrauftrags durch" (Prozessschritt 5): PUT .../fail records the first unsuccessful visit and leaves the order pending; the second closes it, as does endgueltig: true for a legal or factual impossibility.

Reporting the outcome

PUT /api/v1/sperr-orders/{id}/execute and .../fail claim the order first (UPDATE … WHERE status = 'pending') and only then dispatch, so a concurrent execute and fail cannot both reach the wire. The IFTSTA carries SG15 STS Z37 (Sperren) / Z38 (Entsperren) with Z14 erfolgreich or Z13 gescheitert, the EBD Prüfschritt code (pruefschritt_code, a Muss), and DTM+293 Fertigstellungsdatum — Muss on Z14 and ≤ the document date (condition [495]), so a future executed_at is refused at the API (IFTSTA AHB 2.1 § 7.2).

204: recorded, IFTSTA with makod. 202: recorded, dispatch failed, order in the retry queue — the Lieferant has not been told.

A terminal order with iftsta_dispatched_at IS NULL has an uninformed Lieferant. A worker re-sends under the same idempotency key; iftsta_next_attempt_at is both claim lease and backoff, so replicas cannot double-send. It announces de.sperr.iftsta.ausstehend once when the 8 attempts are spent or iftsta_faellig_am has passed. /stats reports iftsta_outstanding (in flight), iftsta_ueberfaellig (past the 1-WT window) and iftsta_stuck (past the retry budget — the number that needs a human). A stuck dispatch almost always means makod has no gpke-sperrung process for that MaLo in ValidationPassed; after fixing the cause, reset iftsta_attempts.

Emitted events

All go through the transactional outbox in the transaction of the state change, delivered to [sperr] erp_webhook_url; without it the notices accumulate in event_outbox and startup warns.

CloudEventEmitted when
de.sperr.auftrag.eingegangenAn order entered the queue, from ORDERS or an operator
de.sperr.ausgefuehrtCarried out — IFTSTA Z14
de.sperr.versuch.gescheitertThe first Sperrversuch failed and the order stayed pending
de.sperr.fehlgeschlagenNot carried out — IFTSTA Z13, with the Prüfschritt code
de.sperr.storniertA pending order was withdrawn; no IFTSTA
de.sperr.ausfuehrung.ueberfaelligThe 6-WT execution window closed with the order still open
de.sperr.iftsta.ausstehendThe retry budget is spent or the § 3.5.1.2 Nr. 5 Frist has passed, and the LF is still uninformed

agentd's sperrd-agent subscribes to the de.sperr.* glob. All four Sperrauftrag Cedar actions require the NB role and a tenant match; tests/it/sperr/authorization_guard.rs fails the build if a handler loses its Claims extractor or Cedar check.


Configuration reference

processd reads a TOML file (PROCESSD_CONFIG, default ./processd.toml); "env:VAR_NAME" values defer secrets to the environment, and any key can be overridden by PROCESSD_<SECTION>__<KEY> (e.g. PROCESSD_DATABASE__URL). Unknown keys are refused, and a config error names every offending field. cargo test -p processd --test it -- published_config parses the block below.

Full processd.toml reference

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

[database]
url       = "env:DATABASE_URL"  # required
pool_size = 10                  # default

[identity]
own_mp_id = "9900357000004"     # required — must match makod.toml [[party]] primary
tenant    = ""                  # optional; defaults to own_mp_id
# Every MP-ID the operator acts under, for the read model's § 7a Abs. 5 EnWG
# affiliate detection: Strom NB (BDEW 99…) + Gas GNB (DVGW 98…). Defaults to
# [own_mp_id].
own_mp_ids = ["9900357000004", "9800357000004"]

[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

# Optional. The LF and MSB modules read contract facts from it; without it
# every contract fact is `Unbekannt` and the decisions asking one escalate.
[vertragd]
url     = "http://vertragd:9780"
api_key = "env:VERTRAGD_API_KEY"

[webhook]
inbound_secret = "env:INBOUND_WEBHOOK_SECRET"   # required unless allow_insecure_no_auth

[subscription]
# Self-registers with marktd on startup (PUT /api/v1/subscriptions/{subscriber_id},
# retried with backoff up to 30 s).
webhook_url   = "http://processd:8580/webhook"  # optional; omit to skip registration
subscriber_id = "processd"                       # default
# One subscription for every module. The default is the union they read;
# leaving one out silently disables the module that consumes it.
event_types = [
  "de.mako.process.initiated",
  "de.mako.process.completed",
  "de.mako.process.failed",
  "de.mako.aperak.accepted",
  "de.mako.aperak.rejected",
  "de.mako.aperak.timeout",
  "de.markt.versorgung.gap-detected",
  "de.markt.versorgung.eog-begonnen",
  "de.markt.versorgung.changed",
]

[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
# NB MP-IDs whose area this supplier is Grund-/Ersatzversorger in (E_0615
# Prüfschritt 20). Empty → every 55013 / 44013 escalates.
grundversorgungs_netzgebiete = ["9900000000001"]
eog_auto_respond = false          # E_0615 / E_3008 dispatch, on top of auto_respond
# SG10 CCI+Z36 on a 55014: ZC9 §38 Ersatzversorgung, ZD0 §36 Grundversorgung,
# ZE3 vertragliche Ersatzbelieferung. Unset → an automatic Zustimmung is queued.
eog_versorgungsart = "ZD0"

# The Bilanzkreise a 55607 Zustimmung (and a start-supply default) may name,
# by Bilanzierungsgebiet and regime. Omit `bilanzierungsgebiet` for the
# fallback row; no table → every 55607 escalates.
[[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
# notify_webhook_secret   = "env:EOG_WEBHOOK_SECRET"

[obs]                               # the process read model's de.obs.* producers
# Target for de.obs.deadline.approaching and de.obs.stp.parity.alert, in
# production marktd's event ingest; omit to disable both sweeps.
outbound_url        = "http://marktd:8180/api/v1/mako/events"
outbound_secret     = "env:PROCESSD_OBS_OUTBOUND_SECRET"  # HMAC; the target's inbound secret
deadline_sweep_secs = 900     # default
deadline_warn_hours = 24      # alert when an Antwortfrist is within this many hours
parity_sweep_secs   = 86400   # default
parity_threshold_pp = 5.0     # the operator's own escalation threshold, not a regulatory one
parity_window_days  = 90      # parity look-back window

[sperr]                             # NB Sperrauftrag queue — de.sperr.* to the ERP
# erp_webhook_url = "https://erp.internal/hooks/sperr"
# erp_hmac_secret = "env:PROCESSD_SPERR_ERP_HMAC_SECRET"   # required with erp_webhook_url

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

# [mcp]                 # optional /mcp bearer keys beside OIDC
# api_key = "env:PROCESSD_MCP_API_KEY"
# [[mcp.named_keys]]    # one named key per agent, so audit logs say which
# name    = "agentd"
# api_key = "env:AGENTD_MCP_KEY"
Flag / Env varDefaultDescription
--check—Probe 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
OTEL_EXPORTER_OTLP_ENDPOINT—OTLP endpoint; unset disables tracing export

MCP tools

/mcp (Streamable HTTP) takes an OIDC token or an [mcp] API key. The transport checks use-mcp, then each tools/call against the tool's own Cedar action; a tool with no mapped action is refused 403. approve_queue_entry / reject_queue_entry stamp the verified caller as decided_by (an unattributable decision is refused) and call processd's own POST /api/v1/queue/{id}/approve / /reject over the loopback, so the REST handler's decide-queue check runs as well.

ToolCedar actionDescription
list_decisionsread-decisionsLast N Anmeldung decisions with ERC codes and affiliate flag
get_decisionread-decisionsSingle Anmeldung decision by process_id, its Antwortcode with tree and BDEW wording
get_stp_rateread-decisionsSTP rate over last N days vs. 95 % target
get_stp_breakdown_by_ercread-decisionsRefusals per Entscheidungsbaum and Antwortcode, each with the BDEW wording
list_affiliate_decisionsread-decisionsDecisions involving affiliated suppliers (Gleichbehandlung evidence)
list_pending_approvalsread-queueTyped { freigaben, count, saturated }, earliest expires_at first; filters status, limit (≤ 200), marktrolle. Each Freigabe: art (gehalten with its frozen antwort, or eskaliert) and per action the codes its tree allows (codes_freigeben, codes_ablehnen)
get_queue_entryread-queueOne Freigabe by UUID
approve_queue_entrydecide-queueDispatch the entry's answer; takes antwort_code / antwort_tree / antwort_codeliste / bemerkung for an escalation
reject_queue_entrydecide-queueDispatch the refusal with the operator's antwort_code, or bemerkung as the Begründung
get_processread-processFull process projection — state, deadline, deadline_source, ERC code
list_processes_by_familyread-processOne family, optionally one state; both are enumerated in the schema and an unknown value is refused
list_open_processesread-processOpen processes with given PIDs, earliest Frist first, a keyset page at a time (nach / weiter)
count_open_processesread-processThe same filter counted: open in total, Fristen ending before the Berlin day does, and the earliest of them
list_overdue_processesread-overduePast the business Antwortfrist, most urgent first; reports saturated
get_kpi_reportread-kpiPer-PID KPIs for a YYYY-MM month, both clocks separately
get_parity_reportread-kpi§ 7a Abs. 5 parity; gap_pp = affiliate − third_party
get_process_stp_rateread-kpiCompletions over the processes that ended in the window, null when none did
list_sperr_ordersread-sperr-orderThe Sperrauftrag queue (NB build)
get_sperr_orderread-sperr-orderOne order, with ORDERS provenance and IFTSTA state
get_sperr_statsread-sperr-orderCounters, incl. iftsta_outstanding / iftsta_stuck
list_due_ordersread-sperr-orderThe field-dispatch list, with the Treffpunkt

Guided prompts ship alongside them — triage-nb-rejection, investigate-stp-drop, triage-msb-wechsel, trigger-lieferbeginn, the read model's audit-kpi and investigate-aperak-violation, and in an NB build the queue's execute-sperrung and iftsta-sweep. An agent key reading the Sperrauftrag tools carries the NB role ([mcp] api_key_roles or a named key's roles).


Monitoring

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
processd_eog_opengaugeErsatz-/Grundversorgung cases not yet closed (§ 36/§ 38 EnWG)

GET /obs/metrics serves the read model's gauges, computed from the store on each scrape: processd_obs_process_projections_total, processd_obs_open_processes_total, processd_obs_overdue_processes_total (open and past their Antwortfrist), and processd_obs_db_pool_size / processd_obs_db_pool_idle. It is unauthenticated — restrict it at the ingress.

AlertConditionUsual cause
STP rateAccept / total < 90 % (target ≥ 95 %)missing malo_grid records
Escalation shareEscalate / total > 5 %grid or register coverage
Overdueprocessd_approval_queue_overdue > 0a business answer Frist was missed
Decision latency> 10 smarktd connectivity

MSB module — WiM MSB-Wechsel STP

An NB build (role-nb-strom / role-nb-gas) answers 55042/44042 (Anmeldung MSB) and 55051/44051 (Ende MSB); role-msb answers 55039/44039 (Kündigung MSB) and 55168/44168 (Verpflichtungsanfrage). One handler serves both Sparten because AWH WiM Gas 2.0 restates WiM Strom Teil 1 use-case for use-case. STP target: ≥ 80 %. The Prüfschritte are mako_pruefung::msb.

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 in the CloudEvent's 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):

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 is not a ground: §5 MsbG gives the Anschlussnutzer a free choice of MSB, §14 MsbG the right to switch.

Ende MSB (55051), E_0202, answered by the NB. A Zuordnungsende inside the 20-Werktage Mindestvorlauffrist is set to the nächstmögliche Zuordnungsende and confirmed Z01 (Kap. 2.4.2 Nr. 2). An Außerbetriebnahme has no lead time.

Kündigung MSB (55039), E_0200, answered by the MSBA. Every Prüfschritt is about the MSBA's Messstellenbetriebsvertrag in vertragd (GET /api/v1/messstellenvertraege/{melo_id}/{msb_mp_id}):

Contract stateOutcome
vertragd unreachable or unconfiguredEscalate
404 — this MSB holds no contract hereZC9
beendet_am setZ29
kuendigung_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 notice period, capped by § 309 Nr. 9 lit. c BGB.

Verpflichtungsanfrage (55168), E_0240. Kap. 2.4.2 Nr. 4 leaves the answer „nach eigenem Ermessen" to the gMSB, so it escalates with its 1-Werktag window. Escalated decisions still write an anmeldung_decisions row.


MSB module — REQOTE auto-response

For PIDs 35001, 35002, 35004 and 35005 (REQOTE Preisanfrage) processd answers from the active PreisblattMessung in marktd:

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

[msb] auto_preisanfrage = false (default true) routes every REQOTE to the approval queue, e.g. while a Preisblatt is being updated.


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:

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
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 code's Cluster picks the answer PID, so Accept and Reject share one command. [esa] auto_accept and auto_reject both default to false and are separate: a wrong Bestätigung commits the MSB to a delivery, a wrong Ablehnung denies a mandated Zusatzleistung.

The Werteanfrage is E_0252 („Anfrage prüfen", refusals A02–A07, EBD 4.3 Kap. 8.25.1), the MSB's tree. E_0253 and E_0258 are the ESA's checks of the offer and the ORDRSP. A refusal goes out as wim.wertebestellung.anfrage-ablehnen with the code's wording in FTX+ACB (the 15003 has no AJT). A surviving Anfrage queues with its 5-WT window: the Angebot's Bindungsfrist, start, prices and OBIS registers are commercial terms. A02–A07 mean different things in E_0252 and E_0256.

The Messprodukt-Katalog. Whether this MSB offers an optional Kapitel-4.6 product is stated in marktd's esa_messprodukt_katalog (PUT /api/v1/esa/messprodukte/{msb_mp_id}, dated, with separate flags for the two Abo modes: A04 declined Abo, A05 declined one-shot). An absent row escalates; only a catalogue that omits the product yields A02/A04/A05. The seven Pflichtprodukte (BNetzA Mitteilung Nr. 3) are served whatever the catalogue holds, from their dated cut-over (9991 00000 077 1 and 078 9: „Optional ab 01.10.2023, Pflicht ab 06.08.2024").

Gerätetechnik (E_0252 Nr. 6, E_0256 Nr. 9) is judged on the union of the Messlokation's registers:

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.richtung — AUSSP Verbrauch, EINSP Erzeugung
Blindarbeit with no Blindarbeit registerthe OBIS C group: 1/2 Wirkarbeit, 3–8 Blindarbeit

Still escalated: a ¼h Lastgang on a non-iMS meter (mako holds the Zaehlertyp, not the RLM capability); no meter or no registers on record; an unknown Abo start on a 17008 (E_0254 Prüfschritt 2 compares against the Bestellung's DTM+203, carried as abo_beginn).


MSB module — §14a Steuerungsauftrag auto-ORDRSP

On a WiM Steuerungsauftrag (makoworkflow = wim-steuerungsauftrag) processd confirms when the SteuerbareRessource has istFernschaltbar = true and the produktcode is in its contracted konfigurationsprodukte (GPKE Teil 3 Kap. 1.3); an uncontracted produktcode is refused (wim.steuerungsauftrag.ablehnen).

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

Escalations queue with the 5-Werktage Steuerungsauftrag window less an hour. Register contracted products via marktd's PUT /api/v1/steuerbare-ressourcen/{sr_id}/konfigurationsprodukte.

Edit this page ↗