Skip to main content

mako_engine/
outbox.rs

1//! Outbox pattern for reliable at-least-once outbound message delivery.
2//!
3//! # Why the outbox?
4//!
5//! When a process transition generates an outbound EDIFACT message (e.g. an
6//! APERAK acknowledgement), two writes must happen atomically:
7//!
8//! 1. Domain events are appended to the event store.
9//! 2. The EDIFACT payload is queued for delivery to the AS4 endpoint.
10//!
11//! Without the outbox, a crash between steps 1 and 2 silently loses the
12//! outbound message. With the outbox, both writes are part of the same
13//! database transaction — a background delivery worker then delivers pending
14//! messages, surviving crashes and transient AS4 failures transparently.
15//!
16//! # Usage
17//!
18//! ```rust,ignore
19//! // After a command dispatch that should trigger an outbound APERAK:
20//! let env = &aperak_envelopes[0];
21//! let msg = OutboxMessage::new(
22//!     process.stream_id().clone(),
23//!     env.process_id,
24//!     env.tenant_id,
25//!     env.correlation_id,
26//!     env.conversation_id,
27//!     env.event_id,
28//!     "APERAK",
29//!     &recipient_gln,
30//!     aperak_payload_json,
31//! );
32//! outbox_store.enqueue(&[msg]).await?;
33//!
34//! // Background delivery worker:
35//! let pending = outbox_store.pending_now(50).await?;
36//! for msg in pending {
37//!     as4_client.send(&msg).await?;
38//!     outbox_store.acknowledge(msg.message_id).await?;
39//! }
40//! ```
41//!
42//! # Atomicity contract
43//!
44//! `InMemoryOutboxStore` does **not** guarantee transactional atomicity
45//! with `InMemoryEventStore`. Persistent backend crates
46//! (`mako-event-store-slatedb`, `mako-event-store-postgres`) MUST enqueue
47//! messages in the same database transaction as the event append.
48
49use std::sync::Arc;
50
51#[cfg(any(test, feature = "testing"))]
52use std::collections::HashMap;
53#[cfg(any(test, feature = "testing"))]
54use tokio::sync::RwLock;
55
56use time::OffsetDateTime;
57
58use crate::{
59    error::EngineError,
60    ids::{ConversationId, CorrelationId, EventId, OutboxMessageId, ProcessId, StreamId, TenantId},
61};
62
63// ── PendingOutbox ─────────────────────────────────────────────────────────────
64
65/// A lightweight outbox message specification produced by [`Workflow::handle`].
66///
67/// [`Workflow::handle`] is a pure function: it cannot know the store-assigned
68/// fields (`event_id`, `stream_id`, `process_id`, etc.) of the events it is
69/// about to emit. `PendingOutbox` carries only the information the domain
70/// workflow can produce deterministically, without I/O or clock access.
71///
72/// The engine fills in the store-assigned fields after the event append
73/// succeeds, converting `PendingOutbox` into a fully materialised
74/// [`OutboxMessage`] inside [`SlateDbStore::append_with_outbox`].
75///
76/// # Example
77///
78/// ```rust,ignore
79/// // Inside Workflow::handle, when DispatchAperak succeeds:
80/// let outbox = vec![
81///     PendingOutbox::new("APERAK", &state.sender_party_id().to_string(), aperak_payload)
82///         .caused_by(0),  // caused by the first event in this batch
83/// ];
84/// Ok(WorkflowOutput { events, outbox })
85/// ```
86///
87/// [`Workflow::handle`]: crate::workflow::Workflow::handle
88/// [`SlateDbStore::append_with_outbox`]: crate::event_store::AtomicAppend::append_with_outbox
89#[derive(Debug, Clone)]
90pub struct PendingOutbox {
91    /// EDIFACT or XML message type (e.g. `"APERAK"`, `"CONTRL"`, `"REMADV"`).
92    pub message_type: Box<str>,
93    /// GLN or EIC code of the intended recipient market participant.
94    pub recipient: Box<str>,
95    /// Domain-level message payload (JSON).
96    ///
97    /// Typically encodes the intent (e.g. positive/negative APERAK reason)
98    /// rather than the final EDIFACT bytes. The delivery worker or AS4
99    /// gateway is responsible for rendering the final wire format.
100    pub payload: serde_json::Value,
101    /// Do not deliver before this time.
102    ///
103    /// `None` means deliver immediately (as soon as the delivery worker runs).
104    /// Must not use the wall clock inside `handle` — derive from domain data
105    /// only (e.g. a schedule date carried in the command).
106    pub deliver_after: Option<OffsetDateTime>,
107    /// BO4E JSON Schema URL that describes the `payload` shape.
108    ///
109    /// Set this to the canonical BO4E schema URL when the payload is a
110    /// BO4E-typed object (e.g. `Marktlokation`, `Messlokation`). Leave
111    /// `None` for raw EDIFACT or untyped payloads.
112    ///
113    /// Example:
114    /// `"https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.1.0/src/bo4e_schemas/bo/Marktlokation.json"`
115    pub payload_schema: Option<Box<str>>,
116    /// Zero-based index into the concurrent events batch that caused this
117    /// outbound message.
118    ///
119    /// Used by the engine to set `causation_event_id` on the materialised
120    /// [`OutboxMessage`] from the stamped [`EventEnvelope`] at the same index.
121    /// Clamped to `events.len() - 1` when out-of-range.
122    ///
123    /// [`EventEnvelope`]: crate::envelope::EventEnvelope
124    pub caused_by_event_index: usize,
125}
126
127impl PendingOutbox {
128    /// Construct a pending outbox message for immediate delivery.
129    ///
130    /// `caused_by_event_index` defaults to `0` (first event in the batch).
131    /// Chain [`caused_by`] to change it.
132    ///
133    /// [`caused_by`]: PendingOutbox::caused_by
134    #[must_use]
135    pub fn new(
136        message_type: impl Into<Box<str>>,
137        recipient: impl Into<Box<str>>,
138        payload: serde_json::Value,
139    ) -> Self {
140        Self {
141            message_type: message_type.into(),
142            recipient: recipient.into(),
143            payload,
144            deliver_after: None,
145            payload_schema: None,
146            caused_by_event_index: 0,
147        }
148    }
149
150    /// Set the zero-based index of the event that caused this outbox message.
151    #[must_use]
152    pub fn caused_by(mut self, index: usize) -> Self {
153        self.caused_by_event_index = index;
154        self
155    }
156
157    /// Set a deferred delivery time (must be derived from domain data, not
158    /// the wall clock, to preserve `Workflow::handle` purity).
159    #[must_use]
160    pub fn with_deliver_after(mut self, deliver_after: OffsetDateTime) -> Self {
161        self.deliver_after = Some(deliver_after);
162        self
163    }
164
165    /// Attach a BO4E JSON Schema URL to the payload.
166    ///
167    /// Use this when the payload is a BO4E-typed object so the ERP adapter
168    /// can deserialise it into the correct type without inspecting the JSON.
169    #[must_use]
170    pub fn with_schema(mut self, schema_url: &'static str) -> Self {
171        self.payload_schema = Some(schema_url.into());
172        self
173    }
174
175    /// APERAK **29002 Anerkennungsmeldung** (`BGM+312`) for a message that
176    /// parsed and was accepted for processing.
177    ///
178    /// `from` is this deployment's MP-ID, `to` the Marktpartner that sent the
179    /// acknowledged message, and `orig_message_ref` its `UNH` DE 0062.
180    ///
181    /// The reference is a parameter rather than an option because `SG2
182    /// RFF+ACE`/`DTM+171`/`RFF+AGO` are **Muss** in both APERAK
183    /// Anwendungsfälle: an acknowledgement that does not say what it
184    /// acknowledges is refused by the receiving Marktpartner, and there is no
185    /// second field it could be recovered from. The two Anwendungsfälle also
186    /// take different `BGM` codes — 29001 admits only `313`, 29002 only `312`
187    /// — so the pairing lives here rather than in each workflow.
188    #[must_use]
189    pub fn aperak_anerkennung(from: &str, to: &str, orig_message_ref: &str) -> Self {
190        Self::new(
191            "APERAK",
192            to,
193            serde_json::json!({
194                "sender":           from,
195                "receiver":         to,
196                "pid":              APERAK_PID_ANERKENNUNG,
197                "orig_message_ref": orig_message_ref,
198            }),
199        )
200    }
201
202    /// APERAK **29001 Verarbeitbarkeitsfehlermeldung** (`BGM+313`) for a
203    /// message that could not be processed.
204    ///
205    /// `error_code` is an `ERC` DE 9321 code from [`crate::erc::codes`];
206    /// `reason` becomes the `FTX+ABO` free text. See
207    /// [`aperak_anerkennung`](Self::aperak_anerkennung) for the other three
208    /// arguments.
209    #[must_use]
210    pub fn aperak_fehler(
211        from: &str,
212        to: &str,
213        orig_message_ref: &str,
214        error_code: &str,
215        reason: impl Into<String>,
216    ) -> Self {
217        Self::new(
218            "APERAK",
219            to,
220            serde_json::json!({
221                "sender":           from,
222                "receiver":         to,
223                "pid":              APERAK_PID_FEHLER,
224                "orig_message_ref": orig_message_ref,
225                "error_code":       error_code,
226                "reason":           reason.into(),
227            }),
228        )
229    }
230}
231
232/// APERAK Anwendungsfall **29001 Fehlermeldung** — `BGM+313`, `SG4`
233/// Fehlerbeschreibung present.
234pub const APERAK_PID_FEHLER: u32 = 29001;
235
236/// APERAK Anwendungsfall **29002 Anerkennungsmeldung** — `BGM+312`, no `SG4`.
237pub const APERAK_PID_ANERKENNUNG: u32 = 29002;
238
239// ── OutboxMessage ─────────────────────────────────────────────────────────────
240
241/// An outbound message queued for delivery via AS4 or another channel.
242///
243/// The message carries both routing information (`recipient`, `message_type`)
244/// and full correlation metadata so the delivery worker can trace every send
245/// back to the domain event that caused it.
246///
247/// Construct with [`OutboxMessage::new`] and optionally chain
248/// [`OutboxMessage::with_deliver_after`] for deferred delivery.
249#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
250pub struct OutboxMessage {
251    /// Stable unique identifier for this outbox entry.
252    pub message_id: OutboxMessageId,
253
254    /// The process stream that produced this outbound message.
255    pub stream_id: StreamId,
256
257    /// The MaKo process instance.
258    pub process_id: ProcessId,
259
260    /// The tenant sending this message.
261    pub tenant_id: TenantId,
262
263    /// Propagated correlation root from the triggering event.
264    pub correlation_id: CorrelationId,
265
266    /// Business conversation this message belongs to (e.g. UTILMD ↔ APERAK).
267    pub conversation_id: ConversationId,
268
269    /// The persisted event that directly caused this outbound message.
270    pub causation_event_id: EventId,
271
272    /// EDIFACT or XML message type (e.g. `"APERAK"`, `"CONTRL"`, `"UTILMD"`).
273    pub message_type: Box<str>,
274
275    /// GLN or EIC code of the intended recipient market participant.
276    pub recipient: Box<str>,
277
278    /// Serialized message payload.
279    ///
280    /// Typically a JSON-encoded string of EDIFACT bytes or a structured
281    /// JSON object for non-EDIFACT channels.
282    pub payload: serde_json::Value,
283
284    /// BO4E JSON Schema URL that validates `payload`, if present.
285    ///
286    /// `None` for raw EDIFACT or untyped payloads. Set by domain workflows
287    /// via [`PendingOutbox::with_schema`] when the payload is a BO4E object.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub payload_schema: Option<Box<str>>,
290
291    /// When this entry was created.
292    #[serde(with = "time::serde::rfc3339")]
293    pub created_at: OffsetDateTime,
294
295    /// Do not deliver before this time.
296    ///
297    /// `None` means deliver immediately (as soon as the delivery worker runs).
298    #[serde(with = "time::serde::rfc3339::option")]
299    pub deliver_after: Option<OffsetDateTime>,
300
301    /// Number of delivery attempts so far. Starts at `0`, incremented by
302    /// [`OutboxStore::reschedule`].
303    pub attempt_count: u32,
304
305    /// Workflow family name that produced this message (e.g. `"gpke-sperrung"`).
306    ///
307    /// Stamped from the `EventEnvelope::workflow_id.name` at materialisation
308    /// time — the path every message a workflow emits takes. Used by the
309    /// `OutboxErpWorker` to populate the `makoworkflow` CloudEvents extension
310    /// attribute, which `marktd` maps to `marktrole` for role-scoped ERP
311    /// fan-out. Empty only on a message built directly with
312    /// [`OutboxMessage::new`], which no workflow does.
313    pub workflow_name: Box<str>,
314
315    /// W3C `traceparent` of the request that caused this message.
316    ///
317    /// Captured from [`crate::trace_ctx`] at creation time and injected into
318    /// outbound deliveries (ERP webhook header + CloudEvents `traceparent`
319    /// extension), so a trace started by the inbound transport continues
320    /// across the asynchronous outbox boundary.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub trace_context: Option<Box<str>>,
323}
324
325impl OutboxMessage {
326    /// Construct a new outbox message.
327    ///
328    /// `message_id` and `created_at` are generated automatically.
329    /// `attempt_count` is initialized to `0`.
330    ///
331    /// Call [`OutboxMessage::with_deliver_after`] to schedule deferred
332    /// delivery.
333    #[allow(clippy::too_many_arguments)]
334    #[must_use]
335    pub fn new(
336        stream_id: StreamId,
337        process_id: ProcessId,
338        tenant_id: TenantId,
339        correlation_id: CorrelationId,
340        conversation_id: ConversationId,
341        causation_event_id: EventId,
342        message_type: impl Into<Box<str>>,
343        recipient: impl Into<Box<str>>,
344        payload: serde_json::Value,
345    ) -> Self {
346        Self {
347            message_id: OutboxMessageId::new(),
348            stream_id,
349            process_id,
350            tenant_id,
351            correlation_id,
352            conversation_id,
353            causation_event_id,
354            message_type: message_type.into(),
355            recipient: recipient.into(),
356            payload,
357            payload_schema: None,
358            created_at: OffsetDateTime::now_utc(),
359            deliver_after: None,
360            attempt_count: 0,
361            workflow_name: "".into(),
362            trace_context: crate::trace_ctx::current().map(Into::into),
363        }
364    }
365
366    /// Set a deferred delivery time.
367    ///
368    /// The message will not appear in [`OutboxStore::pending`] results until
369    /// `now >= deliver_after`.
370    #[must_use]
371    pub fn with_deliver_after(mut self, deliver_after: OffsetDateTime) -> Self {
372        self.deliver_after = Some(deliver_after);
373        self
374    }
375}
376
377// ── OutboxStore ───────────────────────────────────────────────────────────────
378
379/// Storage contract for outbox messages.
380///
381/// ## Atomicity requirement
382///
383/// In production deployments, calls to [`OutboxStore::enqueue`] MUST be
384/// atomic with the corresponding [`EventStore::append`] — both writes MUST
385/// succeed or both MUST fail. Implement this by sharing the same database
386/// transaction across both operations.
387///
388/// ## Delivery worker contract
389///
390/// The delivery worker loop should:
391/// 1. Call [`OutboxStore::pending_now`] to retrieve ready messages.
392/// 2. Attempt delivery to the AS4 endpoint.
393/// 3. On success: call [`OutboxStore::acknowledge`] to remove the message.
394/// 4. On transient failure: call [`OutboxStore::reschedule`] with an
395///    exponential back-off delay.
396///
397/// ## Blanket `Arc` implementation
398///
399/// `Arc<S>` implements `OutboxStore` whenever `S: OutboxStore`, so you can
400/// share a store across a delivery worker and command handlers without
401/// additional wrapper types.
402///
403/// [`EventStore::append`]: crate::event_store::EventStore::append
404#[allow(async_fn_in_trait)]
405pub trait OutboxStore: Send + Sync {
406    /// Persist `messages` durably, ready for delivery.
407    ///
408    /// In a persistent backend this MUST be called within the same
409    /// transaction as the event append.
410    ///
411    /// # Errors
412    ///
413    /// Returns [`EngineError::Outbox`] on storage failure.
414    #[must_use = "dropping an enqueue Result silently loses outbound EDIFACT messages"]
415    async fn enqueue(&self, messages: &[OutboxMessage]) -> Result<(), EngineError>;
416
417    /// Return up to `limit` messages ready for delivery as of `now`.
418    ///
419    /// A message is ready when `deliver_after` is `None` or `<= now`.
420    /// Results are ordered **oldest-first** by `created_at`.
421    ///
422    /// # Errors
423    ///
424    /// Returns [`EngineError::Outbox`] on storage failure.
425    #[must_use = "dropping a pending Result silently discards outbox delivery work"]
426    async fn pending(
427        &self,
428        limit: usize,
429        now: OffsetDateTime,
430    ) -> Result<Vec<OutboxMessage>, EngineError>;
431
432    /// Return up to `limit` messages ready for delivery right now.
433    ///
434    /// Convenience wrapper around [`OutboxStore::pending`] that uses
435    /// `OffsetDateTime::now_utc()` as the reference time.
436    ///
437    /// # Errors
438    ///
439    /// Returns [`EngineError::Outbox`] on storage failure.
440    async fn pending_now(&self, limit: usize) -> Result<Vec<OutboxMessage>, EngineError> {
441        self.pending(limit, OffsetDateTime::now_utc()).await
442    }
443
444    /// Remove a message from the outbox after successful delivery.
445    ///
446    /// Calling this with an unknown `id` is a no-op.
447    ///
448    /// # Errors
449    ///
450    /// Returns [`EngineError::Outbox`] on storage failure.
451    #[must_use = "dropping an acknowledge Result silently hides a store error"]
452    async fn acknowledge(&self, id: OutboxMessageId) -> Result<(), EngineError>;
453
454    /// Reschedule a message for a future delivery attempt.
455    ///
456    /// Implementations MUST increment `attempt_count` on the stored record.
457    /// Calling this with an unknown `id` is a no-op.
458    ///
459    /// # Errors
460    ///
461    /// Returns [`EngineError::Outbox`] on storage failure.
462    #[must_use = "dropping a reschedule Result silently hides a store error"]
463    async fn reschedule(
464        &self,
465        id: OutboxMessageId,
466        deliver_after: OffsetDateTime,
467    ) -> Result<(), EngineError>;
468
469    /// Return the total number of messages currently in the outbox.
470    ///
471    /// # Errors
472    ///
473    /// Returns [`EngineError::Outbox`] on storage failure.
474    #[must_use = "dropping a len Result silently discards a store error"]
475    async fn len(&self) -> Result<usize, EngineError>;
476
477    /// Return `true` when the outbox contains no messages.
478    ///
479    /// # Errors
480    ///
481    /// Returns [`EngineError::Outbox`] on storage failure.
482    async fn is_empty(&self) -> Result<bool, EngineError> {
483        Ok(self.len().await? == 0)
484    }
485}
486
487// ── Arc<S> blanket impl ───────────────────────────────────────────────────────
488
489impl<S: OutboxStore> OutboxStore for Arc<S> {
490    async fn enqueue(&self, messages: &[OutboxMessage]) -> Result<(), EngineError> {
491        self.as_ref().enqueue(messages).await
492    }
493
494    async fn pending(
495        &self,
496        limit: usize,
497        now: OffsetDateTime,
498    ) -> Result<Vec<OutboxMessage>, EngineError> {
499        self.as_ref().pending(limit, now).await
500    }
501
502    async fn acknowledge(&self, id: OutboxMessageId) -> Result<(), EngineError> {
503        self.as_ref().acknowledge(id).await
504    }
505
506    async fn reschedule(
507        &self,
508        id: OutboxMessageId,
509        deliver_after: OffsetDateTime,
510    ) -> Result<(), EngineError> {
511        self.as_ref().reschedule(id, deliver_after).await
512    }
513
514    async fn len(&self) -> Result<usize, EngineError> {
515        self.as_ref().len().await
516    }
517}
518
519// ── NoopOutboxStore ───────────────────────────────────────────────────────────
520
521/// An [`OutboxStore`] that silently discards all messages.
522///
523/// Every `enqueue` succeeds without storing anything. `pending` always
524/// returns an empty list. Use this as the default when outbox delivery is
525/// managed elsewhere or not required.
526///
527/// # ⚠️ Data loss warning
528///
529/// `NoopOutboxStore` **discards every outbound message silently**. No APERAK,
530/// MSCONS, or UTILMD will ever be delivered to the AS4 endpoint. Do not use
531/// in production.
532///
533/// This type is available in all build configurations so it can serve as a
534/// default type parameter in [`EngineBuilder`]. However, `EngineBuilder::new`
535/// (which wires this as the default) is only available with the `testing`
536/// feature or in `cfg(test)`. Production code must call
537/// [`EngineBuilder::with_stores`] instead.
538///
539/// [`EngineBuilder`]: crate::builder::EngineBuilder
540/// [`EngineBuilder::with_stores`]: crate::builder::EngineBuilder::with_stores
541#[derive(Debug, Clone, Copy, Default)]
542#[must_use = "NoopOutboxStore discards all outbound messages silently — use a persistent OutboxStore in production"]
543#[cfg_attr(
544    not(any(test, feature = "testing")),
545    deprecated = "NoopOutboxStore must not be instantiated in production builds; use a durable OutboxStore instead"
546)]
547pub struct NoopOutboxStore;
548
549#[cfg(any(test, feature = "testing"))]
550impl OutboxStore for NoopOutboxStore {
551    async fn enqueue(&self, _messages: &[OutboxMessage]) -> Result<(), EngineError> {
552        Ok(())
553    }
554
555    async fn pending(
556        &self,
557        _limit: usize,
558        _now: OffsetDateTime,
559    ) -> Result<Vec<OutboxMessage>, EngineError> {
560        Ok(Vec::new())
561    }
562
563    async fn acknowledge(&self, _id: OutboxMessageId) -> Result<(), EngineError> {
564        Ok(())
565    }
566
567    async fn reschedule(
568        &self,
569        _id: OutboxMessageId,
570        _deliver_after: OffsetDateTime,
571    ) -> Result<(), EngineError> {
572        Ok(())
573    }
574
575    async fn len(&self) -> Result<usize, EngineError> {
576        Ok(0)
577    }
578}
579
580// ── InMemoryOutboxStore ───────────────────────────────────────────────────────
581
582/// An in-memory [`OutboxStore`] for tests and development.
583///
584/// Backed by a `HashMap` protected by a `RwLock`. Cloning shares the
585/// underlying data via `Arc` — all clones see the same outbox state.
586///
587/// **Not production-safe.** Use this for:
588/// - Unit and integration tests
589/// - Local development and examples
590/// - Verifying the outbox delivery loop without an external message broker
591///
592/// Only available in `#[cfg(test)]` or with the `testing` feature enabled.
593#[cfg(any(test, feature = "testing"))]
594#[derive(Debug, Default, Clone)]
595pub struct InMemoryOutboxStore {
596    inner: Arc<RwLock<HashMap<OutboxMessageId, OutboxMessage>>>,
597}
598
599#[cfg(any(test, feature = "testing"))]
600impl InMemoryOutboxStore {
601    /// Create an empty outbox store.
602    #[must_use]
603    pub fn new() -> Self {
604        Self::default()
605    }
606}
607
608#[cfg(any(test, feature = "testing"))]
609impl OutboxStore for InMemoryOutboxStore {
610    async fn enqueue(&self, messages: &[OutboxMessage]) -> Result<(), EngineError> {
611        let mut map = self.inner.write().await;
612        for msg in messages {
613            map.insert(msg.message_id, msg.clone());
614        }
615        Ok(())
616    }
617
618    async fn pending(
619        &self,
620        limit: usize,
621        now: OffsetDateTime,
622    ) -> Result<Vec<OutboxMessage>, EngineError> {
623        let map = self.inner.read().await;
624        let mut ready: Vec<_> = map
625            .values()
626            .filter(|m| m.deliver_after.is_none_or(|d| d <= now))
627            .cloned()
628            .collect();
629        // Stable ordering: oldest first so the delivery worker processes in
630        // creation order, preserving causal ordering across messages.
631        ready.sort_by_key(|m| m.created_at);
632        ready.truncate(limit);
633        Ok(ready)
634    }
635
636    async fn acknowledge(&self, id: OutboxMessageId) -> Result<(), EngineError> {
637        self.inner.write().await.remove(&id);
638        Ok(())
639    }
640
641    async fn reschedule(
642        &self,
643        id: OutboxMessageId,
644        deliver_after: OffsetDateTime,
645    ) -> Result<(), EngineError> {
646        let mut map = self.inner.write().await;
647        if let Some(msg) = map.get_mut(&id) {
648            msg.deliver_after = Some(deliver_after);
649            msg.attempt_count += 1;
650        }
651        Ok(())
652    }
653
654    async fn len(&self) -> Result<usize, EngineError> {
655        Ok(self.inner.read().await.len())
656    }
657}
658
659// ── Outbox idempotency key ────────────────────────────────────────────────────
660
661/// Compute a deterministic idempotency key for an outbound message.
662///
663/// The key is a UUID v5 (SHA-1 over a stable namespace) derived from the
664/// combination of process id, workflow step name, recipient partner id, and
665/// format version. Identical inputs always produce the same UUID.
666///
667/// # Usage
668///
669/// Store the key alongside the outbox entry and use it as a unique constraint
670/// in persistent backends so that re-dispatching the same command (e.g. after
671/// a retry) does not produce duplicate outbound messages:
672///
673/// ```rust
674/// use mako_engine::outbox::outbox_idempotency_key;
675/// use mako_engine::ids::ProcessId;
676///
677/// let process_id = ProcessId::new();
678/// let key = outbox_idempotency_key(process_id, "DispatchAperak", "4012345000023", "FV2025-10-01");
679/// println!("idempotency key: {key}");
680/// ```
681///
682/// # Key derivation
683///
684/// The key is `UUID_v5(MAKO_ENGINE_OUTBOX_NS, "{process_id}|{step}|{partner}|{fv}")`.
685///
686/// `MAKO_ENGINE_OUTBOX_NS` is a fixed namespace UUID (RFC 4122 §4.3, SHA-1
687/// variant) that scopes all mako-engine outbox keys to avoid collisions with
688/// UUIDs from other namespaces.
689#[must_use]
690pub fn outbox_idempotency_key(
691    process_id: ProcessId,
692    step: &str,
693    recipient: &str,
694    format_version: &str,
695) -> uuid::Uuid {
696    // A fixed namespace UUID for mako-engine outbox keys.
697    // Generated once by uuid::Uuid::new_v4() and hardcoded for stability.
698    // Changing this constant invalidates all existing keys — treat as immutable.
699    const MAKO_ENGINE_OUTBOX_NS: uuid::Uuid = uuid::Uuid::from_bytes([
700        0xd4, 0x7a, 0x2c, 0x9e, 0x5b, 0x31, 0x47, 0xf2, 0x89, 0x0a, 0x1e, 0x6c, 0x8a, 0x3d, 0x5f,
701        0x04,
702    ]);
703    let name = format!("{process_id}|{step}|{recipient}|{format_version}");
704    uuid::Uuid::new_v5(&MAKO_ENGINE_OUTBOX_NS, name.as_bytes())
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use crate::ids::{ConversationId, CorrelationId, EventId, ProcessId, TenantId};
711
712    fn make_msg() -> OutboxMessage {
713        OutboxMessage::new(
714            StreamId::new("process/test"),
715            ProcessId::new(),
716            TenantId::new(),
717            CorrelationId::new(),
718            ConversationId::new(),
719            EventId::new(),
720            "APERAK",
721            "4012345000023",
722            serde_json::json!({"positive": true}),
723        )
724    }
725
726    #[tokio::test]
727    async fn enqueue_appears_in_pending() {
728        let store = InMemoryOutboxStore::new();
729        let msg = make_msg();
730        let id = msg.message_id;
731
732        store.enqueue(&[msg]).await.unwrap();
733
734        assert_eq!(store.len().await.unwrap(), 1);
735        let pending = store.pending_now(10).await.unwrap();
736        assert_eq!(pending.len(), 1);
737        assert_eq!(pending[0].message_id, id);
738    }
739
740    #[tokio::test]
741    async fn acknowledge_removes_message() {
742        let store = InMemoryOutboxStore::new();
743        let msg = make_msg();
744        let id = msg.message_id;
745
746        store.enqueue(&[msg]).await.unwrap();
747        store.acknowledge(id).await.unwrap();
748
749        assert!(store.is_empty().await.unwrap());
750    }
751
752    #[tokio::test]
753    async fn deferred_message_not_in_pending_yet() {
754        let store = InMemoryOutboxStore::new();
755        let future = OffsetDateTime::now_utc() + time::Duration::hours(1);
756        let msg = make_msg().with_deliver_after(future);
757
758        store.enqueue(&[msg]).await.unwrap();
759
760        let pending = store.pending_now(10).await.unwrap();
761        assert!(
762            pending.is_empty(),
763            "deferred message must not appear before its time"
764        );
765    }
766
767    #[tokio::test]
768    async fn deferred_message_appears_after_deadline() {
769        let store = InMemoryOutboxStore::new();
770        let past = OffsetDateTime::now_utc() - time::Duration::seconds(1);
771        let msg = make_msg().with_deliver_after(past);
772
773        store.enqueue(&[msg]).await.unwrap();
774
775        let pending = store.pending_now(10).await.unwrap();
776        assert_eq!(pending.len(), 1);
777    }
778
779    #[tokio::test]
780    async fn reschedule_increments_attempt_count() {
781        let store = InMemoryOutboxStore::new();
782        let msg = make_msg();
783        let id = msg.message_id;
784        let new_time = OffsetDateTime::now_utc() + time::Duration::minutes(5);
785
786        store.enqueue(&[msg]).await.unwrap();
787        store.reschedule(id, new_time).await.unwrap();
788
789        let inner = store.inner.read().await;
790        let stored = inner.get(&id).unwrap();
791        assert_eq!(stored.attempt_count, 1);
792        assert_eq!(stored.deliver_after, Some(new_time));
793    }
794
795    #[tokio::test]
796    async fn pending_ordered_oldest_first() {
797        let store = InMemoryOutboxStore::new();
798        store.enqueue(&[make_msg()]).await.unwrap();
799        store.enqueue(&[make_msg()]).await.unwrap();
800
801        let pending = store.pending_now(10).await.unwrap();
802        assert_eq!(pending.len(), 2);
803        assert!(pending[0].created_at <= pending[1].created_at);
804    }
805
806    #[test]
807    fn outbox_idempotency_key_is_stable_and_deterministic() {
808        let pid = ProcessId::new();
809        let step = "ReceiveAperak";
810        let partner = "4012345000023";
811        let fv = "FV2025-10-01";
812
813        let k1 = outbox_idempotency_key(pid, step, partner, fv);
814        let k2 = outbox_idempotency_key(pid, step, partner, fv);
815        assert_eq!(k1, k2, "same inputs must produce the same key");
816        assert_eq!(k1.to_string().len(), 36, "UUID string is 36 chars");
817
818        // Different step → different key.
819        let k3 = outbox_idempotency_key(pid, "ReceiveContrl", partner, fv);
820        assert_ne!(k1, k3, "different step must produce different key");
821
822        // Different FV → different key.
823        let k4 = outbox_idempotency_key(pid, step, partner, "FV2026-10-01");
824        assert_ne!(k1, k4, "different FV must produce different key");
825    }
826}