Skip to main content

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}