Skip to main content

mako_engine/
builder.rs

1//! [`EngineModule`] trait, [`EngineBuilder`], and [`EngineContext`].
2//!
3// Allow using deprecated Noop stores as *type-level defaults* in EngineBuilder / EngineContext
4// generic parameters.  The types are deprecated to prevent instantiation in production code,
5// but using them as default type parameters in struct definitions (not instantiating them) is
6// the intended pattern for the type-state builder API.
7#![allow(deprecated)]
8//!
9//! # Summary
10//!
11//! `EngineBuilder` assembles all engine infrastructure into a single
12//! [`EngineContext`] value. Domain modules (GPKE, WiM, GeLi Gas, …) register
13//! themselves at startup via the [`EngineModule`] trait, making their names
14//! visible in diagnostics and health checks.
15//!
16//! # Type-state guarantee
17//!
18//! [`EngineBuilder::build`] is only available when the event store type
19//! parameter `ES` implements [`EventStore`]. Forgetting to call
20//! [`with_event_store`] is a **compile-time error**, not a runtime panic.
21//!
22//! All other stores default to their respective `Noop` implementations:
23//!
24//! | Store | Default |
25//! |-------|---------|
26//! | Snapshot store | [`NoopSnapshotStore`] |
27//! | Outbox store | [`NoopOutboxStore`] |
28//! | Deadline store | [`NoopDeadlineStore`] |
29//! | Process registry | [`NoopProcessRegistry`] |
30//!
31//! # Assembly example
32//!
33//! ```rust,ignore
34//! use mako_engine::builder::{EngineBuilder, EngineModule};
35//! use mako_engine::event_store::InMemoryEventStore;
36//! use mako_engine::outbox::InMemoryOutboxStore;
37//! use mako_engine::deadline::InMemoryDeadlineStore;
38//! use mako_engine::registry::InMemoryProcessRegistry;
39//! use mako_engine::snapshot::InMemorySnapshotStore;
40//!
41//! struct GpkeModule;
42//! impl EngineModule for GpkeModule { fn name(&self) -> &'static str { "gpke" } }
43//!
44//! let ctx = EngineBuilder::new()
45//!     .with_event_store(InMemoryEventStore::new())
46//!     .with_snapshot_store(InMemorySnapshotStore::new())
47//!     .with_outbox_store(InMemoryOutboxStore::new())
48//!     .with_deadline_store(InMemoryDeadlineStore::new())
49//!     .with_registry(InMemoryProcessRegistry::new())
50//!     .register(Box::new(GpkeModule))
51//!     .build();
52//!
53//! // Spawn a fresh process:
54//! let p = ctx.spawn::<SupplierChangeWorkflow>(tenant_id, workflow_id);
55//! p.execute(ReceiveUtilmd { .. }).await?;
56//!
57//! // Resume an existing process from a persisted identity:
58//! let identity = ctx.registry.lookup(&conv_id.to_string()).await?.unwrap();
59//! let p = ctx.resume::<SupplierChangeWorkflow>(identity);
60//!
61//! // Access stores for delivery workers / schedulers:
62//! let pending = ctx.outbox_store.pending_now(50).await?;
63//! let overdue = ctx.deadline_store.due_now(50).await?;
64//! ```
65//!
66//! [`with_event_store`]: EngineBuilder::with_event_store
67
68// Type-state generics can produce long signatures that trip up the
69// `type_complexity` lint; suppress it for this module only.
70#![allow(clippy::type_complexity)]
71
72// The Noop* types are marked #[deprecated] to guard against accidental
73// production use.  The builder is the only place they're instantiated as
74// defaults; suppress the lint here explicitly.
75#[allow(deprecated)]
76use crate::{
77    dead_letter::{DeadLetterSink, LogDeadLetterSink},
78    deadline::{Deadline, DeadlineStore, NoopDeadlineStore},
79    error::EngineError,
80    event_store::EventStore,
81    ids::{ProcessIdentity, TenantId},
82    marktrolle::DeploymentRoles,
83    outbox::{NoopOutboxStore, OutboxMessage, OutboxStore},
84    pid_router::PidRouter,
85    process::Process,
86    registry::{NoopProcessRegistry, ProcessRegistry},
87    snapshot::{NoopSnapshotStore, SnapshotStore},
88    version::WorkflowId,
89    workflow::Workflow,
90};
91
92use std::sync::Arc;
93
94// ── EngineModule ──────────────────────────────────────────────────────────────
95
96/// A self-contained domain module that registers with the engine at startup.
97///
98/// Domain crates implement this trait to declare their presence in the engine.
99/// The module name is surfaced in [`EngineContext::registered_modules`] for
100/// diagnostics, health checks, and log output.
101///
102/// ## Startup validation
103///
104/// Override [`configure`] to perform adapter coverage checks at engine startup
105/// time. The engine calls [`configure`] for every registered module during
106/// [`EngineBuilder::build`] and panics with an actionable message if any
107/// module returns `Err`. This surfaces missing adapter registrations as a
108/// startup failure rather than a silent runtime error.
109///
110/// ## Example
111///
112/// ```rust,ignore
113/// pub struct GpkeModule;
114///
115/// impl EngineModule for GpkeModule {
116///     fn name(&self) -> &'static str { "gpke" }
117///
118///     fn configure(&self) -> Result<(), String> {
119///         // Validate that every known BDEW format version has an adapter:
120///         GPKE_ADAPTER_REGISTRY
121///             .validate_policy(&GpkeWorkflow::version_policy(), &KNOWN_FVS)
122///             .map_err(|uncovered| format!(
123///                 "gpke: missing adapters for format versions: {:?}",
124///                 uncovered
125///             ))
126///     }
127/// }
128///
129/// let ctx = EngineBuilder::new()
130///     .with_event_store(my_store)
131///     .register(Box::new(GpkeModule))
132///     .build(); // panics if GpkeModule::configure returns Err
133///
134/// assert_eq!(ctx.registered_modules(), &["gpke"]);
135/// ```
136///
137/// [`configure`]: EngineModule::configure
138pub trait EngineModule: Send + 'static {
139    /// Stable, unique name for this domain module.
140    ///
141    /// Used in diagnostics, health checks, and structured log output.
142    /// Choose a short lowercase identifier (e.g. `"gpke"`, `"wim"`,
143    /// `"geli"`).
144    fn name(&self) -> &'static str;
145
146    /// Register all PIDs this module handles into the shared [`PidRouter`].
147    ///
148    /// # Mutability contract
149    ///
150    /// This method is called **exactly once** by [`EngineBuilder::build`],
151    /// before the resulting [`EngineContext`] is handed to the caller. The
152    /// `&mut PidRouter` reference is only available here, at build time.
153    /// After `build` returns the router is **sealed** — the engine provides
154    /// only a shared `&PidRouter` reference, with no mutation path at runtime.
155    ///
156    /// Consequence: **all PIDs a module will ever need must be registered
157    /// here**. Do not attempt to register PIDs lazily from async handlers or
158    /// after the engine has started — there is no API for that by design.
159    ///
160    /// Two modules claiming one PID for different workflows panics in
161    /// [`PidRouter::register_with_module`] while the engine is being built, so
162    /// the conflict stops the daemon rather than routing a message to whichever
163    /// module registered last.
164    ///
165    /// [`PidRouter::register_with_module`]: crate::pid_router::PidRouter::register_with_module
166    ///
167    /// For role-conditional registration (PIDs that should only be active for
168    /// specific BDEW Marktrollen), override [`register_pids_with_roles`] instead.
169    ///
170    /// # Example
171    ///
172    /// ```rust,ignore
173    /// fn register_pids(&self, router: &mut PidRouter) {
174    ///     // GPKE Lieferantenwechsel / Lieferbeginn (BK6-22-024, PIDs 55001, 55002, 55017)
175    ///     for &pid in &[55001_u32, 55002, 55017] {
176    ///         router.register(pid, "gpke-supplier-change");
177    ///     }
178    /// }
179    /// ```
180    ///
181    /// [`register_pids_with_roles`]: EngineModule::register_pids_with_roles
182    fn register_pids(&self, _router: &mut PidRouter) {}
183
184    /// Register PIDs with role-context awareness.
185    ///
186    /// This is the **preferred override** for modules that have role-conditional
187    /// PID registrations — PIDs that should only be active when this `makod`
188    /// instance holds a specific [`Marktrolle`].
189    ///
190    /// The default implementation calls [`register_pids`] (role-agnostic) so
191    /// existing modules that override `register_pids` continue to work without
192    /// changes.
193    ///
194    /// Override this method instead of `register_pids` when any PID registration
195    /// should be conditional on the deployment role:
196    ///
197    /// ```rust,ignore
198    /// use mako_engine::marktrolle::Marktrolle;
199    ///
200    /// fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
201    ///     // Always register: 55001, 55002 (not role-specific)
202    ///     for pid in [55001_u32, 55002] { router.register_with_module(pid, "gpke-supplier-change", self.name()); }
203    ///
204    ///     // Only when NB role: 19001/19002 inbound ORDRSP from MSB
205    ///     if roles.contains(Marktrolle::Nb) {
206    ///         for pid in [19001_u32, 19002] { router.register_with_module(pid, "gpke-konfiguration", self.name()); }
207    ///     }
208    /// }
209    /// ```
210    ///
211    /// # Conflict guard
212    ///
213    /// Use [`PidRouter::register_with_module`] (not `register`) inside this
214    /// method. The conflict guard panics at build time if two modules register
215    /// the same PID to different workflows — this makes role misconfigurations
216    /// visible at startup rather than silently misrouting messages.
217    ///
218    /// [`Marktrolle`]: crate::marktrolle::Marktrolle
219    /// [`register_pids`]: EngineModule::register_pids
220    fn register_pids_with_roles(&self, router: &mut PidRouter, _roles: &DeploymentRoles) {
221        self.register_pids(router);
222    }
223
224    /// Every workflow this module owns, named.
225    ///
226    /// These names are what [`EngineContext::registered_workflows`] collects,
227    /// and consumers build their deadline-dispatch coverage from that list — so
228    /// this declaration, not [`register_pids`], is what makes a workflow's
229    /// Fristen checkable.
230    ///
231    /// # The invariant
232    ///
233    /// **Every name [`register_pids`] routes to must appear here.**
234    /// [`EngineBuilder::build`] panics otherwise, per module. A workflow that
235    /// is routed but undeclared still runs — it just becomes invisible to every
236    /// check made over the declarations, so a deadline it registers is never
237    /// held against a dispatch arm and fires into nothing.
238    ///
239    /// The converse is deliberately allowed: a command-initiated workflow (one
240    /// an ERP starts over the command API) declares a name and routes no
241    /// inbound Prüfidentifikator.
242    ///
243    /// Prefer each module's own `WORKFLOW_NAME` constant over a string literal.
244    /// A literal here can disagree with the name `register_pids` routes to, and
245    /// the two are only compared at build time:
246    ///
247    /// ```rust,ignore
248    /// fn workflow_names(&self) -> &'static [&'static str] {
249    ///     &[wechselprozesse::WORKFLOW_NAME, abrechnung::WORKFLOW_NAME]
250    /// }
251    /// ```
252    ///
253    /// The default implementation returns an empty slice, which is correct only
254    /// for a module that routes no PIDs at all.
255    ///
256    /// [`register_pids`]: EngineModule::register_pids
257    /// [`EngineContext::registered_workflows`]: crate::builder::EngineContext::registered_workflows
258    fn workflow_names(&self) -> &'static [&'static str] {
259        &[]
260    }
261
262    /// Declare the EDIFACT profile types this module requires at runtime.
263    ///
264    /// Returning a non-empty slice causes [`EngineBuilder::build`] to call the
265    /// registered profile validator for each requirement.  If no active profile
266    /// exists for a required message type, `build` panics with an actionable
267    /// error so deployment fails fast rather than silently.
268    ///
269    /// Domain crates declare their format requirements here rather than
270    /// calling `edi_energy::registry::ReleaseRegistry::global()` inside
271    /// `configure()`, so `edi-energy` stays out of their production
272    /// `[dependencies]`.
273    ///
274    /// ```rust,ignore
275    /// fn profile_requirements(&self) -> &'static [ProfileRequirement] {
276    ///     &[
277    ///         ProfileRequirement { message_type: "UTILMD", label: "UTILMD Strom (GPKE)" },
278    ///         ProfileRequirement { message_type: "INVOIC", label: "INVOIC Abrechnung (GPKE)" },
279    ///     ]
280    /// }
281    /// ```
282    ///
283    /// [`ProfileRequirement`]: crate::profile::ProfileRequirement
284    fn profile_requirements(&self) -> &'static [crate::profile::ProfileRequirement] {
285        &[]
286    }
287
288    /// Validate adapter coverage and configuration at engine startup.
289    ///
290    /// Called by [`EngineBuilder::build`] after all modules are registered.
291    /// Return `Ok(())` when the module is fully configured. Return `Err(msg)`
292    /// with an actionable description when an adapter or configuration is
293    /// missing — the engine will panic with that message so the deployment
294    /// fails early rather than silently.
295    ///
296    /// The default implementation is a no-op (always returns `Ok(())`).
297    /// Override it in domain crates to call
298    /// [`AdapterRegistry::validate_policy`] and emit structured errors.
299    ///
300    /// Note: if your validation needs access to the edi-energy profile
301    /// registry, use [`profile_requirements`] instead — it does not require
302    /// importing `edi-energy` in domain crates.
303    ///
304    /// [`AdapterRegistry::validate_policy`]: crate::message_adapter::AdapterRegistry::validate_policy
305    /// [`profile_requirements`]: EngineModule::profile_requirements
306    ///
307    /// # Errors
308    ///
309    /// Returns a descriptive error string when the module's configuration is invalid.
310    fn configure(&self) -> Result<(), String> {
311        Ok(())
312    }
313}
314
315// ── EngineContext ─────────────────────────────────────────────────────────────
316
317/// Assembled engine infrastructure returned by [`EngineBuilder::build`].
318///
319/// `EngineContext` bundles all stores and the process registry into a single
320/// value. It is the root dependency for:
321///
322/// - Spawning new processes ([`spawn`])
323/// - Resuming existing processes ([`resume`])
324/// - Running outbox delivery workers (`outbox_store.pending_now(…)`)
325/// - Driving the deadline scheduler (`deadline_store.due_now(…)`)
326///
327/// ## Generic parameters
328///
329/// | Param | Role | Default |
330/// |-------|------|---------|
331/// | `ES`  | [`EventStore`] backend | — (required) |
332/// | `SS`  | [`SnapshotStore`] backend | [`NoopSnapshotStore`] |
333/// | `OS`  | [`OutboxStore`] backend  | [`NoopOutboxStore`]   |
334/// | `DS`  | [`DeadlineStore`] backend | [`NoopDeadlineStore`] |
335/// | `PR`  | [`ProcessRegistry`] backend | [`NoopProcessRegistry`] |
336///
337/// In most codebases all type parameters are inferred from the builder calls.
338///
339/// [`spawn`]: EngineContext::spawn
340/// [`resume`]: EngineContext::resume
341pub struct EngineContext<
342    ES,
343    SS = NoopSnapshotStore,
344    OS = NoopOutboxStore,
345    DS = NoopDeadlineStore,
346    PR = NoopProcessRegistry,
347> {
348    event_store: Arc<ES>,
349    snapshot_store: SS,
350    outbox_store: OS,
351    deadline_store: DS,
352    registry: PR,
353    /// Dead-letter sink for unroutable or unprocessable inbound messages.
354    ///
355    /// Stored as `Arc<dyn DeadLetterSink>` so callers can share it across
356    /// tasks without an extra type parameter on `EngineContext`.
357    pub dead_letter_sink: Arc<dyn DeadLetterSink>,
358    /// PID-to-workflow routing table, populated from all registered modules.
359    pid_router: PidRouter,
360    registered_modules: Vec<&'static str>,
361    /// Workflow names declared by all registered modules via
362    /// [`EngineModule::workflow_names`]. Used to validate deadline scheduler
363    /// coverage at runtime (see [`EngineContext::registered_workflows`]).
364    registered_workflows: Vec<&'static str>,
365}
366
367// ── Type aliases ──────────────────────────────────────────────────────────────
368
369/// An [`EngineContext`] with all optional subsystems disabled.
370///
371/// Uses `NoopSnapshotStore` and, in `testing`-enabled builds, Noop
372/// implementations for outbox, deadline, and process registry. Suitable for
373/// tests and minimal deployments where only a durable event store is required.
374///
375/// All five type parameters are inferred from context when used with
376/// [`EngineBuilder`]:
377///
378/// ```rust,ignore
379/// // Only available in test / testing-feature builds:
380/// use mako_engine::builder::{EngineBuilder, MinimalEngine};
381/// use mako_engine::event_store::InMemoryEventStore;
382///
383/// let ctx: MinimalEngine<InMemoryEventStore> = EngineBuilder::new()
384///     .with_event_store(InMemoryEventStore::new())
385///     .build();
386/// ```
387pub type MinimalEngine<ES> = EngineContext<ES>;
388
389impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
390where
391    ES: EventStore,
392{
393    /// Spawn a new process and return a typed `Process<W, Arc<ES>>` handle.
394    ///
395    /// No `ES: Clone` bound is required — the engine stores the event store
396    /// behind an `Arc` so spawning is always a cheap pointer clone.
397    ///
398    /// ```rust,ignore
399    /// let p = ctx.spawn::<SupplierChangeWorkflow>(tenant_id, workflow_id);
400    /// p.execute(ReceiveUtilmd { .. }).await?;
401    /// ```
402    #[must_use]
403    pub fn spawn<W: Workflow>(
404        &self,
405        tenant_id: TenantId,
406        workflow_id: WorkflowId,
407    ) -> Process<W, Arc<ES>> {
408        Process::new(Arc::clone(&self.event_store), tenant_id, workflow_id)
409    }
410
411    /// Resume an existing process from a [`ProcessIdentity`].
412    ///
413    /// ```rust,ignore
414    /// let identity = ctx.registry()
415    ///     .lookup(tenant_id, &conv_id.to_string())
416    ///     .await?
417    ///     .ok_or(EngineError::Registry("unknown conversation".into()))?;
418    /// let p = ctx.resume::<SupplierChangeWorkflow>(identity);
419    /// p.execute(HandleAperak { .. }).await?;
420    /// ```
421    #[must_use]
422    pub fn resume<W: Workflow>(&self, identity: ProcessIdentity) -> Process<W, Arc<ES>> {
423        Process::from_identity(Arc::clone(&self.event_store), identity)
424    }
425
426    /// Names of all domain modules registered with the builder, in
427    /// registration order.
428    #[must_use]
429    pub fn registered_modules(&self) -> &[&'static str] {
430        &self.registered_modules
431    }
432
433    /// Workflow names declared by all registered modules, in registration order.
434    ///
435    /// Use this in the deadline scheduler dispatch function to detect unknown
436    /// workflow names at startup. If a deadline fires for a workflow name that
437    /// is not in this list, the scheduler's dispatch function should emit an
438    /// error rather than silently dropping the deadline:
439    ///
440    /// ```rust,ignore
441    /// let known = ctx.registered_workflows().iter().copied().collect::<HashSet<_>>();
442    /// let scheduler = ctx.run_deadline_scheduler(
443    ///     move |deadline| {
444    ///         let wf = deadline.workflow_id().name.as_ref();
445    ///         if !known.contains(wf) {
446    ///             tracing::error!(workflow = %wf, "deadline fired for unregistered workflow");
447    ///             return Box::pin(async { Ok(()) });
448    ///         }
449    ///         // dispatch by workflow name …
450    ///         Box::pin(async { Ok(()) })
451    ///     },
452    ///     100,
453    ///     Duration::from_secs(30),
454    /// );
455    /// ```
456    #[must_use]
457    pub fn registered_workflows(&self) -> &[&'static str] {
458        &self.registered_workflows
459    }
460
461    /// The event store backend (behind an `Arc`).
462    #[must_use]
463    pub fn event_store(&self) -> &Arc<ES> {
464        &self.event_store
465    }
466
467    /// The snapshot store backend.
468    #[must_use]
469    pub fn snapshot_store(&self) -> &SS {
470        &self.snapshot_store
471    }
472
473    /// The outbox store backend.
474    ///
475    /// Poll `outbox_store().pending_now(limit)` in a background task to drain
476    /// the delivery queue.
477    #[must_use]
478    pub fn outbox_store(&self) -> &OS {
479        &self.outbox_store
480    }
481
482    /// The deadline store backend.
483    ///
484    /// Poll `deadline_store().due_now(limit)` in a background scheduler to
485    /// fire overdue process timers.
486    #[must_use]
487    pub fn deadline_store(&self) -> &DS {
488        &self.deadline_store
489    }
490
491    /// The process routing registry.
492    ///
493    /// Register a [`ProcessIdentity`] under a `(tenant_id, key)` pair at
494    /// process creation, then `lookup` it when routing inbound messages.
495    #[must_use]
496    pub fn registry(&self) -> &PR {
497        &self.registry
498    }
499
500    /// The dead-letter sink for unroutable or unprocessable messages.
501    ///
502    /// Call [`DeadLetterSink::reject`] when an inbound message cannot be
503    /// dispatched to any workflow. The default sink emits `tracing::warn!`
504    /// so rejections are always visible in the log output.
505    #[must_use]
506    pub fn dead_letter_sink(&self) -> &Arc<dyn DeadLetterSink> {
507        &self.dead_letter_sink
508    }
509
510    /// Assert that no Noop store is active — call this during production startup.
511    ///
512    /// Checks the type names of `OS`, `DS`, and `PR` against the string `"Noop"`.
513    /// Panics with a human-readable message if any match, directing the operator
514    /// to configure a persistent backend.
515    ///
516    /// # When to call
517    ///
518    /// Call this early in `makod`'s startup path (and `--check` mode) to catch
519    /// deployments where a Noop store was accidentally wired — e.g. the
520    /// `[outbox]`, `[deadline]`, or `[registry]` configuration section was
521    /// omitted from `makod.toml`.  The check is defence-in-depth: in release
522    /// builds without the `testing` feature, Noop stores cannot implement the
523    /// required traits at all and the compiler would have already rejected them.
524    ///
525    /// # Panics
526    ///
527    /// Panics when any of `OS`, `DS`, or `PR` is a Noop implementation.
528    pub fn assert_production_stores(&self) {
529        let checks: &[(&str, &str)] = &[
530            ("OutboxStore", std::any::type_name::<OS>()),
531            ("DeadlineStore", std::any::type_name::<DS>()),
532            ("ProcessRegistry", std::any::type_name::<PR>()),
533        ];
534        for (trait_name, type_name) in checks {
535            assert!(
536                !type_name.contains("Noop"),
537                "makod: Noop{trait_name} is active — \
538                 configure a persistent {trait_name} backend in makod.toml. \
539                 Type resolved to: {type_name}"
540            );
541        }
542    }
543
544    /// The PID-to-workflow routing table.
545    ///
546    /// Populated **once** during [`EngineBuilder::build`] by calling
547    /// [`EngineModule::register_pids`] on every registered module in
548    /// registration order. After `build` returns the table is **sealed** —
549    /// it is read-only for the lifetime of the `EngineContext` and may be
550    /// freely shared across async tasks without synchronisation.
551    ///
552    /// # Mutability contract
553    ///
554    /// There is intentionally no `pid_router_mut()` accessor. Adding PIDs
555    /// after the engine is built would create a TOCTOU race between the
556    /// dispatch path (which calls `route(pid)`) and any hypothetical
557    /// concurrent mutator. Instead, register all PIDs during the build phase
558    /// via `EngineModule::register_pids`.
559    ///
560    /// If a new process family needs to be added without restarting the
561    /// binary, rebuild and restart `makod` — hot-swap of PID routing is not
562    /// supported.
563    ///
564    /// # Example — dispatch at the AS4 reception boundary
565    ///
566    /// ```rust,ignore
567    /// let workflow_name = ctx.pid_router().route(pid)
568    ///     .ok_or_else(|| EngineError::Workflow(WorkflowError::InvalidCommand(
569    ///         format!("no workflow registered for PID {pid}").into()
570    ///     )))?;
571    ///
572    /// match workflow_name {
573    ///     "gpke-supplier-change" => dispatch::<GpkeSupplierChangeWorkflow>(&ctx, pid, payload).await,
574    ///     "wim-device-change"    => dispatch::<WimDeviceChangeWorkflow>(&ctx, pid, payload).await,
575    ///     other => Err(EngineError::Workflow(WorkflowError::InvalidCommand(
576    ///         format!("unhandled workflow name: {other}").into()
577    ///     ))),
578    /// }
579    /// ```
580    #[must_use]
581    pub fn pid_router(&self) -> &PidRouter {
582        &self.pid_router
583    }
584}
585
586// ── As4Sender ─────────────────────────────────────────────────────────────────
587
588/// Sends a single AS4 / EDIINT-over-HTTP outbound message.
589///
590/// Implement this trait for your AS4 gateway client and pass it to
591/// [`EngineContext::run_outbox_worker`].
592///
593/// # Contract
594///
595/// Return `Ok(())` only after the message has been **durably accepted** by the
596/// receiving MSH.  Return `Err(…)` on transient or permanent failure — the
597/// outbox worker calls [`OutboxStore::reschedule`] so the message is retried.
598pub trait As4Sender: Send + Sync + 'static {
599    /// Transmit `msg` and return when the remote MSH has accepted it.
600    fn send(
601        &self,
602        msg: &OutboxMessage,
603    ) -> impl std::future::Future<Output = Result<(), EngineError>> + Send;
604
605    /// Whether this sender owns `msg`.
606    ///
607    /// One outbox can feed more than one consumer — a wire transport and an ERP
608    /// notifier, say — and nothing else in the store says which message belongs
609    /// to which. Without an ownership rule every consumer picks up every
610    /// message: the ERP notifier skipped what it did not recognise, but the
611    /// transport had no such filter and put internal lifecycle notifications on
612    /// the wire to the market partner, as raw JSON, because they have no
613    /// EDIFACT renderer.
614    ///
615    /// Returning `false` makes the worker leave the message untouched — not
616    /// rescheduled, not dead-lettered, not counted as an attempt — for whichever
617    /// consumer does own it. The default claims everything, which is right for
618    /// the single-consumer deployments this trait started with.
619    fn handles(&self, msg: &OutboxMessage) -> bool {
620        let _ = msg;
621        true
622    }
623}
624
625// ── OutboxWorker ──────────────────────────────────────────────────────────────
626
627/// A background worker that drains the outbox by polling pending
628/// [`OutboxMessage`]s and dispatching them via an [`As4Sender`].
629///
630/// Obtain via [`EngineContext::run_outbox_worker`] and drive by spawning
631/// [`OutboxWorker::run`] in a Tokio task.
632///
633/// # Polling behaviour
634///
635/// When the poll returns an empty batch the worker sleeps for `poll_interval`
636/// before polling again.  Non-empty batches are processed immediately.
637///
638/// # Error handling
639///
640/// Successful sends are acknowledged via [`OutboxStore::acknowledge`].
641/// Failed sends are rescheduled via [`OutboxStore::reschedule`] using
642/// **full-jitter exponential backoff**: `delay = rand(0, min(MAX, BASE * 2^n))`
643/// where `n = attempt_count`. This avoids thundering-herd when multiple
644/// `makod` instances restart simultaneously after a receiver outage.
645///
646/// When `attempt_count >= max_attempts`, the message is **acknowledged** (removed
647/// from the outbox) and a [`DeadLetterReason::OutboxExhausted`] record is written
648/// to the dead-letter sink. This prevents permanently-undeliverable messages
649/// from clogging the outbox forever.
650///
651/// All errors are emitted as structured `tracing` events at `warn` / `error`
652/// level rather than `eprintln!`, so they appear in the application's log
653/// pipeline with full context (message_id, error).
654///
655/// # Example
656///
657/// ```rust,ignore
658/// use std::time::Duration;
659///
660/// let worker = ctx.run_outbox_worker(my_sender, 50, Duration::from_secs(1));
661/// tokio::spawn(async move { worker.run().await });
662/// ```
663///
664/// [`DeadLetterReason::OutboxExhausted`]: crate::dead_letter::DeadLetterReason::OutboxExhausted
665pub struct OutboxWorker<OS: OutboxStore, S: As4Sender, DS: DeadlineStore> {
666    store: OS,
667    sender: S,
668    /// Used to discharge a delivery-window deadline once the message it was
669    /// watching has actually been sent — see [`OutboxWorker::run`].
670    deadline_store: DS,
671    batch_size: usize,
672    poll_interval: std::time::Duration,
673    /// Maximum total delivery attempts before a message is dead-lettered — a
674    /// runaway belt, not the budget. The budget is [`Self::max_retry_window`]:
675    /// the backoff is full-jitter, so an attempt *count* cannot promise a
676    /// retry *duration*, and the BDEW retry duty is stated in hours.
677    max_attempts: u32,
678    /// Maximum age (from `created_at`) a message is retried for before it is
679    /// dead-lettered. This is what honours a time-stated retry duty (BDEW AS4
680    /// Kommunikationshandbuch: 72 h for unacknowledged messages) — see
681    /// `mako_as4::constants::MAX_RETRY_DURATION_SECS`.
682    ///
683    /// Checked only after at least one attempt: a message that aged in a
684    /// stopped worker still gets its first try rather than being buried
685    /// unsent.
686    max_retry_window: std::time::Duration,
687    /// Sink for messages that exceed `max_attempts` or `max_retry_window`.
688    dead_letter_sink: std::sync::Arc<dyn crate::dead_letter::DeadLetterSink>,
689    /// Optional liveness heartbeat — stores the current UTC Unix timestamp
690    /// (seconds) after each poll cycle so health probes can detect stale workers.
691    heartbeat: Option<std::sync::Arc<std::sync::atomic::AtomicI64>>,
692    /// Graceful-shutdown signal. When cancelled the worker finishes the message
693    /// it is delivering, then returns from [`OutboxWorker::run`] — see
694    /// [`OutboxWorker::with_shutdown`].
695    shutdown: Option<tokio_util::sync::CancellationToken>,
696}
697
698/// Sleep for `dur`, returning early if `token` is cancelled.
699///
700/// Returns `true` when the sleep completed and the caller should keep looping,
701/// `false` when the token was cancelled and the caller must return.
702///
703/// A worker that sleeps on a bare `tokio::time::sleep` cannot observe a
704/// shutdown until its poll interval elapses. For the deadline scheduler that is
705/// 30 seconds by default — longer than a typical container termination grace
706/// period, which turns a graceful drain into a SIGKILL.
707///
708/// Public so that binaries running their own poll-loop workers alongside the
709/// engine's (projection catch-up, webhook delivery, retention purges) can honour
710/// the same token and stop before the store is closed.
711pub async fn sleep_or_cancel(
712    dur: std::time::Duration,
713    token: Option<&tokio_util::sync::CancellationToken>,
714) -> bool {
715    let Some(t) = token else {
716        tokio::time::sleep(dur).await;
717        return true;
718    };
719    tokio::select! {
720        () = tokio::time::sleep(dur) => true,
721        () = t.cancelled() => false,
722    }
723}
724
725/// Compute a full-jitter exponential backoff delay.
726///
727/// `attempt` is the number of prior attempts (0 = first retry).
728/// `entropy` provides randomness; derive from a stable message identifier
729/// (e.g. hash of `message_id`) rather than the current timestamp — a
730/// timestamp-derived value is deterministic within a single batch, which
731/// defeats jitter when multiple messages fail simultaneously.
732///
733/// | attempt | window (s) | expected delay (s) |
734/// |---------|------------|-------------------|
735/// | 0       | 5          | 2.5               |
736/// | 1       | 10         | 5                 |
737/// | 2       | 20         | 10                |
738/// | 3       | 40         | 20                |
739/// | 4       | 80         | 40                |
740/// | 5+      | 300 (cap)  | 150               |
741fn backoff_delay(attempt: u32, entropy: u64) -> std::time::Duration {
742    const BASE_SECS: u64 = 5;
743    const MAX_SECS: u64 = 300;
744    // Exponential window: BASE * 2^attempt, capped at MAX.
745    let window = BASE_SECS
746        .saturating_mul(1u64.wrapping_shl(attempt.min(5)))
747        .min(MAX_SECS);
748    // Full jitter: uniform random in [0, window).
749    let jitter_secs = if window == 0 { 0 } else { entropy % window };
750    std::time::Duration::from_secs(jitter_secs)
751}
752
753impl<OS: OutboxStore, S: As4Sender, DS: DeadlineStore> OutboxWorker<OS, S, DS> {
754    /// Run the outbox drain loop until the shutdown token is cancelled.
755    ///
756    /// Without a token (see [`OutboxWorker::with_shutdown`]) the loop runs until
757    /// the task is aborted or the process exits. With one, cancellation is
758    /// observed between messages and during the idle sleep, so an in-flight
759    /// delivery is always finished and acknowledged before the worker returns —
760    /// dropping it mid-`send` would risk a duplicate AS4 delivery on restart.
761    ///
762    /// # Panics
763    ///
764    /// Panics if `time::Duration::try_from(delay)` overflows (unreachable for
765    /// the delay values produced by `backoff_delay`).
766    #[allow(clippy::too_many_lines)]
767    pub async fn run(self) {
768        loop {
769            if self
770                .shutdown
771                .as_ref()
772                .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
773            {
774                tracing::info!("outbox worker: shutdown signalled; stopping");
775                return;
776            }
777            // Tick liveness at the *start* of every poll cycle, ahead of the
778            // early-`continue` paths below.  An idle worker (empty outbox) and
779            // one retrying after a store error are both alive and must keep
780            // ticking; only a worker genuinely hung inside an `.await` stops.
781            if let Some(ref hb) = self.heartbeat {
782                hb.store(
783                    time::OffsetDateTime::now_utc().unix_timestamp(),
784                    std::sync::atomic::Ordering::Relaxed,
785                );
786            }
787
788            let batch = match self.store.pending_now(self.batch_size).await {
789                Ok(b) => b,
790                Err(e) => {
791                    tracing::warn!(error = %e, "outbox worker: store error polling pending messages (will retry)");
792                    if !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
793                        return;
794                    }
795                    continue;
796                }
797            };
798
799            if batch.is_empty() {
800                if !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
801                    return;
802                }
803                continue;
804            }
805
806            // A batch of nothing but other consumers' messages must still sleep.
807            // Polling a queue that is full of another worker's traffic and
808            // looping straight back is a busy spin that burns a core and starves
809            // the runtime — the exact opposite of what the ownership rule is for.
810            let mut handled_any = false;
811            for msg in batch {
812                // Between messages, not inside one: a `send` that is already in
813                // flight must run to its `acknowledge`, or the counterparty
814                // receives a message the outbox still believes is pending and
815                // redelivers it after the restart.
816                if self
817                    .shutdown
818                    .as_ref()
819                    .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
820                {
821                    tracing::info!(
822                        "outbox worker: shutdown signalled mid-batch; \
823                         remaining messages stay queued for the next start"
824                    );
825                    return;
826                }
827                // ── Ownership ─────────────────────────────────────────
828                // Not this sender's message. Leave it exactly as it is:
829                // another consumer of the same outbox owns it, and touching
830                // its attempt count or dead-lettering it here would consume a
831                // retry budget that is not ours to spend.
832                if !self.sender.handles(&msg) {
833                    continue;
834                }
835                handled_any = true;
836                // ── Retry budget ──────────────────────────────────────
837                // `attempt_count` starts at 0 and is incremented on each
838                // `reschedule` call. The message is permanently undeliverable
839                // when the retry *window* has elapsed (the BDEW duty is stated
840                // in hours, and full-jitter backoff makes a count no proxy for
841                // a duration) or the attempt belt is exhausted: acknowledge it
842                // (remove from outbox) and dead-letter it so the regulatory
843                // audit trail is preserved. The window is only consulted after
844                // a first attempt, so a message that aged while the worker was
845                // down still gets tried once.
846                let age = time::OffsetDateTime::now_utc() - msg.created_at;
847                let window_elapsed = msg.attempt_count > 0
848                    && age
849                        >= time::Duration::try_from(self.max_retry_window)
850                            .unwrap_or(time::Duration::hours(72));
851                if msg.attempt_count >= self.max_attempts || window_elapsed {
852                    tracing::error!(
853                        message_id   = %msg.message_id,
854                        message_type = %msg.message_type,
855                        recipient    = %msg.recipient,
856                        attempts     = msg.attempt_count,
857                        max_attempts = self.max_attempts,
858                        age_secs     = age.whole_seconds(),
859                        window_secs  = self.max_retry_window.as_secs(),
860                        "outbox worker: retry budget exhausted; dead-lettering message",
861                    );
862                    self.dead_letter_sink.reject(
863                        &crate::dead_letter::DeadLetterReason::OutboxExhausted {
864                            message_id: msg.message_id,
865                            message_type: msg.message_type.to_string(),
866                            recipient: msg.recipient.to_string(),
867                            last_error: format!(
868                                "delivery exhausted after {} attempts",
869                                msg.attempt_count
870                            ),
871                            attempts: msg.attempt_count,
872                        },
873                    );
874                    if let Err(e) = self.store.acknowledge(msg.message_id).await {
875                        tracing::error!(
876                            message_id = %msg.message_id,
877                            error = %e,
878                            "outbox worker: acknowledge after exhaust failed; message may reappear",
879                        );
880                    }
881                    continue;
882                }
883
884                match self.sender.send(&msg).await {
885                    Ok(()) => {
886                        if let Err(e) = self.store.acknowledge(msg.message_id).await {
887                            tracing::warn!(
888                                message_id = %msg.message_id,
889                                error = %e,
890                                "outbox worker: acknowledge failed",
891                            );
892                        }
893                        // CONTRL AHB 1.0 §1.2: the CONTRL must be delivered
894                        // within 6 wall-clock hours of interchange receipt.
895                        // `msg.created_at` is when the PendingOutbox was
896                        // materialised (which should equal the ingest timestamp
897                        // for transport-layer CONTRL obligations).
898                        if msg.message_type.as_ref() == "CONTRL" {
899                            let elapsed = time::OffsetDateTime::now_utc() - msg.created_at;
900                            if elapsed > time::Duration::hours(mako_fristen::CONTRL_FRIST_HOURS) {
901                                tracing::warn!(
902                                    message_id   = %msg.message_id,
903                                    elapsed_secs = elapsed.whole_seconds(),
904                                    max_secs     = mako_fristen::CONTRL_FRIST_HOURS * 3600,
905                                    "outbox worker: CONTRL delivered OUTSIDE the 6h Übertragungsfrist \
906                                     (CONTRL AHB 1.0 §1.2) — this is a BNetzA compliance violation"
907                                );
908                            }
909                        }
910                        // APERAK AHB 1.0 §2.4.1: Strom UTILMD/ORDERS APERAK must be
911                        // delivered within 45 minutes on weekdays, or by Sunday 12:00
912                        // if received on Saturday.  Log a compliance warning if the
913                        // delivery window was missed so operators can investigate.
914                        if msg.message_type.as_ref() == "APERAK" {
915                            let elapsed = time::OffsetDateTime::now_utc() - msg.created_at;
916                            if elapsed
917                                > time::Duration::minutes(
918                                    mako_fristen::APERAK_STROM_WEEKDAY_MINUTES,
919                                )
920                            {
921                                tracing::warn!(
922                                    message_id   = %msg.message_id,
923                                    elapsed_mins = elapsed.whole_minutes(),
924                                    "outbox worker: APERAK delivered after the 45-minute Strom \
925                                     sending window (APERAK AHB 1.0 §2.4.1) — \
926                                     check OutboxWorker and AS4 transport health"
927                                );
928                            }
929                        }
930                        // The message is out, so any delivery window that was
931                        // watching for it has been answered — retire it.
932                        //
933                        // Nothing else cancels these. A monitoring deadline that
934                        // outlives the obligation it monitors fires for every
935                        // process, including every one that answered on time,
936                        // and the scheduler cannot tell those apart because a
937                        // deadline reaching `due_now` is late by construction.
938                        // Leaving them registered turns the miss counters into
939                        // counts of *processes started*.
940                        self.discharge_delivery_window(&msg).await;
941                    }
942                    // Permanent error: dead-letter immediately without retrying.
943                    // PartnerUnknown requires operator intervention (add --as4-partner);
944                    // Serialization errors will never succeed on retry; a missing
945                    // wire-format renderer cannot appear between attempts — its own
946                    // documentation promises immediate dead-lettering, and until this
947                    // arm matched it, that promise was broken and the message burned
948                    // the whole retry budget first.
949                    Err(ref e)
950                        if e.is_partner_unknown()
951                            || e.is_renderer_not_implemented()
952                            || matches!(e, EngineError::Serialization(_)) =>
953                    {
954                        tracing::error!(
955                            message_id   = %msg.message_id,
956                            message_type = %msg.message_type,
957                            recipient    = %msg.recipient,
958                            error        = %e,
959                            "outbox worker: permanent send failure; dead-lettering without retry",
960                        );
961                        self.dead_letter_sink.reject(
962                            &crate::dead_letter::DeadLetterReason::OutboxExhausted {
963                                message_id: msg.message_id,
964                                message_type: msg.message_type.to_string(),
965                                recipient: msg.recipient.to_string(),
966                                last_error: e.to_string(),
967                                attempts: msg.attempt_count,
968                            },
969                        );
970                        if let Err(re) = self.store.acknowledge(msg.message_id).await {
971                            tracing::error!(
972                                message_id = %msg.message_id,
973                                error = %re,
974                                "outbox worker: acknowledge after permanent failure failed",
975                            );
976                        }
977                    }
978                    Err(e) => {
979                        // Stable jitter entropy derived from the UUID bytes of
980                        // `message_id`.  Using the last 8 bytes as a `u64` gives
981                        // uniform entropy across message IDs (UUIDs are random in
982                        // all 128 bits for v4) and is stable across Rust versions —
983                        // unlike `DefaultHasher`, whose algorithm is explicitly
984                        // documented as unstable.
985                        let entropy = {
986                            let uuid = msg.message_id.as_uuid();
987                            let bytes = uuid.as_bytes();
988                            u64::from_le_bytes(bytes[8..16].try_into().unwrap())
989                        };
990                        let delay = backoff_delay(msg.attempt_count, entropy);
991                        let retry_at = time::OffsetDateTime::now_utc()
992                            + time::Duration::try_from(delay).unwrap_or(time::Duration::minutes(5));
993                        tracing::warn!(
994                            message_id   = %msg.message_id,
995                            attempt      = msg.attempt_count,
996                            max_attempts = self.max_attempts,
997                            retry_in     = ?delay,
998                            error        = %e,
999                            "outbox worker: send failed; rescheduling with backoff",
1000                        );
1001                        if let Err(re) = self.store.reschedule(msg.message_id, retry_at).await {
1002                            tracing::error!(
1003                                message_id = %msg.message_id,
1004                                error      = %re,
1005                                "outbox worker: reschedule failed; message may be stuck",
1006                            );
1007                        }
1008                    }
1009                }
1010            }
1011
1012            // Nothing in this batch was ours: sleep before polling again, or a
1013            // queue held by the other consumer turns this loop into a spin.
1014            if !handled_any && !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
1015                return;
1016            }
1017        }
1018    }
1019}
1020
1021impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
1022where
1023    ES: EventStore,
1024    OS: OutboxStore + Clone,
1025{
1026    /// Construct an [`OutboxWorker`] that drains the outbox via `sender`.
1027    ///
1028    /// `batch_size` — messages fetched per poll cycle.
1029    /// `poll_interval` — sleep duration when the batch is empty.
1030    ///
1031    /// `max_attempts` — attempt belt against runaway loops; the real budget is
1032    /// `max_retry_window`, the message age after which delivery is abandoned.
1033    /// The BDEW AS4 retry duty is stated in *hours* (72 h for unacknowledged
1034    /// messages — `mako_as4::constants::MAX_RETRY_DURATION_SECS`), and the
1035    /// full-jitter backoff makes an attempt count no proxy for a duration, so
1036    /// both are taken and either exhausts the message.
1037    ///
1038    /// ```rust,ignore
1039    /// use std::time::Duration;
1040    ///
1041    /// let worker = ctx.run_outbox_worker(
1042    ///     my_sender, 50, Duration::from_secs(1),
1043    ///     10_000, Duration::from_secs(72 * 3600),
1044    /// );
1045    /// tokio::spawn(async move { worker.run().await });
1046    /// ```
1047    #[must_use]
1048    pub fn run_outbox_worker<S: As4Sender>(
1049        &self,
1050        sender: S,
1051        batch_size: usize,
1052        poll_interval: std::time::Duration,
1053        max_attempts: u32,
1054        max_retry_window: std::time::Duration,
1055    ) -> OutboxWorker<OS, S, DS>
1056    where
1057        DS: DeadlineStore + Clone,
1058    {
1059        OutboxWorker {
1060            store: self.outbox_store.clone(),
1061            sender,
1062            deadline_store: self.deadline_store.clone(),
1063            batch_size,
1064            poll_interval,
1065            max_attempts,
1066            max_retry_window,
1067            dead_letter_sink: self.dead_letter_sink.clone(),
1068            heartbeat: None,
1069            shutdown: None,
1070        }
1071    }
1072}
1073
1074impl<OS: OutboxStore, S: As4Sender, DS: DeadlineStore> OutboxWorker<OS, S, DS> {
1075    /// Attach a liveness heartbeat to this worker.
1076    ///
1077    /// The worker will store the current UTC Unix timestamp (seconds) into
1078    /// `heartbeat` at the end of every poll cycle.  Pass the same
1079    /// `Arc<AtomicI64>` to the health endpoint so it can detect stale workers.
1080    #[must_use]
1081    pub fn with_heartbeat(
1082        mut self,
1083        heartbeat: std::sync::Arc<std::sync::atomic::AtomicI64>,
1084    ) -> Self {
1085        self.heartbeat = Some(heartbeat);
1086        self
1087    }
1088
1089    /// Attach a graceful-shutdown token.
1090    ///
1091    /// Cancelling it makes [`OutboxWorker::run`] return at the next message
1092    /// boundary or immediately out of its idle sleep. Await the worker's
1093    /// `JoinHandle` afterwards: the point of the token is that the caller can
1094    /// close the event store *after* the worker has stopped writing to it.
1095    #[must_use]
1096    pub fn with_shutdown(mut self, shutdown: tokio_util::sync::CancellationToken) -> Self {
1097        self.shutdown = Some(shutdown);
1098        self
1099    }
1100
1101    /// Retire the delivery window `msg` was being watched by, if one is open.
1102    ///
1103    /// A delivery-window deadline exists to answer one question: *did this
1104    /// message go out in time?* Once it has gone out the question is settled,
1105    /// and leaving the deadline registered only guarantees a false alarm later.
1106    /// [`fristen::discharges_delivery_window`] decides which labels a given
1107    /// message type answers for; deadlines that merely share the stream (a
1108    /// process-response window, say) are left alone.
1109    ///
1110    /// Best-effort: a failure here costs a spurious alert at the window's close,
1111    /// never a lost or duplicated message, so it is logged rather than
1112    /// propagated — the delivery itself has already been acknowledged.
1113    ///
1114    /// [`fristen::discharges_delivery_window`]: mako_fristen::discharges_delivery_window
1115    async fn discharge_delivery_window(&self, msg: &crate::outbox::OutboxMessage) {
1116        let open = match self.deadline_store.for_stream(&msg.stream_id).await {
1117            Ok(deadlines) => deadlines,
1118            Err(e) => {
1119                tracing::warn!(
1120                    message_id   = %msg.message_id,
1121                    message_type = %msg.message_type,
1122                    error        = %e,
1123                    "outbox worker: could not read deadlines to discharge the delivery \
1124                     window; it may fire a spurious regulatory alert",
1125                );
1126                return;
1127            }
1128        };
1129
1130        for deadline in open
1131            .iter()
1132            .filter(|d| mako_fristen::discharges_delivery_window(&msg.message_type, d.label()))
1133        {
1134            if let Err(e) = self.deadline_store.cancel(deadline.deadline_id()).await {
1135                tracing::warn!(
1136                    message_id  = %msg.message_id,
1137                    deadline_id = %deadline.deadline_id(),
1138                    label       = %deadline.label(),
1139                    error       = %e,
1140                    "outbox worker: could not discharge the delivery window; \
1141                     it may fire a spurious regulatory alert",
1142                );
1143            } else {
1144                tracing::debug!(
1145                    message_id   = %msg.message_id,
1146                    message_type = %msg.message_type,
1147                    deadline_id  = %deadline.deadline_id(),
1148                    label        = %deadline.label(),
1149                    "outbox worker: message delivered — delivery window discharged",
1150                );
1151            }
1152        }
1153    }
1154}
1155
1156impl<ES, SS, OS, DS, PR> std::fmt::Debug for EngineContext<ES, SS, OS, DS, PR>
1157where
1158    ES: std::fmt::Debug,
1159    SS: std::fmt::Debug,
1160    OS: std::fmt::Debug,
1161    DS: std::fmt::Debug,
1162    PR: std::fmt::Debug,
1163{
1164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1165        f.debug_struct("EngineContext")
1166            .field("registered_modules", &self.registered_modules)
1167            .field("registered_workflows", &self.registered_workflows)
1168            .field("pid_router_len", &self.pid_router.len())
1169            .finish_non_exhaustive()
1170    }
1171}
1172
1173// ── NoopAs4Sender / LogAs4Sender ──────────────────────────────────────────────
1174
1175/// An [`As4Sender`] that succeeds immediately without sending anything.
1176///
1177/// Use in tests and environments where outbound AS4 delivery is not yet
1178/// wired. All outbox messages are acknowledged (removed from the queue)
1179/// without being transmitted.
1180///
1181/// # ⚠️ Data loss warning
1182///
1183/// Every outbox message is **silently discarded** — no EDIFACT message is
1184/// sent to any counterparty. Do not use in production.
1185#[derive(Debug, Clone, Copy, Default)]
1186#[must_use = "NoopAs4Sender discards all outbound messages silently — use a real AS4 gateway in production"]
1187#[cfg_attr(
1188    not(any(test, feature = "testing")),
1189    deprecated = "NoopAs4Sender must not be wired in production builds; every \
1190                  outbound EDIFACT message would be silently discarded. Use \
1191                  a real As4Sender implementation instead."
1192)]
1193pub struct NoopAs4Sender;
1194
1195// The trait impl is test/testing-only: a release build without the `testing`
1196// feature cannot wire NoopAs4Sender into an outbox worker at all.
1197#[cfg(any(test, feature = "testing"))]
1198impl As4Sender for NoopAs4Sender {
1199    async fn send(&self, _msg: &OutboxMessage) -> Result<(), EngineError> {
1200        Ok(())
1201    }
1202}
1203
1204/// An [`As4Sender`] that logs every outbound message at `warn` level and
1205/// succeeds without transmitting.
1206///
1207/// Useful for development and integration-testing environments where the
1208/// full AS4 stack is not yet available but message visibility is desired.
1209/// All outbox messages are acknowledged (removed from the queue) after logging.
1210///
1211/// # ⚠️ Data loss warning
1212///
1213/// No EDIFACT message is sent to any counterparty. Do not use in production.
1214#[derive(Debug, Clone, Copy, Default)]
1215#[must_use = "LogAs4Sender discards all outbound messages — use a real AS4 gateway in production"]
1216pub struct LogAs4Sender;
1217
1218impl As4Sender for LogAs4Sender {
1219    async fn send(&self, msg: &OutboxMessage) -> Result<(), EngineError> {
1220        tracing::warn!(
1221            message_id   = %msg.message_id,
1222            message_type = %msg.message_type,
1223            recipient    = %msg.recipient,
1224            "LogAs4Sender: outbox message dropped — configure a real AS4 gateway for production",
1225        );
1226        Ok(())
1227    }
1228}
1229
1230// ── DeadlineScheduler ─────────────────────────────────────────────────────────
1231
1232/// A background task that polls [`DeadlineStore::due_now`] and dispatches
1233/// deadline commands to the owning processes via a caller-supplied function.
1234///
1235/// Obtain via [`EngineContext::run_deadline_scheduler`] and drive by spawning
1236/// [`DeadlineScheduler::run`] in a Tokio task.
1237///
1238/// # Dispatch function
1239///
1240/// The `dispatch` function receives a fired [`Deadline`] and returns a future
1241/// that dispatches the appropriate timeout command to the process. The function
1242/// is responsible for resuming the correct workflow and calling `execute`.
1243/// After the future completes, the scheduler cancels the deadline from the
1244/// store regardless of the dispatch outcome (to prevent re-firing).
1245///
1246/// ```rust,ignore
1247/// use std::time::Duration;
1248///
1249/// let scheduler = ctx.run_deadline_scheduler(
1250///     |deadline| async move {
1251///         tracing::warn!(
1252///             deadline_id = %deadline.deadline_id(),
1253///             label = %deadline.label(),
1254///             "deadline fired",
1255///         );
1256///         Ok(())
1257///     },
1258///     100,
1259///     Duration::from_secs(30),
1260/// );
1261/// tokio::spawn(async move { scheduler.run().await });
1262/// ```
1263pub struct DeadlineScheduler<DS: DeadlineStore> {
1264    store: DS,
1265    dispatch: Box<
1266        dyn Fn(
1267                Deadline,
1268            ) -> std::pin::Pin<
1269                Box<dyn std::future::Future<Output = Result<(), EngineError>> + Send>,
1270            > + Send
1271            + Sync,
1272    >,
1273    batch_size: usize,
1274    poll_interval: std::time::Duration,
1275    /// Optional liveness heartbeat — stores the current UTC Unix timestamp
1276    /// (seconds) after each poll cycle.
1277    heartbeat: Option<std::sync::Arc<std::sync::atomic::AtomicI64>>,
1278    /// Graceful-shutdown signal — see [`DeadlineScheduler::with_shutdown`].
1279    shutdown: Option<tokio_util::sync::CancellationToken>,
1280}
1281
1282impl<DS: DeadlineStore> DeadlineScheduler<DS> {
1283    /// Run the deadline poll loop until the shutdown token is cancelled.
1284    ///
1285    /// Cancellation is observed between deadlines and during the idle sleep, so
1286    /// a deadline already being dispatched runs to completion. A deadline left
1287    /// undispatched stays registered and fires on the next start — it is due, so
1288    /// the next `due_now` returns it again.
1289    pub async fn run(self) {
1290        loop {
1291            if self
1292                .shutdown
1293                .as_ref()
1294                .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
1295            {
1296                tracing::info!("deadline scheduler: shutdown signalled; stopping");
1297                return;
1298            }
1299            // Tick liveness at the *start* of every poll cycle, ahead of the
1300            // early-`continue` paths below.  An idle scheduler (no due
1301            // deadlines) is alive and must keep ticking; only one genuinely
1302            // hung inside an `.await` stops.
1303            if let Some(ref hb) = self.heartbeat {
1304                hb.store(
1305                    time::OffsetDateTime::now_utc().unix_timestamp(),
1306                    std::sync::atomic::Ordering::Relaxed,
1307                );
1308            }
1309
1310            let result = match self.store.due_now(self.batch_size).await {
1311                Ok(r) => r,
1312                Err(e) => {
1313                    tracing::warn!(
1314                        error = %e,
1315                        "deadline scheduler: store error polling due deadlines (will retry)",
1316                    );
1317                    if !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
1318                        return;
1319                    }
1320                    continue;
1321                }
1322            };
1323
1324            if result.deadlines.is_empty() {
1325                if !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
1326                    return;
1327                }
1328                continue;
1329            }
1330
1331            for deadline in result.deadlines {
1332                // Between deadlines, not inside one: a dispatch already running
1333                // must finish so its events and outbox entries commit together.
1334                if self
1335                    .shutdown
1336                    .as_ref()
1337                    .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
1338                {
1339                    tracing::info!(
1340                        "deadline scheduler: shutdown signalled mid-batch; \
1341                         undispatched deadlines remain due and fire on the next start"
1342                    );
1343                    return;
1344                }
1345                let id = deadline.deadline_id();
1346                let label = deadline.label().to_owned();
1347
1348                // An APERAK delivery window that reaches this point is a
1349                // regulatory violation under APERAK AHB 1.0 §2.4.1 (Strom
1350                // 45 min) / §2.3.1 (Gas 1 Werktag): the outbox worker discharges
1351                // the window the moment the APERAK goes out, so one that is
1352                // still registered when it comes due was never answered.
1353                //
1354                // Do NOT re-test `now > due_at` here. `due_now` selects on
1355                // `due_at <= now`, so that comparison is true by construction and
1356                // says nothing about compliance — it was the reason this counter
1357                // once tracked "Strom processes started" rather than "APERAKs
1358                // missed". The discharge is what carries the meaning.
1359                if label.starts_with(mako_fristen::APERAK_WINDOW_LABEL_PREFIX) {
1360                    let now = time::OffsetDateTime::now_utc();
1361                    crate::metrics::EngineMetrics::global().aperak_missed(&label);
1362                    tracing::error!(
1363                        deadline_id = %id,
1364                        label       = %label,
1365                        due_at      = %deadline.due_at(),
1366                        fired_at    = %now,
1367                        overdue_secs = (now - deadline.due_at()).whole_seconds(),
1368                        "APERAK delivery window closed with no delivery — regulatory \
1369                         violation (APERAK AHB 1.0 §2.4.1 Strom / §2.3.1 Gas). \
1370                         Counter: makod_aperak_missed_total",
1371                    );
1372                }
1373
1374                let should_cancel = match (self.dispatch)(deadline).await {
1375                    Ok(()) => true,
1376                    Err(ref e) if e.is_version_conflict() => {
1377                        // The process was modified concurrently; the timeout
1378                        // command will be retried on the next poll cycle.
1379                        // Do NOT cancel — let the deadline remain due so it
1380                        // fires again until a non-conflict dispatch succeeds.
1381                        tracing::warn!(
1382                            deadline_id = %id,
1383                            label       = %label,
1384                            "deadline scheduler: VersionConflict; will retry on next poll",
1385                        );
1386                        false
1387                    }
1388                    Err(e) => {
1389                        tracing::warn!(
1390                            deadline_id = %id,
1391                            label       = %label,
1392                            error       = %e,
1393                            "deadline scheduler: dispatch failed (permanent); cancelling",
1394                        );
1395                        true
1396                    }
1397                };
1398                if should_cancel && let Err(e) = self.store.cancel(id).await {
1399                    tracing::error!(
1400                        deadline_id = %id,
1401                        error       = %e,
1402                        "deadline scheduler: cancel failed; deadline may fire again",
1403                    );
1404                }
1405            }
1406
1407            // If has_more, loop immediately to drain the batch.
1408        }
1409    }
1410}
1411
1412impl<DS: DeadlineStore> DeadlineScheduler<DS> {
1413    /// Attach a liveness heartbeat to this scheduler.
1414    ///
1415    /// The scheduler will store the current UTC Unix timestamp (seconds) into
1416    /// `heartbeat` at the end of every poll cycle.
1417    #[must_use]
1418    pub fn with_heartbeat(
1419        mut self,
1420        heartbeat: std::sync::Arc<std::sync::atomic::AtomicI64>,
1421    ) -> Self {
1422        self.heartbeat = Some(heartbeat);
1423        self
1424    }
1425
1426    /// Attach a graceful-shutdown token.
1427    ///
1428    /// Cancelling it makes [`DeadlineScheduler::run`] return at the next
1429    /// deadline boundary or immediately out of its idle sleep, so the caller can
1430    /// close the event store once the scheduler has stopped writing to it.
1431    #[must_use]
1432    pub fn with_shutdown(mut self, shutdown: tokio_util::sync::CancellationToken) -> Self {
1433        self.shutdown = Some(shutdown);
1434        self
1435    }
1436}
1437
1438impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
1439where
1440    ES: EventStore,
1441    DS: DeadlineStore + Clone,
1442{
1443    /// Construct a [`DeadlineScheduler`] that polls the deadline store and
1444    /// dispatches fired deadlines via `dispatch`.
1445    ///
1446    /// The `dispatch` function is called for every fired deadline. It should
1447    /// resume the owning process and execute the appropriate timeout command.
1448    ///
1449    /// `batch_size` — deadlines fetched per poll cycle.
1450    /// `poll_interval` — sleep duration when no deadlines are due.
1451    ///
1452    /// ```rust,ignore
1453    /// use std::time::Duration;
1454    ///
1455    /// let scheduler = ctx.run_deadline_scheduler(
1456    ///     |d| async move {
1457    ///         tracing::info!(label = %d.label(), "firing deadline");
1458    ///         Ok(())
1459    ///     },
1460    ///     100,
1461    ///     Duration::from_secs(30),
1462    /// );
1463    /// tokio::spawn(async move { scheduler.run().await });
1464    /// ```
1465    #[must_use]
1466    pub fn run_deadline_scheduler<F, Fut>(
1467        &self,
1468        dispatch: F,
1469        batch_size: usize,
1470        poll_interval: std::time::Duration,
1471    ) -> DeadlineScheduler<DS>
1472    where
1473        F: Fn(Deadline) -> Fut + Send + Sync + 'static,
1474        Fut: std::future::Future<Output = Result<(), EngineError>> + Send + 'static,
1475    {
1476        DeadlineScheduler {
1477            store: self.deadline_store.clone(),
1478            dispatch: Box::new(move |d| Box::pin(dispatch(d))),
1479            batch_size,
1480            poll_interval,
1481            heartbeat: None,
1482            shutdown: None,
1483        }
1484    }
1485}
1486
1487// ── EngineBuilder ─────────────────────────────────────────────────────────────
1488
1489/// Assembles engine infrastructure and produces an [`EngineContext`].
1490///
1491/// Uses type-state to enforce that an event store is provided before
1492/// [`build`] can be called. All other stores default to `Noop`
1493/// implementations.
1494///
1495/// ## Quick start
1496///
1497/// ```rust,ignore
1498/// // Minimal — event store only, all others are Noop:
1499/// let ctx = EngineBuilder::new()
1500///     .with_event_store(InMemoryEventStore::new())
1501///     .build();
1502///
1503/// // Full infrastructure:
1504/// let ctx = EngineBuilder::new()
1505///     .with_event_store(InMemoryEventStore::new())
1506///     .with_snapshot_store(InMemorySnapshotStore::new())
1507///     .with_outbox_store(InMemoryOutboxStore::new())
1508///     .with_deadline_store(InMemoryDeadlineStore::new())
1509///     .with_registry(InMemoryProcessRegistry::new())
1510///     .register(Box::new(GpkeModule))
1511///     .build();
1512/// ```
1513///
1514/// [`build`]: EngineBuilder::build
1515pub struct EngineBuilder<
1516    ES = (),
1517    SS = NoopSnapshotStore,
1518    OS = NoopOutboxStore,
1519    DS = NoopDeadlineStore,
1520    PR = NoopProcessRegistry,
1521> {
1522    event_store: ES,
1523    snapshot_store: SS,
1524    outbox_store: OS,
1525    deadline_store: DS,
1526    registry: PR,
1527    dead_letter_sink: Arc<dyn DeadLetterSink>,
1528    modules: Vec<Box<dyn EngineModule>>,
1529    /// Active [`DeploymentRoles`] for this engine instance.
1530    ///
1531    /// Controls role-conditional PID registration via
1532    /// [`EngineModule::register_pids_with_roles`]. Defaults to
1533    /// [`DeploymentRoles::all()`]: an engine that names no roles registers
1534    /// every PID its modules declare, which is what a test harness and a
1535    /// combined-role deployment both want.
1536    deployment_roles: DeploymentRoles,
1537    /// Optional profile validator injected by `makod` or callers that have
1538    /// access to `edi-energy`.  When `Some`, called for each
1539    /// [`ProfileRequirement`] declared by registered modules.  When `None`,
1540    /// profile requirements are not validated (safe in unit tests).
1541    ///
1542    /// Signature: `fn(message_type: &str) -> bool`
1543    ///
1544    /// [`ProfileRequirement`]: crate::profile::ProfileRequirement
1545    profile_validator: Option<Box<dyn Fn(&str) -> bool + Send + Sync>>,
1546}
1547#[cfg(any(test, feature = "testing"))]
1548impl Default
1549    for EngineBuilder<
1550        (),
1551        NoopSnapshotStore,
1552        NoopOutboxStore,
1553        NoopDeadlineStore,
1554        NoopProcessRegistry,
1555    >
1556{
1557    fn default() -> Self {
1558        Self {
1559            event_store: (),
1560            snapshot_store: NoopSnapshotStore,
1561            outbox_store: NoopOutboxStore,
1562            deadline_store: NoopDeadlineStore,
1563            registry: NoopProcessRegistry,
1564            dead_letter_sink: Arc::new(LogDeadLetterSink),
1565            modules: Vec::new(),
1566            deployment_roles: DeploymentRoles::all(),
1567            profile_validator: None,
1568        }
1569    }
1570}
1571
1572#[cfg(any(test, feature = "testing"))]
1573impl EngineBuilder {
1574    /// Create a new builder with all `Noop` defaults.
1575    ///
1576    /// Only available in `#[cfg(test)]` or with the `testing` feature enabled,
1577    /// because the Noop defaults silently discard outbox messages, deadlines,
1578    /// and process registry entries. Production binaries must wire real stores
1579    /// via the `with_*` builder methods.
1580    ///
1581    /// Call [`with_event_store`] before [`build`] — the event store is
1582    /// **required**.
1583    ///
1584    /// [`with_event_store`]: EngineBuilder::with_event_store
1585    /// [`build`]: EngineBuilder::build
1586    #[must_use]
1587    pub fn new() -> Self {
1588        Self::default()
1589    }
1590}
1591
1592impl<OS, DS, PR> EngineBuilder<(), NoopSnapshotStore, OS, DS, PR>
1593where
1594    OS: OutboxStore,
1595    DS: DeadlineStore,
1596    PR: ProcessRegistry,
1597{
1598    /// Create a production-ready builder with explicit stores for outbox,
1599    /// deadline, and process registry.
1600    ///
1601    /// This constructor is available in all build configurations including
1602    /// production binaries. It enforces that the three stores that can cause
1603    /// silent data loss (`OutboxStore`, `DeadlineStore`, `ProcessRegistry`)
1604    /// are provided explicitly — there is no Noop fallback.
1605    ///
1606    /// `NoopSnapshotStore` is used as the snapshot default because it is safe
1607    /// for production: skipping snapshots means full replay, but no data loss.
1608    /// Override with [`with_snapshot_store`] to enable snapshot-accelerated
1609    /// replay.
1610    ///
1611    /// Call [`with_event_store`] before [`build`] — the event store is
1612    /// **required**.
1613    ///
1614    /// ```rust,ignore
1615    /// let ctx = EngineBuilder::with_stores(outbox, deadline, registry)
1616    ///     .with_event_store(store.clone())
1617    ///     .with_snapshot_store(InMemorySnapshotStore::new())
1618    ///     .build();
1619    /// ```
1620    ///
1621    /// [`with_snapshot_store`]: EngineBuilder::with_snapshot_store
1622    /// [`with_event_store`]: EngineBuilder::with_event_store
1623    /// [`build`]: EngineBuilder::build
1624    #[must_use]
1625    pub fn with_stores(outbox_store: OS, deadline_store: DS, registry: PR) -> Self {
1626        Self {
1627            event_store: (),
1628            snapshot_store: NoopSnapshotStore,
1629            outbox_store,
1630            deadline_store,
1631            registry,
1632            dead_letter_sink: Arc::new(LogDeadLetterSink),
1633            modules: Vec::new(),
1634            deployment_roles: DeploymentRoles::all(),
1635            profile_validator: None,
1636        }
1637    }
1638}
1639
1640impl<ES, SS, OS, DS, PR> EngineBuilder<ES, SS, OS, DS, PR> {
1641    /// Set the event store. **Required** — `build()` is only available once
1642    /// this has been called with a type that implements [`EventStore`].
1643    ///
1644    /// Replaces any previously set event store (type-state transition).
1645    #[must_use]
1646    pub fn with_event_store<ES2: EventStore>(
1647        self,
1648        store: ES2,
1649    ) -> EngineBuilder<ES2, SS, OS, DS, PR> {
1650        EngineBuilder {
1651            event_store: store,
1652            snapshot_store: self.snapshot_store,
1653            outbox_store: self.outbox_store,
1654            deadline_store: self.deadline_store,
1655            registry: self.registry,
1656            dead_letter_sink: self.dead_letter_sink,
1657            modules: self.modules,
1658            deployment_roles: self.deployment_roles,
1659            profile_validator: self.profile_validator,
1660        }
1661    }
1662
1663    /// Set the snapshot store (default: [`NoopSnapshotStore`]).
1664    ///
1665    /// ## Default: `NoopSnapshotStore`
1666    ///
1667    /// Without calling this method the builder uses [`NoopSnapshotStore`],
1668    /// which silently discards all snapshot writes and returns `None` for
1669    /// every snapshot read.  The engine still functions correctly — every
1670    /// command handling call replays the full event log from the beginning
1671    /// instead of starting from a stored snapshot.  For low-volume processes
1672    /// this is fine; for long-lived processes with many events the replay cost
1673    /// can become significant.
1674    ///
1675    /// Enable snapshotting in production by providing a real [`SnapshotStore`]
1676    /// implementation (e.g. the SlateDB-backed store in `makod`).  In tests,
1677    /// `InMemorySnapshotStore` is available behind the `testing` feature flag.
1678    ///
1679    /// Note: [`Process::state_with_snapshot`][crate::process::Process::state_with_snapshot]
1680    /// is a compile-time no-op when the snapshot store is `NoopSnapshotStore`
1681    /// — it never calls the store and always returns `None`, so no snapshot is
1682    /// ever saved or loaded.
1683    #[must_use]
1684    pub fn with_snapshot_store<SS2: SnapshotStore>(
1685        self,
1686        store: SS2,
1687    ) -> EngineBuilder<ES, SS2, OS, DS, PR> {
1688        EngineBuilder {
1689            event_store: self.event_store,
1690            snapshot_store: store,
1691            outbox_store: self.outbox_store,
1692            deadline_store: self.deadline_store,
1693            registry: self.registry,
1694            dead_letter_sink: self.dead_letter_sink,
1695            modules: self.modules,
1696            deployment_roles: self.deployment_roles,
1697            profile_validator: self.profile_validator,
1698        }
1699    }
1700
1701    /// Set the outbox store (default: [`NoopOutboxStore`]).
1702    #[must_use]
1703    pub fn with_outbox_store<OS2: OutboxStore>(
1704        self,
1705        store: OS2,
1706    ) -> EngineBuilder<ES, SS, OS2, DS, PR> {
1707        EngineBuilder {
1708            event_store: self.event_store,
1709            snapshot_store: self.snapshot_store,
1710            outbox_store: store,
1711            deadline_store: self.deadline_store,
1712            registry: self.registry,
1713            dead_letter_sink: self.dead_letter_sink,
1714            modules: self.modules,
1715            deployment_roles: self.deployment_roles,
1716            profile_validator: self.profile_validator,
1717        }
1718    }
1719
1720    /// Set the deadline store (default: [`NoopDeadlineStore`]).
1721    #[must_use]
1722    pub fn with_deadline_store<DS2: DeadlineStore>(
1723        self,
1724        store: DS2,
1725    ) -> EngineBuilder<ES, SS, OS, DS2, PR> {
1726        EngineBuilder {
1727            event_store: self.event_store,
1728            snapshot_store: self.snapshot_store,
1729            outbox_store: self.outbox_store,
1730            deadline_store: store,
1731            registry: self.registry,
1732            dead_letter_sink: self.dead_letter_sink,
1733            modules: self.modules,
1734            deployment_roles: self.deployment_roles,
1735            profile_validator: self.profile_validator,
1736        }
1737    }
1738
1739    /// Set the process registry (default: [`NoopProcessRegistry`]).
1740    #[must_use]
1741    pub fn with_registry<PR2: ProcessRegistry>(
1742        self,
1743        registry: PR2,
1744    ) -> EngineBuilder<ES, SS, OS, DS, PR2> {
1745        EngineBuilder {
1746            event_store: self.event_store,
1747            snapshot_store: self.snapshot_store,
1748            outbox_store: self.outbox_store,
1749            deadline_store: self.deadline_store,
1750            registry,
1751            dead_letter_sink: self.dead_letter_sink,
1752            modules: self.modules,
1753            deployment_roles: self.deployment_roles,
1754            profile_validator: self.profile_validator,
1755        }
1756    }
1757
1758    /// Set the dead-letter sink (default: [`LogDeadLetterSink`]).
1759    ///
1760    /// The dead-letter sink receives every message that cannot be routed to a
1761    /// workflow. The default [`LogDeadLetterSink`] emits `tracing::warn!`
1762    /// events, making rejections visible in log output without configuration.
1763    ///
1764    /// Override with a persistent DLQ implementation in production:
1765    ///
1766    /// ```rust,ignore
1767    /// use mako_engine::dead_letter::LogDeadLetterSink;
1768    ///
1769    /// let ctx = EngineBuilder::new()
1770    ///     .with_event_store(my_store)
1771    ///     .with_dead_letter_sink(MyPersistentDlq::new())
1772    ///     .build();
1773    /// ```
1774    ///
1775    /// [`LogDeadLetterSink`]: crate::dead_letter::LogDeadLetterSink
1776    #[must_use]
1777    pub fn with_dead_letter_sink(mut self, sink: impl DeadLetterSink) -> Self {
1778        self.dead_letter_sink = Arc::new(sink);
1779        self
1780    }
1781
1782    /// Register an `edi-energy` profile validator for startup profile checks.
1783    ///
1784    /// The closure receives a message-type string (e.g. `"UTILMD"`) and must
1785    /// return `true` if at least one active profile for that message type is
1786    /// registered for today's date.
1787    ///
1788    /// Wire this in `makod` using the `edi-energy` global registry:
1789    ///
1790    /// ```rust,ignore
1791    /// use edi_energy::registry::ReleaseRegistry;
1792    ///
1793    /// let today = mako_fristen::heute();
1794    /// builder.with_profile_validator(move |msg_type| {
1795    ///     ReleaseRegistry::global()
1796    ///         .profiles_for_str(msg_type)
1797    ///         .any(|p| match (p.valid_from(), p.valid_until()) {
1798    ///             (Some(f), Some(u)) => f <= today && today <= u,
1799    ///             (Some(f), None)    => f <= today,
1800    ///             (None, _)          => true,
1801    ///         })
1802    /// })
1803    /// ```
1804    ///
1805    /// Domain crates do **not** need to call this — they only declare
1806    /// [`profile_requirements`].
1807    ///
1808    /// [`profile_requirements`]: EngineModule::profile_requirements
1809    #[must_use]
1810    pub fn with_profile_validator(
1811        mut self,
1812        validator: impl Fn(&str) -> bool + Send + Sync + 'static,
1813    ) -> Self {
1814        self.profile_validator = Some(Box::new(validator));
1815        self
1816    }
1817
1818    /// Register a domain module.
1819    ///
1820    /// The module name becomes visible in
1821    /// [`EngineContext::registered_modules`] after [`build`] is called.
1822    ///
1823    /// [`build`]: EngineBuilder::build
1824    #[must_use]
1825    pub fn register(mut self, module: Box<dyn EngineModule>) -> Self {
1826        self.modules.push(module);
1827        self
1828    }
1829
1830    /// Register multiple [`EngineModule`]s at once from a pre-built `Vec`.
1831    ///
1832    /// Equivalent to calling [`register`] in a loop. Useful when the set of
1833    /// modules is assembled conditionally (e.g. via `#[cfg]`-gated pushes to a
1834    /// `Vec<Box<dyn EngineModule>>`) before the builder chain starts.
1835    ///
1836    /// [`register`]: EngineBuilder::register
1837    #[must_use]
1838    pub fn register_many(mut self, modules: Vec<Box<dyn EngineModule>>) -> Self {
1839        self.modules.extend(modules);
1840        self
1841    }
1842
1843    /// Set the active [`DeploymentRoles`] for this engine instance.
1844    ///
1845    /// Controls role-conditional PID registration in [`EngineModule::register_pids_with_roles`].
1846    ///
1847    /// The default is [`DeploymentRoles::all()`], which registers every PID unconditionally
1848    /// — identical to the pre-role-aware behavior. Providing an explicit role set
1849    /// restricts role-conditional blocks to only the declared roles:
1850    ///
1851    /// - **NB-only** (`DeploymentRoles::nb()`): 19001/19002 route to `gpke-konfiguration`;
1852    ///   WiM nMSB blocks are skipped.
1853    /// - **nMSB-only** (`DeploymentRoles::nmsb()`): 19001/19002 route to `wim-geraeteubernahme`;
1854    ///   GPKE NB blocks are skipped.
1855    /// - **NB + gMSB** (`DeploymentRoles::nb_msb()`): most common Stadtwerke combination.
1856    ///
1857    /// # Conflict guard
1858    ///
1859    /// When two modules would register the same PID to **different** workflows, the
1860    /// engine panics during [`build`]. Set explicit roles to prevent both modules from
1861    /// activating the same PID simultaneously:
1862    ///
1863    /// ```rust,ignore
1864    /// use mako_engine::marktrolle::DeploymentRoles;
1865    ///
1866    /// let ctx = EngineBuilder::with_stores(outbox, deadline, registry)
1867    ///     .with_event_store(store)
1868    ///     .with_deployment_roles(DeploymentRoles::nb())  // only NB: GPKE gets 19001/19002
1869    ///     .register(Box::new(GpkeModule))
1870    ///     .register(Box::new(WimModule))  // nMSB block skipped — no conflict
1871    ///     .build();
1872    /// ```
1873    ///
1874    /// [`build`]: EngineBuilder::build
1875    #[must_use]
1876    pub fn with_deployment_roles(mut self, roles: DeploymentRoles) -> Self {
1877        self.deployment_roles = roles;
1878        self
1879    }
1880}
1881
1882impl<ES, SS, OS, DS, PR> EngineBuilder<ES, SS, OS, DS, PR>
1883where
1884    ES: EventStore,
1885    SS: SnapshotStore,
1886    OS: OutboxStore,
1887    DS: DeadlineStore,
1888    PR: ProcessRegistry,
1889{
1890    /// Build the [`EngineContext`].
1891    ///
1892    /// Consumes the builder. All registered modules and configured stores are
1893    /// moved into the returned [`EngineContext`].
1894    ///
1895    /// This method is only available when `ES` implements [`EventStore`].
1896    /// If you have not called [`with_event_store`], this will not compile.
1897    ///
1898    /// # Panics
1899    ///
1900    /// Panics when any registered module returns `Err` from
1901    /// [`EngineModule::configure`]. The panic message includes the module
1902    /// name and the error string so the deployment failure is actionable.
1903    ///
1904    /// [`with_event_store`]: EngineBuilder::with_event_store
1905    #[must_use]
1906    #[allow(clippy::too_many_lines)]
1907    pub fn build(self) -> EngineContext<ES, SS, OS, DS, PR> {
1908        // ── Noop store safety checks ──────────────────────────────────────────
1909        //
1910        // Noop stores lose data silently: NoopDeadlineStore drops every APERAK
1911        // deadline (BNetzA violation), NoopOutboxStore discards all outbound
1912        // messages, NoopProcessRegistry loses conversation routing on restart.
1913        //
1914        // In production builds (no `testing` feature, not running under
1915        // `#[test]`), the Noop constructors are cfg-gated out so this branch
1916        // is dead code and compiles away. In test/testing/tracing builds we
1917        // emit warnings so test harnesses see the configuration in log output.
1918        //
1919        // IMPORTANT: if you are reading this because a panic fired in production,
1920        // it means the `testing` feature was accidentally enabled in the binary.
1921        // Remove it from the production Cargo.toml feature list immediately.
1922        {
1923            let os_name = std::any::type_name::<OS>();
1924            let ds_name = std::any::type_name::<DS>();
1925            let pr_name = std::any::type_name::<PR>();
1926
1927            // Regulatory-critical stores: panic in any build context if these
1928            // are noop. OutboxStore and DeadlineStore must be durable in
1929            // production; ProcessRegistry must survive restarts.
1930            #[cfg(not(any(test, feature = "testing")))]
1931            {
1932                assert!(
1933                    !ds_name.contains("NoopDeadlineStore"),
1934                    "EngineBuilder::build: NoopDeadlineStore is active in a \
1935                     non-testing build. This silently discards all APERAK deadlines, \
1936                     which is an immediately reportable BNetzA violation \
1937                     (BK6-22-024 §5, BK7-24-01-009). \
1938                     Call .with_deadline_store(SlateDbStore::as_deadline_store()) \
1939                     in your production engine assembly. \
1940                     If this is a test, enable the 'testing' feature."
1941                );
1942                assert!(
1943                    !os_name.contains("NoopOutboxStore"),
1944                    "EngineBuilder::build: NoopOutboxStore is active in a \
1945                     non-testing build. This silently discards all outbound \
1946                     APERAK, CONTRL, and UTILMD messages. \
1947                     Call .with_outbox_store(SlateDbStore::as_outbox_store()) \
1948                     in your production engine assembly. \
1949                     If this is a test, enable the 'testing' feature."
1950                );
1951                assert!(
1952                    !pr_name.contains("NoopProcessRegistry"),
1953                    "EngineBuilder::build: NoopProcessRegistry is active in a \
1954                     non-testing build. This means conversation routing \
1955                     (PID → stream_id lookup) is lost on every restart, \
1956                     breaking all WiM, GeLi Gas, and GPKE in-flight processes. \
1957                     Call .with_registry(SlateDbStore::as_process_registry()) \
1958                     in your production engine assembly. \
1959                     If this is a test, enable the 'testing' feature."
1960                );
1961            }
1962
1963            // In test/testing/tracing builds: emit warnings instead of panicking.
1964            #[cfg(any(test, feature = "testing", feature = "tracing"))]
1965            {
1966                let ss_name = std::any::type_name::<SS>();
1967                if ss_name.contains("NoopSnapshotStore") {
1968                    tracing::warn!(
1969                        store = ss_name,
1970                        "EngineBuilder: NoopSnapshotStore is active — snapshots will not be \
1971                         persisted. Use SlateDbStore::as_snapshot_store() in production."
1972                    );
1973                }
1974                if os_name.contains("NoopOutboxStore") {
1975                    tracing::warn!(
1976                        store = os_name,
1977                        "EngineBuilder: NoopOutboxStore is active — outbound messages will be \
1978                         silently discarded. Use SlateDbStore::as_outbox_store() in production."
1979                    );
1980                }
1981                if ds_name.contains("NoopDeadlineStore") {
1982                    tracing::warn!(
1983                        store = ds_name,
1984                        "EngineBuilder: NoopDeadlineStore is active — scheduled deadlines will \
1985                         not fire after restart. Use SlateDbStore::as_deadline_store() in production."
1986                    );
1987                }
1988                if pr_name.contains("NoopProcessRegistry") {
1989                    tracing::warn!(
1990                        store = pr_name,
1991                        "EngineBuilder: NoopProcessRegistry is active — process routing will be \
1992                         lost on restart. Use SlateDbStore::as_process_registry() in production."
1993                    );
1994                }
1995            }
1996        }
1997        // Validate every module before assembling the context.
1998        // A missing adapter or misconfigured module fails at startup (not at
1999        // first inbound message), making deployment failures observable immediately.
2000        for module in &self.modules {
2001            if let Err(msg) = module.configure() {
2002                panic!(
2003                    "EngineBuilder::build: module '{}' failed configuration validation: {}",
2004                    module.name(),
2005                    msg
2006                );
2007            }
2008            // Validate profile requirements via the injected validator.
2009            // Domain crates declare requirements; only the binary crate (makod)
2010            // injects the edi-energy registry — domain crates need no edi-energy
2011            // import for this check.
2012            if let Some(ref validator) = self.profile_validator {
2013                for req in module.profile_requirements() {
2014                    assert!(
2015                        validator(req.message_type),
2016                        "EngineBuilder::build: module '{}' requires an active edi-energy \
2017                             profile for '{}' ({}) but none is registered for today's date. \
2018                             Run `cargo xtask import-profiles` to add the missing profile.",
2019                        module.name(),
2020                        req.message_type,
2021                        req.label,
2022                    );
2023                }
2024            }
2025        }
2026        // Build the PID router from all registered modules.
2027        // Also assert that no two modules claim the same PID — a PID overlap
2028        // is always a configuration error: one module's messages would be
2029        // silently swallowed by another's workflow, producing missing-process
2030        // errors or incorrect audit trails.
2031        let mut pid_router = PidRouter::new();
2032        let mut pid_owners: std::collections::HashMap<u32, &str> = std::collections::HashMap::new();
2033        // Keep each module's scratch router so we can build `pid_router` from
2034        // them in a second pass with the resolved ownership table.
2035        let mut module_scratches: Vec<PidRouter> = Vec::with_capacity(self.modules.len());
2036
2037        // Pass 1 — detect conflicts, determine PID ownership (first-wins for
2038        // explicit roles, last-wins for DeploymentRoles::all()).
2039        for module in &self.modules {
2040            // Temporarily build a scratch router to read this module's PIDs
2041            // for cross-module overlap detection (module-ownership level).
2042            let mut scratch = PidRouter::new();
2043            module.register_pids_with_roles(&mut scratch, &self.deployment_roles);
2044
2045            // A module names its workflows twice — once by routing a PID to a
2046            // name, once by declaring the name — and only the declared list is
2047            // reachable from `EngineContext::registered_workflows`. Consumers
2048            // build their deadline-dispatch coverage from that list, so a
2049            // routed-but-undeclared workflow runs while being invisible to
2050            // every check made over the declarations: its Fristen fire into a
2051            // scheduler arm that was never required to exist.
2052            //
2053            // The converse is legitimate and not checked — a command-initiated
2054            // workflow declares a name and routes no inbound PID.
2055            let declared: std::collections::HashSet<&str> =
2056                module.workflow_names().iter().copied().collect();
2057            let mut undeclared: Vec<&str> = scratch
2058                .workflow_names()
2059                .into_iter()
2060                .filter(|name| !declared.contains(name))
2061                .collect();
2062            undeclared.sort_unstable();
2063            undeclared.dedup();
2064            assert!(
2065                undeclared.is_empty(),
2066                "EngineBuilder::build: module '{}' routes PIDs to workflows it does not \
2067                 declare in `workflow_names()`: {}. A workflow missing from that list is \
2068                 excluded from `EngineContext::registered_workflows`, so any deadline it \
2069                 registers is never checked for a dispatch arm. Add each name to the \
2070                 module's `workflow_names()`.",
2071                module.name(),
2072                undeclared.join(", "),
2073            );
2074
2075            for pid in scratch.registered_pids() {
2076                if let Some(prev) = pid_owners.insert(pid, module.name()) {
2077                    if self.deployment_roles.is_all() {
2078                        // With DeploymentRoles::all() (the default), role-conditional PIDs
2079                        // are registered by all modules that claim them, producing last-wins
2080                        // semantics. This is acceptable for single-role and dev/test deployments.
2081                        //
2082                        // In production multi-role deployments where both an NB and nMSB role
2083                        // are served by the same instance, set explicit roles via
2084                        // `EngineBuilder::with_deployment_roles` to prevent silent misrouting.
2085                        //
2086                        // We emit a debug-level log here (not warn) because the vast majority
2087                        // of deployments are single-role and this overlap is expected/harmless.
2088                        #[cfg(feature = "tracing")]
2089                        tracing::debug!(
2090                            pid,
2091                            previous_module = prev,
2092                            current_module = module.name(),
2093                            "PID registered by multiple modules with DeploymentRoles::all(); \
2094                             last module wins (use with_deployment_roles for strict routing)",
2095                        );
2096                        let _ = prev; // suppress unused-variable warning when tracing is off
2097                    } else {
2098                        // Explicit roles: the FIRST module to register a PID retains ownership.
2099                        // Restore the previous (first) owner and emit a warning so the operator
2100                        // can investigate.  A panic would be too strict: some shared PIDs
2101                        // (e.g. REMADV 33001/33002) are legitimately claimed by both GPKE and
2102                        // WiM billing; conversation-ID routing is the long-term solution, but
2103                        // first-wins gives correct behaviour for all current deployments.
2104                        pid_owners.insert(pid, prev); // restore first owner
2105                        #[cfg(feature = "tracing")]
2106                        tracing::warn!(
2107                            pid,
2108                            first_module = prev,
2109                            second_module = module.name(),
2110                            "PID {pid} claimed by both '{prev}' and '{}' with explicit \
2111                             DeploymentRoles; first module ('{prev}') retains ownership. \
2112                             Verify PID registration is correct for this deployment.",
2113                            module.name(),
2114                        );
2115                        #[cfg(not(feature = "tracing"))]
2116                        let _ = prev; // suppress unused-variable warning when tracing is off
2117                    }
2118                }
2119            }
2120            module_scratches.push(scratch);
2121        }
2122
2123        // Pass 2 — build the real `pid_router` from the scratch pads, respecting
2124        // the ownership table built in pass 1.
2125        for (module, scratch) in self.modules.iter().zip(module_scratches.iter()) {
2126            // Unambiguous (Sparte-agnostic) entries: only register if this module
2127            // owns the PID in the resolved ownership table.
2128            for pid in scratch.registered_pids() {
2129                if pid_owners.get(&pid).copied() == Some(module.name())
2130                    && let Some(wf) = scratch.route(pid)
2131                {
2132                    pid_router.register(pid, wf);
2133                }
2134            }
2135            // Commodity (Sparte-qualified) entries use distinct (pid, Sparte) keys
2136            // and never conflict across modules; register them all unconditionally.
2137            for (pid, sparte, wf) in scratch.registered_commodity_entries() {
2138                pid_router.register_with_sparte(pid, sparte, wf);
2139            }
2140        }
2141        let registered_modules = self.modules.iter().map(|m| m.name()).collect();
2142        let registered_workflows = self
2143            .modules
2144            .iter()
2145            .flat_map(|m| m.workflow_names().iter().copied())
2146            .collect();
2147        EngineContext {
2148            event_store: Arc::new(self.event_store),
2149            snapshot_store: self.snapshot_store,
2150            outbox_store: self.outbox_store,
2151            deadline_store: self.deadline_store,
2152            registry: self.registry,
2153            dead_letter_sink: self.dead_letter_sink,
2154            pid_router,
2155            registered_modules,
2156            registered_workflows,
2157        }
2158    }
2159}
2160
2161#[cfg(test)]
2162mod tests {
2163    use super::*;
2164    use crate::{
2165        deadline::InMemoryDeadlineStore,
2166        error::WorkflowError,
2167        event_store::InMemoryEventStore,
2168        ids::TenantId,
2169        outbox::InMemoryOutboxStore,
2170        pid_router::PidRouter,
2171        registry::InMemoryProcessRegistry,
2172        snapshot::InMemorySnapshotStore,
2173        version::WorkflowId,
2174        workflow::{CommandPayload, EventPayload, Workflow},
2175    };
2176
2177    // ── Minimal workflow for spawn/resume tests ───────────────────────────────
2178
2179    #[derive(serde::Serialize, serde::Deserialize)]
2180    struct PingEvent;
2181
2182    impl EventPayload for PingEvent {
2183        fn event_type(&self) -> &'static str {
2184            "Ping"
2185        }
2186    }
2187
2188    struct PingCommand;
2189
2190    impl CommandPayload for PingCommand {}
2191
2192    #[derive(Default, Clone)]
2193    struct PingState;
2194
2195    struct PingWorkflow;
2196
2197    impl Workflow for PingWorkflow {
2198        type State = PingState;
2199        type Event = PingEvent;
2200        type Command = PingCommand;
2201
2202        fn apply(state: PingState, _: &PingEvent) -> PingState {
2203            state
2204        }
2205
2206        fn handle(
2207            _: &PingState,
2208            _: PingCommand,
2209        ) -> Result<crate::workflow::WorkflowOutput<PingEvent>, WorkflowError> {
2210            Ok(vec![PingEvent].into())
2211        }
2212    }
2213
2214    struct TestModule;
2215
2216    impl EngineModule for TestModule {
2217        fn name(&self) -> &'static str {
2218            "test-module"
2219        }
2220    }
2221
2222    // ── Tests ─────────────────────────────────────────────────────────────────
2223
2224    #[test]
2225    fn build_with_event_store_only() {
2226        let ctx = EngineBuilder::new()
2227            .with_event_store(InMemoryEventStore::new())
2228            .build();
2229        assert!(ctx.registered_modules().is_empty());
2230    }
2231
2232    #[test]
2233    fn build_with_all_stores_and_module() {
2234        let ctx = EngineBuilder::new()
2235            .with_event_store(InMemoryEventStore::new())
2236            .with_snapshot_store(InMemorySnapshotStore::new())
2237            .with_outbox_store(InMemoryOutboxStore::new())
2238            .with_deadline_store(InMemoryDeadlineStore::new())
2239            .with_registry(InMemoryProcessRegistry::new())
2240            .register(Box::new(TestModule))
2241            .build();
2242        assert_eq!(ctx.registered_modules(), &["test-module"]);
2243    }
2244
2245    #[test]
2246    fn multiple_modules_ordered() {
2247        struct ModA;
2248        impl EngineModule for ModA {
2249            fn name(&self) -> &'static str {
2250                "mod-a"
2251            }
2252        }
2253        struct ModB;
2254        impl EngineModule for ModB {
2255            fn name(&self) -> &'static str {
2256                "mod-b"
2257            }
2258        }
2259
2260        let ctx = EngineBuilder::new()
2261            .with_event_store(InMemoryEventStore::new())
2262            .register(Box::new(ModA))
2263            .register(Box::new(ModB))
2264            .build();
2265        assert_eq!(ctx.registered_modules(), &["mod-a", "mod-b"]);
2266    }
2267
2268    #[tokio::test]
2269    async fn spawn_creates_independent_processes() {
2270        let ctx = EngineBuilder::new()
2271            .with_event_store(InMemoryEventStore::new())
2272            .build();
2273        let wf_id = WorkflowId::new("ping", "FV2024-10-01");
2274
2275        let p1 = ctx.spawn::<PingWorkflow>(TenantId::new(), wf_id.clone());
2276        let p2 = ctx.spawn::<PingWorkflow>(TenantId::new(), wf_id);
2277
2278        assert_ne!(p1.process_id(), p2.process_id());
2279    }
2280
2281    #[tokio::test]
2282    async fn resume_sees_previously_appended_events() {
2283        let store = InMemoryEventStore::new();
2284        let ctx = EngineBuilder::new().with_event_store(store).build();
2285
2286        let p = ctx.spawn::<PingWorkflow>(TenantId::new(), WorkflowId::new("ping", "FV2024-10-01"));
2287        p.execute(PingCommand).await.unwrap();
2288
2289        let identity = p.identity();
2290        let resumed = ctx.resume::<PingWorkflow>(identity);
2291        assert_eq!(resumed.event_count().await.unwrap(), 1);
2292    }
2293
2294    #[tokio::test]
2295    async fn registry_routes_process_via_conversation_key() {
2296        use crate::registry::RegistryKey;
2297        let ctx = EngineBuilder::new()
2298            .with_event_store(InMemoryEventStore::new())
2299            .with_registry(InMemoryProcessRegistry::new())
2300            .build();
2301
2302        let p = ctx.spawn::<PingWorkflow>(TenantId::new(), WorkflowId::new("ping", "FV2024-10-01"));
2303        let tenant = p.tenant_id();
2304        let conv_key = RegistryKey::parse("conv:test-conversation-123").expect("valid key");
2305        ctx.registry()
2306            .register(tenant, &conv_key, p.identity())
2307            .await
2308            .unwrap();
2309
2310        let found = ctx
2311            .registry()
2312            .lookup(tenant, &conv_key)
2313            .await
2314            .unwrap()
2315            .expect("must be registered");
2316        let resumed = ctx.resume::<PingWorkflow>(found);
2317        assert_eq!(resumed.process_id(), p.process_id());
2318    }
2319
2320    #[test]
2321    fn pid_router_populated_by_module_register_pids() {
2322        struct PidModule;
2323        impl EngineModule for PidModule {
2324            fn name(&self) -> &'static str {
2325                "pid-module"
2326            }
2327            fn workflow_names(&self) -> &'static [&'static str] {
2328                &["gpke-supplier-change"]
2329            }
2330            fn register_pids(&self, router: &mut PidRouter) {
2331                router.register(55001, "gpke-supplier-change");
2332                router.register(55002, "gpke-supplier-change");
2333            }
2334        }
2335
2336        let ctx = EngineBuilder::new()
2337            .with_event_store(InMemoryEventStore::new())
2338            .register(Box::new(PidModule))
2339            .build();
2340
2341        assert_eq!(ctx.pid_router().route(55001), Some("gpke-supplier-change"));
2342        assert_eq!(ctx.pid_router().route(55002), Some("gpke-supplier-change"));
2343        assert!(ctx.pid_router().route(99999).is_none());
2344        assert_eq!(ctx.pid_router().len(), 2);
2345    }
2346
2347    /// A workflow a module routes but does not declare is a build failure.
2348    ///
2349    /// The two lists are written independently — `register_pids` binds a PID to
2350    /// a name, `workflow_names` declares it — and only the declared one reaches
2351    /// [`EngineContext::registered_workflows`], which is where consumers build
2352    /// their deadline-dispatch coverage from. An undeclared workflow therefore
2353    /// runs while being exempt from every check made over the declarations, so
2354    /// a Frist it registers can fire into a dispatch arm nobody required to
2355    /// exist. Four workflows had drifted this way before the check existed.
2356    #[test]
2357    #[should_panic(expected = "routes PIDs to workflows it does not declare")]
2358    fn a_routed_workflow_must_be_declared() {
2359        struct Undeclaring;
2360        impl EngineModule for Undeclaring {
2361            fn name(&self) -> &'static str {
2362                "undeclaring"
2363            }
2364            fn workflow_names(&self) -> &'static [&'static str] {
2365                &["declared-workflow"]
2366            }
2367            fn register_pids(&self, router: &mut PidRouter) {
2368                router.register(55_001, "declared-workflow");
2369                router.register(55_002, "routed-but-undeclared");
2370            }
2371        }
2372
2373        let _ = EngineBuilder::new()
2374            .with_event_store(InMemoryEventStore::new())
2375            .register(Box::new(Undeclaring))
2376            .build();
2377    }
2378
2379    /// Declaring a workflow that routes no PID is legitimate and must build.
2380    ///
2381    /// A command-initiated workflow — one an ERP starts over the command API —
2382    /// has no inbound Prüfidentifikator, so the containment only holds in one
2383    /// direction. Checking the reverse would refuse every such workflow.
2384    #[test]
2385    fn a_declared_workflow_need_not_route_a_pid() {
2386        struct CommandInitiated;
2387        impl EngineModule for CommandInitiated {
2388            fn name(&self) -> &'static str {
2389                "command-initiated"
2390            }
2391            fn workflow_names(&self) -> &'static [&'static str] {
2392                &["routed", "erp-initiated-only"]
2393            }
2394            fn register_pids(&self, router: &mut PidRouter) {
2395                router.register(55_001, "routed");
2396            }
2397        }
2398
2399        let ctx = EngineBuilder::new()
2400            .with_event_store(InMemoryEventStore::new())
2401            .register(Box::new(CommandInitiated))
2402            .build();
2403
2404        assert_eq!(ctx.registered_workflows().len(), 2);
2405        assert_eq!(ctx.pid_router().workflow_names().len(), 1);
2406    }
2407
2408    /// Verify that `register_pids_with_roles` gates PIDs behind role checks.
2409    ///
2410    /// Scenario: two modules share PID 19001.
2411    /// - ModuleA registers 19001 → "workflow-a" when role `Nb` is present.
2412    /// - ModuleB registers 19001 → "workflow-b" when role `Nmsb` is explicitly set
2413    ///   (not on `all()`).
2414    ///
2415    /// - `all()`: ModuleA fires (Nb ∈ all), ModuleB does NOT (is_all → skip).
2416    ///   → 19001 routes to "workflow-a".
2417    /// - `from_roles([Nb])`: ModuleA fires, ModuleB skips.
2418    ///   → 19001 routes to "workflow-a".
2419    /// - `from_roles([Nmsb])`: ModuleA skips, ModuleB fires.
2420    ///   → 19001 routes to "workflow-b".
2421    #[test]
2422    fn register_pids_with_roles_gates_pids_correctly() {
2423        use crate::marktrolle::{DeploymentRoles, Marktrolle};
2424
2425        struct ModuleA;
2426        impl EngineModule for ModuleA {
2427            fn name(&self) -> &'static str {
2428                "module-a"
2429            }
2430            fn workflow_names(&self) -> &'static [&'static str] {
2431                &["workflow-a"]
2432            }
2433            fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2434                if roles.contains(Marktrolle::Nb) {
2435                    router.register(19_001, "workflow-a");
2436                }
2437            }
2438        }
2439
2440        struct ModuleB;
2441        impl EngineModule for ModuleB {
2442            fn name(&self) -> &'static str {
2443                "module-b"
2444            }
2445            fn workflow_names(&self) -> &'static [&'static str] {
2446                &["workflow-b"]
2447            }
2448            fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2449                // Only fires on explicit Nmsb, not on all() (backward-compat sentinel).
2450                if !roles.is_all() && roles.contains(Marktrolle::Nmsb) {
2451                    router.register(19_001, "workflow-b");
2452                    router.register(19_015, "workflow-b");
2453                }
2454            }
2455        }
2456
2457        let build = |roles: DeploymentRoles| {
2458            EngineBuilder::new()
2459                .with_event_store(InMemoryEventStore::new())
2460                .with_deployment_roles(roles)
2461                .register(Box::new(ModuleA))
2462                .register(Box::new(ModuleB))
2463                .build()
2464        };
2465
2466        // all() → backward compat: ModuleA registers 19001 (Nb ∈ all), ModuleB skips.
2467        let ctx = build(DeploymentRoles::all());
2468        assert_eq!(ctx.pid_router().route(19_001), Some("workflow-a"));
2469        assert!(ctx.pid_router().route(19_015).is_none());
2470
2471        // Explicit Nb → same result: ModuleA registers, ModuleB (nMSB) skips.
2472        let ctx = build(DeploymentRoles::nb());
2473        assert_eq!(ctx.pid_router().route(19_001), Some("workflow-a"));
2474        assert!(ctx.pid_router().route(19_015).is_none());
2475
2476        // Explicit Nmsb → ModuleA skips (Nb ∉ roles), ModuleB registers.
2477        let ctx = build(DeploymentRoles::nmsb());
2478        assert_eq!(ctx.pid_router().route(19_001), Some("workflow-b"));
2479        assert_eq!(ctx.pid_router().route(19_015), Some("workflow-b"));
2480    }
2481
2482    /// Verify that explicit roles with two conflicting modules use first-wins semantics
2483    /// (the first module to register a PID retains ownership; the second is silently skipped).
2484    #[test]
2485    fn register_pids_with_roles_conflict_uses_first_wins_with_explicit_roles() {
2486        use crate::marktrolle::{DeploymentRoles, Marktrolle};
2487
2488        struct ConflictA;
2489        impl EngineModule for ConflictA {
2490            fn name(&self) -> &'static str {
2491                "conflict-a"
2492            }
2493            fn workflow_names(&self) -> &'static [&'static str] {
2494                &["workflow-a"]
2495            }
2496            fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2497                if roles.contains(Marktrolle::Nb) {
2498                    router.register(19_001, "workflow-a");
2499                }
2500            }
2501        }
2502
2503        struct ConflictB;
2504        impl EngineModule for ConflictB {
2505            fn name(&self) -> &'static str {
2506                "conflict-b"
2507            }
2508            fn workflow_names(&self) -> &'static [&'static str] {
2509                &["workflow-b"]
2510            }
2511            fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2512                if !roles.is_all() && roles.contains(Marktrolle::Nmsb) {
2513                    router.register(19_001, "workflow-b"); // same PID, different workflow
2514                }
2515            }
2516        }
2517
2518        // from_roles([Nb, Nmsb]): both modules fire for PID 19_001.
2519        // First-wins: ConflictA (registered first) retains ownership → "workflow-a".
2520        let ctx = EngineBuilder::new()
2521            .with_event_store(InMemoryEventStore::new())
2522            .with_deployment_roles(DeploymentRoles::from_roles([
2523                Marktrolle::Nb,
2524                Marktrolle::Nmsb,
2525            ]))
2526            .register(Box::new(ConflictA))
2527            .register(Box::new(ConflictB))
2528            .build();
2529        assert_eq!(
2530            ctx.pid_router().route(19_001),
2531            Some("workflow-a"),
2532            "first module should win on PID conflict with explicit roles"
2533        );
2534    }
2535
2536    // ── Graceful shutdown ─────────────────────────────────────────────────────
2537
2538    /// Cancelling the token must make `run` return.
2539    ///
2540    /// A worker that does not read the token loops until the process exits,
2541    /// and dropping its `JoinHandle` does not abort a Tokio task — so the event
2542    /// store would close underneath a worker still running. An outbox
2543    /// `acknowledge` losing that race leaves the counterparty holding a message
2544    /// the outbox still shows as pending, and the next start delivers it
2545    /// again.
2546    #[tokio::test]
2547    async fn a_cancelled_outbox_worker_returns() {
2548        let worker = OutboxWorker {
2549            store: InMemoryOutboxStore::new(),
2550            sender: AlwaysDelivers,
2551            deadline_store: InMemoryDeadlineStore::new(),
2552            batch_size: 10,
2553            // Far longer than the timeout below: the point is that cancellation
2554            // interrupts the idle sleep rather than being noticed after it.
2555            poll_interval: std::time::Duration::from_secs(300),
2556            max_attempts: 48,
2557            max_retry_window: std::time::Duration::from_secs(72 * 3600),
2558            dead_letter_sink: std::sync::Arc::new(crate::dead_letter::LogDeadLetterSink),
2559            heartbeat: None,
2560            shutdown: None,
2561        };
2562        let token = tokio_util::sync::CancellationToken::new();
2563        let worker = worker.with_shutdown(token.clone());
2564
2565        let handle = tokio::spawn(worker.run());
2566        // Let it reach the sleep, then signal.
2567        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2568        token.cancel();
2569
2570        tokio::time::timeout(std::time::Duration::from_secs(5), handle)
2571            .await
2572            .expect("outbox worker must return promptly after cancellation")
2573            .expect("outbox worker must not panic");
2574    }
2575
2576    #[tokio::test]
2577    async fn a_cancelled_deadline_scheduler_returns() {
2578        let scheduler = DeadlineScheduler {
2579            store: InMemoryDeadlineStore::new(),
2580            dispatch: Box::new(|_| Box::pin(async { Ok(()) })),
2581            batch_size: 100,
2582            poll_interval: std::time::Duration::from_secs(300),
2583            heartbeat: None,
2584            shutdown: None,
2585        };
2586        let token = tokio_util::sync::CancellationToken::new();
2587        let scheduler = scheduler.with_shutdown(token.clone());
2588
2589        let handle = tokio::spawn(scheduler.run());
2590        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2591        token.cancel();
2592
2593        tokio::time::timeout(std::time::Duration::from_secs(5), handle)
2594            .await
2595            .expect("deadline scheduler must return promptly after cancellation")
2596            .expect("deadline scheduler must not panic");
2597    }
2598
2599    /// A token cancelled before the first poll must stop the worker without it
2600    /// touching the store at all — the case where shutdown arrives during boot.
2601    #[tokio::test]
2602    async fn a_worker_cancelled_before_it_starts_does_no_work() {
2603        let outbox = InMemoryOutboxStore::new();
2604        let stream_id = crate::ids::StreamId::new("gpke/shutdown-test");
2605        let msg = outbox_message(&stream_id, "UTILMD");
2606        outbox.enqueue(std::slice::from_ref(&msg)).await.unwrap();
2607
2608        let token = tokio_util::sync::CancellationToken::new();
2609        token.cancel();
2610
2611        let worker = OutboxWorker {
2612            store: outbox.clone(),
2613            sender: AlwaysDelivers,
2614            deadline_store: InMemoryDeadlineStore::new(),
2615            batch_size: 10,
2616            poll_interval: std::time::Duration::from_millis(5),
2617            max_attempts: 48,
2618            max_retry_window: std::time::Duration::from_secs(72 * 3600),
2619            dead_letter_sink: std::sync::Arc::new(crate::dead_letter::LogDeadLetterSink),
2620            heartbeat: None,
2621            shutdown: None,
2622        }
2623        .with_shutdown(token);
2624
2625        tokio::time::timeout(std::time::Duration::from_secs(5), worker.run())
2626            .await
2627            .expect("an already-cancelled worker must return immediately");
2628
2629        assert_eq!(
2630            outbox.pending_now(10).await.unwrap().len(),
2631            1,
2632            "the message must stay queued for the next start, not be delivered \
2633             by a worker that was told to stop",
2634        );
2635    }
2636
2637    // ── APERAK delivery-window discharge ──────────────────────────────────────
2638
2639    /// A sender that always succeeds, so the worker takes the delivery path.
2640    struct AlwaysDelivers;
2641    impl As4Sender for AlwaysDelivers {
2642        async fn send(&self, _msg: &crate::outbox::OutboxMessage) -> Result<(), EngineError> {
2643            Ok(())
2644        }
2645    }
2646
2647    fn outbox_message(
2648        stream_id: &crate::ids::StreamId,
2649        message_type: &str,
2650    ) -> crate::outbox::OutboxMessage {
2651        crate::outbox::OutboxMessage::new(
2652            stream_id.clone(),
2653            crate::ids::ProcessId::new(),
2654            TenantId::new(),
2655            crate::ids::CorrelationId::new(),
2656            crate::ids::ConversationId::new(),
2657            crate::ids::EventId::new(),
2658            message_type,
2659            "9900357000004",
2660            serde_json::json!({}),
2661        )
2662    }
2663
2664    fn deadline_on(
2665        stream_id: &crate::ids::StreamId,
2666        msg: &crate::outbox::OutboxMessage,
2667        label: &str,
2668    ) -> Deadline {
2669        Deadline::new(
2670            stream_id.clone(),
2671            msg.process_id,
2672            msg.tenant_id,
2673            WorkflowId::new("gpke-supplier-change", "FV2025-10-01"),
2674            label,
2675            time::OffsetDateTime::now_utc() + time::Duration::hours(6),
2676        )
2677    }
2678
2679    /// Deliver `msg` through the worker's real loop and return the labels that
2680    /// survive on its stream.
2681    ///
2682    /// Drives `run` rather than calling the discharge directly — the wiring is
2683    /// the thing under test, and calling the method straight passes even when
2684    /// `run` never invokes it.
2685    async fn labels_surviving_delivery(
2686        msg: &crate::outbox::OutboxMessage,
2687        stream_id: &crate::ids::StreamId,
2688        registered: &[&str],
2689    ) -> Vec<String> {
2690        let deadlines = InMemoryDeadlineStore::new();
2691        for label in registered {
2692            deadlines
2693                .register(&deadline_on(stream_id, msg, label))
2694                .await
2695                .unwrap();
2696        }
2697        let outbox = InMemoryOutboxStore::new();
2698        outbox.enqueue(std::slice::from_ref(msg)).await.unwrap();
2699
2700        let worker = OutboxWorker {
2701            store: outbox.clone(),
2702            sender: AlwaysDelivers,
2703            deadline_store: deadlines.clone(),
2704            batch_size: 10,
2705            poll_interval: std::time::Duration::from_millis(5),
2706            max_attempts: 48,
2707            max_retry_window: std::time::Duration::from_secs(72 * 3600),
2708            dead_letter_sink: std::sync::Arc::new(crate::dead_letter::LogDeadLetterSink),
2709            heartbeat: None,
2710            shutdown: None,
2711        };
2712        // `run` never returns; give it enough cycles to drain the one message.
2713        let _ = tokio::time::timeout(std::time::Duration::from_millis(300), worker.run()).await;
2714        assert!(
2715            outbox.pending_now(10).await.unwrap().is_empty(),
2716            "the message must have been delivered and acknowledged",
2717        );
2718
2719        let mut left: Vec<String> = deadlines
2720            .for_stream(stream_id)
2721            .await
2722            .unwrap()
2723            .iter()
2724            .map(|d| d.label().to_owned())
2725            .collect();
2726        left.sort();
2727        left
2728    }
2729
2730    /// Delivering a message must retire the window that was watching for it.
2731    ///
2732    /// These windows are registered when the message is enqueued and nothing
2733    /// else ever cancels them, so without the discharge they fire for **every**
2734    /// process — including every one that answered on time. The scheduler cannot
2735    /// tell those apart (a deadline reaching `due_now` is late by construction),
2736    /// so the miss counters would track processes started, not obligations
2737    /// missed.
2738    #[tokio::test]
2739    async fn delivering_a_message_discharges_its_delivery_window() {
2740        // (message type, the window it answers for)
2741        for (message_type, window) in [
2742            ("APERAK", mako_fristen::APERAK_STROM_WINDOW_LABEL),
2743            ("APERAK", mako_fristen::APERAK_GAS_FOLGEPROZESS_LABEL),
2744            ("APERAK", mako_fristen::APERAK_GAS_INITIALPROZESS_LABEL),
2745            ("CONTRL", mako_fristen::CONTRL_FRIST_LABEL),
2746        ] {
2747            let stream_id = crate::ids::StreamId::new("gpke-supplier-change-1");
2748            let msg = outbox_message(&stream_id, message_type);
2749            // A process-response deadline shares the stream and must survive:
2750            // it is waiting on the counterparty, not on our delivery.
2751            let left =
2752                labels_surviving_delivery(&msg, &stream_id, &[window, "gpke-response-window"])
2753                    .await;
2754            assert_eq!(
2755                left,
2756                vec!["gpke-response-window"],
2757                "delivering {message_type} must discharge `{window}` and leave \
2758                 every other deadline alone",
2759            );
2760        }
2761    }
2762
2763    /// A delivery must not discharge a *different* message's window.
2764    ///
2765    /// The CONTRL and APERAK obligations run concurrently on the same
2766    /// interchange. Acknowledging syntax (CONTRL) says nothing about whether the
2767    /// application-level APERAK went out, so discharging both on one delivery
2768    /// would silence a real violation.
2769    #[tokio::test]
2770    async fn a_delivery_does_not_discharge_another_messages_window() {
2771        let stream_id = crate::ids::StreamId::new("gpke-supplier-change-1");
2772        let contrl = outbox_message(&stream_id, "CONTRL");
2773
2774        let left = labels_surviving_delivery(
2775            &contrl,
2776            &stream_id,
2777            &[
2778                mako_fristen::CONTRL_FRIST_LABEL,
2779                mako_fristen::APERAK_STROM_WINDOW_LABEL,
2780            ],
2781        )
2782        .await;
2783
2784        assert_eq!(
2785            left,
2786            vec![mako_fristen::APERAK_STROM_WINDOW_LABEL.to_owned()],
2787            "a delivered CONTRL discharges only the CONTRL window; the APERAK \
2788             obligation is still outstanding",
2789        );
2790    }
2791
2792    /// Every delivery-window label must be discharged by the message it watches.
2793    ///
2794    /// This is the invariant the miss counters rest on. A window label that
2795    /// `discharges_delivery_window` does not recognise is never retired, so it
2796    /// fires on every process and is counted as a regulatory violation each
2797    /// time — which is precisely how `makod_aperak_missed_total` once came to
2798    /// count Strom processes rather than missed APERAKs.
2799    ///
2800    /// Adding a delivery window means adding a row here.
2801    #[test]
2802    fn every_delivery_window_label_is_discharged_by_its_message() {
2803        for (message_type, label) in [
2804            ("APERAK", mako_fristen::APERAK_STROM_WINDOW_LABEL),
2805            ("APERAK", mako_fristen::APERAK_GAS_FOLGEPROZESS_LABEL),
2806            ("APERAK", mako_fristen::APERAK_GAS_INITIALPROZESS_LABEL),
2807            ("CONTRL", mako_fristen::CONTRL_FRIST_LABEL),
2808        ] {
2809            assert!(
2810                mako_fristen::discharges_delivery_window(message_type, label),
2811                "delivering {message_type} must discharge `{label}`, or the window \
2812                 outlives its obligation and alerts on every process",
2813            );
2814        }
2815    }
2816
2817    // ── Retry-budget classification ───────────────────────────────────────────
2818
2819    /// Sink double that records every rejection's attempt count.
2820    #[derive(Default)]
2821    struct RecordingSink(std::sync::Mutex<Vec<u32>>);
2822    impl crate::dead_letter::DeadLetterSink for std::sync::Arc<RecordingSink> {
2823        fn reject(&self, reason: &crate::dead_letter::DeadLetterReason) {
2824            if let crate::dead_letter::DeadLetterReason::OutboxExhausted { attempts, .. } = reason {
2825                self.0
2826                    .lock()
2827                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2828                    .push(*attempts);
2829            }
2830        }
2831    }
2832
2833    struct NoRenderer;
2834    impl As4Sender for NoRenderer {
2835        async fn send(&self, msg: &crate::outbox::OutboxMessage) -> Result<(), EngineError> {
2836            Err(EngineError::RendererNotImplemented {
2837                message_type: msg.message_type.as_ref().into(),
2838                message_id: msg.message_id.to_string().into(),
2839            })
2840        }
2841    }
2842
2843    async fn drive_worker<S: As4Sender>(
2844        sender: S,
2845        msg: crate::outbox::OutboxMessage,
2846    ) -> (InMemoryOutboxStore, std::sync::Arc<RecordingSink>) {
2847        let outbox = InMemoryOutboxStore::new();
2848        outbox.enqueue(std::slice::from_ref(&msg)).await.unwrap();
2849        let sink = std::sync::Arc::new(RecordingSink::default());
2850        let worker = OutboxWorker {
2851            store: outbox.clone(),
2852            sender,
2853            deadline_store: InMemoryDeadlineStore::new(),
2854            batch_size: 10,
2855            poll_interval: std::time::Duration::from_millis(5),
2856            max_attempts: 48,
2857            max_retry_window: std::time::Duration::from_secs(72 * 3600),
2858            dead_letter_sink: std::sync::Arc::new(std::sync::Arc::clone(&sink)),
2859            heartbeat: None,
2860            shutdown: None,
2861        };
2862        let _ = tokio::time::timeout(std::time::Duration::from_millis(300), worker.run()).await;
2863        (outbox, sink)
2864    }
2865
2866    /// `RendererNotImplemented` is documented as permanent — the worker must
2867    /// dead-letter it on the *first* attempt, not burn the retry budget on a
2868    /// failure that cannot heal between attempts. Until the permanent arm
2869    /// matched it, this promise was broken.
2870    #[tokio::test(start_paused = true)]
2871    async fn a_missing_renderer_dead_letters_without_retrying() {
2872        let stream_id = crate::ids::StreamId::new("test-renderer-missing");
2873        let msg = outbox_message(&stream_id, "MSCONS");
2874        let (outbox, sink) = drive_worker(NoRenderer, msg).await;
2875
2876        assert!(
2877            outbox.pending_now(10).await.unwrap().is_empty(),
2878            "the message must be acknowledged, not left for another attempt",
2879        );
2880        let rejections = sink
2881            .0
2882            .lock()
2883            .unwrap_or_else(std::sync::PoisonError::into_inner)
2884            .clone();
2885        assert_eq!(
2886            rejections,
2887            vec![0],
2888            "exactly one dead-letter, on the first attempt (attempt_count 0)",
2889        );
2890    }
2891
2892    /// The retry budget is a *window*, not a count: a message whose age has
2893    /// exceeded it after at least one attempt is dead-lettered even though the
2894    /// attempt belt is nowhere near exhausted — full-jitter backoff makes a
2895    /// count no proxy for the 72 h duty.
2896    #[tokio::test(start_paused = true)]
2897    async fn an_aged_message_with_a_prior_attempt_is_dead_lettered() {
2898        let stream_id = crate::ids::StreamId::new("test-window-exhausted");
2899        let mut msg = outbox_message(&stream_id, "UTILMD");
2900        msg.created_at = time::OffsetDateTime::now_utc() - time::Duration::hours(73);
2901        msg.attempt_count = 1;
2902        let (outbox, sink) = drive_worker(AlwaysDelivers, msg).await;
2903
2904        assert!(
2905            outbox.pending_now(10).await.unwrap().is_empty(),
2906            "the exhausted message must leave the outbox",
2907        );
2908        let rejections = sink
2909            .0
2910            .lock()
2911            .unwrap_or_else(std::sync::PoisonError::into_inner)
2912            .clone();
2913        assert_eq!(
2914            rejections,
2915            vec![1],
2916            "the window, not the attempt belt, must have dead-lettered it",
2917        );
2918    }
2919
2920    /// A message that aged past the window while the worker was down still
2921    /// gets its first try — the window is only consulted after an attempt, so
2922    /// downtime never buries a message unsent.
2923    #[tokio::test(start_paused = true)]
2924    async fn an_aged_message_with_no_attempts_is_still_tried_once() {
2925        let stream_id = crate::ids::StreamId::new("test-aged-first-try");
2926        let mut msg = outbox_message(&stream_id, "UTILMD");
2927        msg.created_at = time::OffsetDateTime::now_utc() - time::Duration::hours(200);
2928        let (outbox, sink) = drive_worker(AlwaysDelivers, msg).await;
2929
2930        assert!(
2931            outbox.pending_now(10).await.unwrap().is_empty(),
2932            "the message must have been delivered and acknowledged",
2933        );
2934        assert!(
2935            sink.0
2936                .lock()
2937                .unwrap_or_else(std::sync::PoisonError::into_inner)
2938                .is_empty(),
2939            "delivery, not dead-lettering: age alone must never bury a message",
2940        );
2941    }
2942}