Skip to main content

mako_engine/
builder.rs

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