processd Operator Guide

processd operator guide: Process decision engine for automated NB Anmeldung STP decisions (netz-checker), LF E_0624 auto-response, LFN bootstrap (Strom + Gas), Gas Datenabruf, LFW24 Vorlauffrist validation. Role-gated features for §7 EnWG separation. Cedar ABAC, 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/>EventBus"]
    processd["processd :8580<br/>(this service)"]
    makod["makod :8080"]
    pg["PostgreSQL<br/>anmeldung_decisions<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["netz-checker<br/>6 deterministic checks<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["E_0624 auto-response<br/>45 min window"]
        LFA --> pg
    end

    processd --> NB
    processd --> LF
    NB -->|"gpke.lieferbeginn.bestaetigen/ablehnen<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/malo/{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         ← LF approval queue                 │
│  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│
│  GET  /api/v1/eog           ← EoG gap-closure case log (§36/§38) │
│  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  = []  # LFA E_0624 (PID 55008), LFN Strom bootstrap, INSRPT
role-lf-gas    = []  # LFA GeLi Gas stornierung + LFN Gas bootstrap (PID 44001)
role-nb-strom  = []  # GPKE Anmeldung STP (PIDs 55001, 55016)
role-nb-gas    = []  # GeLi Gas Anmeldung STP (PID 44001)

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

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 (PID 55001/55016/44001)
  → extract AnmeldungAnfrage from event payload
  → GET marktd /api/v1/versorgung/{malo_id}         → VersorgungsStatus
  → GET marktd /api/v1/malo/{malo_id}/grid           → MaloGridRecord
  → GET marktd /api/v1/partners/{lf_mp_id}             → partner_known
  → netz_checker::evaluate(anfrage, vs, grid, partner_known, now_utc())
      Accept   → anmeldung_decisions(Accept)
                 [if NB_AUTO_ACCEPT=true] → makod gpke.lieferbeginn.bestaetigen
      Reject   → anmeldung_decisions(Reject, erc_code) → makod ablehnen
      Escalate → anmeldung_decisions(Escalate) → operator alert

netz-checker — 6 checks

#RuleOn failure
1MaloGridRecord exists for the MaLoEscalate
2MaLo participates in MaKo (not Stillgelegt/Ruhend)Reject A02
3lf_mp_id_next is None (no Anmeldung in Bearbeitung)Reject A06
4Date plausibility (Transaktionsgrund-aware) — Strom: LFW24 future rule; Gas: E03 ≥ 10 WT, E01/E02 retroactive ≤ 6 weeks (+3 WT) for SLPReject A07 (Strom) / Reject E17 (Gas); Gas backdated without Transaktionsgrund → Escalate
5Bilanzierungsgebiet in UTILMD matches grid recordReject A05
6LF MP-ID in partner directoryReject A05

STP rate targets

processd targets ≥ 95 % straight-through processing on NB Anmeldung. The malo_grid record is a prerequisite: netz-checker check 1 (grid record exists) can only pass when the record is present, so NB Anmeldung STP improves markedly once the record is provisioned.

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

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

NB_AUTO_ACCEPT

Set NB_AUTO_ACCEPT=false (default) until you have verified:

  1. Grid record coverage for your MaLo portfolio (GET /api/v1/malo/{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.

Detection logic:

new_supplier_mp_id ∈ obsd.own_mp_ids  →  initiator_is_affiliate = true
                                           auto_accept overridden to false
                                           decision: Escalate (operator review)

Configure the operator's own MP-IDs in obsd.toml (they are shared with processd via the obsd CloudEvent payload):

[identity]
own_mp_ids = ["9900357000004", "9800357000004"]

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.


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: 55005/44005 completed,
  │                                         no announced successor)
  ├─ 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 (55005/44005, no successor)
    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 — E_0624 auto-response

Decision rules (PID 55008)

VersorgungsStatusScenarioDecision
Beliefert + lf_mp_id == own_mp_idStandardeinwilligung
Beliefert + lf_mp_id == own_mp_idEinzugablehnen A32
Beliefert + lf_mp_id == own_mp_idErsatzversorgungeinwilligung
Grundversorgunganyeinwilligung
MaLo unknownanyapproval_queue
lf_mp_id != own_mp_idanyapproval_queue

Approval queue

Entries expire at deadline_at - 5 min (where deadline_at = event_time + 45 min). A background task runs every 60 s and sets status = Expired for stale entries.

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 = event_time + 45 min - 5 min. The approve/reject handlers dispatch to makod before updating the DB — if makod is unavailable, the entry stays Pending so the operator can retry. 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": "10001234567", "lieferbeginn_datum": "2026-10-01"}'
FieldRequiredNotes
malo_id11-digit Strom Marktlokations-ID
lieferbeginn_datumISO-8601 date (YYYY-MM-DD)

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":    "10001234567",
    "zaehlpunkt": "DE00123456789012345678901234567890",
    "process_date": "20261001"
  }'
FieldRequiredNotes
malo_id11-digit Gas-MaLo-ID (IDE+Z19)
zaehlpunktZählpunktbezeichnung (RFF+Z13)
process_dateLieferbeginn date (YYYYMMDD, CET/CEST)

The GNB responds with PID 44002 (Bestätigung) or 44003 (Ablehnung). The LF process (geli-gas-lf-anmeldung) tracks the 10-Werktage response deadline automatically.

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.gas.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.gas.datenabruf.anfragen", "payload": {"malo_id": "10001234567"}}'

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
event_types   = "de.mako.process.initiated"     # default

[nb]
auto_accept = false   # true → dispatch bestaetigen automatically on Accept

[lf]
auto_respond   = true   # false → all E_0624 routed to approval_queue
queue_ttl_secs = 2700   # 45 min — LFW24 deadline

[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

GET /processd/metrics returns Prometheus-compatible domain metrics sourced from PostgreSQL (the generic request-counter /metrics is mounted separately by the shared runner):

MetricTypeDescription
processd_decisions_total{decision,pid}counterNB STP decisions by outcome (Accept/Reject/Escalate) and PID (55001/44001)
processd_approval_queue_depthgaugePending LF E_0624 entries in approval_queue (unresolved)
processd_db_pool_sizegaugePostgreSQL connection pool size
processd_db_pool_idlegaugeIdle PostgreSQL connections

Alert rules

Metric / QueryTarget
processd_decisions_total{decision="Accept"} / processd_decisions_total≥ 95 % (STP rate)
processd_approval_queue_depth approaching TTL0 near-expiry entries
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/malo/{malo_id}/grid endpoint)
  • processd_approval_queue_depth > 0 entries near TTL (LF deadline risk)
  • Decision latency > 10 s (marktd connectivity issue)

MSB module — WiM MSB-Wechsel STP

processd includes a WiM MSB-Wechsel STP engine (feature: role-nb-strom) that automatically evaluates inbound UTILMD 55039 (Kündigung MSB) and 55042 (Anmeldung MSB) requests from new Messstellenbetreiber. STP target: ≥ 80 %.

Decision pipeline (msb_module.rs)

de.mako.process.initiated (PID 55039 / 55042)
  → GET marktd /api/v1/versorgung/{malo_id}   ← MeLo exists?
  → GET marktd /api/v1/partners/{nmsb_mp_id}  ← nMSB registered?
  → GET marktd /api/v1/technische-ressourcen/{sr_id} ← SR linked?
  → evaluate_msb_anmeldung / evaluate_msb_kuendigung (pure, no I/O)
      Accept   → wim.geraetewechsel.bestaetigen
      Reject   → wim.geraetewechsel.ablehnen (ERC code in reason)
      Escalate → operator alert (manual decision required)

Evaluation checks (PID 55042 — Anmeldung)

#CheckERC on failure
1MeLo exists in marktd grid registryA02
2nMSB registered in partner directoryA05
3MeLo has an iMSys device (§14a mandatory MSB eligibility review)Escalate
4SteuerbareRessource linked (§14a eligibility review)Escalate
5Zaehler count > 0 (grid record exists for the MeLo)Escalate

Checks 1–2 are hard rejects (A02/A05). Checks 3–5 trigger operator escalation — processd cannot make the §14a eligibility determination autonomously.

Kündigung (PID 55039) only applies checks 1–2. The NB has no valid grounds to reject termination when the MeLo exists and the nMSB is registered.

Escalation reasons

ScenarioEscalate reason
iMSys device presentMeLo {id} has an iMSys device — §14a eligibility check required
SR-linked §14a loadMeLo {id} has linked SteuerbareRessource {id} — §14a Modul eligibility check required
No Zaehler registeredMeLo {id} has no registered meters — NIS/GIS data import required

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


MSB module — REQOTE auto-response

When processd receives de.mako.process.initiated for PIDs 35001–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–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 → operator alert (no auto-response — prevents blind QUOTES)

Enable in processd.toml:

[msb]
auto_preisanfrage = true   # default: true

Set auto_preisanfrage = false to require manual QUOTES dispatch via ERP (e.g. during PreisblattMessung update windows).


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 (BK6-24-174 §4.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 (BK6-24-174 §4.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 ↗