Skip to main content

mako_engine/
builder.rs

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