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 alertnetz-checker — 6 checks
| # | Rule | On failure |
|---|---|---|
| 1 | MaloGridRecord exists for the MaLo | Escalate |
| 2 | MaLo participates in MaKo (not Stillgelegt/Ruhend) | Reject A02 |
| 3 | lf_mp_id_next is None (no Anmeldung in Bearbeitung) | Reject A06 |
| 4 | Date plausibility (Transaktionsgrund-aware) — Strom: LFW24 future rule; Gas: E03 ≥ 10 WT, E01/E02 retroactive ≤ 6 weeks (+3 WT) for SLP | Reject A07 (Strom) / Reject E17 (Gas); Gas backdated without Transaktionsgrund → Escalate |
| 5 | Bilanzierungsgebiet in UTILMD matches grid record | Reject A05 |
| 6 | LF MP-ID in partner directory | Reject 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:
- Grid record coverage for your MaLo portfolio (
GET /api/v1/malo/{id}/grid) - Partner directory populated for all expected LF MP-IDs
- 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)
| VersorgungsStatus | Scenario | Decision |
|---|---|---|
Beliefert + lf_mp_id == own_mp_id | Standard | einwilligung |
Beliefert + lf_mp_id == own_mp_id | Einzug | ablehnen A32 |
Beliefert + lf_mp_id == own_mp_id | Ersatzversorgung | einwilligung |
Grundversorgung | any | einwilligung |
| MaLo unknown | any | approval_queue |
lf_mp_id != own_mp_id | any | approval_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 tomakodbefore updating the DB — ifmakodis unavailable, the entry staysPendingso the operator can retry. Expired entries log aWARNand 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"}'| Field | Required | Notes |
|---|---|---|
malo_id | ✓ | 11-digit Strom Marktlokations-ID |
lieferbeginn_datum | ✓ | ISO-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 |
|---|---|
| Today | Calendar 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"
}'| Field | Required | Notes |
|---|---|---|
malo_id | ✓ | 11-digit Gas-MaLo-ID (IDE+Z19) |
zaehlpunkt | ✓ | Zählpunktbezeichnung (RFF+Z13) |
process_date | ✓ | Lieferbeginn 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 processdFull 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 var | Default | Description |
|---|---|---|
--check | — | Probe the running instance's /health/ready on loopback and exit 0/non-zero (container HEALTHCHECK) |
PROCESSD_CONFIG | processd.toml | Path to the config file |
RUST_LOG / LOG_LEVEL | info | Log level (info, debug, processd=trace) |
OTEL_EXPORTER_OTLP_ENDPOINT | — | OTLP 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
| Tool | Role | Description |
|---|---|---|
list_decisions | NB | Last N Anmeldung decisions with ERC codes and affiliate flag |
get_decision | NB | Single Anmeldung decision by UUID |
get_stp_rate | NB | STP rate over last N days vs. 95 % target |
get_stp_breakdown_by_erc | NB | Rejection breakdown by ERC code |
list_affiliate_decisions | NB | Decisions involving affiliated suppliers (§20 EnWG parity) |
list_pending_approvals | LF | Pending approval queue entries (most urgent first) |
get_queue_entry | LF | Single queue entry by UUID |
approve_queue_entry | LF | Approve a queue entry (dispatches the response) |
reject_queue_entry | LF | Reject 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):
| Metric | Type | Description |
|---|---|---|
processd_decisions_total{decision,pid} | counter | NB STP decisions by outcome (Accept/Reject/Escalate) and PID (55001/44001) |
processd_approval_queue_depth | gauge | Pending LF E_0624 entries in approval_queue (unresolved) |
processd_db_pool_size | gauge | PostgreSQL connection pool size |
processd_db_pool_idle | gauge | Idle PostgreSQL connections |
Alert rules
| Metric / Query | Target |
|---|---|
processd_decisions_total{decision="Accept"} / processd_decisions_total | ≥ 95 % (STP rate) |
processd_approval_queue_depth approaching TTL | 0 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_gridrecords via marktd’s NB-rolePUT /api/v1/malo/{malo_id}/gridendpoint) 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)
| # | Check | ERC on failure |
|---|---|---|
| 1 | MeLo exists in marktd grid registry | A02 |
| 2 | nMSB registered in partner directory | A05 |
| 3 | MeLo has an iMSys device (§14a mandatory MSB eligibility review) | Escalate |
| 4 | SteuerbareRessource linked (§14a eligibility review) | Escalate |
| 5 | Zaehler 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
| Scenario | Escalate reason |
|---|---|
| iMSys device present | MeLo {id} has an iMSys device — §14a eligibility check required |
| SR-linked §14a load | MeLo {id} has linked SteuerbareRessource {id} — §14a Modul eligibility check required |
| No Zaehler registered | MeLo {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:
- The
SteuerbareRessource.istFernschaltbar = true(remote-switchable), and - The dispatched
produktcodeis in the contractedkonfigurationsproduktelist (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.