Skip to main content

mako_engine/
builder.rs

1//! [`EngineModule`] trait, [`EngineBuilder`], and [`EngineContext`].
2//!
3//! # Summary
4//!
5//! `EngineBuilder` assembles all engine infrastructure into a single
6//! [`EngineContext`] value. Domain modules (GPKE, WiM, GeLi Gas, …) register
7//! themselves at startup via the [`EngineModule`] trait, making their names
8//! visible in diagnostics and health checks.
9//!
10//! # Type-state guarantee
11//!
12//! [`EngineBuilder::build`] is only available when the event store type
13//! parameter `ES` implements [`EventStore`]. Forgetting to call
14//! [`with_event_store`] is a **compile-time error**, not a runtime panic.
15//!
16//! All other stores default to their respective `Noop` implementations:
17//!
18//! | Store | Default |
19//! |-------|---------|
20//! | Snapshot store | [`NoopSnapshotStore`] |
21//! | Outbox store | [`NoopOutboxStore`] |
22//! | Deadline store | [`NoopDeadlineStore`] |
23//! | Process registry | [`NoopProcessRegistry`] |
24//!
25//! # Assembly example
26//!
27//! ```rust,ignore
28//! use mako_engine::builder::{EngineBuilder, EngineModule};
29//! use mako_engine::event_store::InMemoryEventStore;
30//! use mako_engine::outbox::InMemoryOutboxStore;
31//! use mako_engine::deadline::InMemoryDeadlineStore;
32//! use mako_engine::registry::InMemoryProcessRegistry;
33//! use mako_engine::snapshot::InMemorySnapshotStore;
34//!
35//! struct GpkeModule;
36//! impl EngineModule for GpkeModule { fn name(&self) -> &'static str { "gpke" } }
37//!
38//! let ctx = EngineBuilder::new()
39//!     .with_event_store(InMemoryEventStore::new())
40//!     .with_snapshot_store(InMemorySnapshotStore::new())
41//!     .with_outbox_store(InMemoryOutboxStore::new())
42//!     .with_deadline_store(InMemoryDeadlineStore::new())
43//!     .with_registry(InMemoryProcessRegistry::new())
44//!     .register(Box::new(GpkeModule))
45//!     .build();
46//!
47//! // Spawn a fresh process:
48//! let p = ctx.spawn::<SupplierChangeWorkflow>(tenant_id, workflow_id);
49//! p.execute(ReceiveUtilmd { .. }).await?;
50//!
51//! // Resume an existing process from a persisted identity:
52//! let identity = ctx.registry.lookup(&conv_id.to_string()).await?.unwrap();
53//! let p = ctx.resume::<SupplierChangeWorkflow>(identity);
54//!
55//! // Access stores for delivery workers / schedulers:
56//! let pending = ctx.outbox_store.pending_now(50).await?;
57//! let overdue = ctx.deadline_store.due_now(50).await?;
58//! ```
59//!
60//! [`with_event_store`]: EngineBuilder::with_event_store
61
62// Type-state generics can produce long signatures that trip up the
63// `type_complexity` lint; suppress it for this module only.
64#![allow(clippy::type_complexity)]
65
66// The Noop* types are marked #[deprecated] to guard against accidental
67// production use.  The builder is the only place they're instantiated as
68// defaults; suppress the lint here explicitly.
69#[allow(deprecated)]
70use crate::{
71    dead_letter::{DeadLetterSink, LogDeadLetterSink},
72    deadline::{Deadline, DeadlineStore, NoopDeadlineStore},
73    error::EngineError,
74    event_store::EventStore,
75    ids::{ProcessIdentity, TenantId},
76    marktrolle::DeploymentRoles,
77    outbox::{NoopOutboxStore, OutboxMessage, OutboxStore},
78    pid_router::PidRouter,
79    process::Process,
80    registry::{NoopProcessRegistry, ProcessRegistry},
81    snapshot::{NoopSnapshotStore, SnapshotStore},
82    version::WorkflowId,
83    workflow::Workflow,
84};
85
86use std::sync::Arc;
87
88// ── EngineModule ──────────────────────────────────────────────────────────────
89
90/// A self-contained domain module that registers with the engine at startup.
91///
92/// Domain crates implement this trait to declare their presence in the engine.
93/// The module name is surfaced in [`EngineContext::registered_modules`] for
94/// diagnostics, health checks, and log output.
95///
96/// ## Startup validation
97///
98/// Override [`configure`] to perform adapter coverage checks at engine startup
99/// time. The engine calls [`configure`] for every registered module during
100/// [`EngineBuilder::build`] and panics with an actionable message if any
101/// module returns `Err`. This surfaces missing adapter registrations as a
102/// startup failure rather than a silent runtime error.
103///
104/// ## Example
105///
106/// ```rust,ignore
107/// pub struct GpkeModule;
108///
109/// impl EngineModule for GpkeModule {
110///     fn name(&self) -> &'static str { "gpke" }
111///
112///     fn configure(&self) -> Result<(), String> {
113///         // Validate that every known BDEW format version has an adapter:
114///         GPKE_ADAPTER_REGISTRY
115///             .validate_policy(&GpkeWorkflow::version_policy(), &KNOWN_FVS)
116///             .map_err(|uncovered| format!(
117///                 "gpke: missing adapters for format versions: {:?}",
118///                 uncovered
119///             ))
120///     }
121/// }
122///
123/// let ctx = EngineBuilder::new()
124///     .with_event_store(my_store)
125///     .register(Box::new(GpkeModule))
126///     .build(); // panics if GpkeModule::configure returns Err
127///
128/// assert_eq!(ctx.registered_modules(), &["gpke"]);
129/// ```
130///
131/// [`configure`]: EngineModule::configure
132pub trait EngineModule: Send + 'static {
133    /// Stable, unique name for this domain module.
134    ///
135    /// Used in diagnostics, health checks, and structured log output.
136    /// Choose a short lowercase identifier (e.g. `"gpke"`, `"wim"`,
137    /// `"geli"`).
138    fn name(&self) -> &'static str;
139
140    /// Register all PIDs this module handles into the shared [`PidRouter`].
141    ///
142    /// # Mutability contract
143    ///
144    /// This method is called **exactly once** by [`EngineBuilder::build`],
145    /// before the resulting [`EngineContext`] is handed to the caller. The
146    /// `&mut PidRouter` reference is only available here, at build time.
147    /// After `build` returns the router is **sealed** — the engine provides
148    /// only a shared `&PidRouter` reference, with no mutation path at runtime.
149    ///
150    /// Consequence: **all PIDs a module will ever need must be registered
151    /// here**. Do not attempt to register PIDs lazily from async handlers or
152    /// after the engine has started — there is no API for that by design.
153    ///
154    /// Duplicate registrations (same PID from two modules) silently overwrite
155    /// the previous mapping; the last module to register wins. Use
156    /// `cargo xtask validate-pruefids` to catch accidental PID conflicts
157    /// between modules before they reach production.
158    ///
159    /// For role-conditional registration (PIDs that should only be active for
160    /// specific BDEW Marktrollen), override [`register_pids_with_roles`] instead.
161    ///
162    /// # Example
163    ///
164    /// ```rust,ignore
165    /// fn register_pids(&self, router: &mut PidRouter) {
166    ///     // GPKE Lieferantenwechsel / Lieferbeginn (BK6-22-024, PIDs 55001, 55002, 55017)
167    ///     for &pid in &[55001_u32, 55002, 55017] {
168    ///         router.register(pid, "gpke-supplier-change");
169    ///     }
170    /// }
171    /// ```
172    ///
173    /// [`register_pids_with_roles`]: EngineModule::register_pids_with_roles
174    fn register_pids(&self, _router: &mut PidRouter) {}
175
176    /// Register PIDs with role-context awareness.
177    ///
178    /// This is the **preferred override** for modules that have role-conditional
179    /// PID registrations — PIDs that should only be active when this `makod`
180    /// instance holds a specific [`Marktrolle`].
181    ///
182    /// The default implementation calls [`register_pids`] (role-agnostic) so
183    /// existing modules that override `register_pids` continue to work without
184    /// changes.
185    ///
186    /// Override this method instead of `register_pids` when any PID registration
187    /// should be conditional on the deployment role:
188    ///
189    /// ```rust,ignore
190    /// use mako_engine::marktrolle::Marktrolle;
191    ///
192    /// fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
193    ///     // Always register: 55001, 55002 (not role-specific)
194    ///     for pid in [55001_u32, 55002] { router.register_with_module(pid, "gpke-supplier-change", self.name()); }
195    ///
196    ///     // Only when NB role: 19001/19002 inbound ORDRSP from MSB
197    ///     if roles.contains(Marktrolle::Nb) {
198    ///         for pid in [19001_u32, 19002] { router.register_with_module(pid, "gpke-konfiguration", self.name()); }
199    ///     }
200    /// }
201    /// ```
202    ///
203    /// # Conflict guard
204    ///
205    /// Use [`PidRouter::register_with_module`] (not `register`) inside this
206    /// method. The conflict guard panics at build time if two modules register
207    /// the same PID to different workflows — this makes role misconfigurations
208    /// visible at startup rather than silently misrouting messages.
209    ///
210    /// [`Marktrolle`]: crate::marktrolle::Marktrolle
211    /// [`register_pids`]: EngineModule::register_pids
212    fn register_pids_with_roles(&self, router: &mut PidRouter, _roles: &DeploymentRoles) {
213        self.register_pids(router);
214    }
215
216    /// Workflow names this module handles for deadline dispatch.
217    ///
218    /// Return the same name strings that [`register_pids`] maps PIDs to.
219    /// These names are stored in [`EngineContext::registered_workflows`] and
220    /// used to validate that every workflow that has deadlines scheduled is
221    /// covered by the deadline scheduler dispatch function at runtime.
222    ///
223    /// The default implementation returns an empty slice. Override it to
224    /// declare all workflow names that may fire deadlines:
225    ///
226    /// ```rust,ignore
227    /// fn workflow_names(&self) -> &'static [&'static str] {
228    ///     &["gpke-supplier-change", "gpke-abrechnung"]
229    /// }
230    /// ```
231    ///
232    /// [`register_pids`]: EngineModule::register_pids
233    /// [`EngineContext::registered_workflows`]: crate::builder::EngineContext::registered_workflows
234    fn workflow_names(&self) -> &'static [&'static str] {
235        &[]
236    }
237
238    /// Declare the EDIFACT profile types this module requires at runtime.
239    ///
240    /// Returning a non-empty slice causes [`EngineBuilder::build`] to call the
241    /// registered profile validator for each requirement.  If no active profile
242    /// exists for a required message type, `build` panics with an actionable
243    /// error so deployment fails fast rather than silently.
244    ///
245    /// **This replaces the previous pattern** of calling
246    /// `edi_energy::registry::ReleaseRegistry::global()` inside `configure()`.
247    /// Domain crates no longer need `edi-energy` in their production
248    /// `[dependencies]` — they just declare their requirements here.
249    ///
250    /// ```rust,ignore
251    /// fn profile_requirements(&self) -> &'static [ProfileRequirement] {
252    ///     &[
253    ///         ProfileRequirement { message_type: "UTILMD", label: "UTILMD Strom (GPKE)" },
254    ///         ProfileRequirement { message_type: "INVOIC", label: "INVOIC Abrechnung (GPKE)" },
255    ///     ]
256    /// }
257    /// ```
258    ///
259    /// [`ProfileRequirement`]: crate::profile::ProfileRequirement
260    fn profile_requirements(&self) -> &'static [crate::profile::ProfileRequirement] {
261        &[]
262    }
263
264    /// Validate adapter coverage and configuration at engine startup.
265    ///
266    /// Called by [`EngineBuilder::build`] after all modules are registered.
267    /// Return `Ok(())` when the module is fully configured. Return `Err(msg)`
268    /// with an actionable description when an adapter or configuration is
269    /// missing — the engine will panic with that message so the deployment
270    /// fails early rather than silently.
271    ///
272    /// The default implementation is a no-op (always returns `Ok(())`).
273    /// Override it in domain crates to call
274    /// [`AdapterRegistry::validate_policy`] and emit structured errors.
275    ///
276    /// Note: if your validation needs access to the edi-energy profile
277    /// registry, use [`profile_requirements`] instead — it does not require
278    /// importing `edi-energy` in domain crates.
279    ///
280    /// [`AdapterRegistry::validate_policy`]: crate::message_adapter::AdapterRegistry::validate_policy
281    /// [`profile_requirements`]: EngineModule::profile_requirements
282    ///
283    /// # Errors
284    ///
285    /// Returns a descriptive error string when the module's configuration is invalid.
286    fn configure(&self) -> Result<(), String> {
287        Ok(())
288    }
289}
290
291// ── EngineContext ─────────────────────────────────────────────────────────────
292
293/// Assembled engine infrastructure returned by [`EngineBuilder::build`].
294///
295/// `EngineContext` bundles all stores and the process registry into a single
296/// value. It is the root dependency for:
297///
298/// - Spawning new processes ([`spawn`])
299/// - Resuming existing processes ([`resume`])
300/// - Running outbox delivery workers (`outbox_store.pending_now(…)`)
301/// - Driving the deadline scheduler (`deadline_store.due_now(…)`)
302///
303/// ## Generic parameters
304///
305/// | Param | Role | Default |
306/// |-------|------|---------|
307/// | `ES`  | [`EventStore`] backend | — (required) |
308/// | `SS`  | [`SnapshotStore`] backend | [`NoopSnapshotStore`] |
309/// | `OS`  | [`OutboxStore`] backend  | [`NoopOutboxStore`]   |
310/// | `DS`  | [`DeadlineStore`] backend | [`NoopDeadlineStore`] |
311/// | `PR`  | [`ProcessRegistry`] backend | [`NoopProcessRegistry`] |
312///
313/// In most codebases all type parameters are inferred from the builder calls.
314///
315/// [`spawn`]: EngineContext::spawn
316/// [`resume`]: EngineContext::resume
317pub struct EngineContext<
318    ES,
319    SS = NoopSnapshotStore,
320    OS = NoopOutboxStore,
321    DS = NoopDeadlineStore,
322    PR = NoopProcessRegistry,
323> {
324    event_store: Arc<ES>,
325    snapshot_store: SS,
326    outbox_store: OS,
327    deadline_store: DS,
328    registry: PR,
329    /// Dead-letter sink for unroutable or unprocessable inbound messages.
330    ///
331    /// Stored as `Arc<dyn DeadLetterSink>` so callers can share it across
332    /// tasks without an extra type parameter on `EngineContext`.
333    pub dead_letter_sink: Arc<dyn DeadLetterSink>,
334    /// PID-to-workflow routing table, populated from all registered modules.
335    pid_router: PidRouter,
336    registered_modules: Vec<&'static str>,
337    /// Workflow names declared by all registered modules via
338    /// [`EngineModule::workflow_names`]. Used to validate deadline scheduler
339    /// coverage at runtime (see [`EngineContext::registered_workflows`]).
340    registered_workflows: Vec<&'static str>,
341}
342
343// ── Type aliases ──────────────────────────────────────────────────────────────
344
345/// An [`EngineContext`] with all optional subsystems disabled.
346///
347/// Uses `NoopSnapshotStore` and, in `testing`-enabled builds, Noop
348/// implementations for outbox, deadline, and process registry. Suitable for
349/// tests and minimal deployments where only a durable event store is required.
350///
351/// All five type parameters are inferred from context when used with
352/// [`EngineBuilder`]:
353///
354/// ```rust,ignore
355/// // Only available in test / testing-feature builds:
356/// use mako_engine::builder::{EngineBuilder, MinimalEngine};
357/// use mako_engine::event_store::InMemoryEventStore;
358///
359/// let ctx: MinimalEngine<InMemoryEventStore> = EngineBuilder::new()
360///     .with_event_store(InMemoryEventStore::new())
361///     .build();
362/// ```
363pub type MinimalEngine<ES> = EngineContext<ES>;
364
365impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
366where
367    ES: EventStore,
368{
369    /// Spawn a new process and return a typed `Process<W, Arc<ES>>` handle.
370    ///
371    /// No `ES: Clone` bound is required — the engine stores the event store
372    /// behind an `Arc` so spawning is always a cheap pointer clone.
373    ///
374    /// ```rust,ignore
375    /// let p = ctx.spawn::<SupplierChangeWorkflow>(tenant_id, workflow_id);
376    /// p.execute(ReceiveUtilmd { .. }).await?;
377    /// ```
378    #[must_use]
379    pub fn spawn<W: Workflow>(
380        &self,
381        tenant_id: TenantId,
382        workflow_id: WorkflowId,
383    ) -> Process<W, Arc<ES>> {
384        Process::new(Arc::clone(&self.event_store), tenant_id, workflow_id)
385    }
386
387    /// Resume an existing process from a [`ProcessIdentity`].
388    ///
389    /// ```rust,ignore
390    /// let identity = ctx.registry()
391    ///     .lookup(tenant_id, &conv_id.to_string())
392    ///     .await?
393    ///     .ok_or(EngineError::Registry("unknown conversation".into()))?;
394    /// let p = ctx.resume::<SupplierChangeWorkflow>(identity);
395    /// p.execute(HandleAperak { .. }).await?;
396    /// ```
397    #[must_use]
398    pub fn resume<W: Workflow>(&self, identity: ProcessIdentity) -> Process<W, Arc<ES>> {
399        Process::from_identity(Arc::clone(&self.event_store), identity)
400    }
401
402    /// Names of all domain modules registered with the builder, in
403    /// registration order.
404    #[must_use]
405    pub fn registered_modules(&self) -> &[&'static str] {
406        &self.registered_modules
407    }
408
409    /// Workflow names declared by all registered modules, in registration order.
410    ///
411    /// Use this in the deadline scheduler dispatch function to detect unknown
412    /// workflow names at startup. If a deadline fires for a workflow name that
413    /// is not in this list, the scheduler's dispatch function should emit an
414    /// error rather than silently dropping the deadline:
415    ///
416    /// ```rust,ignore
417    /// let known = ctx.registered_workflows().iter().copied().collect::<HashSet<_>>();
418    /// let scheduler = ctx.run_deadline_scheduler(
419    ///     move |deadline| {
420    ///         let wf = deadline.workflow_id().name.as_ref();
421    ///         if !known.contains(wf) {
422    ///             tracing::error!(workflow = %wf, "deadline fired for unregistered workflow");
423    ///             return Box::pin(async { Ok(()) });
424    ///         }
425    ///         // dispatch by workflow name …
426    ///         Box::pin(async { Ok(()) })
427    ///     },
428    ///     100,
429    ///     Duration::from_secs(30),
430    /// );
431    /// ```
432    #[must_use]
433    pub fn registered_workflows(&self) -> &[&'static str] {
434        &self.registered_workflows
435    }
436
437    /// The event store backend (behind an `Arc`).
438    #[must_use]
439    pub fn event_store(&self) -> &Arc<ES> {
440        &self.event_store
441    }
442
443    /// The snapshot store backend.
444    #[must_use]
445    pub fn snapshot_store(&self) -> &SS {
446        &self.snapshot_store
447    }
448
449    /// The outbox store backend.
450    ///
451    /// Poll `outbox_store().pending_now(limit)` in a background task to drain
452    /// the delivery queue.
453    #[must_use]
454    pub fn outbox_store(&self) -> &OS {
455        &self.outbox_store
456    }
457
458    /// The deadline store backend.
459    ///
460    /// Poll `deadline_store().due_now(limit)` in a background scheduler to
461    /// fire overdue process timers.
462    #[must_use]
463    pub fn deadline_store(&self) -> &DS {
464        &self.deadline_store
465    }
466
467    /// The process routing registry.
468    ///
469    /// Register a [`ProcessIdentity`] under a `(tenant_id, key)` pair at
470    /// process creation, then `lookup` it when routing inbound messages.
471    #[must_use]
472    pub fn registry(&self) -> &PR {
473        &self.registry
474    }
475
476    /// The dead-letter sink for unroutable or unprocessable messages.
477    ///
478    /// Call [`DeadLetterSink::reject`] when an inbound message cannot be
479    /// dispatched to any workflow. The default sink emits `tracing::warn!`
480    /// so rejections are always visible in the log output.
481    #[must_use]
482    pub fn dead_letter_sink(&self) -> &Arc<dyn DeadLetterSink> {
483        &self.dead_letter_sink
484    }
485
486    /// Assert that no Noop store is active — call this during production startup.
487    ///
488    /// Checks the type names of `OS`, `DS`, and `PR` against the string `"Noop"`.
489    /// Panics with a human-readable message if any match, directing the operator
490    /// to configure a persistent backend.
491    ///
492    /// # When to call
493    ///
494    /// Call this early in `makod`'s startup path (and `--check` mode) to catch
495    /// deployments where a Noop store was accidentally wired — e.g. the
496    /// `[outbox]`, `[deadline]`, or `[registry]` configuration section was
497    /// omitted from `makod.toml`.  The check is defence-in-depth: in release
498    /// builds without the `testing` feature, Noop stores cannot implement the
499    /// required traits at all and the compiler would have already rejected them.
500    ///
501    /// # Panics
502    ///
503    /// Panics when any of `OS`, `DS`, or `PR` is a Noop implementation.
504    pub fn assert_production_stores(&self) {
505        let checks: &[(&str, &str)] = &[
506            ("OutboxStore", std::any::type_name::<OS>()),
507            ("DeadlineStore", std::any::type_name::<DS>()),
508            ("ProcessRegistry", std::any::type_name::<PR>()),
509        ];
510        for (trait_name, type_name) in checks {
511            assert!(
512                !type_name.contains("Noop"),
513                "makod: Noop{trait_name} is active — \
514                 configure a persistent {trait_name} backend in makod.toml. \
515                 Type resolved to: {type_name}"
516            );
517        }
518    }
519
520    /// The PID-to-workflow routing table.
521    ///
522    /// Populated **once** during [`EngineBuilder::build`] by calling
523    /// [`EngineModule::register_pids`] on every registered module in
524    /// registration order. After `build` returns the table is **sealed** —
525    /// it is read-only for the lifetime of the `EngineContext` and may be
526    /// freely shared across async tasks without synchronisation.
527    ///
528    /// # Mutability contract
529    ///
530    /// There is intentionally no `pid_router_mut()` accessor. Adding PIDs
531    /// after the engine is built would create a TOCTOU race between the
532    /// dispatch path (which calls `route(pid)`) and any hypothetical
533    /// concurrent mutator. Instead, register all PIDs during the build phase
534    /// via `EngineModule::register_pids`.
535    ///
536    /// If a new process family needs to be added without restarting the
537    /// binary, rebuild and restart `makod` — hot-swap of PID routing is not
538    /// supported.
539    ///
540    /// # Example — dispatch at the AS4 reception boundary
541    ///
542    /// ```rust,ignore
543    /// let workflow_name = ctx.pid_router().route(pid)
544    ///     .ok_or_else(|| EngineError::Workflow(WorkflowError::InvalidCommand(
545    ///         format!("no workflow registered for PID {pid}").into()
546    ///     )))?;
547    ///
548    /// match workflow_name {
549    ///     "gpke-supplier-change" => dispatch::<GpkeSupplierChangeWorkflow>(&ctx, pid, payload).await,
550    ///     "wim-device-change"    => dispatch::<WimDeviceChangeWorkflow>(&ctx, pid, payload).await,
551    ///     other => Err(EngineError::Workflow(WorkflowError::InvalidCommand(
552    ///         format!("unhandled workflow name: {other}").into()
553    ///     ))),
554    /// }
555    /// ```
556    #[must_use]
557    pub fn pid_router(&self) -> &PidRouter {
558        &self.pid_router
559    }
560}
561
562// ── As4Sender ─────────────────────────────────────────────────────────────────
563
564/// Sends a single AS4 / EDIINT-over-HTTP outbound message.
565///
566/// Implement this trait for your AS4 gateway client and pass it to
567/// [`EngineContext::run_outbox_worker`].
568///
569/// # Contract
570///
571/// Return `Ok(())` only after the message has been **durably accepted** by the
572/// receiving MSH.  Return `Err(…)` on transient or permanent failure — the
573/// outbox worker calls [`OutboxStore::reschedule`] so the message is retried.
574pub trait As4Sender: Send + Sync + 'static {
575    /// Transmit `msg` and return when the remote MSH has accepted it.
576    fn send(
577        &self,
578        msg: &OutboxMessage,
579    ) -> impl std::future::Future<Output = Result<(), EngineError>> + Send;
580}
581
582// ── OutboxWorker ──────────────────────────────────────────────────────────────
583
584/// A background worker that drains the outbox by polling pending
585/// [`OutboxMessage`]s and dispatching them via an [`As4Sender`].
586///
587/// Obtain via [`EngineContext::run_outbox_worker`] and drive by spawning
588/// [`OutboxWorker::run`] in a Tokio task.
589///
590/// # Polling behaviour
591///
592/// When the poll returns an empty batch the worker sleeps for `poll_interval`
593/// before polling again.  Non-empty batches are processed immediately.
594///
595/// # Error handling
596///
597/// Successful sends are acknowledged via [`OutboxStore::acknowledge`].
598/// Failed sends are rescheduled via [`OutboxStore::reschedule`] using
599/// **full-jitter exponential backoff**: `delay = rand(0, min(MAX, BASE * 2^n))`
600/// where `n = attempt_count`. This avoids thundering-herd when multiple
601/// `makod` instances restart simultaneously after a receiver outage.
602///
603/// When `attempt_count >= max_attempts`, the message is **acknowledged** (removed
604/// from the outbox) and a [`DeadLetterReason::OutboxExhausted`] record is written
605/// to the dead-letter sink. This prevents permanently-undeliverable messages
606/// from clogging the outbox forever.
607///
608/// All errors are emitted as structured `tracing` events at `warn` / `error`
609/// level rather than `eprintln!`, so they appear in the application's log
610/// pipeline with full context (message_id, error).
611///
612/// # Example
613///
614/// ```rust,ignore
615/// use std::time::Duration;
616///
617/// let worker = ctx.run_outbox_worker(my_sender, 50, Duration::from_secs(1));
618/// tokio::spawn(async move { worker.run().await });
619/// ```
620///
621/// [`DeadLetterReason::OutboxExhausted`]: crate::dead_letter::DeadLetterReason::OutboxExhausted
622pub struct OutboxWorker<OS: OutboxStore, S: As4Sender> {
623    store: OS,
624    sender: S,
625    batch_size: usize,
626    poll_interval: std::time::Duration,
627    /// Maximum total delivery attempts before a message is dead-lettered.
628    ///
629    /// Default: 48 (covers ~4 hours at the 300 s backoff cap).
630    /// Set to `u32::MAX` to disable the cap (not recommended for production).
631    max_attempts: u32,
632    /// Sink for messages that exceed `max_attempts`.
633    dead_letter_sink: std::sync::Arc<dyn crate::dead_letter::DeadLetterSink>,
634    /// Optional liveness heartbeat — stores the current UTC Unix timestamp
635    /// (seconds) after each poll cycle so health probes can detect stale workers.
636    heartbeat: Option<std::sync::Arc<std::sync::atomic::AtomicI64>>,
637}
638
639/// Compute a full-jitter exponential backoff delay.
640///
641/// `attempt` is the number of prior attempts (0 = first retry).
642/// `entropy` provides randomness; derive from a stable message identifier
643/// (e.g. hash of `message_id`) rather than the current timestamp — a
644/// timestamp-derived value is deterministic within a single batch, which
645/// defeats jitter when multiple messages fail simultaneously.
646///
647/// | attempt | window (s) | expected delay (s) |
648/// |---------|------------|-------------------|
649/// | 0       | 5          | 2.5               |
650/// | 1       | 10         | 5                 |
651/// | 2       | 20         | 10                |
652/// | 3       | 40         | 20                |
653/// | 4       | 80         | 40                |
654/// | 5+      | 300 (cap)  | 150               |
655fn backoff_delay(attempt: u32, entropy: u64) -> std::time::Duration {
656    const BASE_SECS: u64 = 5;
657    const MAX_SECS: u64 = 300;
658    // Exponential window: BASE * 2^attempt, capped at MAX.
659    let window = BASE_SECS
660        .saturating_mul(1u64.wrapping_shl(attempt.min(5)))
661        .min(MAX_SECS);
662    // Full jitter: uniform random in [0, window).
663    let jitter_secs = if window == 0 { 0 } else { entropy % window };
664    std::time::Duration::from_secs(jitter_secs)
665}
666
667impl<OS: OutboxStore, S: As4Sender> OutboxWorker<OS, S> {
668    /// Run the outbox drain loop until the task is cancelled.
669    ///
670    /// # Panics
671    ///
672    /// Panics if `time::Duration::try_from(delay)` overflows (unreachable for
673    /// the delay values produced by `backoff_delay`).
674    #[allow(clippy::too_many_lines)]
675    pub async fn run(self) {
676        loop {
677            let batch = match self.store.pending_now(self.batch_size).await {
678                Ok(b) => b,
679                Err(e) => {
680                    tracing::warn!(error = %e, "outbox worker: store error polling pending messages (will retry)");
681                    tokio::time::sleep(self.poll_interval).await;
682                    continue;
683                }
684            };
685
686            if batch.is_empty() {
687                tokio::time::sleep(self.poll_interval).await;
688                continue;
689            }
690
691            for msg in batch {
692                // ── Max-attempt cap ───────────────────────────────────
693                // `attempt_count` starts at 0 and is incremented on each
694                // `reschedule` call.  When it reaches `max_attempts` the
695                // message is considered permanently undeliverable: acknowledge
696                // it (remove from outbox) and dead-letter it so the regulatory
697                // audit trail is preserved.
698                if msg.attempt_count >= self.max_attempts {
699                    tracing::error!(
700                        message_id   = %msg.message_id,
701                        message_type = %msg.message_type,
702                        recipient    = %msg.recipient,
703                        attempts     = msg.attempt_count,
704                        max_attempts = self.max_attempts,
705                        "outbox worker: max delivery attempts reached; dead-lettering message",
706                    );
707                    self.dead_letter_sink.reject(
708                        &crate::dead_letter::DeadLetterReason::OutboxExhausted {
709                            message_id: msg.message_id,
710                            message_type: msg.message_type.to_string(),
711                            recipient: msg.recipient.to_string(),
712                            last_error: format!(
713                                "delivery exhausted after {} attempts",
714                                msg.attempt_count
715                            ),
716                            attempts: msg.attempt_count,
717                        },
718                    );
719                    if let Err(e) = self.store.acknowledge(msg.message_id).await {
720                        tracing::error!(
721                            message_id = %msg.message_id,
722                            error = %e,
723                            "outbox worker: acknowledge after exhaust failed; message may reappear",
724                        );
725                    }
726                    continue;
727                }
728
729                match self.sender.send(&msg).await {
730                    Ok(()) => {
731                        if let Err(e) = self.store.acknowledge(msg.message_id).await {
732                            tracing::warn!(
733                                message_id = %msg.message_id,
734                                error = %e,
735                                "outbox worker: acknowledge failed",
736                            );
737                        }
738                        // CONTRL AHB 1.0 §1.2: the CONTRL must be delivered
739                        // within 6 wall-clock hours of interchange receipt.
740                        // `msg.created_at` is when the PendingOutbox was
741                        // materialised (which should equal the ingest timestamp
742                        // for transport-layer CONTRL obligations).
743                        if msg.message_type.as_ref() == "CONTRL" {
744                            let elapsed = time::OffsetDateTime::now_utc() - msg.created_at;
745                            if elapsed > time::Duration::hours(crate::fristen::CONTRL_FRIST_HOURS) {
746                                tracing::warn!(
747                                    message_id   = %msg.message_id,
748                                    elapsed_secs = elapsed.whole_seconds(),
749                                    max_secs     = crate::fristen::CONTRL_FRIST_HOURS * 3600,
750                                    "outbox worker: CONTRL delivered OUTSIDE the 6h Übertragungsfrist \
751                                     (CONTRL AHB 1.0 §1.2) — this is a BNetzA compliance violation"
752                                );
753                            }
754                        }
755                        // APERAK AHB 1.0 §2.4.1: Strom UTILMD/ORDERS APERAK must be
756                        // delivered within 45 minutes on weekdays, or by Sunday 12:00
757                        // if received on Saturday.  Log a compliance warning if the
758                        // delivery window was missed so operators can investigate.
759                        if msg.message_type.as_ref() == "APERAK" {
760                            let elapsed = time::OffsetDateTime::now_utc() - msg.created_at;
761                            if elapsed
762                                > time::Duration::minutes(
763                                    crate::fristen::APERAK_STROM_WEEKDAY_MINUTES,
764                                )
765                            {
766                                tracing::warn!(
767                                    message_id   = %msg.message_id,
768                                    elapsed_mins = elapsed.whole_minutes(),
769                                    "outbox worker: APERAK delivered after the 45-minute Strom \
770                                     sending window (APERAK AHB 1.0 §2.4.1) — \
771                                     check OutboxWorker and AS4 transport health"
772                                );
773                            }
774                        }
775                    }
776                    // Permanent error: dead-letter immediately without retrying.
777                    // PartnerUnknown requires operator intervention (add --as4-partner);
778                    // Serialization errors will never succeed on retry.
779                    Err(ref e)
780                        if e.is_partner_unknown() || matches!(e, EngineError::Serialization(_)) =>
781                    {
782                        tracing::error!(
783                            message_id   = %msg.message_id,
784                            message_type = %msg.message_type,
785                            recipient    = %msg.recipient,
786                            error        = %e,
787                            "outbox worker: permanent send failure; dead-lettering without retry",
788                        );
789                        self.dead_letter_sink.reject(
790                            &crate::dead_letter::DeadLetterReason::OutboxExhausted {
791                                message_id: msg.message_id,
792                                message_type: msg.message_type.to_string(),
793                                recipient: msg.recipient.to_string(),
794                                last_error: e.to_string(),
795                                attempts: msg.attempt_count,
796                            },
797                        );
798                        if let Err(re) = self.store.acknowledge(msg.message_id).await {
799                            tracing::error!(
800                                message_id = %msg.message_id,
801                                error = %re,
802                                "outbox worker: acknowledge after permanent failure failed",
803                            );
804                        }
805                    }
806                    Err(e) => {
807                        // Stable jitter entropy derived from the UUID bytes of
808                        // `message_id`.  Using the last 8 bytes as a `u64` gives
809                        // uniform entropy across message IDs (UUIDs are random in
810                        // all 128 bits for v4) and is stable across Rust versions —
811                        // unlike `DefaultHasher`, whose algorithm is explicitly
812                        // documented as unstable.
813                        let entropy = {
814                            let uuid = msg.message_id.as_uuid();
815                            let bytes = uuid.as_bytes();
816                            u64::from_le_bytes(bytes[8..16].try_into().unwrap())
817                        };
818                        let delay = backoff_delay(msg.attempt_count, entropy);
819                        let retry_at = time::OffsetDateTime::now_utc()
820                            + time::Duration::try_from(delay).unwrap_or(time::Duration::minutes(5));
821                        tracing::warn!(
822                            message_id   = %msg.message_id,
823                            attempt      = msg.attempt_count,
824                            max_attempts = self.max_attempts,
825                            retry_in     = ?delay,
826                            error        = %e,
827                            "outbox worker: send failed; rescheduling with backoff",
828                        );
829                        if let Err(re) = self.store.reschedule(msg.message_id, retry_at).await {
830                            tracing::error!(
831                                message_id = %msg.message_id,
832                                error      = %re,
833                                "outbox worker: reschedule failed; message may be stuck",
834                            );
835                        }
836                    }
837                }
838            }
839            // Tick liveness heartbeat at the end of every poll cycle so the
840            // health endpoint can detect a stale (hung) outbox worker.
841            if let Some(ref hb) = self.heartbeat {
842                hb.store(
843                    time::OffsetDateTime::now_utc().unix_timestamp(),
844                    std::sync::atomic::Ordering::Relaxed,
845                );
846            }
847        }
848    }
849}
850
851impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
852where
853    ES: EventStore,
854    OS: OutboxStore + Clone,
855{
856    /// Construct an [`OutboxWorker`] that drains the outbox via `sender`.
857    ///
858    /// `batch_size` — messages fetched per poll cycle.
859    /// `poll_interval` — sleep duration when the batch is empty.
860    ///
861    /// `max_attempts` — maximum total delivery attempts before dead-lettering.
862    /// Pass `48` for a ~4-hour retry budget at the 300 s backoff cap, or
863    /// `u32::MAX` to disable the cap (not recommended for production).
864    ///
865    /// ```rust,ignore
866    /// use std::time::Duration;
867    ///
868    /// let worker = ctx.run_outbox_worker(my_sender, 50, Duration::from_secs(1), 48);
869    /// tokio::spawn(async move { worker.run().await });
870    /// ```
871    #[must_use]
872    pub fn run_outbox_worker<S: As4Sender>(
873        &self,
874        sender: S,
875        batch_size: usize,
876        poll_interval: std::time::Duration,
877        max_attempts: u32,
878    ) -> OutboxWorker<OS, S> {
879        OutboxWorker {
880            store: self.outbox_store.clone(),
881            sender,
882            batch_size,
883            poll_interval,
884            max_attempts,
885            dead_letter_sink: self.dead_letter_sink.clone(),
886            heartbeat: None,
887        }
888    }
889}
890
891impl<OS: OutboxStore, S: As4Sender> OutboxWorker<OS, S> {
892    /// Attach a liveness heartbeat to this worker.
893    ///
894    /// The worker will store the current UTC Unix timestamp (seconds) into
895    /// `heartbeat` at the end of every poll cycle.  Pass the same
896    /// `Arc<AtomicI64>` to the health endpoint so it can detect stale workers.
897    #[must_use]
898    pub fn with_heartbeat(
899        mut self,
900        heartbeat: std::sync::Arc<std::sync::atomic::AtomicI64>,
901    ) -> Self {
902        self.heartbeat = Some(heartbeat);
903        self
904    }
905}
906
907impl<ES, SS, OS, DS, PR> std::fmt::Debug for EngineContext<ES, SS, OS, DS, PR>
908where
909    ES: std::fmt::Debug,
910    SS: std::fmt::Debug,
911    OS: std::fmt::Debug,
912    DS: std::fmt::Debug,
913    PR: std::fmt::Debug,
914{
915    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
916        f.debug_struct("EngineContext")
917            .field("registered_modules", &self.registered_modules)
918            .field("registered_workflows", &self.registered_workflows)
919            .field("pid_router_len", &self.pid_router.len())
920            .finish_non_exhaustive()
921    }
922}
923
924// ── NoopAs4Sender / LogAs4Sender ──────────────────────────────────────────────
925
926/// An [`As4Sender`] that succeeds immediately without sending anything.
927///
928/// Use in tests and environments where outbound AS4 delivery is not yet
929/// wired. All outbox messages are acknowledged (removed from the queue)
930/// without being transmitted.
931///
932/// # ⚠️ Data loss warning
933///
934/// Every outbox message is **silently discarded** — no EDIFACT message is
935/// sent to any counterparty. Do not use in production.
936#[derive(Debug, Clone, Copy, Default)]
937#[must_use = "NoopAs4Sender discards all outbound messages silently — use a real AS4 gateway in production"]
938pub struct NoopAs4Sender;
939
940impl As4Sender for NoopAs4Sender {
941    async fn send(&self, _msg: &OutboxMessage) -> Result<(), EngineError> {
942        Ok(())
943    }
944}
945
946/// An [`As4Sender`] that logs every outbound message at `warn` level and
947/// succeeds without transmitting.
948///
949/// Useful for development and integration-testing environments where the
950/// full AS4 stack is not yet available but message visibility is desired.
951/// All outbox messages are acknowledged (removed from the queue) after logging.
952///
953/// # ⚠️ Data loss warning
954///
955/// No EDIFACT message is sent to any counterparty. Do not use in production.
956#[derive(Debug, Clone, Copy, Default)]
957#[must_use = "LogAs4Sender discards all outbound messages — use a real AS4 gateway in production"]
958pub struct LogAs4Sender;
959
960impl As4Sender for LogAs4Sender {
961    async fn send(&self, msg: &OutboxMessage) -> Result<(), EngineError> {
962        tracing::warn!(
963            message_id   = %msg.message_id,
964            message_type = %msg.message_type,
965            recipient    = %msg.recipient,
966            "LogAs4Sender: outbox message dropped — configure a real AS4 gateway for production",
967        );
968        Ok(())
969    }
970}
971
972// ── DeadlineScheduler ─────────────────────────────────────────────────────────
973
974/// A background task that polls [`DeadlineStore::due_now`] and dispatches
975/// deadline commands to the owning processes via a caller-supplied function.
976///
977/// Obtain via [`EngineContext::run_deadline_scheduler`] and drive by spawning
978/// [`DeadlineScheduler::run`] in a Tokio task.
979///
980/// # Dispatch function
981///
982/// The `dispatch` function receives a fired [`Deadline`] and returns a future
983/// that dispatches the appropriate timeout command to the process. The function
984/// is responsible for resuming the correct workflow and calling `execute`.
985/// After the future completes, the scheduler cancels the deadline from the
986/// store regardless of the dispatch outcome (to prevent re-firing).
987///
988/// ```rust,ignore
989/// use std::time::Duration;
990///
991/// let scheduler = ctx.run_deadline_scheduler(
992///     |deadline| async move {
993///         tracing::warn!(
994///             deadline_id = %deadline.deadline_id(),
995///             label = %deadline.label(),
996///             "deadline fired",
997///         );
998///         Ok(())
999///     },
1000///     100,
1001///     Duration::from_secs(30),
1002/// );
1003/// tokio::spawn(async move { scheduler.run().await });
1004/// ```
1005pub struct DeadlineScheduler<DS: DeadlineStore> {
1006    store: DS,
1007    dispatch: Box<
1008        dyn Fn(
1009                Deadline,
1010            ) -> std::pin::Pin<
1011                Box<dyn std::future::Future<Output = Result<(), EngineError>> + Send>,
1012            > + Send
1013            + Sync,
1014    >,
1015    batch_size: usize,
1016    poll_interval: std::time::Duration,
1017    /// Optional liveness heartbeat — stores the current UTC Unix timestamp
1018    /// (seconds) after each poll cycle.
1019    heartbeat: Option<std::sync::Arc<std::sync::atomic::AtomicI64>>,
1020}
1021
1022impl<DS: DeadlineStore> DeadlineScheduler<DS> {
1023    /// Run the deadline poll loop until the task is cancelled.
1024    pub async fn run(self) {
1025        loop {
1026            let result = match self.store.due_now(self.batch_size).await {
1027                Ok(r) => r,
1028                Err(e) => {
1029                    tracing::warn!(
1030                        error = %e,
1031                        "deadline scheduler: store error polling due deadlines (will retry)",
1032                    );
1033                    tokio::time::sleep(self.poll_interval).await;
1034                    continue;
1035                }
1036            };
1037
1038            if result.deadlines.is_empty() {
1039                tokio::time::sleep(self.poll_interval).await;
1040                continue;
1041            }
1042
1043            for deadline in result.deadlines {
1044                let id = deadline.deadline_id();
1045                let label = deadline.label().to_owned();
1046                let should_cancel = match (self.dispatch)(deadline).await {
1047                    Ok(()) => true,
1048                    Err(ref e) if e.is_version_conflict() => {
1049                        // The process was modified concurrently; the timeout
1050                        // command will be retried on the next poll cycle.
1051                        // Do NOT cancel — let the deadline remain due so it
1052                        // fires again until a non-conflict dispatch succeeds.
1053                        tracing::warn!(
1054                            deadline_id = %id,
1055                            label       = %label,
1056                            "deadline scheduler: VersionConflict; will retry on next poll",
1057                        );
1058                        false
1059                    }
1060                    Err(e) => {
1061                        tracing::warn!(
1062                            deadline_id = %id,
1063                            label       = %label,
1064                            error       = %e,
1065                            "deadline scheduler: dispatch failed (permanent); cancelling",
1066                        );
1067                        true
1068                    }
1069                };
1070                if should_cancel && let Err(e) = self.store.cancel(id).await {
1071                    tracing::error!(
1072                        deadline_id = %id,
1073                        error       = %e,
1074                        "deadline scheduler: cancel failed; deadline may fire again",
1075                    );
1076                }
1077            }
1078
1079            // If has_more, loop immediately to drain the batch.
1080
1081            // Tick liveness heartbeat at the end of every poll cycle so the
1082            // health endpoint can detect a stale (hung) deadline scheduler.
1083            if let Some(ref hb) = self.heartbeat {
1084                hb.store(
1085                    time::OffsetDateTime::now_utc().unix_timestamp(),
1086                    std::sync::atomic::Ordering::Relaxed,
1087                );
1088            }
1089        }
1090    }
1091}
1092
1093impl<DS: DeadlineStore> DeadlineScheduler<DS> {
1094    /// Attach a liveness heartbeat to this scheduler.
1095    ///
1096    /// The scheduler will store the current UTC Unix timestamp (seconds) into
1097    /// `heartbeat` at the end of every poll cycle.
1098    #[must_use]
1099    pub fn with_heartbeat(
1100        mut self,
1101        heartbeat: std::sync::Arc<std::sync::atomic::AtomicI64>,
1102    ) -> Self {
1103        self.heartbeat = Some(heartbeat);
1104        self
1105    }
1106}
1107
1108impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
1109where
1110    ES: EventStore,
1111    DS: DeadlineStore + Clone,
1112{
1113    /// Construct a [`DeadlineScheduler`] that polls the deadline store and
1114    /// dispatches fired deadlines via `dispatch`.
1115    ///
1116    /// The `dispatch` function is called for every fired deadline. It should
1117    /// resume the owning process and execute the appropriate timeout command.
1118    ///
1119    /// `batch_size` — deadlines fetched per poll cycle.
1120    /// `poll_interval` — sleep duration when no deadlines are due.
1121    ///
1122    /// ```rust,ignore
1123    /// use std::time::Duration;
1124    ///
1125    /// let scheduler = ctx.run_deadline_scheduler(
1126    ///     |d| async move {
1127    ///         tracing::info!(label = %d.label(), "firing deadline");
1128    ///         Ok(())
1129    ///     },
1130    ///     100,
1131    ///     Duration::from_secs(30),
1132    /// );
1133    /// tokio::spawn(async move { scheduler.run().await });
1134    /// ```
1135    #[must_use]
1136    pub fn run_deadline_scheduler<F, Fut>(
1137        &self,
1138        dispatch: F,
1139        batch_size: usize,
1140        poll_interval: std::time::Duration,
1141    ) -> DeadlineScheduler<DS>
1142    where
1143        F: Fn(Deadline) -> Fut + Send + Sync + 'static,
1144        Fut: std::future::Future<Output = Result<(), EngineError>> + Send + 'static,
1145    {
1146        DeadlineScheduler {
1147            store: self.deadline_store.clone(),
1148            dispatch: Box::new(move |d| Box::pin(dispatch(d))),
1149            batch_size,
1150            poll_interval,
1151            heartbeat: None,
1152        }
1153    }
1154}
1155
1156// ── EngineBuilder ─────────────────────────────────────────────────────────────
1157
1158/// Assembles engine infrastructure and produces an [`EngineContext`].
1159///
1160/// Uses type-state to enforce that an event store is provided before
1161/// [`build`] can be called. All other stores default to `Noop`
1162/// implementations.
1163///
1164/// ## Quick start
1165///
1166/// ```rust,ignore
1167/// // Minimal — event store only, all others are Noop:
1168/// let ctx = EngineBuilder::new()
1169///     .with_event_store(InMemoryEventStore::new())
1170///     .build();
1171///
1172/// // Full infrastructure:
1173/// let ctx = EngineBuilder::new()
1174///     .with_event_store(InMemoryEventStore::new())
1175///     .with_snapshot_store(InMemorySnapshotStore::new())
1176///     .with_outbox_store(InMemoryOutboxStore::new())
1177///     .with_deadline_store(InMemoryDeadlineStore::new())
1178///     .with_registry(InMemoryProcessRegistry::new())
1179///     .register(Box::new(GpkeModule))
1180///     .build();
1181/// ```
1182///
1183/// [`build`]: EngineBuilder::build
1184pub struct EngineBuilder<
1185    ES = (),
1186    SS = NoopSnapshotStore,
1187    OS = NoopOutboxStore,
1188    DS = NoopDeadlineStore,
1189    PR = NoopProcessRegistry,
1190> {
1191    event_store: ES,
1192    snapshot_store: SS,
1193    outbox_store: OS,
1194    deadline_store: DS,
1195    registry: PR,
1196    dead_letter_sink: Arc<dyn DeadLetterSink>,
1197    modules: Vec<Box<dyn EngineModule>>,
1198    /// Active [`DeploymentRoles`] for this engine instance.
1199    ///
1200    /// Controls role-conditional PID registration via
1201    /// [`EngineModule::register_pids_with_roles`]. Defaults to
1202    /// [`DeploymentRoles::all()`] for backward compatibility.
1203    deployment_roles: DeploymentRoles,
1204    /// Optional profile validator injected by `makod` or callers that have
1205    /// access to `edi-energy`.  When `Some`, called for each
1206    /// [`ProfileRequirement`] declared by registered modules.  When `None`,
1207    /// profile requirements are not validated (safe in unit tests).
1208    ///
1209    /// Signature: `fn(message_type: &str) -> bool`
1210    ///
1211    /// [`ProfileRequirement`]: crate::profile::ProfileRequirement
1212    profile_validator: Option<Box<dyn Fn(&str) -> bool + Send + Sync>>,
1213}
1214#[cfg(any(test, feature = "testing"))]
1215impl Default
1216    for EngineBuilder<
1217        (),
1218        NoopSnapshotStore,
1219        NoopOutboxStore,
1220        NoopDeadlineStore,
1221        NoopProcessRegistry,
1222    >
1223{
1224    fn default() -> Self {
1225        Self {
1226            event_store: (),
1227            snapshot_store: NoopSnapshotStore,
1228            outbox_store: NoopOutboxStore,
1229            deadline_store: NoopDeadlineStore,
1230            registry: NoopProcessRegistry,
1231            dead_letter_sink: Arc::new(LogDeadLetterSink),
1232            modules: Vec::new(),
1233            deployment_roles: DeploymentRoles::all(),
1234            profile_validator: None,
1235        }
1236    }
1237}
1238
1239#[cfg(any(test, feature = "testing"))]
1240impl EngineBuilder {
1241    /// Create a new builder with all `Noop` defaults.
1242    ///
1243    /// Only available in `#[cfg(test)]` or with the `testing` feature enabled,
1244    /// because the Noop defaults silently discard outbox messages, deadlines,
1245    /// and process registry entries. Production binaries must wire real stores
1246    /// via the `with_*` builder methods.
1247    ///
1248    /// Call [`with_event_store`] before [`build`] — the event store is
1249    /// **required**.
1250    ///
1251    /// [`with_event_store`]: EngineBuilder::with_event_store
1252    /// [`build`]: EngineBuilder::build
1253    #[must_use]
1254    pub fn new() -> Self {
1255        Self::default()
1256    }
1257}
1258
1259impl<OS, DS, PR> EngineBuilder<(), NoopSnapshotStore, OS, DS, PR>
1260where
1261    OS: OutboxStore,
1262    DS: DeadlineStore,
1263    PR: ProcessRegistry,
1264{
1265    /// Create a production-ready builder with explicit stores for outbox,
1266    /// deadline, and process registry.
1267    ///
1268    /// This constructor is available in all build configurations including
1269    /// production binaries. It enforces that the three stores that can cause
1270    /// silent data loss (`OutboxStore`, `DeadlineStore`, `ProcessRegistry`)
1271    /// are provided explicitly — there is no Noop fallback.
1272    ///
1273    /// `NoopSnapshotStore` is used as the snapshot default because it is safe
1274    /// for production: skipping snapshots means full replay, but no data loss.
1275    /// Override with [`with_snapshot_store`] to enable snapshot-accelerated
1276    /// replay.
1277    ///
1278    /// Call [`with_event_store`] before [`build`] — the event store is
1279    /// **required**.
1280    ///
1281    /// ```rust,ignore
1282    /// let ctx = EngineBuilder::with_stores(outbox, deadline, registry)
1283    ///     .with_event_store(store.clone())
1284    ///     .with_snapshot_store(InMemorySnapshotStore::new())
1285    ///     .build();
1286    /// ```
1287    ///
1288    /// [`with_snapshot_store`]: EngineBuilder::with_snapshot_store
1289    /// [`with_event_store`]: EngineBuilder::with_event_store
1290    /// [`build`]: EngineBuilder::build
1291    #[must_use]
1292    pub fn with_stores(outbox_store: OS, deadline_store: DS, registry: PR) -> Self {
1293        Self {
1294            event_store: (),
1295            snapshot_store: NoopSnapshotStore,
1296            outbox_store,
1297            deadline_store,
1298            registry,
1299            dead_letter_sink: Arc::new(LogDeadLetterSink),
1300            modules: Vec::new(),
1301            deployment_roles: DeploymentRoles::all(),
1302            profile_validator: None,
1303        }
1304    }
1305}
1306
1307impl<ES, SS, OS, DS, PR> EngineBuilder<ES, SS, OS, DS, PR> {
1308    /// Set the event store. **Required** — `build()` is only available once
1309    /// this has been called with a type that implements [`EventStore`].
1310    ///
1311    /// Replaces any previously set event store (type-state transition).
1312    #[must_use]
1313    pub fn with_event_store<ES2: EventStore>(
1314        self,
1315        store: ES2,
1316    ) -> EngineBuilder<ES2, SS, OS, DS, PR> {
1317        EngineBuilder {
1318            event_store: store,
1319            snapshot_store: self.snapshot_store,
1320            outbox_store: self.outbox_store,
1321            deadline_store: self.deadline_store,
1322            registry: self.registry,
1323            dead_letter_sink: self.dead_letter_sink,
1324            modules: self.modules,
1325            deployment_roles: self.deployment_roles,
1326            profile_validator: self.profile_validator,
1327        }
1328    }
1329
1330    /// Set the snapshot store (default: [`NoopSnapshotStore`]).
1331    ///
1332    /// ## Default: `NoopSnapshotStore`
1333    ///
1334    /// Without calling this method the builder uses [`NoopSnapshotStore`],
1335    /// which silently discards all snapshot writes and returns `None` for
1336    /// every snapshot read.  The engine still functions correctly — every
1337    /// command handling call replays the full event log from the beginning
1338    /// instead of starting from a stored snapshot.  For low-volume processes
1339    /// this is fine; for long-lived processes with many events the replay cost
1340    /// can become significant.
1341    ///
1342    /// Enable snapshotting in production by providing a real [`SnapshotStore`]
1343    /// implementation (e.g. the SlateDB-backed store in `makod`).  In tests,
1344    /// `InMemorySnapshotStore` is available behind the `testing` feature flag.
1345    ///
1346    /// Note: [`Process::state_with_snapshot`][crate::process::Process::state_with_snapshot]
1347    /// is a compile-time no-op when the snapshot store is `NoopSnapshotStore`
1348    /// — it never calls the store and always returns `None`, so no snapshot is
1349    /// ever saved or loaded.
1350    #[must_use]
1351    pub fn with_snapshot_store<SS2: SnapshotStore>(
1352        self,
1353        store: SS2,
1354    ) -> EngineBuilder<ES, SS2, OS, DS, PR> {
1355        EngineBuilder {
1356            event_store: self.event_store,
1357            snapshot_store: store,
1358            outbox_store: self.outbox_store,
1359            deadline_store: self.deadline_store,
1360            registry: self.registry,
1361            dead_letter_sink: self.dead_letter_sink,
1362            modules: self.modules,
1363            deployment_roles: self.deployment_roles,
1364            profile_validator: self.profile_validator,
1365        }
1366    }
1367
1368    /// Set the outbox store (default: [`NoopOutboxStore`]).
1369    #[must_use]
1370    pub fn with_outbox_store<OS2: OutboxStore>(
1371        self,
1372        store: OS2,
1373    ) -> EngineBuilder<ES, SS, OS2, DS, PR> {
1374        EngineBuilder {
1375            event_store: self.event_store,
1376            snapshot_store: self.snapshot_store,
1377            outbox_store: store,
1378            deadline_store: self.deadline_store,
1379            registry: self.registry,
1380            dead_letter_sink: self.dead_letter_sink,
1381            modules: self.modules,
1382            deployment_roles: self.deployment_roles,
1383            profile_validator: self.profile_validator,
1384        }
1385    }
1386
1387    /// Set the deadline store (default: [`NoopDeadlineStore`]).
1388    #[must_use]
1389    pub fn with_deadline_store<DS2: DeadlineStore>(
1390        self,
1391        store: DS2,
1392    ) -> EngineBuilder<ES, SS, OS, DS2, PR> {
1393        EngineBuilder {
1394            event_store: self.event_store,
1395            snapshot_store: self.snapshot_store,
1396            outbox_store: self.outbox_store,
1397            deadline_store: store,
1398            registry: self.registry,
1399            dead_letter_sink: self.dead_letter_sink,
1400            modules: self.modules,
1401            deployment_roles: self.deployment_roles,
1402            profile_validator: self.profile_validator,
1403        }
1404    }
1405
1406    /// Set the process registry (default: [`NoopProcessRegistry`]).
1407    #[must_use]
1408    pub fn with_registry<PR2: ProcessRegistry>(
1409        self,
1410        registry: PR2,
1411    ) -> EngineBuilder<ES, SS, OS, DS, PR2> {
1412        EngineBuilder {
1413            event_store: self.event_store,
1414            snapshot_store: self.snapshot_store,
1415            outbox_store: self.outbox_store,
1416            deadline_store: self.deadline_store,
1417            registry,
1418            dead_letter_sink: self.dead_letter_sink,
1419            modules: self.modules,
1420            deployment_roles: self.deployment_roles,
1421            profile_validator: self.profile_validator,
1422        }
1423    }
1424
1425    /// Set the dead-letter sink (default: [`LogDeadLetterSink`]).
1426    ///
1427    /// The dead-letter sink receives every message that cannot be routed to a
1428    /// workflow. The default [`LogDeadLetterSink`] emits `tracing::warn!`
1429    /// events, making rejections visible in log output without configuration.
1430    ///
1431    /// Override with a persistent DLQ implementation in production:
1432    ///
1433    /// ```rust,ignore
1434    /// use mako_engine::dead_letter::LogDeadLetterSink;
1435    ///
1436    /// let ctx = EngineBuilder::new()
1437    ///     .with_event_store(my_store)
1438    ///     .with_dead_letter_sink(MyPersistentDlq::new())
1439    ///     .build();
1440    /// ```
1441    ///
1442    /// [`LogDeadLetterSink`]: crate::dead_letter::LogDeadLetterSink
1443    #[must_use]
1444    pub fn with_dead_letter_sink(mut self, sink: impl DeadLetterSink) -> Self {
1445        self.dead_letter_sink = Arc::new(sink);
1446        self
1447    }
1448
1449    /// Register an `edi-energy` profile validator for startup profile checks.
1450    ///
1451    /// The closure receives a message-type string (e.g. `"UTILMD"`) and must
1452    /// return `true` if at least one active profile for that message type is
1453    /// registered for today's date.
1454    ///
1455    /// Wire this in `makod` using the `edi-energy` global registry:
1456    ///
1457    /// ```rust,ignore
1458    /// use edi_energy::registry::ReleaseRegistry;
1459    ///
1460    /// let today = time::OffsetDateTime::now_utc().date();
1461    /// builder.with_profile_validator(move |msg_type| {
1462    ///     ReleaseRegistry::global()
1463    ///         .profiles_for_str(msg_type)
1464    ///         .any(|p| match (p.valid_from(), p.valid_until()) {
1465    ///             (Some(f), Some(u)) => f <= today && today <= u,
1466    ///             (Some(f), None)    => f <= today,
1467    ///             (None, _)          => true,
1468    ///         })
1469    /// })
1470    /// ```
1471    ///
1472    /// Domain crates do **not** need to call this — they only declare
1473    /// [`profile_requirements`].
1474    ///
1475    /// [`profile_requirements`]: EngineModule::profile_requirements
1476    #[must_use]
1477    pub fn with_profile_validator(
1478        mut self,
1479        validator: impl Fn(&str) -> bool + Send + Sync + 'static,
1480    ) -> Self {
1481        self.profile_validator = Some(Box::new(validator));
1482        self
1483    }
1484
1485    /// Register a domain module.
1486    ///
1487    /// The module name becomes visible in
1488    /// [`EngineContext::registered_modules`] after [`build`] is called.
1489    ///
1490    /// [`build`]: EngineBuilder::build
1491    #[must_use]
1492    pub fn register(mut self, module: Box<dyn EngineModule>) -> Self {
1493        self.modules.push(module);
1494        self
1495    }
1496
1497    /// Register multiple [`EngineModule`]s at once from a pre-built `Vec`.
1498    ///
1499    /// Equivalent to calling [`register`] in a loop. Useful when the set of
1500    /// modules is assembled conditionally (e.g. via `#[cfg]`-gated pushes to a
1501    /// `Vec<Box<dyn EngineModule>>`) before the builder chain starts.
1502    ///
1503    /// [`register`]: EngineBuilder::register
1504    #[must_use]
1505    pub fn register_many(mut self, modules: Vec<Box<dyn EngineModule>>) -> Self {
1506        self.modules.extend(modules);
1507        self
1508    }
1509
1510    /// Set the active [`DeploymentRoles`] for this engine instance.
1511    ///
1512    /// Controls role-conditional PID registration in [`EngineModule::register_pids_with_roles`].
1513    ///
1514    /// The default is [`DeploymentRoles::all()`], which registers every PID unconditionally
1515    /// — identical to the pre-role-aware behavior. Providing an explicit role set
1516    /// restricts role-conditional blocks to only the declared roles:
1517    ///
1518    /// - **NB-only** (`DeploymentRoles::nb()`): 19001/19002 route to `gpke-konfiguration`;
1519    ///   WiM nMSB blocks are skipped.
1520    /// - **nMSB-only** (`DeploymentRoles::nmsb()`): 19001/19002 route to `wim-geraeteubernahme`;
1521    ///   GPKE NB blocks are skipped.
1522    /// - **NB + gMSB** (`DeploymentRoles::nb_msb()`): most common Stadtwerke combination.
1523    ///
1524    /// # Conflict guard
1525    ///
1526    /// When two modules would register the same PID to **different** workflows, the
1527    /// engine panics during [`build`]. Set explicit roles to prevent both modules from
1528    /// activating the same PID simultaneously:
1529    ///
1530    /// ```rust,ignore
1531    /// use mako_engine::marktrolle::DeploymentRoles;
1532    ///
1533    /// let ctx = EngineBuilder::with_stores(outbox, deadline, registry)
1534    ///     .with_event_store(store)
1535    ///     .with_deployment_roles(DeploymentRoles::nb())  // only NB: GPKE gets 19001/19002
1536    ///     .register(Box::new(GpkeModule))
1537    ///     .register(Box::new(WimModule))  // nMSB block skipped — no conflict
1538    ///     .build();
1539    /// ```
1540    ///
1541    /// [`build`]: EngineBuilder::build
1542    #[must_use]
1543    pub fn with_deployment_roles(mut self, roles: DeploymentRoles) -> Self {
1544        self.deployment_roles = roles;
1545        self
1546    }
1547}
1548
1549impl<ES, SS, OS, DS, PR> EngineBuilder<ES, SS, OS, DS, PR>
1550where
1551    ES: EventStore,
1552    SS: SnapshotStore,
1553    OS: OutboxStore,
1554    DS: DeadlineStore,
1555    PR: ProcessRegistry,
1556{
1557    /// Build the [`EngineContext`].
1558    ///
1559    /// Consumes the builder. All registered modules and configured stores are
1560    /// moved into the returned [`EngineContext`].
1561    ///
1562    /// This method is only available when `ES` implements [`EventStore`].
1563    /// If you have not called [`with_event_store`], this will not compile.
1564    ///
1565    /// # Panics
1566    ///
1567    /// Panics when any registered module returns `Err` from
1568    /// [`EngineModule::configure`]. The panic message includes the module
1569    /// name and the error string so the deployment failure is actionable.
1570    ///
1571    /// [`with_event_store`]: EngineBuilder::with_event_store
1572    #[must_use]
1573    #[allow(clippy::too_many_lines)]
1574    pub fn build(self) -> EngineContext<ES, SS, OS, DS, PR> {
1575        // ── Noop store safety checks ──────────────────────────────────────────
1576        //
1577        // Noop stores lose data silently: NoopDeadlineStore drops every APERAK
1578        // deadline (BNetzA violation), NoopOutboxStore discards all outbound
1579        // messages, NoopProcessRegistry loses conversation routing on restart.
1580        //
1581        // In production builds (no `testing` feature, not running under
1582        // `#[test]`), the Noop constructors are cfg-gated out so this branch
1583        // is dead code and compiles away. In test/testing/tracing builds we
1584        // emit warnings so test harnesses see the configuration in log output.
1585        //
1586        // IMPORTANT: if you are reading this because a panic fired in production,
1587        // it means the `testing` feature was accidentally enabled in the binary.
1588        // Remove it from the production Cargo.toml feature list immediately.
1589        {
1590            let os_name = std::any::type_name::<OS>();
1591            let ds_name = std::any::type_name::<DS>();
1592            let pr_name = std::any::type_name::<PR>();
1593
1594            // Regulatory-critical stores: panic in any build context if these
1595            // are noop. OutboxStore and DeadlineStore must be durable in
1596            // production; ProcessRegistry must survive restarts.
1597            #[cfg(not(any(test, feature = "testing")))]
1598            {
1599                assert!(
1600                    !ds_name.contains("NoopDeadlineStore"),
1601                    "EngineBuilder::build: NoopDeadlineStore is active in a \
1602                     non-testing build. This silently discards all APERAK deadlines, \
1603                     which is an immediately reportable BNetzA violation \
1604                     (BK6-22-024 §5, BK7-24-01-009). \
1605                     Call .with_deadline_store(SlateDbStore::as_deadline_store()) \
1606                     in your production engine assembly. \
1607                     If this is a test, enable the 'testing' feature."
1608                );
1609                assert!(
1610                    !os_name.contains("NoopOutboxStore"),
1611                    "EngineBuilder::build: NoopOutboxStore is active in a \
1612                     non-testing build. This silently discards all outbound \
1613                     APERAK, CONTRL, and UTILMD messages. \
1614                     Call .with_outbox_store(SlateDbStore::as_outbox_store()) \
1615                     in your production engine assembly. \
1616                     If this is a test, enable the 'testing' feature."
1617                );
1618                assert!(
1619                    !pr_name.contains("NoopProcessRegistry"),
1620                    "EngineBuilder::build: NoopProcessRegistry is active in a \
1621                     non-testing build. This means conversation routing \
1622                     (PID → stream_id lookup) is lost on every restart, \
1623                     breaking all WiM, GeLi Gas, and GPKE in-flight processes. \
1624                     Call .with_registry(SlateDbStore::as_process_registry()) \
1625                     in your production engine assembly. \
1626                     If this is a test, enable the 'testing' feature."
1627                );
1628            }
1629
1630            // In test/testing/tracing builds: emit warnings instead of panicking.
1631            #[cfg(any(test, feature = "testing", feature = "tracing"))]
1632            {
1633                let ss_name = std::any::type_name::<SS>();
1634                if ss_name.contains("NoopSnapshotStore") {
1635                    tracing::warn!(
1636                        store = ss_name,
1637                        "EngineBuilder: NoopSnapshotStore is active — snapshots will not be \
1638                         persisted. Use SlateDbStore::as_snapshot_store() in production."
1639                    );
1640                }
1641                if os_name.contains("NoopOutboxStore") {
1642                    tracing::warn!(
1643                        store = os_name,
1644                        "EngineBuilder: NoopOutboxStore is active — outbound messages will be \
1645                         silently discarded. Use SlateDbStore::as_outbox_store() in production."
1646                    );
1647                }
1648                if ds_name.contains("NoopDeadlineStore") {
1649                    tracing::warn!(
1650                        store = ds_name,
1651                        "EngineBuilder: NoopDeadlineStore is active — scheduled deadlines will \
1652                         not fire after restart. Use SlateDbStore::as_deadline_store() in production."
1653                    );
1654                }
1655                if pr_name.contains("NoopProcessRegistry") {
1656                    tracing::warn!(
1657                        store = pr_name,
1658                        "EngineBuilder: NoopProcessRegistry is active — process routing will be \
1659                         lost on restart. Use SlateDbStore::as_process_registry() in production."
1660                    );
1661                }
1662            }
1663        }
1664        // Validate every module before assembling the context.
1665        // A missing adapter or misconfigured module fails at startup (not at
1666        // first inbound message), making deployment failures observable immediately.
1667        for module in &self.modules {
1668            if let Err(msg) = module.configure() {
1669                panic!(
1670                    "EngineBuilder::build: module '{}' failed configuration validation: {}",
1671                    module.name(),
1672                    msg
1673                );
1674            }
1675            // Validate profile requirements via the injected validator.
1676            // Domain crates declare requirements; only the binary crate (makod)
1677            // injects the edi-energy registry — domain crates need no edi-energy
1678            // import for this check.
1679            if let Some(ref validator) = self.profile_validator {
1680                for req in module.profile_requirements() {
1681                    assert!(
1682                        validator(req.message_type),
1683                        "EngineBuilder::build: module '{}' requires an active edi-energy \
1684                             profile for '{}' ({}) but none is registered for today's date. \
1685                             Run `cargo xtask codegen` to add the missing profile.",
1686                        module.name(),
1687                        req.message_type,
1688                        req.label,
1689                    );
1690                }
1691            }
1692        }
1693        // Build the PID router from all registered modules.
1694        // Also assert that no two modules claim the same PID — a PID overlap
1695        // is always a configuration error: one module's messages would be
1696        // silently swallowed by another's workflow, producing missing-process
1697        // errors or incorrect audit trails.
1698        let mut pid_router = PidRouter::new();
1699        let mut pid_owners: std::collections::HashMap<u32, &str> = std::collections::HashMap::new();
1700        // Keep each module's scratch router so we can build `pid_router` from
1701        // them in a second pass with the resolved ownership table.
1702        let mut module_scratches: Vec<PidRouter> = Vec::with_capacity(self.modules.len());
1703
1704        // Pass 1 — detect conflicts, determine PID ownership (first-wins for
1705        // explicit roles, last-wins for DeploymentRoles::all()).
1706        for module in &self.modules {
1707            // Temporarily build a scratch router to read this module's PIDs
1708            // for cross-module overlap detection (module-ownership level).
1709            let mut scratch = PidRouter::new();
1710            module.register_pids_with_roles(&mut scratch, &self.deployment_roles);
1711            for pid in scratch.registered_pids() {
1712                if let Some(prev) = pid_owners.insert(pid, module.name()) {
1713                    if self.deployment_roles.is_all() {
1714                        // With DeploymentRoles::all() (the default), role-conditional PIDs
1715                        // are registered by all modules that claim them, producing last-wins
1716                        // semantics. This is acceptable for single-role and dev/test deployments.
1717                        //
1718                        // In production multi-role deployments where both an NB and nMSB role
1719                        // are served by the same instance, set explicit roles via
1720                        // `EngineBuilder::with_deployment_roles` to prevent silent misrouting.
1721                        //
1722                        // We emit a debug-level log here (not warn) because the vast majority
1723                        // of deployments are single-role and this overlap is expected/harmless.
1724                        #[cfg(feature = "tracing")]
1725                        tracing::debug!(
1726                            pid,
1727                            previous_module = prev,
1728                            current_module = module.name(),
1729                            "PID registered by multiple modules with DeploymentRoles::all(); \
1730                             last module wins (use with_deployment_roles for strict routing)",
1731                        );
1732                        let _ = prev; // suppress unused-variable warning when tracing is off
1733                    } else {
1734                        // Explicit roles: the FIRST module to register a PID retains ownership.
1735                        // Restore the previous (first) owner and emit a warning so the operator
1736                        // can investigate.  A panic would be too strict: some shared PIDs
1737                        // (e.g. REMADV 33001/33002) are legitimately claimed by both GPKE and
1738                        // WiM billing; conversation-ID routing is the long-term solution, but
1739                        // first-wins gives correct behaviour for all current deployments.
1740                        pid_owners.insert(pid, prev); // restore first owner
1741                        #[cfg(feature = "tracing")]
1742                        tracing::warn!(
1743                            pid,
1744                            first_module = prev,
1745                            second_module = module.name(),
1746                            "PID {pid} claimed by both '{prev}' and '{}' with explicit \
1747                             DeploymentRoles; first module ('{prev}') retains ownership. \
1748                             Verify PID registration is correct for this deployment.",
1749                            module.name(),
1750                        );
1751                        #[cfg(not(feature = "tracing"))]
1752                        let _ = prev; // suppress unused-variable warning when tracing is off
1753                    }
1754                }
1755            }
1756            module_scratches.push(scratch);
1757        }
1758
1759        // Pass 2 — build the real `pid_router` from the scratch pads, respecting
1760        // the ownership table built in pass 1.
1761        for (module, scratch) in self.modules.iter().zip(module_scratches.iter()) {
1762            // Unambiguous (Sparte-agnostic) entries: only register if this module
1763            // owns the PID in the resolved ownership table.
1764            for pid in scratch.registered_pids() {
1765                if pid_owners.get(&pid).copied() == Some(module.name())
1766                    && let Some(wf) = scratch.route(pid)
1767                {
1768                    pid_router.register(pid, wf);
1769                }
1770            }
1771            // Commodity (Sparte-qualified) entries use distinct (pid, Sparte) keys
1772            // and never conflict across modules; register them all unconditionally.
1773            for (pid, sparte, wf) in scratch.registered_commodity_entries() {
1774                pid_router.register_with_sparte(pid, sparte, wf);
1775            }
1776        }
1777        let registered_modules = self.modules.iter().map(|m| m.name()).collect();
1778        let registered_workflows = self
1779            .modules
1780            .iter()
1781            .flat_map(|m| m.workflow_names().iter().copied())
1782            .collect();
1783        EngineContext {
1784            event_store: Arc::new(self.event_store),
1785            snapshot_store: self.snapshot_store,
1786            outbox_store: self.outbox_store,
1787            deadline_store: self.deadline_store,
1788            registry: self.registry,
1789            dead_letter_sink: self.dead_letter_sink,
1790            pid_router,
1791            registered_modules,
1792            registered_workflows,
1793        }
1794    }
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799    use super::*;
1800    use crate::{
1801        deadline::InMemoryDeadlineStore,
1802        error::WorkflowError,
1803        event_store::InMemoryEventStore,
1804        ids::TenantId,
1805        outbox::InMemoryOutboxStore,
1806        pid_router::PidRouter,
1807        registry::InMemoryProcessRegistry,
1808        snapshot::InMemorySnapshotStore,
1809        version::WorkflowId,
1810        workflow::{CommandPayload, EventPayload, Workflow},
1811    };
1812
1813    // ── Minimal workflow for spawn/resume tests ───────────────────────────────
1814
1815    #[derive(serde::Serialize, serde::Deserialize)]
1816    struct PingEvent;
1817
1818    impl EventPayload for PingEvent {
1819        fn event_type(&self) -> &'static str {
1820            "Ping"
1821        }
1822    }
1823
1824    struct PingCommand;
1825
1826    impl CommandPayload for PingCommand {}
1827
1828    #[derive(Default, Clone)]
1829    struct PingState;
1830
1831    struct PingWorkflow;
1832
1833    impl Workflow for PingWorkflow {
1834        type State = PingState;
1835        type Event = PingEvent;
1836        type Command = PingCommand;
1837
1838        fn apply(state: PingState, _: &PingEvent) -> PingState {
1839            state
1840        }
1841
1842        fn handle(
1843            _: &PingState,
1844            _: PingCommand,
1845        ) -> Result<crate::workflow::WorkflowOutput<PingEvent>, WorkflowError> {
1846            Ok(vec![PingEvent].into())
1847        }
1848    }
1849
1850    struct TestModule;
1851
1852    impl EngineModule for TestModule {
1853        fn name(&self) -> &'static str {
1854            "test-module"
1855        }
1856    }
1857
1858    // ── Tests ─────────────────────────────────────────────────────────────────
1859
1860    #[test]
1861    fn build_with_event_store_only() {
1862        let ctx = EngineBuilder::new()
1863            .with_event_store(InMemoryEventStore::new())
1864            .build();
1865        assert!(ctx.registered_modules().is_empty());
1866    }
1867
1868    #[test]
1869    fn build_with_all_stores_and_module() {
1870        let ctx = EngineBuilder::new()
1871            .with_event_store(InMemoryEventStore::new())
1872            .with_snapshot_store(InMemorySnapshotStore::new())
1873            .with_outbox_store(InMemoryOutboxStore::new())
1874            .with_deadline_store(InMemoryDeadlineStore::new())
1875            .with_registry(InMemoryProcessRegistry::new())
1876            .register(Box::new(TestModule))
1877            .build();
1878        assert_eq!(ctx.registered_modules(), &["test-module"]);
1879    }
1880
1881    #[test]
1882    fn multiple_modules_ordered() {
1883        struct ModA;
1884        impl EngineModule for ModA {
1885            fn name(&self) -> &'static str {
1886                "mod-a"
1887            }
1888        }
1889        struct ModB;
1890        impl EngineModule for ModB {
1891            fn name(&self) -> &'static str {
1892                "mod-b"
1893            }
1894        }
1895
1896        let ctx = EngineBuilder::new()
1897            .with_event_store(InMemoryEventStore::new())
1898            .register(Box::new(ModA))
1899            .register(Box::new(ModB))
1900            .build();
1901        assert_eq!(ctx.registered_modules(), &["mod-a", "mod-b"]);
1902    }
1903
1904    #[tokio::test]
1905    async fn spawn_creates_independent_processes() {
1906        let ctx = EngineBuilder::new()
1907            .with_event_store(InMemoryEventStore::new())
1908            .build();
1909        let wf_id = WorkflowId::new("ping", "FV2024-10-01");
1910
1911        let p1 = ctx.spawn::<PingWorkflow>(TenantId::new(), wf_id.clone());
1912        let p2 = ctx.spawn::<PingWorkflow>(TenantId::new(), wf_id);
1913
1914        assert_ne!(p1.process_id(), p2.process_id());
1915    }
1916
1917    #[tokio::test]
1918    async fn resume_sees_previously_appended_events() {
1919        let store = InMemoryEventStore::new();
1920        let ctx = EngineBuilder::new().with_event_store(store).build();
1921
1922        let p = ctx.spawn::<PingWorkflow>(TenantId::new(), WorkflowId::new("ping", "FV2024-10-01"));
1923        p.execute(PingCommand).await.unwrap();
1924
1925        let identity = p.identity();
1926        let resumed = ctx.resume::<PingWorkflow>(identity);
1927        assert_eq!(resumed.event_count().await.unwrap(), 1);
1928    }
1929
1930    #[tokio::test]
1931    async fn registry_routes_process_via_conversation_key() {
1932        use crate::registry::RegistryKey;
1933        let ctx = EngineBuilder::new()
1934            .with_event_store(InMemoryEventStore::new())
1935            .with_registry(InMemoryProcessRegistry::new())
1936            .build();
1937
1938        let p = ctx.spawn::<PingWorkflow>(TenantId::new(), WorkflowId::new("ping", "FV2024-10-01"));
1939        let tenant = p.tenant_id();
1940        let conv_key = RegistryKey::parse("conv:test-conversation-123").expect("valid key");
1941        ctx.registry()
1942            .register(tenant, &conv_key, p.identity())
1943            .await
1944            .unwrap();
1945
1946        let found = ctx
1947            .registry()
1948            .lookup(tenant, &conv_key)
1949            .await
1950            .unwrap()
1951            .expect("must be registered");
1952        let resumed = ctx.resume::<PingWorkflow>(found);
1953        assert_eq!(resumed.process_id(), p.process_id());
1954    }
1955
1956    #[test]
1957    fn pid_router_populated_by_module_register_pids() {
1958        struct PidModule;
1959        impl EngineModule for PidModule {
1960            fn name(&self) -> &'static str {
1961                "pid-module"
1962            }
1963            fn register_pids(&self, router: &mut PidRouter) {
1964                router.register(55001, "gpke-supplier-change");
1965                router.register(55002, "gpke-supplier-change");
1966            }
1967        }
1968
1969        let ctx = EngineBuilder::new()
1970            .with_event_store(InMemoryEventStore::new())
1971            .register(Box::new(PidModule))
1972            .build();
1973
1974        assert_eq!(ctx.pid_router().route(55001), Some("gpke-supplier-change"));
1975        assert_eq!(ctx.pid_router().route(55002), Some("gpke-supplier-change"));
1976        assert!(ctx.pid_router().route(99999).is_none());
1977        assert_eq!(ctx.pid_router().len(), 2);
1978    }
1979
1980    /// Verify that `register_pids_with_roles` gates PIDs behind role checks.
1981    ///
1982    /// Scenario: two modules share PID 19001.
1983    /// - ModuleA registers 19001 → "workflow-a" when role `Nb` is present.
1984    /// - ModuleB registers 19001 → "workflow-b" when role `Nmsb` is explicitly set
1985    ///   (not on `all()`).
1986    ///
1987    /// - `all()`: ModuleA fires (Nb ∈ all), ModuleB does NOT (is_all → skip).
1988    ///   → 19001 routes to "workflow-a".
1989    /// - `from_roles([Nb])`: ModuleA fires, ModuleB skips.
1990    ///   → 19001 routes to "workflow-a".
1991    /// - `from_roles([Nmsb])`: ModuleA skips, ModuleB fires.
1992    ///   → 19001 routes to "workflow-b".
1993    #[test]
1994    fn register_pids_with_roles_gates_pids_correctly() {
1995        use crate::marktrolle::{DeploymentRoles, Marktrolle};
1996
1997        struct ModuleA;
1998        impl EngineModule for ModuleA {
1999            fn name(&self) -> &'static str {
2000                "module-a"
2001            }
2002            fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2003                if roles.contains(Marktrolle::Nb) {
2004                    router.register(19_001, "workflow-a");
2005                }
2006            }
2007        }
2008
2009        struct ModuleB;
2010        impl EngineModule for ModuleB {
2011            fn name(&self) -> &'static str {
2012                "module-b"
2013            }
2014            fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2015                // Only fires on explicit Nmsb, not on all() (backward-compat sentinel).
2016                if !roles.is_all() && roles.contains(Marktrolle::Nmsb) {
2017                    router.register(19_001, "workflow-b");
2018                    router.register(19_015, "workflow-b");
2019                }
2020            }
2021        }
2022
2023        let build = |roles: DeploymentRoles| {
2024            EngineBuilder::new()
2025                .with_event_store(InMemoryEventStore::new())
2026                .with_deployment_roles(roles)
2027                .register(Box::new(ModuleA))
2028                .register(Box::new(ModuleB))
2029                .build()
2030        };
2031
2032        // all() → backward compat: ModuleA registers 19001 (Nb ∈ all), ModuleB skips.
2033        let ctx = build(DeploymentRoles::all());
2034        assert_eq!(ctx.pid_router().route(19_001), Some("workflow-a"));
2035        assert!(ctx.pid_router().route(19_015).is_none());
2036
2037        // Explicit Nb → same result: ModuleA registers, ModuleB (nMSB) skips.
2038        let ctx = build(DeploymentRoles::nb());
2039        assert_eq!(ctx.pid_router().route(19_001), Some("workflow-a"));
2040        assert!(ctx.pid_router().route(19_015).is_none());
2041
2042        // Explicit Nmsb → ModuleA skips (Nb ∉ roles), ModuleB registers.
2043        let ctx = build(DeploymentRoles::nmsb());
2044        assert_eq!(ctx.pid_router().route(19_001), Some("workflow-b"));
2045        assert_eq!(ctx.pid_router().route(19_015), Some("workflow-b"));
2046    }
2047
2048    /// Verify that explicit roles with two conflicting modules use first-wins semantics
2049    /// (the first module to register a PID retains ownership; the second is silently skipped).
2050    #[test]
2051    fn register_pids_with_roles_conflict_uses_first_wins_with_explicit_roles() {
2052        use crate::marktrolle::{DeploymentRoles, Marktrolle};
2053
2054        struct ConflictA;
2055        impl EngineModule for ConflictA {
2056            fn name(&self) -> &'static str {
2057                "conflict-a"
2058            }
2059            fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2060                if roles.contains(Marktrolle::Nb) {
2061                    router.register(19_001, "workflow-a");
2062                }
2063            }
2064        }
2065
2066        struct ConflictB;
2067        impl EngineModule for ConflictB {
2068            fn name(&self) -> &'static str {
2069                "conflict-b"
2070            }
2071            fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2072                if !roles.is_all() && roles.contains(Marktrolle::Nmsb) {
2073                    router.register(19_001, "workflow-b"); // same PID, different workflow
2074                }
2075            }
2076        }
2077
2078        // from_roles([Nb, Nmsb]): both modules fire for PID 19_001.
2079        // First-wins: ConflictA (registered first) retains ownership → "workflow-a".
2080        let ctx = EngineBuilder::new()
2081            .with_event_store(InMemoryEventStore::new())
2082            .with_deployment_roles(DeploymentRoles::from_roles([
2083                Marktrolle::Nb,
2084                Marktrolle::Nmsb,
2085            ]))
2086            .register(Box::new(ConflictA))
2087            .register(Box::new(ConflictB))
2088            .build();
2089        assert_eq!(
2090            ctx.pid_router().route(19_001),
2091            Some("workflow-a"),
2092            "first module should win on PID conflict with explicit roles"
2093        );
2094    }
2095}