mako_events/lib.rs
1//! Compile-time catalog of every CloudEvents `type` used across the mako
2//! workspace.
3//!
4//! One `pub const` per event type, organized in bounded-context modules.
5//! Emitters and subscribers reference these constants instead of inline
6//! string literals, so a rename is a one-line change and drift between
7//! producer and consumer is a compile error rather than a silent mismatch.
8//!
9//! # Conventions
10//!
11//! - Every type starts with `de.` and is entirely lowercase
12//! (CloudEvents §3.1 recommends lowercase reverse-DNS types).
13//! - Segments are separated by `.`, and a multi-word segment joins its words
14//! with `-` — never `_`. German domain nouns keep their established spelling
15//! (participles like `beliefert`, compound nouns like `nb-contract`).
16//! `segments_use_hyphen_not_underscore` enforces this.
17//! - A namespace names its facts in one language. `de.vertrag.*` is German
18//! throughout (`gekuendigt`, `preisgarantie-hinterlegt`); `de.mako.*`,
19//! `de.gabi.*` and `de.accounting.*` are technical or English-domain and stay
20//! English. `german_namespaces_use_german_participles` enforces the German
21//! set. This is about the *participle*, not the noun — and it does not mean
22//! `.updated` and `.geaendert` are interchangeable: in `de.markt.*` they name
23//! different facts (any master-data write vs. a regulated GPKE
24//! Stammdatenänderung carrying its patch).
25//! - `⚠ phantom:` in a doc comment means **no service in this workspace emits
26//! this type**. That is the whole claim, and it is the one
27//! `unreferenced_constants_are_marked_phantom` checks. It says nothing about
28//! whether anything *subscribes*: some phantoms are subscriptions placed ahead
29//! of their emitter, others are neither emitted nor consumed, and each
30//! constant's own doc comment says which and why. A phantom is a recorded gap,
31//! not a promise — an external consumer may match on it, but this platform
32//! will not make it fire.
33//!
34//! Glob subscription patterns (e.g. `de.mako.*` in `agentd` trigger
35//! configs) are not part of this catalog — only concrete types are. The
36//! canonical pattern matcher every subscription mechanism uses is
37//! [`matches()`](matches()).
38//!
39//! # Scope (why `mako-events`, not `mako-common`)
40//!
41//! This crate deliberately stays a single-purpose leaf: constants and logic
42//! *about CloudEvents types* (the catalog, the naming convention, the
43//! pattern matcher) and nothing else. A general `mako-common`/`mako-core`
44//! grab-bag would become a dependency magnet — every crate depends on it,
45//! every change rebuilds the workspace, and unrelated helpers accrete
46//! without an owner. Shared logic already has purpose-named homes:
47//! domain runtime in `mako-engine`, service framework in `mako-service`,
48//! master-data types in `mako-markt`. New shared code goes to the crate
49//! whose purpose it serves; a new purpose gets a new purpose-named crate.
50
51#![deny(unsafe_code)]
52
53/// Core MaKo process lifecycle + EDIFACT transport events (`de.mako.*`).
54///
55/// Emitted by `makod` toward the ERP adapter / event bus; consumed by
56/// `obsd`, `processd`, `edmd`, `invoicd`, `marktd`, `vertragd` and `agentd`.
57pub mod mako {
58 /// A MaKo market process was started (first outbound message built).
59 pub const PROCESS_INITIATED: &str = "de.mako.process.initiated";
60 /// Happy path finished — the process reached its terminal success state.
61 pub const PROCESS_COMPLETED: &str = "de.mako.process.completed";
62 /// Unrecoverable failure — the process was cancelled/failed.
63 pub const PROCESS_FAILED: &str = "de.mako.process.failed";
64 /// APERAK acknowledged the outbound message.
65 pub const APERAK_ACCEPTED: &str = "de.mako.aperak.accepted";
66 /// APERAK rejected the outbound message (carries BDEW ERC code).
67 pub const APERAK_REJECTED: &str = "de.mako.aperak.rejected";
68 /// APERAK deadline missed.
69 pub const APERAK_TIMEOUT: &str = "de.mako.aperak.timeout";
70 /// CONTRL received for an outbound interchange.
71 pub const CONTRL_RECEIVED: &str = "de.mako.contrl.received";
72 /// MaLo identified during Lieferantenwechsel (GPKE identification step).
73 pub const MALO_IDENTIFIED: &str = "de.mako.malo.identified";
74 /// The LFA answered the NB's Anfrage zur Beendigung der Zuordnung — or its
75 /// 09:00 window lapsed, which the Festlegung reads as a Zustimmung.
76 ///
77 /// Its own type rather than a `PROCESS_*` one because it is an **input to a
78 /// different process**: `processd` resumes the *Anmeldung* decision on it,
79 /// walking `E_0623` Prüfschritte 30–50 with the answer. `data` carries
80 /// `malo_id`, `anmeldung_process_id`, `lfa_mp_id`, `zustimmung`,
81 /// `fristablauf`, and — when the LFA named them — `antwortcode`, `grund`
82 /// and the Fall-b `zuordnungsende`.
83 pub const ABMELDEANFRAGE_BEANTWORTET: &str = "de.mako.abmeldeanfrage.beantwortet";
84 /// Outbound EDIFACT interchange handed to the webhook EDIFACT sender.
85 ///
86 /// Emitted **only** by `WebhookEdifactSender`, the development / ERP
87 /// integration transport used when there is no AS4 infrastructure; the
88 /// production `BdewAs4Sender` path does not emit it. The CloudEvent is the
89 /// delivery envelope carrying the interchange, not a notification about it.
90 pub const EDIFACT_OUTBOUND: &str = "de.mako.edifact.outbound";
91 // Design note: there are deliberately NO per-process outcome types
92 // (`de.mako.gpke.lieferbeginn.bestaetigt` and friends). Process outcomes
93 // are the generic `PROCESS_*`/`APERAK_*` family — the process family
94 // rides in `data.workflow`, and `vertragd` matches outcome *suffixes*
95 // (`.bestaetigt`/`.abgelehnt`/`.completed`/…) so a future fine-grained
96 // emitter would be consumed without code changes. Minting per-process
97 // constants here without an emitter would institutionalize fiction (an
98 // earlier draft carried four such test-fixture types; they were removed).
99 /// §20 NZV Netzzugang: aggregated Übermittlungsbedarf toward the NB.
100 pub const NETZZUGANG_UEBERMITTLUNGSBEDARF: &str = "de.mako.netzzugang.uebermittlungsbedarf";
101}
102
103/// Market master-data events (`de.markt.*`), emitted by `marktd`.
104pub mod markt {
105 /// Marktlokation stammdaten changed.
106 pub const MALO_UPDATED: &str = "de.markt.malo.updated";
107 /// A UTILMD Stammdatenänderung (GPKE Teil 4 / GeLi Gas) was applied to a
108 /// MaLo's typed columns — carries the applied `patch` for ERP audit.
109 pub const MALO_STAMMDATEN_GEAENDERT: &str = "de.markt.malo.stammdaten-geaendert";
110 /// Object-generic Stammdatenänderung applied to a non-MaLo master-data object
111 /// (MeLo/NeLo/Tranche). The subject is the object's location id; the payload
112 /// carries the `objekt` marker.
113 pub const STAMMDATEN_GEAENDERT: &str = "de.markt.stammdaten.geaendert";
114 /// Messlokation stammdaten changed.
115 pub const MELO_UPDATED: &str = "de.markt.melo.updated";
116 /// Marktpartner record changed.
117 pub const PARTNER_UPDATED: &str = "de.markt.partner.updated";
118 /// Netzbetreiber contract (Lieferanten-Rahmenvertrag) changed.
119 pub const NB_CONTRACT_UPDATED: &str = "de.markt.nb-contract.updated";
120 /// MSB-Rahmenvertrag Gas changed.
121 pub const MSB_RAHMENVERTRAG_GAS_UPDATED: &str = "de.markt.msb-rahmenvertrag-gas.updated";
122 /// Device (Gerät) configuration changed.
123 pub const GERAET_KONFIGURATION_UPDATED: &str = "de.markt.geraet.konfiguration.updated";
124 /// Steuerbare-Ressource Konfigurationsprodukt changed (§14a).
125 pub const SR_KONFIGURATIONSPRODUKT_UPDATED: &str = "de.markt.sr.konfigurationsprodukt.updated";
126 /// §20 NZV Netzzugang application state changed.
127 pub const NETZZUGANG_ANTRAG_UPDATED: &str = "de.markt.netzzugang.antrag.updated";
128 /// Einwilligung (consent) granted.
129 pub const EINWILLIGUNG_ERTEILT: &str = "de.markt.einwilligung.erteilt";
130 /// Einwilligung (consent) revoked.
131 pub const EINWILLIGUNG_WIDERRUFEN: &str = "de.markt.einwilligung.widerrufen";
132 /// Versorgung state changed (generic transition).
133 pub const VERSORGUNG_CHANGED: &str = "de.markt.versorgung.changed";
134 /// ⚠ phantom: no emitter. Versorgung entered `BELIEFERT` (supply active).
135 ///
136 /// Superseded before it was ever wired. `marktd` announces **every**
137 /// Versorgung transition — the EDIFACT-driven one in `event_ingest` and the
138 /// REST one in `handlers::versorgung` alike — as [`VERSORGUNG_CHANGED`]
139 /// carrying `data.lieferstatus`, so reaching `Beliefert` is already on the
140 /// bus and a second type for one particular value of that field would put
141 /// the same fact on two types. The family keeps exactly two status-specific
142 /// types beside the generic one, and both earn it by carrying a payload the
143 /// generic event does not: [`VERSORGUNG_GAP_DETECTED`] (no announced
144 /// successor) and [`VERSORGUNG_EOG_BEGONNEN`] (`eog_art`, `eog_seit`).
145 /// `Beliefert` carries nothing extra.
146 ///
147 /// Kept rather than deleted: it is *redundant*, not *wrong*. It names a fact
148 /// the bus already carries, so its presence misleads nobody, and the
149 /// catalogue is a published contract where withdrawing a declared type
150 /// breaks a consumer matching on it for no gain. A phantom whose emission
151 /// would itself be a defect gets deleted instead — that is why there is no
152 /// generic `de.eeg.settlement.berechnet` (see the [`crate::eeg`] module).
153 pub const VERSORGUNG_BELIEFERT: &str = "de.markt.versorgung.beliefert";
154 /// Supply gap detected: a MaLo became `Unbeliefert` with no announced
155 /// successor — the NB must activate Ersatz-/Grundversorgung (§38 EnWG).
156 /// Consumed by the `processd` EoG gap-closure automation.
157 pub const VERSORGUNG_GAP_DETECTED: &str = "de.markt.versorgung.gap-detected";
158 /// Statutory fallback supply began (Ersatz- or Grundversorgung);
159 /// `data.eog_art` carries the regime, `data.eog_seit` the start date.
160 pub const VERSORGUNG_EOG_BEGONNEN: &str = "de.markt.versorgung.eog-begonnen";
161 /// §38 Abs. 4 EnWG: a running Ersatzversorgung approaches its 3-month
162 /// maximum. Emitted by the `processd` EoG timer.
163 pub const VERSORGUNG_ERSATZ_AUSLAUFEND: &str = "de.markt.versorgung.ersatz-auslaufend";
164 /// PRICAT price catalog published.
165 pub const PRICAT_PUBLISHED: &str = "de.markt.pricat.published";
166 /// MMMA import batch succeeded.
167 pub const MMMA_IMPORT_SUCCESS: &str = "de.markt.mmma.import.success";
168 /// MMMA import batch failed.
169 pub const MMMA_IMPORT_FAILED: &str = "de.markt.mmma.import.failed";
170 /// Subscription self-test event (`POST /subscriptions/{id}/test`).
171 pub const SUBSCRIPTION_TEST: &str = "de.markt.subscription.test";
172}
173
174/// Billing events (`de.billing.*`), emitted by `billingd`.
175pub mod billing {
176 /// Invoice (Rechnung) created. A credit note / Stornorechnung is the same
177 /// event with `data.is_correction = true` and a negated amount — there is
178 /// deliberately no separate `gutschrift` type (one signed document stream is
179 /// cleaner for the double-entry ledger, and avoids a double-booking hazard).
180 pub const RECHNUNG_ERSTELLT: &str = "de.billing.rechnung.erstellt";
181 /// Monthly Abrechnungsinformation (§40 EnWG) generated.
182 pub const ABRECHNUNGSINFORMATION_MONATLICH: &str =
183 "de.billing.abrechnungsinformation.monatlich";
184 /// XRechnung for a B2G recipient is ready for dispatch.
185 pub const XRECHNUNG_B2G_READY: &str = "de.billing.xrechnung.b2g.ready";
186}
187
188/// INVOIC receipt/payment events (`de.invoic.*`), emitted by `invoicd`.
189pub mod invoic {
190 /// Inbound INVOIC disputed (REMADV Ablehnung path).
191 pub const RECEIPT_DISPUTED: &str = "de.invoic.receipt.disputed";
192 /// Inbound INVOIC dispatched to the ERP.
193 pub const RECEIPT_DISPATCHED: &str = "de.invoic.receipt.dispatched";
194 /// Inbound INVOIC settled (REMADV Zahlungsavis).
195 pub const RECEIPT_SETTLED: &str = "de.invoic.receipt.settled";
196 /// Outbound INVOIC payment overdue (no REMADV in time).
197 pub const PAYMENT_OVERDUE: &str = "de.invoic.payment.overdue";
198}
199
200/// EEG/KWKG settlement events (`de.eeg.*`), emitted by `einsd`.
201///
202/// `agentd` additionally subscribes to the globs `de.eeg.*`,
203/// `de.eeg.anlage.*`, `de.eeg.verguetung.*`, `de.eeg.marktpraemie.*` and
204/// `de.eeg.compliance.*` (no concrete `de.eeg.compliance.*` type exists
205/// yet). There is deliberately no `de.eeg.*` catch-all subscription.
206///
207/// There is also deliberately **no generic `de.eeg.settlement.berechnet`**. A
208/// settlement announces itself as exactly one of [`eeg::VERGUETUNG_BERECHNET`] or
209/// [`eeg::MARKTPRAEMIE_BERECHNET`], chosen in `einsd::handlers::enqueue_settlement_ce`
210/// from the plant's Vergütungsmodell — and that choice *is* the payout gate:
211/// `accountingd` credits the Massenkontokorrent and issues the pain.001 on those
212/// two types and no other. Every entry point (REST, batch, the monthly worker
213/// and the MCP `trigger_settle` tool) goes through that one function, so a third
214/// type could only ever be a second announcement of a payout already announced.
215pub mod eeg {
216 /// Einspeisevergütung settlement computed (feed-in tariff schemes).
217 pub const VERGUETUNG_BERECHNET: &str = "de.eeg.verguetung.berechnet";
218 /// Marktprämie settlement computed (Direktvermarktung schemes).
219 pub const MARKTPRAEMIE_BERECHNET: &str = "de.eeg.marktpraemie.berechnet";
220 /// Monthly auto-settle batch trigger. Emitted by einsd's auto-settle worker
221 /// (`emit_batch_due_ce`); subscribed by agentd's `einsd-batch-agent`.
222 pub const SETTLEMENT_BATCH_DUE: &str = "de.eeg.settlement.batch-due";
223 /// EEG-Anlage Förderung ends within the warning window.
224 pub const ANLAGE_FOERDERUNG_AUSLAUFEND: &str = "de.eeg.anlage.foerderung-auslaufend";
225 /// EEG-Anlage MaStR registration confirmed.
226 pub const ANLAGE_MASTR_REGISTRIERT: &str = "de.eeg.anlage.mastr-registriert";
227 /// §21b Veräußerungsform switched.
228 pub const VERAEUSSERUNGSFORM_GEWECHSELT: &str = "de.eeg.veraeusserungsform.gewechselt";
229}
230
231/// FI-CA subledger events (`de.accounting.*`), emitted by `accountingd`.
232pub mod accounting {
233 /// Dunning notice (Mahnung) issued.
234 pub const MAHNUNG_ISSUED: &str = "de.accounting.mahnung.issued";
235 /// Abschlag (installment) posted.
236 pub const ABSCHLAG_POSTED: &str = "de.accounting.abschlag.posted";
237 /// Payment due notification.
238 pub const PAYMENT_DUE: &str = "de.accounting.payment.due";
239 /// Bank statement payment imported and matched.
240 pub const PAYMENT_IMPORTED: &str = "de.accounting.payment.imported";
241 /// Refund (Erstattung) due to the customer.
242 pub const ERSTATTUNG_FAELLIG: &str = "de.accounting.erstattung.faellig";
243 /// § 40b Abs. 1 EnWG — the annual reconciliation for one Marktlokation is
244 /// committed.
245 ///
246 /// Emitted **unconditionally**, on every settlement, whatever it came to.
247 /// [`ERSTATTUNG_FAELLIG`] is not a substitute: it fires only on a refund and
248 /// only where an ERP webhook is configured, so a Nachzahlung and an
249 /// exactly-balanced year would announce nothing.
250 ///
251 /// Carries the settlement, its components and the recalibrated monthly
252 /// Abschlag, so a consumer reacting to the annual cycle needs no second
253 /// call.
254 pub const JAHRESABSCHLUSS_ABGESCHLOSSEN: &str = "de.accounting.jahresabschluss.abgeschlossen";
255 /// Late-payment interest (§288 BGB) charged.
256 pub const INTEREST_CHARGED: &str = "de.accounting.interest.charged";
257 /// EEG payout rejected (e.g. missing bank data).
258 pub const EEG_PAYOUT_REJECTED: &str = "de.accounting.eeg.payout.rejected";
259 /// §41f Abs. 1 EnWG — Sperrandrohung: disconnection threatened after an
260 /// unresolved Mahnstufe-3 dunning case, opening the 4-Wochen-Frist.
261 pub const SPERRANDROHUNG: &str = "de.accounting.sperrandrohung";
262 /// §41f Abs. 5 EnWG — Sperrankündigung: the concrete disconnection date is
263 /// announced 8 Werktage in advance (after the 4-Wochen Androhung Frist).
264 pub const SPERRANKUENDIGUNG: &str = "de.accounting.sperrankuendigung";
265 /// §41f Abs. 1 EnWG — Sperrauftrag: **ORDERS 17115** dispatched to the
266 /// Netzbetreiber once the Androhung (4 Wochen) and Ankündigung (8 Werktage)
267 /// Fristen have elapsed, the Abs. 3 arrears gates still hold, and no
268 /// Abwendungsvereinbarung / Unverhältnismäßigkeit halted the sequence. The
269 /// event announces the market message; it does not carry it.
270 pub const SPERRAUFTRAG: &str = "de.accounting.sperrauftrag";
271 /// §41f Abs. 7 EnWG — Entsperrauftrag: **ORDERS 17117** dispatched once the
272 /// grounds for the interruption are gone (the arrears were settled). The
273 /// statute makes the restoration *unverzüglich* and unconditional on being
274 /// asked, so this follows automatically from the case settling.
275 pub const ENTSPERRAUFTRAG: &str = "de.accounting.entsperrauftrag";
276 /// §41g Abs. 1 S. 2 EnWG — the Grundversorger's offer of an
277 /// Abwendungsvereinbarung: due within one week of the customer demanding it
278 /// after the Androhung, and at the latest together with the Ankündigung.
279 /// Carries the interest-free instalment terms (§41g Abs. 1 S. 7–9).
280 pub const ABWENDUNG_ANGEBOTEN: &str = "de.accounting.abwendung.angeboten";
281 /// §41g Abs. 1 S. 11 EnWG — an accepted Abwendungsvereinbarung was broken.
282 /// The supplier may resume the interruption, but must re-observe §41f
283 /// Abs. 1 S. 2 and issue a **fresh** Ankündigung (§41f Abs. 5).
284 pub const ABWENDUNG_GEBROCHEN: &str = "de.accounting.abwendung.gebrochen";
285 /// SEPA direct-debit return (Bankrücklastschrift) — emitted by
286 /// `accountingd` when a camt.053/054 booking carries a return reason code
287 /// or debits the account, and subscribed by agentd's `payment-agent`.
288 pub const BANKRUECKLAST: &str = "de.accounting.bankruecklast";
289 /// A pain.002 rejected a submitted direct-debit collection: the money will
290 /// never arrive, so the receivable stays open and the mandate needs
291 /// attention (`AC01` wrong IBAN, `MD01` no mandate, `AM04` no funds).
292 ///
293 /// Distinct from [`BANKRUECKLAST`], which is a collection that *settled*
294 /// and was then returned — a different reconciliation, and a different
295 /// R-transaction fee.
296 pub const SEPA_COLLECTION_REJECTED: &str = "de.accounting.sepa.collection-rejected";
297 /// The creditor gave a settled collection back via pain.007.
298 pub const SEPA_REVERSAL_ISSUED: &str = "de.accounting.sepa.reversal-issued";
299 /// A camt.055 recall was sent for a submitted pain.008: the creditor is
300 /// asking the bank to stop a collection **before** it settles.
301 ///
302 /// Distinct from [`SEPA_REVERSAL_ISSUED`], which gives back a collection
303 /// that already settled — a different message, a different moment, and a
304 /// different reconciliation.
305 pub const SEPA_RECALL_REQUESTED: &str = "de.accounting.sepa.recall-requested";
306 /// The bank answered a camt.055 with a camt.029. The outcome is `ACCR`
307 /// (stopped), `RJCR` (the collection stands), `PDCR` (still open) or `PACR`
308 /// (some of the named transactions).
309 pub const SEPA_RECALL_RESOLVED: &str = "de.accounting.sepa.recall-resolved";
310 /// Verification of Payee reported something other than a match for an
311 /// outgoing credit transfer. Mandatory for euro credit transfers since
312 /// 9 October 2025: executing after a `RVNM` no-match shifts liability to
313 /// the payer, so this is a decision an operator has to make.
314 pub const PAYEE_VERIFICATION_MISMATCH: &str = "de.accounting.payee.verification-mismatch";
315}
316
317/// MaBiS/Netzbilanzierung INVOIC events (`de.netzbilanz.*`), emitted by
318/// `netzbilanzd`.
319pub mod netzbilanz {
320 /// A Netzbetreiber invoice was settled and stored as a draft.
321 pub const INVOIC_DRAFTED: &str = "de.netzbilanz.invoic.drafted";
322 /// The invoice was handed to `makod` for EDIFACT dispatch.
323 pub const INVOIC_DISPATCHED: &str = "de.netzbilanz.invoic.dispatched";
324 /// A draft is still undispatched past its window.
325 pub const INVOIC_DISPATCH_OVERDUE: &str = "de.netzbilanz.invoic.dispatch-overdue";
326 /// The counterparty confirmed payment (REMADV 33001, the only Bestätigung).
327 pub const INVOIC_PAID: &str = "de.netzbilanz.invoic.paid";
328 /// The counterparty rejected the invoice (REMADV 33002/33003/33004).
329 pub const INVOIC_DISPUTED: &str = "de.netzbilanz.invoic.disputed";
330 /// Kostenblatt computed.
331 pub const KOSTENBLATT_COMPUTED: &str = "de.netzbilanz.kostenblatt.computed";
332 /// Kostenblatt submission deadline approaching.
333 pub const KOSTENBLATT_DEADLINE_APPROACHING: &str =
334 "de.netzbilanz.kostenblatt.deadline-approaching";
335}
336
337/// Meter-reading / energy-data events (`de.messwert.*`), emitted by `edmd`.
338///
339/// Renamed from the legacy `de.edmd.*` prefix — the context is the
340/// Messwert (meter value), not the daemon that happens to store it.
341pub mod messwert {
342 /// Hampel grade C/F, or any V-rule finding, on newly ingested readings.
343 pub const READING_QUALITY_WARNING: &str = "de.messwert.reading.quality.warning";
344 /// Direct iMSys/SMGW push stored.
345 pub const READING_DIRECT_STORED: &str = "de.messwert.reading.direct.stored";
346 /// Ablesesteuerung reading order failed.
347 pub const READING_ORDER_FAILED: &str = "de.messwert.reading.order.failed";
348 /// Expected reading confirmation overdue.
349 pub const READING_CONFIRMATION_OVERDUE: &str = "de.messwert.reading.confirmation.overdue";
350 /// A measuring point has stopped delivering, or is delivering too little of
351 /// the settlement window to bill.
352 ///
353 /// The counterpart to [`READING_QUALITY_WARNING`], which can only fire on
354 /// data that *arrived*. Silence produces no ingest and therefore no
355 /// validation, so without this a head-end that simply stops is invisible
356 /// until a settlement run comes up short — by which point the window in
357 /// which the values could still have been re-read has closed
358 /// (§ 60 Abs. 1 MsbG — the duty is to transmit „zu den Zeitpunkten …,
359 /// die diese … vorgeben", which is what a stalled head-end breaches;
360 /// Abs. 2 is a Soll-rule about processing inside the Smart-Meter-Gateway).
361 pub const READING_DELIVERY_OVERDUE: &str = "de.messwert.reading.delivery.overdue";
362 /// A measuring point that was overdue is delivering again.
363 pub const READING_DELIVERY_RESUMED: &str = "de.messwert.reading.delivery.resumed";
364 /// §14a SMGW/CLS compliance issue **opened** (§ 25 MsbG monitoring duty).
365 ///
366 /// Fires on the transition into a fault, not on every sweep that still sees
367 /// it — see `cls_compliance_issues`.
368 pub const CLS_COMPLIANCE_ISSUE: &str = "de.messwert.cls.compliance-issue";
369 /// A §14a SMGW/CLS compliance issue a later sweep no longer finds.
370 pub const CLS_COMPLIANCE_RESOLVED: &str = "de.messwert.cls.compliance-resolved";
371 /// An **ESA Typ-2** subscription has stopped delivering.
372 ///
373 /// Distinct from [`READING_DELIVERY_OVERDUE`], which watches the
374 /// authoritative Typ-1 stream: a Typ-2 gap breaches the §60 Abs. 1 MsbG
375 /// delivery duty toward one Energieserviceanbieter and reaches no billing
376 /// run that could come up short, so nothing else would notice it. Mixing
377 /// the two would also cross the Typ-1/Typ-2 separation the whole store
378 /// split exists to keep (Codeliste der Konfigurationen 1.4 Kap. 4.6).
379 pub const ESA_TYP2_DELIVERY_OVERDUE: &str = "de.messwert.esa.typ2.delivery.overdue";
380 /// An ESA Typ-2 subscription that was overdue is delivering again.
381 pub const ESA_TYP2_DELIVERY_RESUMED: &str = "de.messwert.esa.typ2.delivery.resumed";
382 /// SMGW certificate approaching expiry — tiered advance warning at 90 / 30 /
383 /// 7 days before `valid_to`, once per tier per certificate.
384 ///
385 /// The ladder is operational, not statutory: BSI TR-03109-4 binds
386 /// certificate runtimes while the Root-CP fixes the renewal lead time and
387 /// the Zertifikatswechsel overlap. An expired certificate silently ends §14a
388 /// Fernsteuerbarkeit.
389 pub const SMGW_CERT_EXPIRY_WARNING: &str = "de.messwert.smgw.cert.expiry-warning";
390}
391
392/// Product & tariff catalog events (`de.tarif.*`), emitted by `productd`.
393///
394/// Renamed from the legacy `de.tarifbd.*` prefix (and the stray top-level
395/// `de.angebot.angenommen`).
396pub mod tarif {
397 /// Product created/updated in the catalog.
398 pub const PRODUCT_UPDATED: &str = "de.tarif.product.updated";
399 /// B2B Angebot accepted — vertragd auto-creates the Rahmenvertrag.
400 pub const ANGEBOT_ANGENOMMEN: &str = "de.tarif.angebot.angenommen";
401 /// ⚠ phantom: B2B quote expired. Subscribed by agentd (`productd-agent`),
402 /// emitted by nothing — `productd` expires an Angebot in place without
403 /// announcing it.
404 pub const ANGEBOT_ABGELAUFEN: &str = "de.tarif.angebot.abgelaufen";
405 /// ⚠ phantom: EPEX D-1 prices not imported by 18:00 CET. Subscribed by
406 /// agentd (`productd-agent`), emitted by nothing — no worker watches the
407 /// import window.
408 pub const EPEX_MISSING: &str = "de.tarif.epex.missing";
409}
410
411/// Contract lifecycle events (`de.vertrag.*`), emitted by `vertragd`.
412///
413/// `agentd` additionally subscribes to the glob `de.vertrag.*`.
414pub mod vertrag {
415 /// All components NB-confirmed — billing may start.
416 pub const AKTIV: &str = "de.vertrag.aktiv";
417 /// Lieferende dispatched (Rahmenvertrag cascade, per child).
418 pub const GEKUENDIGT: &str = "de.vertrag.gekuendigt";
419 /// Kündigung accepted; the Lieferende and the Schlussablesung are
420 /// enqueued. Carries the § 41 Abs. 8 Nr. 2 EnWG Textform confirmation the
421 /// supplier owes the customer — the document is produced downstream, the
422 /// instruction to produce it commits with the termination.
423 ///
424 /// A cascade Kündigung over a Rahmenvertrag emits [`GEKUENDIGT`] per child
425 /// instead, because the framework contract is what was terminated.
426 pub const KUENDIGUNG: &str = "de.vertrag.kuendigung";
427 /// Kündigung withdrawn before Lieferende.
428 pub const KUENDIGUNG_WIDERRUFEN: &str = "de.vertrag.kuendigung-widerrufen";
429 /// Product change applied immediately.
430 pub const TARIFWECHSEL: &str = "de.vertrag.tarifwechsel";
431 /// Future-dated product change stored.
432 pub const TARIFWECHSEL_GEPLANT: &str = "de.vertrag.tarifwechsel-geplant";
433 /// Price guarantee stored/replaced.
434 pub const PREISGARANTIE_HINTERLEGT: &str = "de.vertrag.preisgarantie-hinterlegt";
435 /// § 41 Abs. 5 EnWG price-change notice. Sent as soon as the change is
436 /// scheduled — Satz 2 is a floor, not a ceiling — and carrying the regime
437 /// that applied plus the Satz 4 Sonderkündigungsrecht.
438 pub const PREISAENDERUNG_ANKUENDIGUNG: &str = "de.vertrag.preisaenderung.ankuendigung";
439 /// 30 days before auto-renewal.
440 pub const AUTOERNEUERUNG_ANKUENDIGUNG: &str = "de.vertrag.autoerneuerung.ankuendigung";
441 /// 30 days before vertragsende / preisgarantie_bis.
442 pub const ABLAUF_ANKUENDIGUNG: &str = "de.vertrag.ablauf.ankuendigung";
443 /// Supply has actually ended: every commodity has passed its Lieferende and
444 /// the contract is `ABGELAUFEN`. Distinct from [`GEKUENDIGT`], which is the
445 /// day the termination was *accepted* — months earlier for a notice period
446 /// that long, and with supply and invoicing running throughout. This is the
447 /// event a Schlussrechnung and the § 147 AO retention clock hang off.
448 pub const ABGESCHLOSSEN: &str = "de.vertrag.abgeschlossen";
449}
450
451/// Virtual-power-plant events (`de.vpp.*`).
452pub mod vpp {
453 /// VPP dispatch confirmed (ERP event type, emitted via mako-engine).
454 pub const DISPATCH_CONFIRMED: &str = "de.vpp.dispatch.confirmed";
455 /// VPP settlement computed (emitted by `billingd`).
456 pub const SETTLEMENT_BERECHNET: &str = "de.vpp.settlement.berechnet";
457}
458
459/// Agent runtime events (`de.agent.*`), emitted by `agentd`.
460pub mod agent {
461 /// An agent run reached a terminal state and produced a decision.
462 ///
463 /// Carries the run's outcome — `completed`, `failed`, `suspended`,
464 /// `exhausted`, `quarantined`, `replanning`, `cancelled` or
465 /// `not-admitted` — so a subscriber sees a run awaiting human approval as
466 /// readily as a successful one. Beside it: `run_id` (the journal key, which
467 /// `GET /api/v1/oversight/runs/{run_id}` takes), `waiting_for` (present only
468 /// when suspended — an approval, a message, an instant) and `tokens`.
469 ///
470 /// There is no separate dead-letter event: a run that fails is resumable
471 /// from its journal rather than a message that has nowhere left to go.
472 pub const DECISION_MADE: &str = "de.agent.decision.made";
473}
474
475/// GaBi Gas balancing events (`de.gabi.*`), defined in `mako-gabi-gas`.
476///
477/// # What is emitted
478///
479/// Four types, all through the same path: a `mako-gabi-gas` workflow enqueues a
480/// `PendingOutbox` entry whose `message_type` string
481/// `makod::core::erp_adapter::map_message_type_to_erp_event` maps to an
482/// `ErpEventType`, and `OutboxErpWorker` delivers that as a CloudEvent.
483/// [`gabi::ALOCAT_MISSING`] comes from `gabi-gas-allocation`; [`gabi::NOMINATION_CURTAILED`],
484/// [`gabi::NOMINATION_REJECTED`] and [`gabi::NOMRES_MISSING`] from `gabi-gas-nomination`.
485///
486/// # What is not: the success path is silent
487///
488/// All four are failures. GaBi Gas announces **nothing** when a gas day goes
489/// right — and unlike every other domain here, it has no generic fallback
490/// either: `mako-gabi-gas` never enqueues a `ProcessInitiated` or
491/// `ProcessCompleted` entry, so its ALOCAT and NOMINT/NOMRES streams put no
492/// `de.mako.process.*` event on the bus for a subscriber to fall back on. A BKV
493/// watching `de.gabi.*` sees a curtailment and never sees a confirmation.
494///
495/// That is a feature gap, not dead vocabulary, and the ten phantom constants
496/// below are the shape it would take. What each still needs:
497///
498/// | Constant | Where it would come from | What is missing |
499/// |---|---|---|
500/// | [`gabi::NOMINATION_CONFIRMED`] | `nomination::ReceiveNomres`, the `NomresAcceptance::Accepted` arm — the one arm of four that builds an empty outbox while its three siblings enqueue | only the wiring: the state and the payload are already in hand |
501/// | [`gabi::ALLOCATION_COMPLETED`] | `allocation::ReceiveAlocat` with `AllocationVersion::Final`, the moment `AllocationState::is_settled` turns true and the gas day's imbalance becomes settleable | only the wiring |
502/// | [`gabi::CORRECTION_CREATED`] | the same arm with `AllocationVersion::Correction(n)` (§ 46 KoV XV) | only the wiring |
503/// | [`gabi::NOMINATION_CREATED`] | `nomination::SendNomint`, beside the NOMINT it already puts on the wire | only the wiring |
504/// | [`gabi::IMBALANCE_CALCULATED`] | `GasImbalanceSaldo::calculate` exists and is tested, but nothing calls it | a process. Nomination and allocation are separate streams keyed differently, so no single workflow holds both sides of one gas day |
505/// | [`gabi::GAS_QUALITY_VIOLATION`] | `GasBeschaffenheit::validate` (DVGW G 260 ranges) exists and is tested, but nothing calls it | an inbound source. Gasbeschaffenheitsdaten arrive on MSCONS 13007, which is a `mako-geli-gas` PID — no GaBi Gas message carries a Brennwert to check |
506/// | [`gabi::MEASUREMENT_RECEIVED`] | MSCONS 13013, the Gas Allokationsliste | nothing, and it should stay unemitted: `gabi-gas-mmma` is a PID registration with no workflow of its own, and `makod` delegates 13013 to `GpkeAllokationslisteWorkflow`. The fact is already announced under `gpke-allokationsliste` |
507/// | [`gabi::INVOIC_MMM_RECEIVED`] (31007/31008), [`gabi::INVOIC_KAPAZITAET_RECEIVED`] (31010) | the shared `mako_invoic::InvoicWorkflow` | nothing, and these should stay unemitted: that machine already enqueues `ProcessInitiated` carrying `data.workflow = "gabi-gas-invoic"` and `data.pid`, so both facts are on the bus. A `de.gabi.*` twin would name them a second time and would require the family-generic machine to grow a per-family hook |
508/// | [`gabi::FINAL_ALOCAT_DEADLINE`] | — | nothing: it duplicates [`gabi::ALOCAT_MISSING`], which is what the § 47 Ziffer 1 window closing unsettled already means |
509///
510/// Wiring the first four is a three-file change and none of the files is this
511/// one: a `PendingOutbox` in `mako-gabi-gas`, an `ErpEventType` variant plus its
512/// `cloud_event_type()` arm in `mako-engine`, and a `map_message_type_to_erp_event`
513/// arm in `makod`. Nothing fires until all three land — an unmapped
514/// `message_type` is skipped by `OutboxErpWorker`, silently.
515///
516/// # Nothing subscribes either
517///
518/// `gabi-gas-agent` triggers only on [`gabi::ALOCAT_MISSING`], and agentd pins the
519/// absence of an imbalance pattern. So an emitter for any of the ten has to
520/// arrive with a subscriber, or it swaps one silence for another.
521pub mod gabi {
522 /// ⚠ phantom: no emitter yet.
523 pub const MEASUREMENT_RECEIVED: &str = "de.gabi.measurement.received";
524 /// ⚠ phantom: no emitter yet.
525 pub const ALLOCATION_COMPLETED: &str = "de.gabi.allocation.completed";
526 /// ⚠ phantom: no emitter yet.
527 pub const NOMINATION_CREATED: &str = "de.gabi.nomination.created";
528 /// ⚠ phantom: no emitter yet.
529 pub const NOMINATION_CONFIRMED: &str = "de.gabi.nomination.confirmed";
530 /// The FNB/MGV confirmed **less** than was nominated.
531 ///
532 /// Emitted by `makod` from `gabi-gas-nomination` when the NOMRES states a
533 /// quantity below the nomination's. The BKV's portfolio is short by the
534 /// difference until it re-nominates or buys the gap, so the `data` payload
535 /// carries `gas_day`, `nominated_kwh`, `confirmed_kwh` and `curtailed_kwh`
536 /// beside the parties.
537 pub const NOMINATION_CURTAILED: &str = "de.gabi.nomination.curtailed";
538 /// The FNB/MGV refused the nomination.
539 ///
540 /// Emitted by `makod` from `gabi-gas-nomination`; the `data` payload
541 /// carries `gas_day`, `reason` and the parties. Nothing flows on this
542 /// nomination, so the BKV must re-nominate.
543 pub const NOMINATION_REJECTED: &str = "de.gabi.nomination.rejected";
544 /// The `KoV` NOMRES window closed with no answer on file.
545 ///
546 /// Emitted by `makod` when the `gabi-gas-nomination` deadline fires: the
547 /// nomination's status is unknown at gas-day start, which is an operator
548 /// call rather than something to assume either way.
549 pub const NOMRES_MISSING: &str = "de.gabi.nomres.missing";
550 /// ⚠ phantom: no emitter yet.
551 pub const IMBALANCE_CALCULATED: &str = "de.gabi.imbalance.calculated";
552 /// ⚠ phantom: no emitter yet.
553 pub const CORRECTION_CREATED: &str = "de.gabi.correction.created";
554 /// ⚠ phantom: no emitter yet.
555 pub const INVOIC_MMM_RECEIVED: &str = "de.gabi.invoic.mmm.received";
556 /// ⚠ phantom: no emitter yet.
557 pub const INVOIC_KAPAZITAET_RECEIVED: &str = "de.gabi.invoic.kapazitaet.received";
558 /// The § 47 Ziffer 1 KoV XV final-allocation window closed with no binding final
559 /// ALOCAT on file, so the gas day's imbalance cannot be settled.
560 ///
561 /// Emitted by `makod` from the `gabi-gas-allocation` deadline via
562 /// `ErpEventType::GabiFinalAllocationOverdue`. The `data` payload carries
563 /// `gas_day`, `deadline_label`, `sender_eic`, `receiver_eic` and
564 /// `pruefidentifikator` — the key the emitter actually writes
565 /// (`mako_gabi_gas::allocation`). The operator's action is to open a
566 /// Clearingfall with the FNB/MGV.
567 pub const ALOCAT_MISSING: &str = "de.gabi.alocat.missing";
568 /// ⚠ phantom: no emitter yet.
569 pub const GAS_QUALITY_VIOLATION: &str = "de.gabi.quality.violation";
570 /// ⚠ phantom: no emitter yet.
571 pub const FINAL_ALOCAT_DEADLINE: &str = "de.gabi.alocat.final.deadline";
572}
573
574/// Process-observability events (`de.obs.*`).
575///
576/// Produced by `obsd`'s background sweep workers (`services/obsd/src/worker.rs`)
577/// and consumed by `agentd` (`compliance-agent`, `deadline-alert-agent`).
578pub mod obs {
579 /// §20 EnWG STP parity-gap alert: the completion-rate gap between affiliate-
580 /// and non-affiliate-initiated Anmeldungen exceeds the configured threshold.
581 /// Emitted by obsd's parity sweep; consumed by agentd (`compliance-agent`).
582 pub const STP_PARITY_ALERT: &str = "de.obs.stp.parity.alert";
583 /// A tracked process is approaching its regulatory response deadline (within
584 /// the warn window). Emitted per process by obsd's deadline sweep; consumed
585 /// by agentd (`deadline-alert-agent`).
586 pub const DEADLINE_APPROACHING: &str = "de.obs.deadline.approaching";
587}
588
589/// Sperr/Entsperr execution events (`de.sperr.*`), emitted by `sperrd`.
590///
591/// These are the **NB side**: the grid operator's record of a Sperr- or
592/// Entsperrauftrag it received (ORDERS 17115/17117) and physically carried out.
593/// They are not the LF's §41f notices — those are `de.accounting.sperr*`.
594///
595/// Consumed by agentd's `sperrd-agent`, which watches the execution SLA and the
596/// IFTSTA 21039 dispatch.
597pub mod sperr {
598 /// A Sperr-/Entsperrauftrag entered the field-service queue — either from an
599 /// inbound ORDERS 17115/17117 or from an operator creating one directly.
600 pub const AUFTRAG_EINGEGANGEN: &str = "de.sperr.auftrag.eingegangen";
601 /// The field team carried the order out. The IFTSTA 21039 reporting
602 /// `STS+Z37/Z38 → Z14 erfolgreich` has been handed to `makod`.
603 pub const AUSGEFUEHRT: &str = "de.sperr.ausgefuehrt";
604 /// The order could not be carried out (`Z13 gescheitert`) — meter access
605 /// denied, safety block, address not found. Carries the EBD Prüfschritt code
606 /// so the LF learns *why* instead of waiting out its ORDRSP deadline.
607 pub const FEHLGESCHLAGEN: &str = "de.sperr.fehlgeschlagen";
608 /// A Sperrversuch did not succeed but the order stays in the queue: GPKE
609 /// Teil 2 § 3.5.1.2 Nr. 5 gives the NB **two** Sperrversuche within one
610 /// Sperrauftrag, and only the second turns into `FEHLGESCHLAGEN`.
611 pub const VERSUCH_GESCHEITERT: &str = "de.sperr.versuch.gescheitert";
612 /// A pending order was withdrawn before execution (operator action, or an
613 /// inbound ORDCHG 39000 Stornierung). No IFTSTA is dispatched.
614 pub const STORNIERT: &str = "de.sperr.storniert";
615 /// A pending order is past the window GPKE Teil 2 § 3.5.1.2 Nr. 1 gives the
616 /// NB for the physical act — 6 Werktage after the frühestmöglicher
617 /// Sperrtermin. Announced once per order.
618 pub const AUSFUEHRUNG_UEBERFAELLIG: &str = "de.sperr.ausfuehrung.ueberfaellig";
619 /// An order is terminal in `sperrd` but its IFTSTA 21039 has still not
620 /// reached `makod` after the retry budget. Until it does, the LF's
621 /// `gpke-sperrung-lf` process cannot close — this is the one state in the
622 /// service that needs a human.
623 pub const IFTSTA_AUSSTEHEND: &str = "de.sperr.iftsta.ausstehend";
624}
625
626/// MaBiS Summenzeitreihe submission events (`de.mabis.*`), emitted by
627/// `mabis-syncd`.
628///
629/// Both are failure signals: a healthy submission cycle is silent, because the
630/// scheduled Erstaufschlag run filing on time is the normal case and an event
631/// per success would be noise nobody subscribes to.
632pub mod mabis {
633 /// A Summenzeitreihe aggregation or BIKO submission failed
634 /// (BK6-24-174 Anlage 3 §3.10). Carries the run id, the
635 /// Bilanzierungsgebiet, the period, the phase and `attempt_count` — after
636 /// three attempts the scheduler stops retrying and a human has to look.
637 pub const SUBMISSION_FAILED: &str = "de.mabis.submission.failed";
638 /// A negative Prüfmitteilung opened a Korrekturbedarf (§9.8.1): the BIKO
639 /// or a BKV objected to a filed Summenzeitreihe, and a corrected version
640 /// must be submitted within the Clearing window.
641 pub const KORREKTURBEDARF_OPENED: &str = "de.mabis.korrekturbedarf.opened";
642}
643
644/// Every concrete CloudEvents type in the catalog.
645#[must_use]
646pub fn all() -> &'static [&'static str] {
647 &[
648 // de.mako.*
649 mako::PROCESS_INITIATED,
650 mako::PROCESS_COMPLETED,
651 mako::PROCESS_FAILED,
652 mako::APERAK_ACCEPTED,
653 mako::APERAK_REJECTED,
654 mako::APERAK_TIMEOUT,
655 mako::CONTRL_RECEIVED,
656 mako::MALO_IDENTIFIED,
657 mako::ABMELDEANFRAGE_BEANTWORTET,
658 mako::EDIFACT_OUTBOUND,
659 mako::NETZZUGANG_UEBERMITTLUNGSBEDARF,
660 // de.markt.*
661 markt::MALO_UPDATED,
662 markt::MALO_STAMMDATEN_GEAENDERT,
663 markt::STAMMDATEN_GEAENDERT,
664 markt::MELO_UPDATED,
665 markt::PARTNER_UPDATED,
666 markt::NB_CONTRACT_UPDATED,
667 markt::MSB_RAHMENVERTRAG_GAS_UPDATED,
668 markt::GERAET_KONFIGURATION_UPDATED,
669 markt::SR_KONFIGURATIONSPRODUKT_UPDATED,
670 markt::NETZZUGANG_ANTRAG_UPDATED,
671 markt::EINWILLIGUNG_ERTEILT,
672 markt::EINWILLIGUNG_WIDERRUFEN,
673 markt::VERSORGUNG_CHANGED,
674 markt::VERSORGUNG_BELIEFERT,
675 markt::VERSORGUNG_GAP_DETECTED,
676 markt::VERSORGUNG_EOG_BEGONNEN,
677 markt::VERSORGUNG_ERSATZ_AUSLAUFEND,
678 markt::PRICAT_PUBLISHED,
679 markt::MMMA_IMPORT_SUCCESS,
680 markt::MMMA_IMPORT_FAILED,
681 markt::SUBSCRIPTION_TEST,
682 // de.billing.*
683 billing::RECHNUNG_ERSTELLT,
684 billing::ABRECHNUNGSINFORMATION_MONATLICH,
685 billing::XRECHNUNG_B2G_READY,
686 // de.invoic.*
687 invoic::RECEIPT_DISPUTED,
688 invoic::RECEIPT_DISPATCHED,
689 invoic::RECEIPT_SETTLED,
690 invoic::PAYMENT_OVERDUE,
691 // de.eeg.*
692 eeg::VERGUETUNG_BERECHNET,
693 eeg::MARKTPRAEMIE_BERECHNET,
694 eeg::SETTLEMENT_BATCH_DUE,
695 eeg::ANLAGE_FOERDERUNG_AUSLAUFEND,
696 eeg::ANLAGE_MASTR_REGISTRIERT,
697 eeg::VERAEUSSERUNGSFORM_GEWECHSELT,
698 // de.accounting.*
699 accounting::MAHNUNG_ISSUED,
700 accounting::ABSCHLAG_POSTED,
701 accounting::PAYMENT_DUE,
702 accounting::PAYMENT_IMPORTED,
703 accounting::ERSTATTUNG_FAELLIG,
704 accounting::INTEREST_CHARGED,
705 accounting::EEG_PAYOUT_REJECTED,
706 accounting::JAHRESABSCHLUSS_ABGESCHLOSSEN,
707 accounting::SPERRANDROHUNG,
708 accounting::SPERRANKUENDIGUNG,
709 accounting::SPERRAUFTRAG,
710 accounting::ENTSPERRAUFTRAG,
711 accounting::ABWENDUNG_ANGEBOTEN,
712 accounting::ABWENDUNG_GEBROCHEN,
713 accounting::BANKRUECKLAST,
714 accounting::SEPA_COLLECTION_REJECTED,
715 accounting::SEPA_REVERSAL_ISSUED,
716 accounting::SEPA_RECALL_REQUESTED,
717 accounting::SEPA_RECALL_RESOLVED,
718 accounting::PAYEE_VERIFICATION_MISMATCH,
719 // de.netzbilanz.*
720 netzbilanz::INVOIC_DRAFTED,
721 netzbilanz::INVOIC_DISPATCHED,
722 netzbilanz::INVOIC_DISPATCH_OVERDUE,
723 netzbilanz::INVOIC_PAID,
724 netzbilanz::INVOIC_DISPUTED,
725 netzbilanz::KOSTENBLATT_COMPUTED,
726 netzbilanz::KOSTENBLATT_DEADLINE_APPROACHING,
727 // de.messwert.*
728 messwert::READING_QUALITY_WARNING,
729 messwert::READING_DIRECT_STORED,
730 messwert::READING_ORDER_FAILED,
731 messwert::READING_CONFIRMATION_OVERDUE,
732 messwert::READING_DELIVERY_OVERDUE,
733 messwert::READING_DELIVERY_RESUMED,
734 messwert::ESA_TYP2_DELIVERY_OVERDUE,
735 messwert::ESA_TYP2_DELIVERY_RESUMED,
736 messwert::CLS_COMPLIANCE_ISSUE,
737 messwert::CLS_COMPLIANCE_RESOLVED,
738 messwert::SMGW_CERT_EXPIRY_WARNING,
739 // de.tarif.*
740 tarif::PRODUCT_UPDATED,
741 tarif::ANGEBOT_ANGENOMMEN,
742 tarif::ANGEBOT_ABGELAUFEN,
743 tarif::EPEX_MISSING,
744 // de.vertrag.*
745 vertrag::ABGESCHLOSSEN,
746 vertrag::AKTIV,
747 vertrag::GEKUENDIGT,
748 vertrag::KUENDIGUNG,
749 vertrag::KUENDIGUNG_WIDERRUFEN,
750 vertrag::TARIFWECHSEL,
751 vertrag::TARIFWECHSEL_GEPLANT,
752 vertrag::PREISGARANTIE_HINTERLEGT,
753 vertrag::PREISAENDERUNG_ANKUENDIGUNG,
754 vertrag::AUTOERNEUERUNG_ANKUENDIGUNG,
755 vertrag::ABLAUF_ANKUENDIGUNG,
756 // de.vpp.*
757 vpp::DISPATCH_CONFIRMED,
758 vpp::SETTLEMENT_BERECHNET,
759 // de.agent.*
760 agent::DECISION_MADE,
761 // de.gabi.*
762 gabi::MEASUREMENT_RECEIVED,
763 gabi::ALLOCATION_COMPLETED,
764 gabi::NOMINATION_CREATED,
765 gabi::NOMINATION_CURTAILED,
766 gabi::NOMINATION_REJECTED,
767 gabi::NOMRES_MISSING,
768 gabi::NOMINATION_CONFIRMED,
769 gabi::IMBALANCE_CALCULATED,
770 gabi::CORRECTION_CREATED,
771 gabi::INVOIC_MMM_RECEIVED,
772 gabi::INVOIC_KAPAZITAET_RECEIVED,
773 gabi::ALOCAT_MISSING,
774 gabi::GAS_QUALITY_VIOLATION,
775 gabi::FINAL_ALOCAT_DEADLINE,
776 // de.obs.*
777 obs::STP_PARITY_ALERT,
778 obs::DEADLINE_APPROACHING,
779 // de.sperr.*
780 sperr::AUFTRAG_EINGEGANGEN,
781 sperr::VERSUCH_GESCHEITERT,
782 sperr::AUSFUEHRUNG_UEBERFAELLIG,
783 sperr::AUSGEFUEHRT,
784 sperr::FEHLGESCHLAGEN,
785 sperr::STORNIERT,
786 sperr::IFTSTA_AUSSTEHEND,
787 // de.mabis.*
788 mabis::SUBMISSION_FAILED,
789 mabis::KORREKTURBEDARF_OPENED,
790 ]
791}
792
793/// The canonical event-type pattern matcher, shared by every subscription
794/// mechanism in the workspace (marktd webhook subscriptions, agentd trigger
795/// patterns).
796///
797/// Semantics: `*` matches any (possibly empty) sequence, `?` matches exactly
798/// one character, everything else is literal. A bare `*` matches everything.
799/// Trailing-`*` prefix patterns (`de.mako.*`) therefore behave exactly like
800/// the historical marktd prefix matcher, and mid-pattern globs
801/// (`de.*.rechnung.*`) work too.
802///
803/// There is deliberately ONE implementation: before 2026-07 marktd and agentd
804/// each carried their own with silently different semantics (exact+prefix vs
805/// full glob).
806#[must_use]
807pub fn matches(pattern: &str, event_type: &str) -> bool {
808 if pattern == "*" {
809 return true;
810 }
811 let p: Vec<char> = pattern.chars().collect();
812 let v: Vec<char> = event_type.chars().collect();
813 let mut pi = 0usize;
814 let mut vi = 0usize;
815 let mut star_pi: Option<usize> = None;
816 let mut star_vi = 0usize;
817
818 while vi < v.len() {
819 if pi < p.len() && (p[pi] == '?' || p[pi] == v[vi]) {
820 pi += 1;
821 vi += 1;
822 } else if pi < p.len() && p[pi] == '*' {
823 star_pi = Some(pi);
824 star_vi = vi;
825 pi += 1;
826 } else if let Some(sp) = star_pi {
827 pi = sp + 1;
828 star_vi += 1;
829 vi = star_vi;
830 } else {
831 return false;
832 }
833 }
834 while pi < p.len() && p[pi] == '*' {
835 pi += 1;
836 }
837 pi == p.len()
838}
839
840#[cfg(test)]
841mod matcher_tests {
842 use super::matches;
843
844 #[test]
845 fn exact_and_bare_star() {
846 assert!(matches(
847 super::mako::PROCESS_INITIATED,
848 super::mako::PROCESS_INITIATED
849 ));
850 assert!(!matches(
851 super::mako::PROCESS_INITIATED,
852 super::mako::PROCESS_COMPLETED
853 ));
854 assert!(matches("*", "de.anything.at.all"));
855 }
856
857 #[test]
858 fn trailing_star_is_prefix_match() {
859 // The historical marktd subscription semantics.
860 assert!(matches("de.mako.*", "de.mako.process.initiated"));
861 assert!(matches("de.markt.*", "de.markt.malo.updated"));
862 assert!(!matches("de.mako.*", "de.markt.malo.updated"));
863 // Prefix boundary is character-wise, exactly like the old
864 // `starts_with(trim_end_matches('*'))`.
865 assert!(matches("de.mako.process.*", "de.mako.process.failed"));
866 }
867
868 #[test]
869 fn mid_glob_and_question_mark() {
870 assert!(matches(
871 "de.*.rechnung.erstellt",
872 "de.billing.rechnung.erstellt"
873 ));
874 assert!(matches(
875 "de.e?g.verguetung.berechnet",
876 "de.eeg.verguetung.berechnet"
877 ));
878 assert!(!matches(
879 "de.e?g.verguetung.berechnet",
880 "de.eeeg.verguetung.berechnet"
881 ));
882 }
883
884 #[test]
885 fn empty_pattern_matches_only_empty() {
886 assert!(matches("", ""));
887 assert!(!matches("", "de.x"));
888 }
889}
890
891#[cfg(test)]
892mod tests {
893 use super::all;
894
895 #[test]
896 fn every_type_starts_with_de_prefix() {
897 for ty in all() {
898 assert!(ty.starts_with("de."), "{ty} must start with `de.`");
899 }
900 }
901
902 /// Segments are dot-separated; multi-word segments join with `-`, never `_`.
903 ///
904 /// The catalog is a published contract, so a drifting separator means two
905 /// spellings of the same concept reach subscribers. Hyphen is the single
906 /// convention; namespaces whose domain vocabulary is German keep German
907 /// participles.
908 ///
909 /// `de.vertrag.*` is the clearest case: every event is a contract-lifecycle
910 /// fact named in the language the contract itself uses — `gekuendigt`,
911 /// `kuendigung-widerrufen`, `tarifwechsel-geplant`, `abgelaufen`. One event
912 /// used an English participle on a German noun
913 /// (`de.vertrag.preisgarantie-updated`), so a subscriber reading the
914 /// namespace had to know which of two languages each fact was named in.
915 ///
916 /// This is deliberately narrow. It is **not** a rule that every event must
917 /// be German: `de.mako.*` (EDIFACT transport), `de.gabi.*` and
918 /// `de.accounting.*` are technical or English-domain namespaces and stay
919 /// English. Nor does it merge `.updated` into `.geaendert` elsewhere —
920 /// in `de.markt.*` those are different facts (`malo.updated` is any
921 /// master-data write; `malo.stammdaten-geaendert` is a regulated GPKE
922 /// Stammdatenänderung carrying the applied patch for audit), and collapsing
923 /// them would lose a distinction the ERP relies on.
924 /// A constant nothing references must say so.
925 ///
926 /// `⚠ phantom:` marks a type the catalog declares but no service emits. A
927 /// marker only helps if it is true, and prose about which constants those
928 /// are rots faster than the constants do.
929 ///
930 /// So the annotation is checked rather than trusted: any constant not named
931 /// anywhere outside this crate must carry the marker.
932 ///
933 /// The reverse does not hold, and must not: being *named* outside this crate
934 /// is not being emitted. `de.eeg.compliance.*` is subscribed by two agentd
935 /// specialists with no emitter behind it, and several `de.gabi.*` phantoms
936 /// are named only by their own crate's unit tests. A constant may therefore
937 /// carry the marker while this test would not have demanded it — the
938 /// marker's claim is about emission, the test only catches the loudest way
939 /// to violate it.
940 /// **Every declared type is in the catalogue.**
941 ///
942 /// `all()` is what a subscriber is checked against, what an inventory
943 /// counts, and what a doc table is generated from. A `pub const` missing
944 /// from it is a published type nothing can subscribe to: `agentd`'s
945 /// subscription guard refuses a pattern that matches no catalogue entry, so
946 /// a specialist that should watch it *cannot be wired* — and the refusal
947 /// names the pattern, which reads like a typo rather than like a gap here.
948 ///
949 /// Three of accountingd's own emitted types were in that state:
950 /// `sepa.collection-rejected`, `sepa.reversal-issued` and
951 /// `payee.verification-mismatch`. All three passed the phantom check, which
952 /// asks the opposite question — *is this referenced anywhere* — and cannot
953 /// see a constant that is emitted and uncatalogued.
954 #[test]
955 fn every_declared_constant_is_in_the_catalogue() {
956 let src = include_str!("lib.rs");
957 let catalog = src.split("#[cfg(test)]").next().expect("catalog section");
958
959 let declared: Vec<&str> = catalog
960 .lines()
961 .filter_map(|line| {
962 let rest = line.trim().strip_prefix("pub const ")?;
963 let (_, value) = rest.split_once("= \"")?;
964 value.split('"').next()
965 })
966 .filter(|v| v.starts_with("de."))
967 .collect();
968
969 assert!(
970 declared.len() > 80,
971 "parsed only {} constants — the parser broke, not the catalog",
972 declared.len()
973 );
974
975 let missing: Vec<&&str> = declared.iter().filter(|v| !all().contains(v)).collect();
976 assert!(
977 missing.is_empty(),
978 "these event types are declared and absent from `all()`, so nothing can \
979 subscribe to them and no inventory counts them: {missing:#?}"
980 );
981 }
982
983 #[test]
984 fn unreferenced_constants_are_marked_phantom() {
985 let src = include_str!("lib.rs");
986 let catalog = src.split("#[cfg(test)]").next().expect("catalog section");
987
988 // Every `pub const NAME: &str = "value";` with its module and the doc
989 // block above it.
990 let lines: Vec<&str> = catalog.lines().collect();
991 let mut entries: Vec<Entry> = Vec::new();
992 let mut module = String::new();
993 for (i, line) in lines.iter().enumerate() {
994 let trimmed = line.trim();
995 if let Some(rest) = trimmed.strip_prefix("pub mod ") {
996 module = rest.trim_end_matches(" {").trim().to_owned();
997 continue;
998 }
999 let Some(rest) = trimmed.strip_prefix("pub const ") else {
1000 continue;
1001 };
1002 let Some(name) = rest.split(':').next() else {
1003 continue;
1004 };
1005 let Some(value) = rest
1006 .split_once("= \"")
1007 .and_then(|(_, v)| v.split('"').next())
1008 else {
1009 continue;
1010 };
1011 // Walk back over *this* constant's own doc block only. A fixed
1012 // lookback window spans into the previous entry's comment, and in
1013 // `gabi` every neighbour carries the marker — so a constant whose
1014 // own marker is missing would still read as marked.
1015 let mut phantom = false;
1016 for prev in lines[..i].iter().rev() {
1017 let t = prev.trim();
1018 if t.starts_with("///") {
1019 if t.contains("⚠ phantom:") {
1020 phantom = true;
1021 }
1022 } else if !t.is_empty() {
1023 break;
1024 }
1025 }
1026 entries.push(Entry {
1027 module: module.clone(),
1028 name: name.trim().to_owned(),
1029 value: value.to_owned(),
1030 phantom,
1031 });
1032 }
1033 assert!(
1034 entries.len() > 80,
1035 "parsed only {} constants — the parser broke, not the catalog",
1036 entries.len()
1037 );
1038
1039 // Concatenate every other Rust source in the workspace.
1040 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1041 .parent()
1042 .and_then(std::path::Path::parent)
1043 .expect("workspace root");
1044 let mut blob = String::new();
1045 for dir in ["crates", "services"] {
1046 collect_rs(&root.join(dir), &mut blob);
1047 }
1048
1049 let unmarked: Vec<&str> = entries
1050 .iter()
1051 .filter(|e| !e.phantom && !e.is_referenced(&blob, &entries))
1052 .map(|e| e.name.as_str())
1053 .collect();
1054
1055 assert!(
1056 unmarked.is_empty(),
1057 "these constants are referenced nowhere outside `mako-events` but carry no \
1058 `⚠ phantom:` marker:\n {unmarked:?}\n\
1059 Either wire an emitter, or document the gap with `⚠ phantom:` so the \
1060 catalog does not imply the event exists."
1061 );
1062 }
1063
1064 /// One catalogue constant as the phantom guard parses it.
1065 struct Entry {
1066 module: String,
1067 name: String,
1068 value: String,
1069 phantom: bool,
1070 }
1071
1072 impl Entry {
1073 /// Is this constant named anywhere outside `mako-events`?
1074 ///
1075 /// The bare constant name is **not** enough on its own when two modules
1076 /// declare the same one. `vpp::SETTLEMENT_BERECHNET` is emitted by
1077 /// `billingd`; a search for the string `SETTLEMENT_BERECHNET` therefore
1078 /// hit, and reported the long-dead `eeg::SETTLEMENT_BERECHNET` — which
1079 /// no service emitted and no glob matched — as live. The marker it
1080 /// should have carried was never demanded, so the catalog declared a
1081 /// settlement type that could not fire, with a doc comment naming an
1082 /// emitter that emits something else.
1083 ///
1084 /// So a name shared by two modules must be found *qualified*
1085 /// (`eeg::SETTLEMENT_BERECHNET`) or by its wire value. A unique name
1086 /// still matches bare, because that is how most call sites spell it
1087 /// after a grouped `use`.
1088 fn is_referenced(&self, blob: &str, all: &[Entry]) -> bool {
1089 if blob.contains(&self.value)
1090 || blob.contains(&format!("{}::{}", self.module, self.name))
1091 {
1092 return true;
1093 }
1094 let unique = all.iter().filter(|e| e.name == self.name).count() == 1;
1095 unique && blob.contains(&self.name)
1096 }
1097 }
1098
1099 /// Append every `.rs` file under `dir` (skipping this crate) to `out`.
1100 fn collect_rs(dir: &std::path::Path, out: &mut String) {
1101 let Ok(entries) = std::fs::read_dir(dir) else {
1102 return;
1103 };
1104 for entry in entries.flatten() {
1105 let path = entry.path();
1106 if path.is_dir() {
1107 if path.file_name().is_some_and(|n| n == "mako-events") {
1108 continue;
1109 }
1110 collect_rs(&path, out);
1111 } else if path.extension().is_some_and(|e| e == "rs")
1112 && let Ok(s) = std::fs::read_to_string(&path)
1113 {
1114 out.push_str(&s);
1115 }
1116 }
1117 }
1118
1119 #[test]
1120 fn german_namespaces_use_german_participles() {
1121 /// Namespaces whose events are named in German.
1122 const GERMAN_NAMESPACES: &[&str] = &["vertrag"];
1123 /// English participles that have an established German form already in
1124 /// use elsewhere in the catalog.
1125 const ENGLISH_PARTICIPLES: &[&str] = &[
1126 "updated",
1127 "changed",
1128 "created",
1129 "deleted",
1130 "stored",
1131 "replaced",
1132 "cancelled",
1133 "canceled",
1134 "renewed",
1135 "expired",
1136 "planned",
1137 ];
1138
1139 for ty in all() {
1140 let Some(ns) = ty.split('.').nth(1) else {
1141 continue;
1142 };
1143 if !GERMAN_NAMESPACES.contains(&ns) {
1144 continue;
1145 }
1146 for bad in ENGLISH_PARTICIPLES {
1147 assert!(
1148 !ty.ends_with(&format!("-{bad}")) && !ty.ends_with(&format!(".{bad}")),
1149 "{ty}: `de.{ns}.*` names its facts in German — use the German \
1150 participle instead of {bad:?} (e.g. `preisgarantie-hinterlegt`, \
1151 not `preisgarantie-updated`)"
1152 );
1153 }
1154 }
1155 }
1156
1157 #[test]
1158 fn segments_use_hyphen_not_underscore() {
1159 for ty in all() {
1160 assert!(
1161 !ty.contains('_'),
1162 "{ty} must join multi-word segments with `-`, not `_`"
1163 );
1164 for segment in ty.split('.') {
1165 assert!(
1166 !segment.is_empty(),
1167 "{ty} must not contain an empty segment"
1168 );
1169 assert!(
1170 !segment.starts_with('-') && !segment.ends_with('-'),
1171 "{ty}: segment {segment:?} must not start or end with `-`"
1172 );
1173 assert!(
1174 segment
1175 .bytes()
1176 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'),
1177 "{ty}: segment {segment:?} must match [a-z0-9-]+"
1178 );
1179 }
1180 }
1181 }
1182
1183 /// No two catalog entries may differ only by separator or case.
1184 #[test]
1185 fn no_two_types_normalise_to_the_same_name() {
1186 let mut seen: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
1187 for ty in all() {
1188 let key = ty.replace(['-', '_'], "").to_lowercase();
1189 if let Some(prev) = seen.insert(key, ty) {
1190 assert_eq!(prev, *ty, "{prev} and {ty} collide after normalisation");
1191 }
1192 }
1193 }
1194
1195 #[test]
1196 fn every_type_is_lowercase() {
1197 for ty in all() {
1198 assert_eq!(
1199 *ty,
1200 ty.to_lowercase(),
1201 "{ty} must be entirely lowercase (CloudEvents type convention)"
1202 );
1203 }
1204 }
1205
1206 #[test]
1207 fn every_type_has_valid_segments() {
1208 for ty in all() {
1209 assert!(
1210 !ty.contains(' ') && !ty.contains('*'),
1211 "{ty} must be a concrete type — no whitespace, no globs"
1212 );
1213 for segment in ty.split('.') {
1214 assert!(!segment.is_empty(), "{ty} has an empty `.` segment");
1215 assert!(
1216 segment
1217 .chars()
1218 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "-_".contains(c)),
1219 "{ty}: segment `{segment}` has characters outside [a-z0-9_-]"
1220 );
1221 }
1222 }
1223 }
1224
1225 #[test]
1226 fn no_duplicates() {
1227 let types = all();
1228 let mut seen = std::collections::BTreeSet::new();
1229 for ty in types {
1230 assert!(seen.insert(*ty), "duplicate catalog entry: {ty}");
1231 }
1232 }
1233
1234 #[test]
1235 fn legacy_prefixes_are_gone() {
1236 for ty in all() {
1237 assert!(
1238 !ty.starts_with("de.edmd.") && !ty.starts_with("de.tarifbd."),
1239 "{ty} uses a retired service-name prefix (use de.messwert / de.tarif)"
1240 );
1241 assert_ne!(
1242 *ty, "de.angebot.angenommen",
1243 "moved to de.tarif.angebot.angenommen"
1244 );
1245 }
1246 }
1247}