mako-events 0.20.0

Compile-time catalog of every CloudEvents `type` used across the mako workspace
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
//! Compile-time catalog of every CloudEvents `type` used across the mako
//! workspace.
//!
//! One `pub const` per event type, organized in bounded-context modules.
//! Emitters and subscribers reference these constants instead of inline
//! string literals, so a rename is a one-line change and drift between
//! producer and consumer is a compile error rather than a silent mismatch.
//!
//! # Conventions
//!
//! - Every type starts with `de.` and is entirely lowercase
//!   (CloudEvents §3.1 recommends lowercase reverse-DNS types).
//! - Segments are separated by `.`, and a multi-word segment joins its words
//!   with `-` — never `_`. German domain nouns keep their established spelling
//!   (participles like `beliefert`, compound nouns like `nb-contract`).
//!   `segments_use_hyphen_not_underscore` enforces this.
//! - A namespace names its facts in one language. `de.vertrag.*` is German
//!   throughout (`gekuendigt`, `preisgarantie-hinterlegt`); `de.mako.*`,
//!   `de.gabi.*` and `de.accounting.*` are technical or English-domain and stay
//!   English. `german_namespaces_use_german_participles` enforces the German
//!   set. This is about the *participle*, not the noun — and it does not mean
//!   `.updated` and `.geaendert` are interchangeable: in `de.markt.*` they name
//!   different facts (any master-data write vs. a regulated GPKE
//!   Stammdatenänderung carrying its patch).
//! - `⚠ phantom:` in a doc comment means **no service in this workspace emits
//!   this type**. That is the whole claim, and it is the one
//!   `unreferenced_constants_are_marked_phantom` checks. It says nothing about
//!   whether anything *subscribes*: some phantoms are subscriptions placed ahead
//!   of their emitter, others are neither emitted nor consumed, and each
//!   constant's own doc comment says which and why. A phantom is a recorded gap,
//!   not a promise — an external consumer may match on it, but this platform
//!   will not make it fire.
//!
//! Glob subscription patterns (e.g. `de.mako.*` in `agentd` trigger
//! configs) are not part of this catalog — only concrete types are. The
//! canonical pattern matcher every subscription mechanism uses is
//! [`matches()`](matches()).
//!
//! # Scope (why `mako-events`, not `mako-common`)
//!
//! This crate deliberately stays a single-purpose leaf: constants and logic
//! *about CloudEvents types* (the catalog, the naming convention, the
//! pattern matcher) and nothing else. A general `mako-common`/`mako-core`
//! grab-bag would become a dependency magnet — every crate depends on it,
//! every change rebuilds the workspace, and unrelated helpers accrete
//! without an owner. Shared logic already has purpose-named homes:
//! domain runtime in `mako-engine`, service framework in `mako-service`,
//! master-data types in `mako-markt`. New shared code goes to the crate
//! whose purpose it serves; a new purpose gets a new purpose-named crate.

#![deny(unsafe_code)]

/// Core MaKo process lifecycle + EDIFACT transport events (`de.mako.*`).
///
/// Emitted by `makod` toward the ERP adapter / event bus; consumed by
/// `obsd`, `processd`, `edmd`, `invoicd`, `marktd`, `vertragd` and `agentd`.
pub mod mako {
    /// A MaKo market process was started (first outbound message built).
    pub const PROCESS_INITIATED: &str = "de.mako.process.initiated";
    /// Happy path finished — the process reached its terminal success state.
    pub const PROCESS_COMPLETED: &str = "de.mako.process.completed";
    /// Unrecoverable failure — the process was cancelled/failed.
    pub const PROCESS_FAILED: &str = "de.mako.process.failed";
    /// APERAK acknowledged the outbound message.
    pub const APERAK_ACCEPTED: &str = "de.mako.aperak.accepted";
    /// APERAK rejected the outbound message (carries BDEW ERC code).
    pub const APERAK_REJECTED: &str = "de.mako.aperak.rejected";
    /// APERAK deadline missed.
    pub const APERAK_TIMEOUT: &str = "de.mako.aperak.timeout";
    /// CONTRL received for an outbound interchange.
    pub const CONTRL_RECEIVED: &str = "de.mako.contrl.received";
    /// MaLo identified during Lieferantenwechsel (GPKE identification step).
    pub const MALO_IDENTIFIED: &str = "de.mako.malo.identified";
    /// The LFA answered the NB's Anfrage zur Beendigung der Zuordnung — or its
    /// 09:00 window lapsed, which the Festlegung reads as a Zustimmung.
    ///
    /// Its own type rather than a `PROCESS_*` one because it is an **input to a
    /// different process**: `processd` resumes the *Anmeldung* decision on it,
    /// walking `E_0623` Prüfschritte 30–50 with the answer. `data` carries
    /// `malo_id`, `anmeldung_process_id`, `lfa_mp_id`, `zustimmung`,
    /// `fristablauf`, and — when the LFA named them — `antwortcode`, `grund`
    /// and the Fall-b `zuordnungsende`.
    pub const ABMELDEANFRAGE_BEANTWORTET: &str = "de.mako.abmeldeanfrage.beantwortet";
    /// Outbound EDIFACT interchange handed to the webhook EDIFACT sender.
    ///
    /// Emitted **only** by `WebhookEdifactSender`, the development / ERP
    /// integration transport used when there is no AS4 infrastructure; the
    /// production `BdewAs4Sender` path does not emit it. The CloudEvent is the
    /// delivery envelope carrying the interchange, not a notification about it.
    pub const EDIFACT_OUTBOUND: &str = "de.mako.edifact.outbound";
    // Design note: there are deliberately NO per-process outcome types
    // (`de.mako.gpke.lieferbeginn.bestaetigt` and friends). Process outcomes
    // are the generic `PROCESS_*`/`APERAK_*` family — the process family
    // rides in `data.workflow`, and `vertragd` matches outcome *suffixes*
    // (`.bestaetigt`/`.abgelehnt`/`.completed`/…) so a future fine-grained
    // emitter would be consumed without code changes. Minting per-process
    // constants here without an emitter would institutionalize fiction (an
    // earlier draft carried four such test-fixture types; they were removed).
    /// §20 NZV Netzzugang: aggregated Übermittlungsbedarf toward the NB.
    pub const NETZZUGANG_UEBERMITTLUNGSBEDARF: &str = "de.mako.netzzugang.uebermittlungsbedarf";
}

/// Market master-data events (`de.markt.*`), emitted by `marktd`.
pub mod markt {
    /// Marktlokation stammdaten changed.
    pub const MALO_UPDATED: &str = "de.markt.malo.updated";
    /// A UTILMD Stammdatenänderung (GPKE Teil 4 / GeLi Gas) was applied to a
    /// MaLo's typed columns — carries the applied `patch` for ERP audit.
    pub const MALO_STAMMDATEN_GEAENDERT: &str = "de.markt.malo.stammdaten-geaendert";
    /// Object-generic Stammdatenänderung applied to a non-MaLo master-data object
    /// (MeLo/NeLo/Tranche). The subject is the object's location id; the payload
    /// carries the `objekt` marker.
    pub const STAMMDATEN_GEAENDERT: &str = "de.markt.stammdaten.geaendert";
    /// Messlokation stammdaten changed.
    pub const MELO_UPDATED: &str = "de.markt.melo.updated";
    /// Marktpartner record changed.
    pub const PARTNER_UPDATED: &str = "de.markt.partner.updated";
    /// Netzbetreiber contract (Lieferanten-Rahmenvertrag) changed.
    pub const NB_CONTRACT_UPDATED: &str = "de.markt.nb-contract.updated";
    /// MSB-Rahmenvertrag Gas changed.
    pub const MSB_RAHMENVERTRAG_GAS_UPDATED: &str = "de.markt.msb-rahmenvertrag-gas.updated";
    /// Device (Gerät) configuration changed.
    pub const GERAET_KONFIGURATION_UPDATED: &str = "de.markt.geraet.konfiguration.updated";
    /// Steuerbare-Ressource Konfigurationsprodukt changed (§14a).
    pub const SR_KONFIGURATIONSPRODUKT_UPDATED: &str = "de.markt.sr.konfigurationsprodukt.updated";
    /// §20 NZV Netzzugang application state changed.
    pub const NETZZUGANG_ANTRAG_UPDATED: &str = "de.markt.netzzugang.antrag.updated";
    /// Einwilligung (consent) granted.
    pub const EINWILLIGUNG_ERTEILT: &str = "de.markt.einwilligung.erteilt";
    /// Einwilligung (consent) revoked.
    pub const EINWILLIGUNG_WIDERRUFEN: &str = "de.markt.einwilligung.widerrufen";
    /// Versorgung state changed (generic transition).
    pub const VERSORGUNG_CHANGED: &str = "de.markt.versorgung.changed";
    /// ⚠ phantom: no emitter. Versorgung entered `BELIEFERT` (supply active).
    ///
    /// Superseded before it was ever wired. `marktd` announces **every**
    /// Versorgung transition — the EDIFACT-driven one in `event_ingest` and the
    /// REST one in `handlers::versorgung` alike — as [`VERSORGUNG_CHANGED`]
    /// carrying `data.lieferstatus`, so reaching `Beliefert` is already on the
    /// bus and a second type for one particular value of that field would put
    /// the same fact on two types. The family keeps exactly two status-specific
    /// types beside the generic one, and both earn it by carrying a payload the
    /// generic event does not: [`VERSORGUNG_GAP_DETECTED`] (no announced
    /// successor) and [`VERSORGUNG_EOG_BEGONNEN`] (`eog_art`, `eog_seit`).
    /// `Beliefert` carries nothing extra.
    ///
    /// Kept rather than deleted: it is *redundant*, not *wrong*. It names a fact
    /// the bus already carries, so its presence misleads nobody, and the
    /// catalogue is a published contract where withdrawing a declared type
    /// breaks a consumer matching on it for no gain. A phantom whose emission
    /// would itself be a defect gets deleted instead — that is why there is no
    /// generic `de.eeg.settlement.berechnet` (see the [`crate::eeg`] module).
    pub const VERSORGUNG_BELIEFERT: &str = "de.markt.versorgung.beliefert";
    /// Supply gap detected: a MaLo became `Unbeliefert` with no announced
    /// successor — the NB must activate Ersatz-/Grundversorgung (§38 EnWG).
    /// Consumed by the `processd` EoG gap-closure automation.
    pub const VERSORGUNG_GAP_DETECTED: &str = "de.markt.versorgung.gap-detected";
    /// Statutory fallback supply began (Ersatz- or Grundversorgung);
    /// `data.eog_art` carries the regime, `data.eog_seit` the start date.
    pub const VERSORGUNG_EOG_BEGONNEN: &str = "de.markt.versorgung.eog-begonnen";
    /// §38 Abs. 4 EnWG: a running Ersatzversorgung approaches its 3-month
    /// maximum. Emitted by the `processd` EoG timer.
    pub const VERSORGUNG_ERSATZ_AUSLAUFEND: &str = "de.markt.versorgung.ersatz-auslaufend";
    /// PRICAT price catalog published.
    pub const PRICAT_PUBLISHED: &str = "de.markt.pricat.published";
    /// MMMA import batch succeeded.
    pub const MMMA_IMPORT_SUCCESS: &str = "de.markt.mmma.import.success";
    /// MMMA import batch failed.
    pub const MMMA_IMPORT_FAILED: &str = "de.markt.mmma.import.failed";
    /// Subscription self-test event (`POST /subscriptions/{id}/test`).
    pub const SUBSCRIPTION_TEST: &str = "de.markt.subscription.test";
}

/// Billing events (`de.billing.*`), emitted by `billingd`.
pub mod billing {
    /// Invoice (Rechnung) created. A credit note / Stornorechnung is the same
    /// event with `data.is_correction = true` and a negated amount — there is
    /// deliberately no separate `gutschrift` type (one signed document stream is
    /// cleaner for the double-entry ledger, and avoids a double-booking hazard).
    pub const RECHNUNG_ERSTELLT: &str = "de.billing.rechnung.erstellt";
    /// Monthly Abrechnungsinformation (§40 EnWG) generated.
    pub const ABRECHNUNGSINFORMATION_MONATLICH: &str =
        "de.billing.abrechnungsinformation.monatlich";
    /// XRechnung for a B2G recipient is ready for dispatch.
    pub const XRECHNUNG_B2G_READY: &str = "de.billing.xrechnung.b2g.ready";
}

/// INVOIC receipt/payment events (`de.invoic.*`), emitted by `invoicd`.
pub mod invoic {
    /// Inbound INVOIC disputed (REMADV Ablehnung path).
    pub const RECEIPT_DISPUTED: &str = "de.invoic.receipt.disputed";
    /// Inbound INVOIC dispatched to the ERP.
    pub const RECEIPT_DISPATCHED: &str = "de.invoic.receipt.dispatched";
    /// Inbound INVOIC settled (REMADV Zahlungsavis).
    pub const RECEIPT_SETTLED: &str = "de.invoic.receipt.settled";
    /// Outbound INVOIC payment overdue (no REMADV in time).
    pub const PAYMENT_OVERDUE: &str = "de.invoic.payment.overdue";
}

/// EEG/KWKG settlement events (`de.eeg.*`), emitted by `einsd`.
///
/// `agentd` additionally subscribes to the globs `de.eeg.*`,
/// `de.eeg.anlage.*`, `de.eeg.verguetung.*`, `de.eeg.marktpraemie.*` and
/// `de.eeg.compliance.*` (no concrete `de.eeg.compliance.*` type exists
/// yet). There is deliberately no `de.eeg.*` catch-all subscription.
///
/// There is also deliberately **no generic `de.eeg.settlement.berechnet`**. A
/// settlement announces itself as exactly one of [`eeg::VERGUETUNG_BERECHNET`] or
/// [`eeg::MARKTPRAEMIE_BERECHNET`], chosen in `einsd::handlers::enqueue_settlement_ce`
/// from the plant's Vergütungsmodell — and that choice *is* the payout gate:
/// `accountingd` credits the Massenkontokorrent and issues the pain.001 on those
/// two types and no other. Every entry point (REST, batch, the monthly worker
/// and the MCP `trigger_settle` tool) goes through that one function, so a third
/// type could only ever be a second announcement of a payout already announced.
pub mod eeg {
    /// Einspeisevergütung settlement computed (feed-in tariff schemes).
    pub const VERGUETUNG_BERECHNET: &str = "de.eeg.verguetung.berechnet";
    /// Marktprämie settlement computed (Direktvermarktung schemes).
    pub const MARKTPRAEMIE_BERECHNET: &str = "de.eeg.marktpraemie.berechnet";
    /// Monthly auto-settle batch trigger. Emitted by einsd's auto-settle worker
    /// (`emit_batch_due_ce`); subscribed by agentd's `einsd-batch-agent`.
    pub const SETTLEMENT_BATCH_DUE: &str = "de.eeg.settlement.batch-due";
    /// EEG-Anlage Förderung ends within the warning window.
    pub const ANLAGE_FOERDERUNG_AUSLAUFEND: &str = "de.eeg.anlage.foerderung-auslaufend";
    /// EEG-Anlage MaStR registration confirmed.
    pub const ANLAGE_MASTR_REGISTRIERT: &str = "de.eeg.anlage.mastr-registriert";
    /// §21b Veräußerungsform switched.
    pub const VERAEUSSERUNGSFORM_GEWECHSELT: &str = "de.eeg.veraeusserungsform.gewechselt";
}

/// FI-CA subledger events (`de.accounting.*`), emitted by `accountingd`.
pub mod accounting {
    /// Dunning notice (Mahnung) issued.
    pub const MAHNUNG_ISSUED: &str = "de.accounting.mahnung.issued";
    /// Abschlag (installment) posted.
    pub const ABSCHLAG_POSTED: &str = "de.accounting.abschlag.posted";
    /// Payment due notification.
    pub const PAYMENT_DUE: &str = "de.accounting.payment.due";
    /// Bank statement payment imported and matched.
    pub const PAYMENT_IMPORTED: &str = "de.accounting.payment.imported";
    /// Refund (Erstattung) due to the customer.
    pub const ERSTATTUNG_FAELLIG: &str = "de.accounting.erstattung.faellig";
    /// § 40b Abs. 1 EnWG — the annual reconciliation for one Marktlokation is
    /// committed.
    ///
    /// Emitted **unconditionally**, on every settlement, whatever it came to.
    /// [`ERSTATTUNG_FAELLIG`] is not a substitute: it fires only on a refund and
    /// only where an ERP webhook is configured, so a Nachzahlung and an
    /// exactly-balanced year would announce nothing.
    ///
    /// Carries the settlement, its components and the recalibrated monthly
    /// Abschlag, so a consumer reacting to the annual cycle needs no second
    /// call.
    pub const JAHRESABSCHLUSS_ABGESCHLOSSEN: &str = "de.accounting.jahresabschluss.abgeschlossen";
    /// Late-payment interest (§288 BGB) charged.
    pub const INTEREST_CHARGED: &str = "de.accounting.interest.charged";
    /// EEG payout rejected (e.g. missing bank data).
    pub const EEG_PAYOUT_REJECTED: &str = "de.accounting.eeg.payout.rejected";
    /// §41f Abs. 1 EnWG — Sperrandrohung: disconnection threatened after an
    /// unresolved Mahnstufe-3 dunning case, opening the 4-Wochen-Frist.
    pub const SPERRANDROHUNG: &str = "de.accounting.sperrandrohung";
    /// §41f Abs. 5 EnWG — Sperrankündigung: the concrete disconnection date is
    /// announced 8 Werktage in advance (after the 4-Wochen Androhung Frist).
    pub const SPERRANKUENDIGUNG: &str = "de.accounting.sperrankuendigung";
    /// §41f Abs. 1 EnWG — Sperrauftrag: **ORDERS 17115** dispatched to the
    /// Netzbetreiber once the Androhung (4 Wochen) and Ankündigung (8 Werktage)
    /// Fristen have elapsed, the Abs. 3 arrears gates still hold, and no
    /// Abwendungsvereinbarung / Unverhältnismäßigkeit halted the sequence. The
    /// event announces the market message; it does not carry it.
    pub const SPERRAUFTRAG: &str = "de.accounting.sperrauftrag";
    /// §41f Abs. 7 EnWG — Entsperrauftrag: **ORDERS 17117** dispatched once the
    /// grounds for the interruption are gone (the arrears were settled). The
    /// statute makes the restoration *unverzüglich* and unconditional on being
    /// asked, so this follows automatically from the case settling.
    pub const ENTSPERRAUFTRAG: &str = "de.accounting.entsperrauftrag";
    /// §41g Abs. 1 S. 2 EnWG — the Grundversorger's offer of an
    /// Abwendungsvereinbarung: due within one week of the customer demanding it
    /// after the Androhung, and at the latest together with the Ankündigung.
    /// Carries the interest-free instalment terms (§41g Abs. 1 S. 7–9).
    pub const ABWENDUNG_ANGEBOTEN: &str = "de.accounting.abwendung.angeboten";
    /// §41g Abs. 1 S. 11 EnWG — an accepted Abwendungsvereinbarung was broken.
    /// The supplier may resume the interruption, but must re-observe §41f
    /// Abs. 1 S. 2 and issue a **fresh** Ankündigung (§41f Abs. 5).
    pub const ABWENDUNG_GEBROCHEN: &str = "de.accounting.abwendung.gebrochen";
    /// SEPA direct-debit return (Bankrücklastschrift) — emitted by
    /// `accountingd` when a camt.053/054 booking carries a return reason code
    /// or debits the account, and subscribed by agentd's `payment-agent`.
    pub const BANKRUECKLAST: &str = "de.accounting.bankruecklast";
    /// A pain.002 rejected a submitted direct-debit collection: the money will
    /// never arrive, so the receivable stays open and the mandate needs
    /// attention (`AC01` wrong IBAN, `MD01` no mandate, `AM04` no funds).
    ///
    /// Distinct from [`BANKRUECKLAST`], which is a collection that *settled*
    /// and was then returned — a different reconciliation, and a different
    /// R-transaction fee.
    pub const SEPA_COLLECTION_REJECTED: &str = "de.accounting.sepa.collection-rejected";
    /// The creditor gave a settled collection back via pain.007.
    pub const SEPA_REVERSAL_ISSUED: &str = "de.accounting.sepa.reversal-issued";
    /// A camt.055 recall was sent for a submitted pain.008: the creditor is
    /// asking the bank to stop a collection **before** it settles.
    ///
    /// Distinct from [`SEPA_REVERSAL_ISSUED`], which gives back a collection
    /// that already settled — a different message, a different moment, and a
    /// different reconciliation.
    pub const SEPA_RECALL_REQUESTED: &str = "de.accounting.sepa.recall-requested";
    /// The bank answered a camt.055 with a camt.029. The outcome is `ACCR`
    /// (stopped), `RJCR` (the collection stands), `PDCR` (still open) or `PACR`
    /// (some of the named transactions).
    pub const SEPA_RECALL_RESOLVED: &str = "de.accounting.sepa.recall-resolved";
    /// Verification of Payee reported something other than a match for an
    /// outgoing credit transfer. Mandatory for euro credit transfers since
    /// 9 October 2025: executing after a `RVNM` no-match shifts liability to
    /// the payer, so this is a decision an operator has to make.
    pub const PAYEE_VERIFICATION_MISMATCH: &str = "de.accounting.payee.verification-mismatch";
}

/// MaBiS/Netzbilanzierung INVOIC events (`de.netzbilanz.*`), emitted by
/// `netzbilanzd`.
pub mod netzbilanz {
    /// A Netzbetreiber invoice was settled and stored as a draft.
    pub const INVOIC_DRAFTED: &str = "de.netzbilanz.invoic.drafted";
    /// The invoice was handed to `makod` for EDIFACT dispatch.
    pub const INVOIC_DISPATCHED: &str = "de.netzbilanz.invoic.dispatched";
    /// A draft is still undispatched past its window.
    pub const INVOIC_DISPATCH_OVERDUE: &str = "de.netzbilanz.invoic.dispatch-overdue";
    /// The counterparty confirmed payment (REMADV 33001, the only Bestätigung).
    pub const INVOIC_PAID: &str = "de.netzbilanz.invoic.paid";
    /// The counterparty rejected the invoice (REMADV 33002/33003/33004).
    pub const INVOIC_DISPUTED: &str = "de.netzbilanz.invoic.disputed";
    /// Kostenblatt computed.
    pub const KOSTENBLATT_COMPUTED: &str = "de.netzbilanz.kostenblatt.computed";
    /// Kostenblatt submission deadline approaching.
    pub const KOSTENBLATT_DEADLINE_APPROACHING: &str =
        "de.netzbilanz.kostenblatt.deadline-approaching";
}

/// Meter-reading / energy-data events (`de.messwert.*`), emitted by `edmd`.
///
/// Renamed from the legacy `de.edmd.*` prefix — the context is the
/// Messwert (meter value), not the daemon that happens to store it.
pub mod messwert {
    /// Hampel grade C/F, or any V-rule finding, on newly ingested readings.
    pub const READING_QUALITY_WARNING: &str = "de.messwert.reading.quality.warning";
    /// Direct iMSys/SMGW push stored.
    pub const READING_DIRECT_STORED: &str = "de.messwert.reading.direct.stored";
    /// Ablesesteuerung reading order failed.
    pub const READING_ORDER_FAILED: &str = "de.messwert.reading.order.failed";
    /// Expected reading confirmation overdue.
    pub const READING_CONFIRMATION_OVERDUE: &str = "de.messwert.reading.confirmation.overdue";
    /// A measuring point has stopped delivering, or is delivering too little of
    /// the settlement window to bill.
    ///
    /// The counterpart to [`READING_QUALITY_WARNING`], which can only fire on
    /// data that *arrived*. Silence produces no ingest and therefore no
    /// validation, so without this a head-end that simply stops is invisible
    /// until a settlement run comes up short — by which point the window in
    /// which the values could still have been re-read has closed
    /// (§ 60 Abs. 1 MsbG — the duty is to transmit „zu den Zeitpunkten …,
    /// die diese … vorgeben", which is what a stalled head-end breaches;
    /// Abs. 2 is a Soll-rule about processing inside the Smart-Meter-Gateway).
    pub const READING_DELIVERY_OVERDUE: &str = "de.messwert.reading.delivery.overdue";
    /// A measuring point that was overdue is delivering again.
    pub const READING_DELIVERY_RESUMED: &str = "de.messwert.reading.delivery.resumed";
    /// §14a SMGW/CLS compliance issue **opened** (§ 25 MsbG monitoring duty).
    ///
    /// Fires on the transition into a fault, not on every sweep that still sees
    /// it — see `cls_compliance_issues`.
    pub const CLS_COMPLIANCE_ISSUE: &str = "de.messwert.cls.compliance-issue";
    /// A §14a SMGW/CLS compliance issue a later sweep no longer finds.
    pub const CLS_COMPLIANCE_RESOLVED: &str = "de.messwert.cls.compliance-resolved";
    /// An **ESA Typ-2** subscription has stopped delivering.
    ///
    /// Distinct from [`READING_DELIVERY_OVERDUE`], which watches the
    /// authoritative Typ-1 stream: a Typ-2 gap breaches the §60 Abs. 1 MsbG
    /// delivery duty toward one Energieserviceanbieter and reaches no billing
    /// run that could come up short, so nothing else would notice it. Mixing
    /// the two would also cross the Typ-1/Typ-2 separation the whole store
    /// split exists to keep (Codeliste der Konfigurationen 1.4 Kap. 4.6).
    pub const ESA_TYP2_DELIVERY_OVERDUE: &str = "de.messwert.esa.typ2.delivery.overdue";
    /// An ESA Typ-2 subscription that was overdue is delivering again.
    pub const ESA_TYP2_DELIVERY_RESUMED: &str = "de.messwert.esa.typ2.delivery.resumed";
    /// SMGW certificate approaching expiry — tiered advance warning at 90 / 30 /
    /// 7 days before `valid_to`, once per tier per certificate.
    ///
    /// The ladder is operational, not statutory: BSI TR-03109-4 binds
    /// certificate runtimes while the Root-CP fixes the renewal lead time and
    /// the Zertifikatswechsel overlap. An expired certificate silently ends §14a
    /// Fernsteuerbarkeit.
    pub const SMGW_CERT_EXPIRY_WARNING: &str = "de.messwert.smgw.cert.expiry-warning";
}

/// Product & tariff catalog events (`de.tarif.*`), emitted by `productd`.
///
/// Renamed from the legacy `de.tarifbd.*` prefix (and the stray top-level
/// `de.angebot.angenommen`).
pub mod tarif {
    /// Product created/updated in the catalog.
    pub const PRODUCT_UPDATED: &str = "de.tarif.product.updated";
    /// B2B Angebot accepted — vertragd auto-creates the Rahmenvertrag.
    pub const ANGEBOT_ANGENOMMEN: &str = "de.tarif.angebot.angenommen";
    /// ⚠ phantom: B2B quote expired. Subscribed by agentd (`productd-agent`),
    /// emitted by nothing — `productd` expires an Angebot in place without
    /// announcing it.
    pub const ANGEBOT_ABGELAUFEN: &str = "de.tarif.angebot.abgelaufen";
    /// ⚠ phantom: EPEX D-1 prices not imported by 18:00 CET. Subscribed by
    /// agentd (`productd-agent`), emitted by nothing — no worker watches the
    /// import window.
    pub const EPEX_MISSING: &str = "de.tarif.epex.missing";
}

/// Contract lifecycle events (`de.vertrag.*`), emitted by `vertragd`.
///
/// `agentd` additionally subscribes to the glob `de.vertrag.*`.
pub mod vertrag {
    /// All components NB-confirmed — billing may start.
    pub const AKTIV: &str = "de.vertrag.aktiv";
    /// Lieferende dispatched (Rahmenvertrag cascade, per child).
    pub const GEKUENDIGT: &str = "de.vertrag.gekuendigt";
    /// Kündigung accepted; the Lieferende and the Schlussablesung are
    /// enqueued. Carries the § 41 Abs. 8 Nr. 2 EnWG Textform confirmation the
    /// supplier owes the customer — the document is produced downstream, the
    /// instruction to produce it commits with the termination.
    ///
    /// A cascade Kündigung over a Rahmenvertrag emits [`GEKUENDIGT`] per child
    /// instead, because the framework contract is what was terminated.
    pub const KUENDIGUNG: &str = "de.vertrag.kuendigung";
    /// Kündigung withdrawn before Lieferende.
    pub const KUENDIGUNG_WIDERRUFEN: &str = "de.vertrag.kuendigung-widerrufen";
    /// Product change applied immediately.
    pub const TARIFWECHSEL: &str = "de.vertrag.tarifwechsel";
    /// Future-dated product change stored.
    pub const TARIFWECHSEL_GEPLANT: &str = "de.vertrag.tarifwechsel-geplant";
    /// Price guarantee stored/replaced.
    pub const PREISGARANTIE_HINTERLEGT: &str = "de.vertrag.preisgarantie-hinterlegt";
    /// § 41 Abs. 5 EnWG price-change notice. Sent as soon as the change is
    /// scheduled — Satz 2 is a floor, not a ceiling — and carrying the regime
    /// that applied plus the Satz 4 Sonderkündigungsrecht.
    pub const PREISAENDERUNG_ANKUENDIGUNG: &str = "de.vertrag.preisaenderung.ankuendigung";
    /// 30 days before auto-renewal.
    pub const AUTOERNEUERUNG_ANKUENDIGUNG: &str = "de.vertrag.autoerneuerung.ankuendigung";
    /// 30 days before vertragsende / preisgarantie_bis.
    pub const ABLAUF_ANKUENDIGUNG: &str = "de.vertrag.ablauf.ankuendigung";
    /// Supply has actually ended: every commodity has passed its Lieferende and
    /// the contract is `ABGELAUFEN`. Distinct from [`GEKUENDIGT`], which is the
    /// day the termination was *accepted* — months earlier for a notice period
    /// that long, and with supply and invoicing running throughout. This is the
    /// event a Schlussrechnung and the § 147 AO retention clock hang off.
    pub const ABGESCHLOSSEN: &str = "de.vertrag.abgeschlossen";
}

/// Virtual-power-plant events (`de.vpp.*`).
pub mod vpp {
    /// VPP dispatch confirmed (ERP event type, emitted via mako-engine).
    pub const DISPATCH_CONFIRMED: &str = "de.vpp.dispatch.confirmed";
    /// VPP settlement computed (emitted by `billingd`).
    pub const SETTLEMENT_BERECHNET: &str = "de.vpp.settlement.berechnet";
}

/// Agent runtime events (`de.agent.*`), emitted by `agentd`.
pub mod agent {
    /// An agent run reached a terminal state and produced a decision.
    ///
    /// Carries the run's outcome — `completed`, `failed`, `suspended`,
    /// `exhausted`, `quarantined`, `replanning`, `cancelled` or
    /// `not-admitted` — so a subscriber sees a run awaiting human approval as
    /// readily as a successful one. Beside it: `run_id` (the journal key, which
    /// `GET /api/v1/oversight/runs/{run_id}` takes), `waiting_for` (present only
    /// when suspended — an approval, a message, an instant) and `tokens`.
    ///
    /// There is no separate dead-letter event: a run that fails is resumable
    /// from its journal rather than a message that has nowhere left to go.
    pub const DECISION_MADE: &str = "de.agent.decision.made";
}

/// GaBi Gas balancing events (`de.gabi.*`), defined in `mako-gabi-gas`.
///
/// # What is emitted
///
/// Four types, all through the same path: a `mako-gabi-gas` workflow enqueues a
/// `PendingOutbox` entry whose `message_type` string
/// `makod::core::erp_adapter::map_message_type_to_erp_event` maps to an
/// `ErpEventType`, and `OutboxErpWorker` delivers that as a CloudEvent.
/// [`gabi::ALOCAT_MISSING`] comes from `gabi-gas-allocation`; [`gabi::NOMINATION_CURTAILED`],
/// [`gabi::NOMINATION_REJECTED`] and [`gabi::NOMRES_MISSING`] from `gabi-gas-nomination`.
///
/// # What is not: the success path is silent
///
/// All four are failures. GaBi Gas announces **nothing** when a gas day goes
/// right — and unlike every other domain here, it has no generic fallback
/// either: `mako-gabi-gas` never enqueues a `ProcessInitiated` or
/// `ProcessCompleted` entry, so its ALOCAT and NOMINT/NOMRES streams put no
/// `de.mako.process.*` event on the bus for a subscriber to fall back on. A BKV
/// watching `de.gabi.*` sees a curtailment and never sees a confirmation.
///
/// That is a feature gap, not dead vocabulary, and the ten phantom constants
/// below are the shape it would take. What each still needs:
///
/// | Constant | Where it would come from | What is missing |
/// |---|---|---|
/// | [`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 |
/// | [`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 |
/// | [`gabi::CORRECTION_CREATED`] | the same arm with `AllocationVersion::Correction(n)` (§ 46 KoV XV) | only the wiring |
/// | [`gabi::NOMINATION_CREATED`] | `nomination::SendNomint`, beside the NOMINT it already puts on the wire | only the wiring |
/// | [`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 |
/// | [`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 |
/// | [`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` |
/// | [`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 |
/// | [`gabi::FINAL_ALOCAT_DEADLINE`] | — | nothing: it duplicates [`gabi::ALOCAT_MISSING`], which is what the § 47 Ziffer 1 window closing unsettled already means |
///
/// Wiring the first four is a three-file change and none of the files is this
/// one: a `PendingOutbox` in `mako-gabi-gas`, an `ErpEventType` variant plus its
/// `cloud_event_type()` arm in `mako-engine`, and a `map_message_type_to_erp_event`
/// arm in `makod`. Nothing fires until all three land — an unmapped
/// `message_type` is skipped by `OutboxErpWorker`, silently.
///
/// # Nothing subscribes either
///
/// `gabi-gas-agent` triggers only on [`gabi::ALOCAT_MISSING`], and agentd pins the
/// absence of an imbalance pattern. So an emitter for any of the ten has to
/// arrive with a subscriber, or it swaps one silence for another.
pub mod gabi {
    /// ⚠ phantom: no emitter yet.
    pub const MEASUREMENT_RECEIVED: &str = "de.gabi.measurement.received";
    /// ⚠ phantom: no emitter yet.
    pub const ALLOCATION_COMPLETED: &str = "de.gabi.allocation.completed";
    /// ⚠ phantom: no emitter yet.
    pub const NOMINATION_CREATED: &str = "de.gabi.nomination.created";
    /// ⚠ phantom: no emitter yet.
    pub const NOMINATION_CONFIRMED: &str = "de.gabi.nomination.confirmed";
    /// The FNB/MGV confirmed **less** than was nominated.
    ///
    /// Emitted by `makod` from `gabi-gas-nomination` when the NOMRES states a
    /// quantity below the nomination's. The BKV's portfolio is short by the
    /// difference until it re-nominates or buys the gap, so the `data` payload
    /// carries `gas_day`, `nominated_kwh`, `confirmed_kwh` and `curtailed_kwh`
    /// beside the parties.
    pub const NOMINATION_CURTAILED: &str = "de.gabi.nomination.curtailed";
    /// The FNB/MGV refused the nomination.
    ///
    /// Emitted by `makod` from `gabi-gas-nomination`; the `data` payload
    /// carries `gas_day`, `reason` and the parties. Nothing flows on this
    /// nomination, so the BKV must re-nominate.
    pub const NOMINATION_REJECTED: &str = "de.gabi.nomination.rejected";
    /// The `KoV` NOMRES window closed with no answer on file.
    ///
    /// Emitted by `makod` when the `gabi-gas-nomination` deadline fires: the
    /// nomination's status is unknown at gas-day start, which is an operator
    /// call rather than something to assume either way.
    pub const NOMRES_MISSING: &str = "de.gabi.nomres.missing";
    /// ⚠ phantom: no emitter yet.
    pub const IMBALANCE_CALCULATED: &str = "de.gabi.imbalance.calculated";
    /// ⚠ phantom: no emitter yet.
    pub const CORRECTION_CREATED: &str = "de.gabi.correction.created";
    /// ⚠ phantom: no emitter yet.
    pub const INVOIC_MMM_RECEIVED: &str = "de.gabi.invoic.mmm.received";
    /// ⚠ phantom: no emitter yet.
    pub const INVOIC_KAPAZITAET_RECEIVED: &str = "de.gabi.invoic.kapazitaet.received";
    /// The § 47 Ziffer 1 KoV XV final-allocation window closed with no binding final
    /// ALOCAT on file, so the gas day's imbalance cannot be settled.
    ///
    /// Emitted by `makod` from the `gabi-gas-allocation` deadline via
    /// `ErpEventType::GabiFinalAllocationOverdue`. The `data` payload carries
    /// `gas_day`, `deadline_label`, `sender_eic`, `receiver_eic` and
    /// `pruefidentifikator` — the key the emitter actually writes
    /// (`mako_gabi_gas::allocation`). The operator's action is to open a
    /// Clearingfall with the FNB/MGV.
    pub const ALOCAT_MISSING: &str = "de.gabi.alocat.missing";
    /// ⚠ phantom: no emitter yet.
    pub const GAS_QUALITY_VIOLATION: &str = "de.gabi.quality.violation";
    /// ⚠ phantom: no emitter yet.
    pub const FINAL_ALOCAT_DEADLINE: &str = "de.gabi.alocat.final.deadline";
}

/// Process-observability events (`de.obs.*`).
///
/// Produced by `obsd`'s background sweep workers (`services/obsd/src/worker.rs`)
/// and consumed by `agentd` (`compliance-agent`, `deadline-alert-agent`).
pub mod obs {
    /// §20 EnWG STP parity-gap alert: the completion-rate gap between affiliate-
    /// and non-affiliate-initiated Anmeldungen exceeds the configured threshold.
    /// Emitted by obsd's parity sweep; consumed by agentd (`compliance-agent`).
    pub const STP_PARITY_ALERT: &str = "de.obs.stp.parity.alert";
    /// A tracked process is approaching its regulatory response deadline (within
    /// the warn window). Emitted per process by obsd's deadline sweep; consumed
    /// by agentd (`deadline-alert-agent`).
    pub const DEADLINE_APPROACHING: &str = "de.obs.deadline.approaching";
}

/// Sperr/Entsperr execution events (`de.sperr.*`), emitted by `sperrd`.
///
/// These are the **NB side**: the grid operator's record of a Sperr- or
/// Entsperrauftrag it received (ORDERS 17115/17117) and physically carried out.
/// They are not the LF's §41f notices — those are `de.accounting.sperr*`.
///
/// Consumed by agentd's `sperrd-agent`, which watches the execution SLA and the
/// IFTSTA 21039 dispatch.
pub mod sperr {
    /// A Sperr-/Entsperrauftrag entered the field-service queue — either from an
    /// inbound ORDERS 17115/17117 or from an operator creating one directly.
    pub const AUFTRAG_EINGEGANGEN: &str = "de.sperr.auftrag.eingegangen";
    /// The field team carried the order out. The IFTSTA 21039 reporting
    /// `STS+Z37/Z38 → Z14 erfolgreich` has been handed to `makod`.
    pub const AUSGEFUEHRT: &str = "de.sperr.ausgefuehrt";
    /// The order could not be carried out (`Z13 gescheitert`) — meter access
    /// denied, safety block, address not found. Carries the EBD Prüfschritt code
    /// so the LF learns *why* instead of waiting out its ORDRSP deadline.
    pub const FEHLGESCHLAGEN: &str = "de.sperr.fehlgeschlagen";
    /// A Sperrversuch did not succeed but the order stays in the queue: GPKE
    /// Teil 2 § 3.5.1.2 Nr. 5 gives the NB **two** Sperrversuche within one
    /// Sperrauftrag, and only the second turns into `FEHLGESCHLAGEN`.
    pub const VERSUCH_GESCHEITERT: &str = "de.sperr.versuch.gescheitert";
    /// A pending order was withdrawn before execution (operator action, or an
    /// inbound ORDCHG 39000 Stornierung). No IFTSTA is dispatched.
    pub const STORNIERT: &str = "de.sperr.storniert";
    /// A pending order is past the window GPKE Teil 2 § 3.5.1.2 Nr. 1 gives the
    /// NB for the physical act — 6 Werktage after the frühestmöglicher
    /// Sperrtermin. Announced once per order.
    pub const AUSFUEHRUNG_UEBERFAELLIG: &str = "de.sperr.ausfuehrung.ueberfaellig";
    /// An order is terminal in `sperrd` but its IFTSTA 21039 has still not
    /// reached `makod` after the retry budget. Until it does, the LF's
    /// `gpke-sperrung-lf` process cannot close — this is the one state in the
    /// service that needs a human.
    pub const IFTSTA_AUSSTEHEND: &str = "de.sperr.iftsta.ausstehend";
}

/// MaBiS Summenzeitreihe submission events (`de.mabis.*`), emitted by
/// `mabis-syncd`.
///
/// Both are failure signals: a healthy submission cycle is silent, because the
/// scheduled Erstaufschlag run filing on time is the normal case and an event
/// per success would be noise nobody subscribes to.
pub mod mabis {
    /// A Summenzeitreihe aggregation or BIKO submission failed
    /// (BK6-24-174 Anlage 3 §3.10). Carries the run id, the
    /// Bilanzierungsgebiet, the period, the phase and `attempt_count` — after
    /// three attempts the scheduler stops retrying and a human has to look.
    pub const SUBMISSION_FAILED: &str = "de.mabis.submission.failed";
    /// A negative Prüfmitteilung opened a Korrekturbedarf (§9.8.1): the BIKO
    /// or a BKV objected to a filed Summenzeitreihe, and a corrected version
    /// must be submitted within the Clearing window.
    pub const KORREKTURBEDARF_OPENED: &str = "de.mabis.korrekturbedarf.opened";
}

/// Every concrete CloudEvents type in the catalog.
#[must_use]
pub fn all() -> &'static [&'static str] {
    &[
        // de.mako.*
        mako::PROCESS_INITIATED,
        mako::PROCESS_COMPLETED,
        mako::PROCESS_FAILED,
        mako::APERAK_ACCEPTED,
        mako::APERAK_REJECTED,
        mako::APERAK_TIMEOUT,
        mako::CONTRL_RECEIVED,
        mako::MALO_IDENTIFIED,
        mako::ABMELDEANFRAGE_BEANTWORTET,
        mako::EDIFACT_OUTBOUND,
        mako::NETZZUGANG_UEBERMITTLUNGSBEDARF,
        // de.markt.*
        markt::MALO_UPDATED,
        markt::MALO_STAMMDATEN_GEAENDERT,
        markt::STAMMDATEN_GEAENDERT,
        markt::MELO_UPDATED,
        markt::PARTNER_UPDATED,
        markt::NB_CONTRACT_UPDATED,
        markt::MSB_RAHMENVERTRAG_GAS_UPDATED,
        markt::GERAET_KONFIGURATION_UPDATED,
        markt::SR_KONFIGURATIONSPRODUKT_UPDATED,
        markt::NETZZUGANG_ANTRAG_UPDATED,
        markt::EINWILLIGUNG_ERTEILT,
        markt::EINWILLIGUNG_WIDERRUFEN,
        markt::VERSORGUNG_CHANGED,
        markt::VERSORGUNG_BELIEFERT,
        markt::VERSORGUNG_GAP_DETECTED,
        markt::VERSORGUNG_EOG_BEGONNEN,
        markt::VERSORGUNG_ERSATZ_AUSLAUFEND,
        markt::PRICAT_PUBLISHED,
        markt::MMMA_IMPORT_SUCCESS,
        markt::MMMA_IMPORT_FAILED,
        markt::SUBSCRIPTION_TEST,
        // de.billing.*
        billing::RECHNUNG_ERSTELLT,
        billing::ABRECHNUNGSINFORMATION_MONATLICH,
        billing::XRECHNUNG_B2G_READY,
        // de.invoic.*
        invoic::RECEIPT_DISPUTED,
        invoic::RECEIPT_DISPATCHED,
        invoic::RECEIPT_SETTLED,
        invoic::PAYMENT_OVERDUE,
        // de.eeg.*
        eeg::VERGUETUNG_BERECHNET,
        eeg::MARKTPRAEMIE_BERECHNET,
        eeg::SETTLEMENT_BATCH_DUE,
        eeg::ANLAGE_FOERDERUNG_AUSLAUFEND,
        eeg::ANLAGE_MASTR_REGISTRIERT,
        eeg::VERAEUSSERUNGSFORM_GEWECHSELT,
        // de.accounting.*
        accounting::MAHNUNG_ISSUED,
        accounting::ABSCHLAG_POSTED,
        accounting::PAYMENT_DUE,
        accounting::PAYMENT_IMPORTED,
        accounting::ERSTATTUNG_FAELLIG,
        accounting::INTEREST_CHARGED,
        accounting::EEG_PAYOUT_REJECTED,
        accounting::JAHRESABSCHLUSS_ABGESCHLOSSEN,
        accounting::SPERRANDROHUNG,
        accounting::SPERRANKUENDIGUNG,
        accounting::SPERRAUFTRAG,
        accounting::ENTSPERRAUFTRAG,
        accounting::ABWENDUNG_ANGEBOTEN,
        accounting::ABWENDUNG_GEBROCHEN,
        accounting::BANKRUECKLAST,
        accounting::SEPA_COLLECTION_REJECTED,
        accounting::SEPA_REVERSAL_ISSUED,
        accounting::SEPA_RECALL_REQUESTED,
        accounting::SEPA_RECALL_RESOLVED,
        accounting::PAYEE_VERIFICATION_MISMATCH,
        // de.netzbilanz.*
        netzbilanz::INVOIC_DRAFTED,
        netzbilanz::INVOIC_DISPATCHED,
        netzbilanz::INVOIC_DISPATCH_OVERDUE,
        netzbilanz::INVOIC_PAID,
        netzbilanz::INVOIC_DISPUTED,
        netzbilanz::KOSTENBLATT_COMPUTED,
        netzbilanz::KOSTENBLATT_DEADLINE_APPROACHING,
        // de.messwert.*
        messwert::READING_QUALITY_WARNING,
        messwert::READING_DIRECT_STORED,
        messwert::READING_ORDER_FAILED,
        messwert::READING_CONFIRMATION_OVERDUE,
        messwert::READING_DELIVERY_OVERDUE,
        messwert::READING_DELIVERY_RESUMED,
        messwert::ESA_TYP2_DELIVERY_OVERDUE,
        messwert::ESA_TYP2_DELIVERY_RESUMED,
        messwert::CLS_COMPLIANCE_ISSUE,
        messwert::CLS_COMPLIANCE_RESOLVED,
        messwert::SMGW_CERT_EXPIRY_WARNING,
        // de.tarif.*
        tarif::PRODUCT_UPDATED,
        tarif::ANGEBOT_ANGENOMMEN,
        tarif::ANGEBOT_ABGELAUFEN,
        tarif::EPEX_MISSING,
        // de.vertrag.*
        vertrag::ABGESCHLOSSEN,
        vertrag::AKTIV,
        vertrag::GEKUENDIGT,
        vertrag::KUENDIGUNG,
        vertrag::KUENDIGUNG_WIDERRUFEN,
        vertrag::TARIFWECHSEL,
        vertrag::TARIFWECHSEL_GEPLANT,
        vertrag::PREISGARANTIE_HINTERLEGT,
        vertrag::PREISAENDERUNG_ANKUENDIGUNG,
        vertrag::AUTOERNEUERUNG_ANKUENDIGUNG,
        vertrag::ABLAUF_ANKUENDIGUNG,
        // de.vpp.*
        vpp::DISPATCH_CONFIRMED,
        vpp::SETTLEMENT_BERECHNET,
        // de.agent.*
        agent::DECISION_MADE,
        // de.gabi.*
        gabi::MEASUREMENT_RECEIVED,
        gabi::ALLOCATION_COMPLETED,
        gabi::NOMINATION_CREATED,
        gabi::NOMINATION_CURTAILED,
        gabi::NOMINATION_REJECTED,
        gabi::NOMRES_MISSING,
        gabi::NOMINATION_CONFIRMED,
        gabi::IMBALANCE_CALCULATED,
        gabi::CORRECTION_CREATED,
        gabi::INVOIC_MMM_RECEIVED,
        gabi::INVOIC_KAPAZITAET_RECEIVED,
        gabi::ALOCAT_MISSING,
        gabi::GAS_QUALITY_VIOLATION,
        gabi::FINAL_ALOCAT_DEADLINE,
        // de.obs.*
        obs::STP_PARITY_ALERT,
        obs::DEADLINE_APPROACHING,
        // de.sperr.*
        sperr::AUFTRAG_EINGEGANGEN,
        sperr::VERSUCH_GESCHEITERT,
        sperr::AUSFUEHRUNG_UEBERFAELLIG,
        sperr::AUSGEFUEHRT,
        sperr::FEHLGESCHLAGEN,
        sperr::STORNIERT,
        sperr::IFTSTA_AUSSTEHEND,
        // de.mabis.*
        mabis::SUBMISSION_FAILED,
        mabis::KORREKTURBEDARF_OPENED,
    ]
}

/// The canonical event-type pattern matcher, shared by every subscription
/// mechanism in the workspace (marktd webhook subscriptions, agentd trigger
/// patterns).
///
/// Semantics: `*` matches any (possibly empty) sequence, `?` matches exactly
/// one character, everything else is literal. A bare `*` matches everything.
/// Trailing-`*` prefix patterns (`de.mako.*`) therefore behave exactly like
/// the historical marktd prefix matcher, and mid-pattern globs
/// (`de.*.rechnung.*`) work too.
///
/// There is deliberately ONE implementation: before 2026-07 marktd and agentd
/// each carried their own with silently different semantics (exact+prefix vs
/// full glob).
#[must_use]
pub fn matches(pattern: &str, event_type: &str) -> bool {
    if pattern == "*" {
        return true;
    }
    let p: Vec<char> = pattern.chars().collect();
    let v: Vec<char> = event_type.chars().collect();
    let mut pi = 0usize;
    let mut vi = 0usize;
    let mut star_pi: Option<usize> = None;
    let mut star_vi = 0usize;

    while vi < v.len() {
        if pi < p.len() && (p[pi] == '?' || p[pi] == v[vi]) {
            pi += 1;
            vi += 1;
        } else if pi < p.len() && p[pi] == '*' {
            star_pi = Some(pi);
            star_vi = vi;
            pi += 1;
        } else if let Some(sp) = star_pi {
            pi = sp + 1;
            star_vi += 1;
            vi = star_vi;
        } else {
            return false;
        }
    }
    while pi < p.len() && p[pi] == '*' {
        pi += 1;
    }
    pi == p.len()
}

#[cfg(test)]
mod matcher_tests {
    use super::matches;

    #[test]
    fn exact_and_bare_star() {
        assert!(matches(
            super::mako::PROCESS_INITIATED,
            super::mako::PROCESS_INITIATED
        ));
        assert!(!matches(
            super::mako::PROCESS_INITIATED,
            super::mako::PROCESS_COMPLETED
        ));
        assert!(matches("*", "de.anything.at.all"));
    }

    #[test]
    fn trailing_star_is_prefix_match() {
        // The historical marktd subscription semantics.
        assert!(matches("de.mako.*", "de.mako.process.initiated"));
        assert!(matches("de.markt.*", "de.markt.malo.updated"));
        assert!(!matches("de.mako.*", "de.markt.malo.updated"));
        // Prefix boundary is character-wise, exactly like the old
        // `starts_with(trim_end_matches('*'))`.
        assert!(matches("de.mako.process.*", "de.mako.process.failed"));
    }

    #[test]
    fn mid_glob_and_question_mark() {
        assert!(matches(
            "de.*.rechnung.erstellt",
            "de.billing.rechnung.erstellt"
        ));
        assert!(matches(
            "de.e?g.verguetung.berechnet",
            "de.eeg.verguetung.berechnet"
        ));
        assert!(!matches(
            "de.e?g.verguetung.berechnet",
            "de.eeeg.verguetung.berechnet"
        ));
    }

    #[test]
    fn empty_pattern_matches_only_empty() {
        assert!(matches("", ""));
        assert!(!matches("", "de.x"));
    }
}

#[cfg(test)]
mod tests {
    use super::all;

    #[test]
    fn every_type_starts_with_de_prefix() {
        for ty in all() {
            assert!(ty.starts_with("de."), "{ty} must start with `de.`");
        }
    }

    /// Segments are dot-separated; multi-word segments join with `-`, never `_`.
    ///
    /// The catalog is a published contract, so a drifting separator means two
    /// spellings of the same concept reach subscribers. Hyphen is the single
    /// convention; namespaces whose domain vocabulary is German keep German
    /// participles.
    ///
    /// `de.vertrag.*` is the clearest case: every event is a contract-lifecycle
    /// fact named in the language the contract itself uses — `gekuendigt`,
    /// `kuendigung-widerrufen`, `tarifwechsel-geplant`, `abgelaufen`. One event
    /// used an English participle on a German noun
    /// (`de.vertrag.preisgarantie-updated`), so a subscriber reading the
    /// namespace had to know which of two languages each fact was named in.
    ///
    /// This is deliberately narrow. It is **not** a rule that every event must
    /// be German: `de.mako.*` (EDIFACT transport), `de.gabi.*` and
    /// `de.accounting.*` are technical or English-domain namespaces and stay
    /// English. Nor does it merge `.updated` into `.geaendert` elsewhere —
    /// in `de.markt.*` those are different facts (`malo.updated` is any
    /// master-data write; `malo.stammdaten-geaendert` is a regulated GPKE
    /// Stammdatenänderung carrying the applied patch for audit), and collapsing
    /// them would lose a distinction the ERP relies on.
    /// A constant nothing references must say so.
    ///
    /// `⚠ phantom:` marks a type the catalog declares but no service emits. A
    /// marker only helps if it is true, and prose about which constants those
    /// are rots faster than the constants do.
    ///
    /// So the annotation is checked rather than trusted: any constant not named
    /// anywhere outside this crate must carry the marker.
    ///
    /// The reverse does not hold, and must not: being *named* outside this crate
    /// is not being emitted. `de.eeg.compliance.*` is subscribed by two agentd
    /// specialists with no emitter behind it, and several `de.gabi.*` phantoms
    /// are named only by their own crate's unit tests. A constant may therefore
    /// carry the marker while this test would not have demanded it — the
    /// marker's claim is about emission, the test only catches the loudest way
    /// to violate it.
    /// **Every declared type is in the catalogue.**
    ///
    /// `all()` is what a subscriber is checked against, what an inventory
    /// counts, and what a doc table is generated from. A `pub const` missing
    /// from it is a published type nothing can subscribe to: `agentd`'s
    /// subscription guard refuses a pattern that matches no catalogue entry, so
    /// a specialist that should watch it *cannot be wired* — and the refusal
    /// names the pattern, which reads like a typo rather than like a gap here.
    ///
    /// Three of accountingd's own emitted types were in that state:
    /// `sepa.collection-rejected`, `sepa.reversal-issued` and
    /// `payee.verification-mismatch`. All three passed the phantom check, which
    /// asks the opposite question — *is this referenced anywhere* — and cannot
    /// see a constant that is emitted and uncatalogued.
    #[test]
    fn every_declared_constant_is_in_the_catalogue() {
        let src = include_str!("lib.rs");
        let catalog = src.split("#[cfg(test)]").next().expect("catalog section");

        let declared: Vec<&str> = catalog
            .lines()
            .filter_map(|line| {
                let rest = line.trim().strip_prefix("pub const ")?;
                let (_, value) = rest.split_once("= \"")?;
                value.split('"').next()
            })
            .filter(|v| v.starts_with("de."))
            .collect();

        assert!(
            declared.len() > 80,
            "parsed only {} constants — the parser broke, not the catalog",
            declared.len()
        );

        let missing: Vec<&&str> = declared.iter().filter(|v| !all().contains(v)).collect();
        assert!(
            missing.is_empty(),
            "these event types are declared and absent from `all()`, so nothing can \
             subscribe to them and no inventory counts them: {missing:#?}"
        );
    }

    #[test]
    fn unreferenced_constants_are_marked_phantom() {
        let src = include_str!("lib.rs");
        let catalog = src.split("#[cfg(test)]").next().expect("catalog section");

        // Every `pub const NAME: &str = "value";` with its module and the doc
        // block above it.
        let lines: Vec<&str> = catalog.lines().collect();
        let mut entries: Vec<Entry> = Vec::new();
        let mut module = String::new();
        for (i, line) in lines.iter().enumerate() {
            let trimmed = line.trim();
            if let Some(rest) = trimmed.strip_prefix("pub mod ") {
                module = rest.trim_end_matches(" {").trim().to_owned();
                continue;
            }
            let Some(rest) = trimmed.strip_prefix("pub const ") else {
                continue;
            };
            let Some(name) = rest.split(':').next() else {
                continue;
            };
            let Some(value) = rest
                .split_once("= \"")
                .and_then(|(_, v)| v.split('"').next())
            else {
                continue;
            };
            // Walk back over *this* constant's own doc block only. A fixed
            // lookback window spans into the previous entry's comment, and in
            // `gabi` every neighbour carries the marker — so a constant whose
            // own marker is missing would still read as marked.
            let mut phantom = false;
            for prev in lines[..i].iter().rev() {
                let t = prev.trim();
                if t.starts_with("///") {
                    if t.contains("⚠ phantom:") {
                        phantom = true;
                    }
                } else if !t.is_empty() {
                    break;
                }
            }
            entries.push(Entry {
                module: module.clone(),
                name: name.trim().to_owned(),
                value: value.to_owned(),
                phantom,
            });
        }
        assert!(
            entries.len() > 80,
            "parsed only {} constants — the parser broke, not the catalog",
            entries.len()
        );

        // Concatenate every other Rust source in the workspace.
        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .and_then(std::path::Path::parent)
            .expect("workspace root");
        let mut blob = String::new();
        for dir in ["crates", "services"] {
            collect_rs(&root.join(dir), &mut blob);
        }

        let unmarked: Vec<&str> = entries
            .iter()
            .filter(|e| !e.phantom && !e.is_referenced(&blob, &entries))
            .map(|e| e.name.as_str())
            .collect();

        assert!(
            unmarked.is_empty(),
            "these constants are referenced nowhere outside `mako-events` but carry no \
             `⚠ phantom:` marker:\n  {unmarked:?}\n\
             Either wire an emitter, or document the gap with `⚠ phantom:` so the \
             catalog does not imply the event exists."
        );
    }

    /// One catalogue constant as the phantom guard parses it.
    struct Entry {
        module: String,
        name: String,
        value: String,
        phantom: bool,
    }

    impl Entry {
        /// Is this constant named anywhere outside `mako-events`?
        ///
        /// The bare constant name is **not** enough on its own when two modules
        /// declare the same one. `vpp::SETTLEMENT_BERECHNET` is emitted by
        /// `billingd`; a search for the string `SETTLEMENT_BERECHNET` therefore
        /// hit, and reported the long-dead `eeg::SETTLEMENT_BERECHNET` — which
        /// no service emitted and no glob matched — as live. The marker it
        /// should have carried was never demanded, so the catalog declared a
        /// settlement type that could not fire, with a doc comment naming an
        /// emitter that emits something else.
        ///
        /// So a name shared by two modules must be found *qualified*
        /// (`eeg::SETTLEMENT_BERECHNET`) or by its wire value. A unique name
        /// still matches bare, because that is how most call sites spell it
        /// after a grouped `use`.
        fn is_referenced(&self, blob: &str, all: &[Entry]) -> bool {
            if blob.contains(&self.value)
                || blob.contains(&format!("{}::{}", self.module, self.name))
            {
                return true;
            }
            let unique = all.iter().filter(|e| e.name == self.name).count() == 1;
            unique && blob.contains(&self.name)
        }
    }

    /// Append every `.rs` file under `dir` (skipping this crate) to `out`.
    fn collect_rs(dir: &std::path::Path, out: &mut String) {
        let Ok(entries) = std::fs::read_dir(dir) else {
            return;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                if path.file_name().is_some_and(|n| n == "mako-events") {
                    continue;
                }
                collect_rs(&path, out);
            } else if path.extension().is_some_and(|e| e == "rs")
                && let Ok(s) = std::fs::read_to_string(&path)
            {
                out.push_str(&s);
            }
        }
    }

    #[test]
    fn german_namespaces_use_german_participles() {
        /// Namespaces whose events are named in German.
        const GERMAN_NAMESPACES: &[&str] = &["vertrag"];
        /// English participles that have an established German form already in
        /// use elsewhere in the catalog.
        const ENGLISH_PARTICIPLES: &[&str] = &[
            "updated",
            "changed",
            "created",
            "deleted",
            "stored",
            "replaced",
            "cancelled",
            "canceled",
            "renewed",
            "expired",
            "planned",
        ];

        for ty in all() {
            let Some(ns) = ty.split('.').nth(1) else {
                continue;
            };
            if !GERMAN_NAMESPACES.contains(&ns) {
                continue;
            }
            for bad in ENGLISH_PARTICIPLES {
                assert!(
                    !ty.ends_with(&format!("-{bad}")) && !ty.ends_with(&format!(".{bad}")),
                    "{ty}: `de.{ns}.*` names its facts in German — use the German \
                     participle instead of {bad:?} (e.g. `preisgarantie-hinterlegt`, \
                     not `preisgarantie-updated`)"
                );
            }
        }
    }

    #[test]
    fn segments_use_hyphen_not_underscore() {
        for ty in all() {
            assert!(
                !ty.contains('_'),
                "{ty} must join multi-word segments with `-`, not `_`"
            );
            for segment in ty.split('.') {
                assert!(
                    !segment.is_empty(),
                    "{ty} must not contain an empty segment"
                );
                assert!(
                    !segment.starts_with('-') && !segment.ends_with('-'),
                    "{ty}: segment {segment:?} must not start or end with `-`"
                );
                assert!(
                    segment
                        .bytes()
                        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'),
                    "{ty}: segment {segment:?} must match [a-z0-9-]+"
                );
            }
        }
    }

    /// No two catalog entries may differ only by separator or case.
    #[test]
    fn no_two_types_normalise_to_the_same_name() {
        let mut seen: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
        for ty in all() {
            let key = ty.replace(['-', '_'], "").to_lowercase();
            if let Some(prev) = seen.insert(key, ty) {
                assert_eq!(prev, *ty, "{prev} and {ty} collide after normalisation");
            }
        }
    }

    #[test]
    fn every_type_is_lowercase() {
        for ty in all() {
            assert_eq!(
                *ty,
                ty.to_lowercase(),
                "{ty} must be entirely lowercase (CloudEvents type convention)"
            );
        }
    }

    #[test]
    fn every_type_has_valid_segments() {
        for ty in all() {
            assert!(
                !ty.contains(' ') && !ty.contains('*'),
                "{ty} must be a concrete type — no whitespace, no globs"
            );
            for segment in ty.split('.') {
                assert!(!segment.is_empty(), "{ty} has an empty `.` segment");
                assert!(
                    segment
                        .chars()
                        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "-_".contains(c)),
                    "{ty}: segment `{segment}` has characters outside [a-z0-9_-]"
                );
            }
        }
    }

    #[test]
    fn no_duplicates() {
        let types = all();
        let mut seen = std::collections::BTreeSet::new();
        for ty in types {
            assert!(seen.insert(*ty), "duplicate catalog entry: {ty}");
        }
    }

    #[test]
    fn legacy_prefixes_are_gone() {
        for ty in all() {
            assert!(
                !ty.starts_with("de.edmd.") && !ty.starts_with("de.tarifbd."),
                "{ty} uses a retired service-name prefix (use de.messwert / de.tarif)"
            );
            assert_ne!(
                *ty, "de.angebot.angenommen",
                "moved to de.tarif.angebot.angenommen"
            );
        }
    }
}