Skip to main content

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}