obsd Operator Guide
obsd operator guide: process projections, the two deadline clocks, per-PID KPIs and the § 7a Abs. 5 EnWG Gleichbehandlung parity evidence.
On this page 11 sections
obsd subscribes to every de.mako.* CloudEvent from marktd and projects it
into one row per business process. From that row it answers three questions the
engine's own metrics cannot:
- Which processes are at risk of missing their business answer window?
- How did this Prüfidentifikator perform last month?
- Did we treat our own Lieferant better than a third party's?
It never connects to makod. It is a read-model with two producers of its own,
and an MCP surface agentd's specialists read.
The vocabulary this page runs on. A
Prüfidentifikator (PID) is the five-digit
BDEW code naming which business case a message carries, and it is the unit obsd
reports on. A Frist is the regulatory deadline
for answering one, counted in Werktage or fixed to a clock time — missing it
is what „at risk" measures. STP rate (Straight-Through Processing) is
completions over the processes that ended in the window — not over all
processes started. An APERAK is the EDIFACT technical
acknowledgement a partner returns
(message types); an ERC code is the BDEW error
code recorded when a process is rejected (E01, Z29, …). A Festlegung is a binding BNetzA determination — the source
of the rules obsd measures against
(BNetzA reference). The four market roles and the
market objects are the domain model, and every term above is defined
once in the glossary.
graph TB
marktd["marktd :8180<br/>fan-out"]
obsd["obsd :8480<br/>(this service)"]
pg["PostgreSQL<br/>process_projections"]
fristen["mako-fristen<br/>Werktage + Antwortfrist tables"]
erp["ERP / Gleichbehandlungsbericht"]
marktd -->|"de.mako.*<br/>signed POST /webhook"| obsd
fristen -.->|"deadline_at + deadline_source"| obsd
obsd --> pg
obsd -->|"de.obs.deadline.approaching<br/>de.obs.stp.parity.alert<br/>(signed POST → marktd fan-out → agentd)"| marktd
erp -->|"GET /obs/*<br/>GET /api/v1/audit/gleichbehandlung"| obsd
Two deadline clocks
Every MaKo process carries two independent deadlines, and obsd reports them
as two numbers. Conflating them is the defect this service is shaped to prevent.
| Clock | Window | A breach means | Where it shows |
|---|---|---|---|
| APERAK Frist | 45 min Strom weekday; Gas next Werktag 12:00 (Folgeprozess) or 3 Werktage (Initialprozess) | the message was not acknowledged — a transport or validation fault | state = aperak_timeout, from de.mako.aperak.timeout |
| Antwortfrist | per PID — see below | the business answer is owed and has not been sent | deadline_at, computed on process.initiated |
They differ by orders of magnitude and fail for different reasons. Never report
one under the other's name: total_aperak_timeout and total_frist_breached are
separate fields on every report for exactly this reason.
state = aperak_timeout is not terminal. A counterparty that missed the
acknowledgement window can still answer the business message, so those processes
stay in the Antwortfrist sweep — they are the ones most likely to breach it too.
Port layout
┌───────────────────────────────────────────────────────────────────────┐
│ obsd :8480 │
│ │
│ POST /webhook ← marktd CloudEvents (signed) │
│ GET /obs/processes ← list / filter projections │
│ GET /obs/processes/{id} ← single process by UUID │
│ GET /obs/kpis ← per-PID KPIs for a month │
│ GET /obs/overdue ← past the business Antwortfrist │
│ GET /api/v1/audit/gleichbehandlung ← § 7a Abs. 5 EnWG evidence │
│ GET /obs/metrics ← obsd business gauges (Prom) │
│ GET /metrics ← request metrics (runner) │
│ GET /health/live /health/ready ← liveness + real DB ping │
│ POST|GET /mcp ← MCP Streamable HTTP │
└───────────────────────────────────────────────────────────────────────┘
There are no command-line flags: mako_service::run owns the lifecycle and
obsd.toml owns the settings.
Cedar ABAC
obsd is read-only, so its policy is one rule: same tenant, any role. There
is no write action to gate, and no role distinction to make — a read model of the
deployment's own processes is not more sensitive to an LF-role service account
than to an NB-role one. What it is sensitive to is another tenant, and that is
the check.
| Action | Routes |
|---|---|
read-process | GET /obs/processes, GET /obs/processes/{id} |
read-kpi | GET /obs/kpis, GET /api/v1/audit/gleichbehandlung |
read-overdue | GET /obs/overdue |
use-mcp | POST / GET /mcp |
The resource tenant is the deployment's own [identity] tenant, not a
caller-supplied parameter, so context.principal_tenant == context.resource_tenant
cannot be satisfied by asking for someone else's. The shipped policy is
policies/obsd.cedar.
POST /webhook is outside Cedar: it is authenticated by the HMAC in
webhook.inbound_secret, because marktd delivers events without a user token.
ProcessProjection
| Field | Description |
|---|---|
process_id | UUID — the CloudEvent subject |
pid | BDEW Prüfidentifikator (e.g. 55001) |
family | gpke, geli-gas, wim, wim-gas, gabi-gas, mabis, invoic-storno, unknown — wim-gas is a reporting label derived from the Prüfidentifikator, not a workflow name, and covers only the Gas-only PIDs: the Sparte-neutral ones (INSRPT 23001–23012 minus 23005/23009, INVOIC 31003) report under wim |
workflow_name | From the makoworkflow CE extension |
state | initiated | running | aperak_timeout | completed | rejected | failed |
malo_id | 11-digit Marktlokations-ID |
partner_mp_id | MP-ID of the counterparty (NB/GNB/MSB) |
mdm_role | Canonical Marktrolle (LF, NB, …) |
started_at | First event seen for this process |
last_event_at | Most recent event |
tenant | Operator MP-ID the row belongs to |
deadline_at | The business Antwortfrist. null = no published window for this PID |
deadline_source | The Festlegung deadline_at came from |
deadline_risk | unknown | green | amber (< 24 h) | red (past) |
erc_code | BDEW ERC code when state = rejected |
initiator_is_affiliate | The Lieferant belongs to this operator's own undertaking |
process_projections carries one column that is not on the wire:
completed_at, set once when the state first becomes terminal and never
overwritten. It is the cycle-time input and the breach test's ceiling
(deadline_at < COALESCE(completed_at, now())), so a process that closed late is
counted as breached rather than as closed.
failed is the projection of de.mako.process.failed. Not cancelled: nothing
in mako emits a cancellation, and that name puts unrecoverable failures in a
bucket the STP rate reads as a normal ending.
The Antwortfrist
obsd does not compute deadlines. mako-fristen does — a leaf crate holding
the BDEW Werktage arithmetic, the MaKo holiday calendar and the
per-Prüfidentifikator Antwortfrist tables. makod registers the deadline on the
process from the same table and processd sizes its operator queue by it, so all
three name one instant.
| Family | Window | Source |
|---|---|---|
| GPKE Strom | a 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. WT | BK6-24-174 GPKE Teil 2 |
| GeLi Gas | Ablauf 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 WT | BK6-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 else | null — unknown, never unbounded | — |
There is no 24-hour GPKE window and no 10-Werktage GeLi Gas answer window. Both approximations fail in the direction that does not announce itself: a GPKE Anmeldung arriving Friday afternoon is answerable until Monday, and one arriving Tuesday evening has under sixteen hours — so a flat window breaches the first early and reports the second healthy after its Frist has lapsed. The GeLi Gas „10 Werktage" is the supplier's Vorlauffrist, how far ahead the LF must send; the Netzbetreiber's answer window is 4 Werktage.
null is unknown, not compliant
A PID with no published window carries no deadline and is absent from every
breach sweep, rather than being measured against an instant nobody can cite. Its
deadline_risk is unknown — never green, because "we have not read that
Festlegung" and "there is time" are different statements.
Every stored deadline carries deadline_source, and it travels into the
CloudEvent and out through the MCP tools. Quote it: a recommendation that cites
the rule beats one that asserts a number.
§ 7a Abs. 5 EnWG Gleichbehandlung parity
A vertically integrated undertaking's Gleichbehandlungsbeauftragte files a report
with the Bundesnetzagentur by 31 March each year, covering the preceding
calendar year, and publishes it in non-personalised form (§ 7a Abs. 5 EnWG).
Lieferantenwechsel is among the areas those reports examine. obsd produces the
Lieferantenwechsel evidence for it.
The underlying duties are § 6a EnWG (informatorische Entflechtung) and § 20 Abs. 1 Satz 1 EnWG (diskriminierungsfreier Netzzugang).
What is compared
The processes the operator's network arm answers for a Lieferant — the ones
where the network operator is the party doing the treating. The set is derived
from the Antwortfrist table (answered_by == "NB" in the GPKE and GeLi Gas
families), so it cannot drift from the Festlegung: currently 55001, 55077, 55004,
44001 and 44004.
The Kündigung (55016, 44016) is deliberately not in the set: it is answered by the old supplier, never by the Netzbetreiber.
initiator_is_affiliate is set when the Lieferant's MP-ID — new_supplier where
the message names one, the counterparty otherwise — matches any entry in
[identity] own_mp_ids.
The sign convention
gap_pp = affiliate − third_party, in percentage points. Positive means the
affiliate fared better, which is the concern. The same convention is used by the
REST report, the de.obs.stp.parity.alert CloudEvent and the MCP tool.
Two gaps are reported, under that one convention: gap_pp over the completion
rate, and frist_gap_pp over the share answered inside the published
Antwortfrist. They are kept apart because they answer different questions and
can point opposite ways — a rejection can be entirely legitimate, so two groups
can complete at the same rate while one of them is routinely answered late.
Missing a statutory window is the discrimination the Gleichbehandlungsbericht is
actually asked about, and the one axis mako can measure exactly, because the
window comes from mako-fristen rather than from a judgement.
Both are null when either group has fewer than 10 processes: the gap is
unstatable, not zero — and not a hundred points off a single process.
There is no regulatory threshold
The Bundesnetzagentur publishes no numeric parity limit for this figure. § 7a
Abs. 5 asks the Gleichbehandlungsbeauftragte to describe the measures taken, not
to meet a number. [worker] parity_threshold_pp is the operator's own
escalation policy and is labelled as such everywhere it appears. The same applies
to any STP-rate target.
Multi-MP-ID configuration
An integrated NB+GNB instance operates under several MP-IDs. Configure all of them, or affiliate processes under the unlisted ones count as third-party:
[identity]
tenant = "9900357000004" # primary — Cedar resource checks
# Strom NB (BDEW 99…) + Gas GNB (DVGW 98…)
own_mp_ids = ["9900357000004", "9800357000004"]
Omitting own_mp_ids defaults it to [tenant].
Export
# JSON, for the current year
curl -s "http://obsd:8480/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
curl -s "http://obsd:8480/api/v1/audit/gleichbehandlung?year=2025&format=csv" \
-H "Authorization: Bearer $TOKEN" > gleichbehandlung-2025.csv
# 15 columns: pid, both groups' total/completed/rejected/frist_breached and their
# two rates, then gap_pp and frist_gap_pp. An unstatable rate is an empty cell,
# never a 0 that would read as "completed none of them".
The report year is the year each process started, not the year its row was last touched — so re-running a closed year reproduces it. An annual filing that changes when you re-run it is not evidence.
Events produced
Two background sweeps produce de.obs.* CloudEvents. They run only when
webhook.outbound_url is configured (in production, marktd's event-ingest
endpoint, whose fan-out delivers to agentd), and are HMAC-signed when
webhook.outbound_secret is set.
| Event | Producer | When | Consumed by |
|---|---|---|---|
de.obs.deadline.approaching | deadline sweep (deadline_sweep_secs) | an open process has deadline_at within deadline_warn_hours, or already past it, and has not been alerted (idempotent per process via deadline_alerted_at) | agentd deadline-alert-agent |
de.obs.stp.parity.alert | parity sweep (parity_sweep_secs) | either gap — completion rate or Frist compliance — passes parity_threshold_pp, with both groups ≥ 10 processes | agentd compliance-agent |
de.obs.deadline.approaching: process_id, pid, family,
workflow_name, malo_id, partner_mp_id, due_at (RFC 3339),
hours_remaining, breached, deadline_risk, deadline_source, tenant.
de.obs.stp.parity.alert: tenant, window_days, threshold_pp,
affiliate + third_party {total, completed, rejected, frist_breached},
gap_pp and frist_gap_pp (both signed), favours, min_sample,
gap_convention, basis.
Either gap alone crossing the threshold raises the alert: requiring both would let a group with too few completions hide a Fristen disparity.
Only a delivered alert is stamped, so a downed webhook target cannot silently
consume a warning. A sweep that returns a full batch reports saturated: at least
the cap was waiting — never that the cap was all there was. A quiet sweep logs
nothing, so a line in the log is worth reading.
Configuration reference
obsd reads a TOML file (default obsd.toml, or the path in OBSD_CONFIG),
with secrets deferred to environment variables via "env:VAR_NAME". Individual
keys may be overridden by OBSD_-prefixed variables (__ separates nested
sections, e.g. OBSD_DATABASE__URL); RUST_LOG sets the log level.
Unknown keys are refused at startup, at every level — a typo is a refusal to boot rather than a setting that silently does nothing.
[http]
addr = "0.0.0.0:8480" # default
[database]
url = "env:DATABASE_URL" # required
pool_size = 10 # default
[identity]
tenant = "9900357000004" # required — MP-ID of the operator
own_mp_ids = ["9900357000004", "9800357000004"] # § 7a Abs. 5 affiliate detection
[marktd]
url = "http://marktd:8180" # required
api_key = "env:OBSD_MARKTD_API_KEY" # required
[webhook]
inbound_secret = "env:OBSD_INBOUND_SECRET" # verifies inbound POST /webhook
# Outbound target for the de.obs.* events. In production the marktd
# event-ingest endpoint, whose fan-out delivers to agentd. Omit to disable
# the sweep producers entirely.
outbound_url = "env:OBSD_OUTBOUND_URL" # e.g. http://marktd:8180/api/v1/mako/events
outbound_secret = "env:OBSD_OUTBOUND_SECRET" # HMAC; must match the target's inbound secret
[worker]
deadline_sweep_secs = 900 # deadline sweep interval (default 15 min)
deadline_warn_hours = 24 # alert when an Antwortfrist is within this many hours
parity_sweep_secs = 86400 # parity sweep interval (default daily)
# The OPERATOR'S escalation threshold, in percentage points. Not a regulatory
# limit — the BNetzA publishes none for this figure.
parity_threshold_pp = 5.0
parity_window_days = 90 # parity look-back window
[subscription]
# Self-registers with marktd on startup — no manual curl required.
webhook_url = "http://obsd:8480/webhook"
subscriber_id = "obsd"
event_types = [
"de.mako.process.initiated",
"de.mako.process.completed",
"de.mako.aperak.accepted",
"de.mako.aperak.timeout",
"de.mako.aperak.rejected",
"de.mako.process.failed",
]
# Required. Omitting it is a startup refusal unless `allow_insecure_no_auth`
# is set, which is how a dev stack opts out deliberately rather than by leaving
# a section out.
[oidc]
# issuer = "https://login.microsoftonline.com/{tenant-id}/v2.0"
# audience = "api://mako-obsd"
# [mcp] # MCP server auth: OIDC + optional API-key fallback
# api_key = "env:OBSD_MCP_API_KEY"
Query examples
# Open GPKE Anmeldungen, newest first
curl -s "http://obsd:8480/obs/processes?family=gpke&state=initiated&pid=55001" \
-H "Authorization: Bearer $TOKEN" \
| jq '.[] | {process_id, started_at, deadline_at, deadline_source}'
# Per-PID KPIs for one calendar month
curl -s "http://obsd:8480/obs/kpis?pid=55001&period=2026-07" \
-H "Authorization: Bearer $TOKEN" | jq .
# Past the business Antwortfrist
curl -s "http://obsd:8480/obs/overdue" \
-H "Authorization: Bearer $TOKEN" \
| jq '.[] | {pid, malo_id, deadline_at, deadline_source, deadline_risk}'
state accepts exactly the six stored spellings; anything else is a 400 naming
them, not a silently dropped filter.
Reading a KPI report honestly
{
"pid": 55001,
"total_initiated": 412,
"total_aperak_timeout": 3,
"total_frist_breached": 11,
"total_with_frist": 412,
"frist_compliance_rate": 0.9733,
"avg_cycle_time_hours": 9.4,
"p95_cycle_time_hours": 21.8
}
total_aperak_timeoutandtotal_frist_breachedare different clocks.- Check
total_with_fristbefore quotingfrist_compliance_rate: a small denominator means the bucket is mostly unmeasured, not compliant. - A
nullrate means nothing measurable in the bucket. It is not0, and it is not perfect performance.
Monitoring
GET /obs/metrics serves business gauges, computed by querying the store on each
scrape (a counter loses its decrement when a process crashes; a census does not):
| Gauge | Meaning |
|---|---|
obsd_process_projections_total | rows in the read-model |
obsd_open_processes_total | non-terminal processes |
obsd_overdue_processes_total | open and past their Antwortfrist |
obsd_db_pool_size / obsd_db_pool_idle | connection pool |
It is unauthenticated by design — restrict it at the ingress. Every other route that returns business data requires OIDC and passes Cedar.
There is no Alertmanager bridge. Alerting is Prometheus's job over these
gauges, or agentd's over de.obs.deadline.approaching.
MCP server
obsd exposes a read-only MCP server at /mcp. Every tool goes through the same
repository the REST surface uses, so the two cannot answer differently.
Tools (6)
| Tool | Description |
|---|---|
get_process(process_id) | Full projection — state, deadline, deadline_source, ERC code |
list_overdue_processes | Past the business Antwortfrist, most urgent first; reports saturated |
get_kpi_report(pid, period) | Per-PID KPIs for a YYYY-MM month, both clocks separately |
get_parity_report(days) | § 7a Abs. 5 parity; gap_pp = affiliate − third_party |
get_stp_rate(days) | Completions over processes that ended |
list_processes_by_family(family, state, limit) | Drill into one family |
Prompts (2)
| Prompt | Description |
|---|---|
audit-kpi | Walk a reporting period's KPIs for a BNetzA Qualitätsbericht |
investigate-aperak-violation | Root-cause a missed APERAK acknowledgement |
Example
curl -X POST http://obsd:8480/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"get_stp_rate","arguments":{"days":30}}}'
Returns ended, completed, rejected, failed, in_flight,
aperak_timeout, frist_breached and stp_rate — null when nothing ended in
the window, because a window with no endings has no rate.
See also
- agentd Operator Guide — the specialists that
consume
de.obs.*. - processd Operator Guide — the operator queue sized by the same Antwortfrist table.