mako_engine/dead_letter.rs
1//! Dead-letter sink for unroutable or unprocessable inbound messages.
2//!
3//! BDEW AS4 requires that every received message is either processed or
4//! explicitly refused (CONTRL negative acknowledgement). Messages that
5//! cannot be routed to a workflow — because the PID is unknown, the
6//! conversation is not in-flight, or the format version has no adapter —
7//! must not be silently dropped.
8//!
9//! # Design
10//!
11//! [`DeadLetterSink`] is a synchronous trait that receives a structured
12//! [`DeadLetterReason`] for every rejected message. The synchronous contract
13//! keeps dispatch-path hot code fast; implementations that need async work
14//! (e.g. persisting to a durable DLQ or sending a CONTRL) can use
15//! `tokio::spawn` internally.
16//!
17//! # Implementations
18//!
19//! | Type | Behaviour |
20//! |------|-----------|
21//! | [`LogDeadLetterSink`] | Emits structured `tracing::warn!`; suitable for all deployments |
22//! | [`NoopDeadLetterSink`] | Silently discards; **only for testing** |
23//!
24//! # Wiring
25//!
26//! Pass an implementation to [`EngineBuilder::with_dead_letter_sink`].
27//! The default is [`LogDeadLetterSink`] so unroutable messages are always
28//! visible in the log output without any configuration.
29//!
30//! ```rust
31//! use mako_engine::dead_letter::{AuditContext, DeadLetterReason, DeadLetterSink, LogDeadLetterSink};
32//! use mako_engine::ids::Pid;
33//!
34//! let sink = LogDeadLetterSink;
35//! sink.reject(&DeadLetterReason::UnknownPid { pid: Pid::new(99999), context: AuditContext::now() });
36//! ```
37//!
38//! [`EngineBuilder::with_dead_letter_sink`]: crate::builder::EngineBuilder::with_dead_letter_sink
39
40use std::sync::Arc;
41
42use time_tz::{OffsetDateTimeExt as _, timezones};
43
44// ── AuditContext ──────────────────────────────────────────────────────────────
45
46/// Structured audit context attached to every dead-letter event.
47///
48/// All fields are `Option` because they are only partially known at rejection
49/// time (e.g. `pid` is not available for a parse failure before the PID is
50/// decoded). Callers fill in as many fields as they have.
51///
52/// These fields map to §22 MessZV audit-log requirements for AS4 message
53/// rejection events:
54///
55/// | Field | §22 MessZV requirement |
56/// |---|---|
57/// | `message_type` | Nachrichtentyp (UTILMD, MSCONS, APERAK, …) |
58/// | `release_code` | Releasekennung (S2.1, G1.1, 2.4c, …) |
59/// | `pid` | Prüfidentifikator |
60/// | `sender_eic` | GLN des Absenders |
61/// | `receiver_eic` | GLN des Empfängers |
62/// | `message_ref` | UNH-Referenz |
63/// | `process_id` | Geschäftsvorfallkennung |
64/// | `tenant_id` | Mandant |
65/// | `correlation_id` | AS4 `ConversationId` or similar |
66/// | `timestamp` | Zeitstempel des Eingangs (German local time) |
67#[derive(Debug, Clone)]
68pub struct AuditContext {
69 /// EDIFACT message type (e.g. `"UTILMD"`, `"MSCONS"`, `"APERAK"`).
70 pub message_type: Option<String>,
71 /// BDEW release code (e.g. `"S2.1"`, `"G1.1"`, `"2.4c"`).
72 pub release_code: Option<String>,
73 /// BDEW Prüfidentifikator numeric code.
74 pub pid: Option<crate::ids::Pid>,
75 /// GLN of the AS4 sender.
76 pub sender_eic: Option<String>,
77 /// GLN of the AS4 receiver.
78 pub receiver_eic: Option<String>,
79 /// UNH message reference (interchange + message ref).
80 pub message_ref: Option<String>,
81 /// Internal process / workflow stream ID.
82 pub process_id: Option<String>,
83 /// Tenant identifier (Mandant).
84 pub tenant_id: Option<String>,
85 /// AS4 ConversationId or engine correlation key.
86 pub correlation_id: Option<String>,
87 /// Timestamp of message receipt, in German local time (CET/CEST).
88 pub timestamp: time::OffsetDateTime,
89}
90
91impl AuditContext {
92 /// Create an `AuditContext` with only a timestamp, all other fields `None`.
93 ///
94 /// The timestamp is set to the current wall-clock time in **German local time**
95 /// (CET = UTC+1 in winter, CEST = UTC+2 in summer), satisfying the §22 MessZV
96 /// requirement for German-timezone audit records.
97 ///
98 /// Use builder-style setters to fill in known fields:
99 /// ```rust
100 /// use mako_engine::dead_letter::AuditContext;
101 /// use mako_engine::ids::Pid;
102 ///
103 /// let ctx = AuditContext::now()
104 /// .with_message_type("UTILMD")
105 /// .with_pid(Pid::new(55001))
106 /// .with_sender_eic("4012345000023");
107 /// ```
108 #[must_use]
109 pub fn now() -> Self {
110 let berlin = timezones::db::europe::BERLIN;
111 Self {
112 message_type: None,
113 release_code: None,
114 pid: None,
115 sender_eic: None,
116 receiver_eic: None,
117 message_ref: None,
118 process_id: None,
119 tenant_id: None,
120 correlation_id: None,
121 // Use Berlin local time so audit records align with the German
122 // regulatory clock — an off-by-one-hour error at DST transitions
123 // is a reportable BNetzA regulatory violation.
124 timestamp: time::OffsetDateTime::now_utc().to_timezone(berlin),
125 }
126 }
127
128 /// Populate an `AuditContext` from an interchange header and known optional fields.
129 ///
130 /// Fills in `sender_eic`, `receiver_eic`, and `message_ref` (interchange control
131 /// reference) from the parsed UNB header. All remaining fields (pid, process_id,
132 /// tenant_id, correlation_id) are `None` and should be set via builder setters
133 /// when available.
134 ///
135 /// Satisfies the §22 MessZV requirement that every dead-letter record carries at
136 /// minimum the sender GLN, receiver GLN, and interchange reference.
137 #[must_use]
138 pub fn from_interchange(sender_id: &str, receiver_id: &str, control_ref: &str) -> Self {
139 Self::now()
140 .with_sender_eic(sender_id)
141 .with_receiver_eic(receiver_id)
142 .with_message_ref(control_ref)
143 }
144
145 /// Set the message type.
146 #[must_use]
147 pub fn with_message_type(mut self, mt: impl Into<String>) -> Self {
148 self.message_type = Some(mt.into());
149 self
150 }
151
152 /// Set the BDEW release code.
153 #[must_use]
154 pub fn with_release_code(mut self, rc: impl Into<String>) -> Self {
155 self.release_code = Some(rc.into());
156 self
157 }
158
159 /// Set the Prüfidentifikator.
160 #[must_use]
161 pub fn with_pid(mut self, pid: crate::ids::Pid) -> Self {
162 self.pid = Some(pid);
163 self
164 }
165
166 /// Set the sender GLN.
167 #[must_use]
168 pub fn with_sender_eic(mut self, eic: impl Into<String>) -> Self {
169 self.sender_eic = Some(eic.into());
170 self
171 }
172
173 /// Set the receiver GLN.
174 #[must_use]
175 pub fn with_receiver_eic(mut self, eic: impl Into<String>) -> Self {
176 self.receiver_eic = Some(eic.into());
177 self
178 }
179
180 /// Set the UNH message reference.
181 #[must_use]
182 pub fn with_message_ref(mut self, r: impl Into<String>) -> Self {
183 self.message_ref = Some(r.into());
184 self
185 }
186
187 /// Set the internal process / stream ID.
188 #[must_use]
189 pub fn with_process_id(mut self, id: impl Into<String>) -> Self {
190 self.process_id = Some(id.into());
191 self
192 }
193
194 /// Set the tenant identifier.
195 #[must_use]
196 pub fn with_tenant_id(mut self, id: impl Into<String>) -> Self {
197 self.tenant_id = Some(id.into());
198 self
199 }
200
201 /// Set the AS4 correlation / conversation ID.
202 #[must_use]
203 pub fn with_correlation_id(mut self, id: impl Into<String>) -> Self {
204 self.correlation_id = Some(id.into());
205 self
206 }
207}
208
209impl Default for AuditContext {
210 fn default() -> Self {
211 Self::now()
212 }
213}
214
215// ── DeadLetterReason ──────────────────────────────────────────────────────────
216
217/// Structured reason why an inbound message was rejected.
218///
219/// The variant gives the dispatch path enough information to emit an
220/// actionable CONTRL or log entry. Each variant carries an [`AuditContext`]
221/// with the §22 MessZV fields required for regulatory audit logging.
222///
223/// Adding new variants is a non-breaking change thanks to `#[non_exhaustive]`.
224#[derive(Debug, Clone)]
225#[non_exhaustive]
226pub enum DeadLetterReason {
227 /// No workflow is registered for this PID in the [`PidRouter`].
228 ///
229 /// The PID is either from a future BDEW release not yet deployed or a
230 /// malformed message. Respond with a CONTRL negative acknowledgement.
231 ///
232 /// [`PidRouter`]: crate::pid_router::PidRouter
233 UnknownPid {
234 /// The numeric Prüfidentifikator that had no registered workflow.
235 pid: crate::ids::Pid,
236 /// §22 MessZV structured audit context.
237 context: AuditContext,
238 },
239
240 /// No in-flight process matched the inbound `conversation_id`.
241 ///
242 /// This typically means the process completed, was never started, or
243 /// the [`ProcessRegistry`] was lost on restart (see.
244 ///
245 /// [`ProcessRegistry`]: crate::registry::ProcessRegistry
246 UnknownConversation {
247 /// The `conversation_id` from the inbound EDIFACT interchange.
248 conversation_id: String,
249 /// §22 MessZV structured audit context.
250 context: AuditContext,
251 },
252
253 /// The message's format version has no registered [`MessageAdapter`].
254 ///
255 /// Either the adapter registry is incomplete (see or the sender
256 /// is using a deprecated / future format version.
257 ///
258 /// [`MessageAdapter`]: crate::message_adapter::MessageAdapter
259 VersionMismatch {
260 /// The format version string the adapter registry expected.
261 expected: String,
262 /// The format version string carried in the inbound message.
263 received: String,
264 /// §22 MessZV structured audit context.
265 context: AuditContext,
266 },
267
268 /// A message with this inbox key was already accepted (AS4 duplicate).
269 ///
270 /// The AS4 sender retries for up to 72 hours. The [`InboxStore`]
271 /// detected the duplicate and the message must not be processed again.
272 ///
273 /// [`InboxStore`]: crate::inbox::InboxStore
274 DuplicateMessage {
275 /// The inbox deduplication key (typically the AS4 `MessageId`).
276 inbox_key: String,
277 /// §22 MessZV structured audit context.
278 context: AuditContext,
279 },
280
281 /// A workflow or adapter returned a processing error.
282 ///
283 /// The message was routed correctly but could not be processed. Use
284 /// this variant when the failure is definitive (not retriable).
285 ProcessingError {
286 /// Short, human-readable description of the failure.
287 message: String,
288 /// §22 MessZV structured audit context.
289 context: AuditContext,
290 },
291
292 /// An interchange flagged with UNB DE0035 = 1 (test indicator) was received
293 /// on a production endpoint.
294 ///
295 /// Per Allgemeine Festlegungen V6.1d §3, test interchanges **must not** be
296 /// processed as production. The interchange is rejected at the ingest boundary
297 /// without being forwarded to any workflow.
298 TestMessage {
299 /// §22 MessZV structured audit context (contains sender, receiver, control_ref).
300 context: AuditContext,
301 },
302
303 /// The outbox delivery worker gave up after exhausting all retry attempts.
304 ///
305 /// The message was re-queued `max_attempts` times and never successfully
306 /// delivered to the AS4 endpoint (or ERP webhook). The message is removed
307 /// from the outbox and recorded here for regulatory audit.
308 OutboxExhausted {
309 /// The outbox message ID of the undeliverable message.
310 message_id: crate::ids::OutboxMessageId,
311 /// The message type (e.g. `"APERAK"`, `"CONTRL"`).
312 message_type: String,
313 /// The intended recipient GLN.
314 recipient: String,
315 /// The last error returned by the AS4 sender.
316 last_error: String,
317 /// How many delivery attempts were made.
318 attempts: u32,
319 },
320}
321
322impl DeadLetterReason {
323 /// Short label identifying the rejection category.
324 ///
325 /// Suitable for structured log fields and metric labels.
326 #[must_use]
327 pub fn label(&self) -> &'static str {
328 match self {
329 Self::UnknownPid { .. } => "unknown_pid",
330 Self::UnknownConversation { .. } => "unknown_conversation",
331 Self::VersionMismatch { .. } => "version_mismatch",
332 Self::DuplicateMessage { .. } => "duplicate_message",
333 Self::ProcessingError { .. } => "processing_error",
334 Self::TestMessage { .. } => "test_message",
335 Self::OutboxExhausted { .. } => "outbox_exhausted",
336 }
337 }
338
339 /// Return the [`AuditContext`] embedded in this reason, if present.
340 ///
341 /// `OutboxExhausted` does not carry an `AuditContext` because it refers
342 /// to an outbound message (not an inbound AS4 message).
343 #[must_use]
344 pub fn audit_context(&self) -> Option<&AuditContext> {
345 match self {
346 Self::UnknownPid { context, .. }
347 | Self::UnknownConversation { context, .. }
348 | Self::VersionMismatch { context, .. }
349 | Self::DuplicateMessage { context, .. }
350 | Self::ProcessingError { context, .. }
351 | Self::TestMessage { context, .. } => Some(context),
352 Self::OutboxExhausted { .. } => None,
353 }
354 }
355}
356
357impl std::fmt::Display for DeadLetterReason {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 match self {
360 Self::UnknownPid { pid, .. } => write!(f, "unknown PID {pid}"),
361 Self::UnknownConversation {
362 conversation_id, ..
363 } => {
364 write!(f, "unknown conversation {conversation_id}")
365 }
366 Self::VersionMismatch {
367 expected, received, ..
368 } => write!(
369 f,
370 "version mismatch: expected {expected}, received {received}"
371 ),
372 Self::DuplicateMessage { inbox_key, .. } => write!(f, "duplicate message {inbox_key}"),
373 Self::ProcessingError { message, .. } => write!(f, "processing error: {message}"),
374 Self::TestMessage { context } => write!(
375 f,
376 "test interchange rejected (DE0035=1): sender={}, receiver={}, ref={}",
377 context.sender_eic.as_deref().unwrap_or(""),
378 context.receiver_eic.as_deref().unwrap_or(""),
379 context.message_ref.as_deref().unwrap_or(""),
380 ),
381 Self::OutboxExhausted {
382 message_id,
383 message_type,
384 recipient,
385 attempts,
386 ..
387 } => write!(
388 f,
389 "outbox exhausted after {attempts} attempts: {message_type} → {recipient} (id={message_id})"
390 ),
391 }
392 }
393}
394
395// ── DeadLetterSink trait ──────────────────────────────────────────────────────
396
397/// Receives messages that cannot be routed or processed.
398///
399/// Implement this trait to:
400/// - Emit CONTRL negative acknowledgements for unroutable messages
401/// - Persist rejections to a durable dead-letter queue for manual review
402/// - Trigger alerts when duplicate-message counts exceed a threshold
403///
404/// The method is **synchronous**. Implementations that require async work
405/// (network calls, database writes) must use `tokio::spawn` internally.
406///
407/// # Default
408///
409/// The default dead-letter sink is [`LogDeadLetterSink`], which emits
410/// `tracing::warn!` events. Override with
411/// [`EngineBuilder::with_dead_letter_sink`] to add CONTRL dispatch or
412/// persistent DLQ storage.
413///
414/// [`EngineBuilder::with_dead_letter_sink`]: crate::builder::EngineBuilder::with_dead_letter_sink
415pub trait DeadLetterSink: Send + Sync + 'static {
416 /// Record a rejected message.
417 ///
418 /// Called by the dispatch path synchronously, before the inbound
419 /// message is acknowledged at the AS4 transport layer. Must not block.
420 fn reject(&self, reason: &DeadLetterReason);
421}
422
423// ── LogDeadLetterSink ─────────────────────────────────────────────────────────
424
425/// A [`DeadLetterSink`] that emits a structured `tracing::warn!` event for
426/// every rejected message.
427///
428/// Suitable for all deployment tiers. In production, combine with a
429/// `tracing` subscriber that forwards `warn`-level events to your alert
430/// pipeline (Loki, CloudWatch, etc.).
431///
432/// This is the **default** dead-letter sink in [`EngineBuilder`].
433///
434/// [`EngineBuilder`]: crate::builder::EngineBuilder
435#[derive(Debug, Clone, Default)]
436pub struct LogDeadLetterSink;
437
438impl DeadLetterSink for LogDeadLetterSink {
439 fn reject(&self, reason: &DeadLetterReason) {
440 // Increment Prometheus counter for every rejection, regardless of
441 // which sink is wired — mirrors SlateDbDeadLetterSink behaviour so
442 // alerting works in non-SlateDB and smoke environments too.
443 crate::metrics::EngineMetrics::global().dead_letter_recorded(reason.label());
444 // Emit all §22 MessZV structured audit fields when available.
445 if let Some(ctx) = reason.audit_context() {
446 tracing::warn!(
447 reason = reason.label(),
448 message_type = ctx.message_type.as_deref().unwrap_or(""),
449 release_code = ctx.release_code.as_deref().unwrap_or(""),
450 pid = ctx.pid.map_or(0, crate::ids::Pid::as_u32),
451 sender_eic = ctx.sender_eic.as_deref().unwrap_or(""),
452 receiver_eic = ctx.receiver_eic.as_deref().unwrap_or(""),
453 message_ref = ctx.message_ref.as_deref().unwrap_or(""),
454 process_id = ctx.process_id.as_deref().unwrap_or(""),
455 tenant_id = ctx.tenant_id.as_deref().unwrap_or(""),
456 correlation_id = ctx.correlation_id.as_deref().unwrap_or(""),
457 %ctx.timestamp,
458 "dead letter: {reason}",
459 );
460 } else {
461 // OutboxExhausted has no inbound audit context; log its own fields.
462 match reason {
463 DeadLetterReason::OutboxExhausted {
464 message_id,
465 message_type,
466 recipient,
467 last_error,
468 attempts,
469 } => {
470 tracing::error!(
471 %message_id,
472 message_type,
473 recipient,
474 last_error,
475 attempts,
476 reason = reason.label(),
477 "dead letter: outbox exhausted — message removed after max delivery \
478 attempts; manual intervention required to deliver this message",
479 );
480 }
481 _ => {
482 tracing::warn!(reason = reason.label(), "dead letter: {reason}");
483 }
484 }
485 }
486 }
487}
488
489// ── NoopDeadLetterSink ────────────────────────────────────────────────────────
490
491/// A [`DeadLetterSink`] that silently discards all rejected messages.
492///
493/// **Use only in unit tests** where dead-letter events are not the subject
494/// under test. Using this in production means unroutable messages are lost
495/// without any diagnostic output, violating BDEW AS4 requirements.
496#[derive(Debug, Clone, Default)]
497#[must_use = "NoopDeadLetterSink discards all rejections; use LogDeadLetterSink in production"]
498#[cfg_attr(
499 not(any(test, feature = "testing")),
500 deprecated = "NoopDeadLetterSink must not be used in production builds; use LogDeadLetterSink instead"
501)]
502pub struct NoopDeadLetterSink;
503
504#[cfg(any(test, feature = "testing"))]
505impl DeadLetterSink for NoopDeadLetterSink {
506 fn reject(&self, _reason: &DeadLetterReason) {}
507}
508
509// ── ArcDeadLetterSink ─────────────────────────────────────────────────────────
510
511/// Blanket implementation so `Arc<T>` is a `DeadLetterSink` whenever `T` is.
512///
513/// This allows passing `Arc<LogDeadLetterSink>` or `Arc<dyn DeadLetterSink>`
514/// wherever a `DeadLetterSink` is expected without an extra wrapper.
515impl<T: DeadLetterSink> DeadLetterSink for Arc<T> {
516 fn reject(&self, reason: &DeadLetterReason) {
517 self.as_ref().reject(reason);
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn dead_letter_reason_labels() {
527 assert_eq!(
528 DeadLetterReason::UnknownPid {
529 pid: crate::ids::Pid::new(55001),
530 context: AuditContext::now()
531 }
532 .label(),
533 "unknown_pid"
534 );
535 assert_eq!(
536 DeadLetterReason::UnknownConversation {
537 conversation_id: "abc".into(),
538 context: AuditContext::now(),
539 }
540 .label(),
541 "unknown_conversation"
542 );
543 assert_eq!(
544 DeadLetterReason::VersionMismatch {
545 expected: "FV2025-10-01".into(),
546 received: "FV2026-10-01".into(),
547 context: AuditContext::now(),
548 }
549 .label(),
550 "version_mismatch"
551 );
552 assert_eq!(
553 DeadLetterReason::DuplicateMessage {
554 inbox_key: "msg-1".into(),
555 context: AuditContext::now(),
556 }
557 .label(),
558 "duplicate_message"
559 );
560 assert_eq!(
561 DeadLetterReason::ProcessingError {
562 message: "invalid state".into(),
563 context: AuditContext::now(),
564 }
565 .label(),
566 "processing_error"
567 );
568 }
569
570 #[test]
571 fn log_sink_does_not_panic() {
572 let sink = LogDeadLetterSink;
573 sink.reject(&DeadLetterReason::UnknownPid {
574 pid: crate::ids::Pid::new(99999),
575 context: AuditContext::now(),
576 });
577 sink.reject(&DeadLetterReason::UnknownConversation {
578 conversation_id: "conv-123".into(),
579 context: AuditContext::now(),
580 });
581 sink.reject(&DeadLetterReason::VersionMismatch {
582 expected: "FV2025-10-01".into(),
583 received: "FV2026-10-01".into(),
584 context: AuditContext::now(),
585 });
586 sink.reject(&DeadLetterReason::DuplicateMessage {
587 inbox_key: "msg-42".into(),
588 context: AuditContext::now(),
589 });
590 sink.reject(&DeadLetterReason::ProcessingError {
591 message: "workflow rejected command".into(),
592 context: AuditContext::now(),
593 });
594 }
595
596 #[test]
597 fn noop_sink_does_not_panic() {
598 let sink = NoopDeadLetterSink;
599 sink.reject(&DeadLetterReason::UnknownPid {
600 pid: crate::ids::Pid::new(55001),
601 context: AuditContext::now(),
602 });
603 }
604
605 #[test]
606 fn arc_blanket_impl_works() {
607 let sink: Arc<LogDeadLetterSink> = Arc::new(LogDeadLetterSink);
608 sink.reject(&DeadLetterReason::UnknownPid {
609 pid: crate::ids::Pid::new(1),
610 context: AuditContext::now(),
611 });
612 }
613
614 #[test]
615 fn dead_letter_reason_display() {
616 assert_eq!(
617 DeadLetterReason::UnknownPid {
618 pid: crate::ids::Pid::new(55001),
619 context: AuditContext::now()
620 }
621 .to_string(),
622 "unknown PID 55001"
623 );
624 assert!(
625 DeadLetterReason::VersionMismatch {
626 expected: "FV2025-10-01".into(),
627 received: "FV2026-10-01".into(),
628 context: AuditContext::now(),
629 }
630 .to_string()
631 .contains("version mismatch")
632 );
633 }
634
635 #[test]
636 fn audit_context_builder() {
637 let ctx = AuditContext::now()
638 .with_message_type("UTILMD")
639 .with_pid(crate::ids::Pid::new(55001))
640 .with_sender_eic("4012345000023")
641 .with_receiver_eic("9900357000004")
642 .with_message_ref("00001")
643 .with_tenant_id("tenant-a")
644 .with_correlation_id("conv-xyz");
645
646 assert_eq!(ctx.message_type.as_deref(), Some("UTILMD"));
647 assert_eq!(ctx.pid, Some(crate::ids::Pid::new(55001)));
648 assert_eq!(ctx.sender_eic.as_deref(), Some("4012345000023"));
649 assert_eq!(ctx.correlation_id.as_deref(), Some("conv-xyz"));
650 }
651
652 #[test]
653 fn audit_context_returned_for_inbound_reasons() {
654 let r = DeadLetterReason::UnknownPid {
655 pid: crate::ids::Pid::new(99),
656 context: AuditContext::now().with_pid(crate::ids::Pid::new(99)),
657 };
658 assert!(r.audit_context().is_some());
659 }
660}