sperrd Operator Guide

Operator guide for sperrd — the Netzbetreiber's Sperr-/Entsperrauftrag execution queue: ORDERS 17115/17117 in, field dispatch, IFTSTA 21039 out, with a retry queue for the outcomes that do not reach the Lieferant.

sperrd Operator Guide

sperrd is 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 that outcome message the Lieferant's gpke-sperrung-lf process never reaches a terminal state, and GPKE gives them no way to find out what happened but to ask. Dispatching it is the service's reason to exist, and the one state it will not let fall silently on the floor.

Port: :8780 Storage: PostgreSQL (sperr_orders) Role: NB (Netzbetreiber)

  1. TOC {:toc}

Where orders come from

flowchart LR
    LF["Lieferant"] -->|"ORDERS 17115 / 17117"| AS4["AS4"]
    AS4 --> MAKOD["makod<br/>gpke-sperrung"]
    MAKOD -->|"de.mako.process.initiated"| WH["sperrd<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

Two producers, one queue:

  • The market inbox. POST /webhook consumes de.mako.process.initiated and turns PIDs 17115 and 17117 into work orders, keyed on the makod process so a redelivery over AS4 does not queue a second disconnection. This is what makes sperrd an NB service: a Sperrauftrag arriving from a third-party Lieferant reaches an executing party rather than only a makod process.
  • Operators. POST /api/v1/sperr-orders for an order with no market correspondent. It has no process_id, so no IFTSTA is owed for it.

PID 17116 (Anfrage Sperrung) is deliberately not queued: it is the NB asking the Messstellenbetreiber whether the meter is reachable, not an order to execute.

What the queue carries

The row is shaped by what the ORDERS AHB actually sends:

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 database CHECK. The distinction matters operationally: a missed fixed date is a broken commitment to the Lieferant, while a passed earliest-start only means the job became schedulable.

Timing — three clocks, all published

BK6-24-174 GPKE Teil 2 §§ 3.5.1.2 / 3.5.2.2 state every deadline on a Sperr-/Entsperrauftrag, and they are three different ones:

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)sperrdausfuehrung_faellig_am
IFTSTA 21039„spätester ÜT ist der 1. WT nach dem Abschluss des Sperrauftrags" (Prozessschritt 5)sperrdiftsta_faellig_am

The Lieferant's DTM+203 / DTM+469 is a fourth date and a different question: when the LF wanted the work done, not when the Festlegung requires it. /stats reports both — overdue_pending for the LF's date, frist_ueberschritten for the regulatory window. A pending order past its 6-Werktage window is announced once as de.sperr.ausfuehrung.ueberfaellig.

…and three Vorlauffristen, which count the other way

Every clock above runs forward. Prozessschritt 1 and 3 state windows that run backwards from the Sperrtermin, so reading either as „n Werktage nach Eingang" is wrong by the whole lead time:

ProzessschrittFristOwed by
Sperrauftrag, nicht termingebunden (Nr. 1)spätester ÜT ist der 6. WT vor dem frühestmöglichen SperrterminLF
Sperrauftrag, termingebunden (Nr. 1)spätester ÜT ist der 12. WT vor dem SperrterminLF
Anfrage Sperrung an den MSB (Nr. 3)spätester ÜT ist der 3. WT vor dem SperrterminNB

The termingebundene case is not a variant of the ordinary one: it fixes Datum, Uhrzeit und Ort — the Festlegung's example is a Gerichtsvollzieher — so the NB cannot move the visit to fit its own scheduling and the LF has to give it twice the room. The wire tells the two apart on its own, because DTM+203 and DTM+469 are mutually exclusive on a 17115.

sperrd records the verdict per order (vorlauffrist_eingehalten, plus the latest ÜT the order could have carried) and reports the total as vorlauffrist_verletzt. It does not refuse on it: Prozessschritt 2 lists what the NB checks before it answers — „ob die Marktlokation dem LF zugeordnet ist, ob die Marktlokation identifiziert werden kann und die Zusicherung der Berechtigung nach Netznutzungsvertrag vorliegt" — and the Vorlauffrist is not among them.

Nr. 2 puts a floor on the NB instead: without a generelle Zustimmung des MSB „ist der Sperrtermin vom NB so festzulegen, dass dem MSB noch eine fristgerechte Antwort auf Anfrage vor dem Sperrtermin möglich ist" — the Anfrage's 3 WT plus the MSB's own 3 WT to answer, so six Werktage out.

Two Sperrversuche

„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 — a gerichtliche Verfügung, or a glaubhaft gemachter Verhinderungsgrund such as lebenserhaltende medizinische Geräte.

A guard test rejects the two claims that contradict Prozessschritt 1: a „2 Werktage" window, and the assertion that GPKE fixes none.

Reporting the outcome

PUT /api/v1/sperr-orders/{id}/execute and .../fail are the two terminal transitions. Both claim the order first with a single guarded UPDATE … WHERE status = 'pending' and only then dispatch, so a concurrent execute and fail cannot both put a message on the wire — which would send the Lieferant an Ausführungs- and a Fehlmeldung for the same order.

What the IFTSTA carries, per AHB 2.1 §7.2:

FieldSource
SG15 STS DE9015Z37 Auftragsstatus Sperren / Z38 Entsperren — derived from order_type
SG15 STS DE4405Z14 erfolgreich (execute) / Z13 gescheitert (fail)
SG15 STS DE9013The EBD Prüfschritt code — a Muss, so pruefschritt_code is what makes the message valid
DTM+293Fertigstellungsdatum — Muss on Z14, and condition [495] requires it ≤ the document date, so a future executed_at is refused at the API
SG25 FTX+ACBThe note or reason free text

The response tells you which of two things happened:

  • 204 — recorded, and the IFTSTA is with makod.
  • 202 — recorded, but the dispatch failed. The order is in the retry queue and the Lieferant has not been told. The outcome is kept regardless: a field team's report is a fact about the physical world, and discarding it because a downstream service was unreachable is worse than retrying.

The IFTSTA retry queue

A terminal order whose iftsta_dispatched_at is NULL is an order whose Lieferant does not know the outcome.

A background worker drains it, re-sending under the same idempotency key makod deduplicates on, so a re-send after a lost response is the same command rather than a second IFTSTA. After IFTSTA_MAX_ATTEMPTS it announces de.sperr.iftsta.ausstehend once and stops — a dispatch that has failed eight times is not a transport problem but a makod process in the wrong state, and retrying that forever only hides it behind a rising attempt count.

/stats reports the three apart:

FieldMeaning
iftsta_outstandingDispatches in flight. Normal for seconds after an execution.
iftsta_ueberfaelligPast the 1-WT window of GPKE Teil 2 § 3.5.1.2 Nr. 5.
iftsta_stuckPast the retry budget. This is the number that needs a human.
vorlauffrist_verletztOrders the Lieferant sent later than its own Vorlauffrist allowed. A contract question, not an operations one — the NB executes them anyway.

Diagnosing a stuck IFTSTA

Read iftsta_last_error on the order. The usual cause is that makod has no gpke-sperrung process for that MaLo in ValidationPassed — its BestaetigueSperrung command refuses any other state. Check whether the inbound ORDERS spawned a process at all; if it did not, the order reached the queue by another route and there is no market correspondent to report to. After fixing the cause, reset iftsta_attempts and the worker picks the order up again.

Emitted events

All through the transactional outbox.

CloudEventEmitted when
de.sperr.auftrag.eingegangenAn order entered the queue, from ORDERS or an operator
de.sperr.ausgefuehrtCarried out — IFTSTA Z14
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 and the LF is still uninformed

agentd's sperrd-agent subscribes to the de.sperr.* glob.

Authentication and authorization

Every REST route requires an OIDC token and passes a Cedar check. Authentication alone would let a valid token from any tenant order a disconnection in this operator's name.

ActionRoutes
read-sperr-orderGET the queue, an order, /stats
create-sperr-orderPOST /api/v1/sperr-orders
execute-sperr-orderPUT .../execute, .../fail
cancel-sperr-orderPUT .../cancel

All four require the NB market role in mako_roles and a tenant match. The /webhook ingest is authenticated by the inbound Standard Webhooks signature instead, because makod holds no bearer token for this service.

tests/authorization_guard.rs fails the build if a handler loses its Claims extractor or its Cedar check, if a checked action appears in no policy (Cedar is default-deny, so that is a permanent 403), or if a policy grants an action nothing checks.

Schema

CREATE TABLE sperr_orders (
    id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant               TEXT NOT NULL,
    malo_id              TEXT NOT NULL,
    lf_mp_id             TEXT NOT NULL,
    order_type           TEXT NOT NULL CHECK (order_type IN ('sperrung','entsperrung')),
    pruefidentifikator   INTEGER CHECK (pruefidentifikator IN (17115, 17117)),
    process_id           TEXT,
    ausfuehrung_am       DATE,
    fruehestens_am       DATE,
    CHECK (ausfuehrung_am IS NULL OR fruehestens_am IS NULL),
    ausfuehrung_faellig_am   DATE,          -- 6 WT, GPKE Teil 2 § 3.5.1.2 Nr. 1
    ausfuehrung_eskaliert_at TIMESTAMPTZ,
    sperrversuche            INTEGER NOT NULL DEFAULT 0,   -- max. 2, Nr. 5
    letzter_versuch_am       DATE,
    letzter_versuch_grund    TEXT,
    iftsta_faellig_am        DATE,          -- 1. WT nach Abschluss, Nr. 5
    arbeitszeit          TEXT CHECK (arbeitszeit IN ('innerhalb','auch_ausserhalb')),
    treffpunkt_hinweis   TEXT,
    treffpunkt_strasse   TEXT,
    treffpunkt_plz       TEXT,
    treffpunkt_ort       TEXT,
    treffpunkt_land      TEXT CHECK (treffpunkt_land IS NULL OR treffpunkt_land ~ '^[A-Z]{2}$'),
    hinweis              TEXT,
    status               TEXT NOT NULL DEFAULT 'pending'
                         CHECK (status IN ('pending','executed','failed','cancelled')),
    executed_at          TIMESTAMPTZ,
    execution_note       TEXT,
    fail_reason          TEXT,
    pruefschritt_code    TEXT,
    CHECK (status <> 'executed' OR executed_at IS NOT NULL),
    CHECK (status <> 'failed'   OR fail_reason IS NOT NULL),
    iftsta_ref           TEXT,
    iftsta_dispatched_at TIMESTAMPTZ,
    iftsta_attempts      INTEGER NOT NULL DEFAULT 0,
    iftsta_last_error    TEXT,
    iftsta_escalated_at  TIMESTAMPTZ,
    created_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (tenant, process_id)
);

MCP surface

Read-only by construction: withdrawing a § 41f disconnection order stays on the authenticated REST routes, with an operator.

ToolDescription
list_sperr_orders(status, malo_id, due, limit)The queue
get_sperr_order(id)One order, with ORDERS provenance and IFTSTA state
get_sperr_statsCounters, incl. iftsta_outstanding / iftsta_stuck
list_due_ordersThe field-dispatch list, with the Treffpunkt

Prompts: execute-sperrung, iftsta-sweep.

Configuration

port           = 8780
tenant         = "9900357000004"

makod_url      = "http://makod:8080"
makod_api_key  = "env:SPERRD_MAKOD_API_KEY"
inbound_hmac_secret = "env:SPERRD_INBOUND_HMAC_SECRET"

[database]
url       = "env:SPERRD_DATABASE_URL"
pool_size = 10

[oidc]
issuer   = "https://keycloak:8080/realms/mako"
audience = "sperrd"

Omitting [oidc] is a startup failure unless allow_insecure_no_auth = true is written down explicitly: these routes create and confirm physical disconnections.

Tests

cargo test -p sperrd runs the unit and guard tests. just test-sperrd-db runs 13 scenarios against real PostgreSQL: the redelivery guard, the claim guard, a failed dispatch keeping the report and queueing a retry, budget exhaustion escalating once, tenant isolation, and the mutually-exclusive ORDERS dates.

Edit this page ↗