Skip to main content

mako_engine/
workflow.rs

1//! [`Workflow`] trait, [`EventPayload`], [`CommandPayload`], and [`CommandContext`].
2//!
3//! # Design contract
4//!
5//! Workflows are **pure state machines**:
6//!
7//! - [`Workflow::apply`] folds a domain event into the current state.
8//! - [`Workflow::handle`] validates a command against the current state and
9//!   returns the events to emit. It has no I/O, no side effects, and no
10//!   clock access. The same state + command always produce the same events.
11//!
12//! All I/O (parsing raw bytes, calling external services) must happen
13//! **before** the command is constructed and passed to the write path.
14//! This keeps workflows deterministic and trivially replayable.
15//!
16//! # Serialization boundary
17//!
18//! Domain events must implement [`serde::Serialize`] and
19//! [`serde::de::DeserializeOwned`] so the engine can persist them as JSON
20//! inside [`EventEnvelope::payload`]. The [`EventPayload`] trait adds a
21//! stable `event_type` discriminant for projection routing.
22//!
23//! # Write path
24//!
25//! The public write path is [`Process::execute`] / [`Process::execute_with`].
26//! These delegate to the crate-internal `execute_command` function. Direct
27//! use of `execute_command` is intentionally not part of the public API;
28//! use [`Process`] instead.
29//!
30//! [`Process`]: crate::process::Process
31//! [`Process::execute`]: crate::process::Process::execute
32//! [`Process::execute_with`]: crate::process::Process::execute_with
33
34use crate::{
35    deadline::Deadline,
36    envelope::{EventEnvelope, NewEvent},
37    error::{EngineError, WorkflowError},
38    event_store::{EventStore, ExpectedVersion},
39    ids::{CausationId, ConversationId, CorrelationId, ProcessId, TenantId},
40    outbox::PendingOutbox,
41    version::{WorkflowId, WorkflowVersionPolicy},
42};
43
44// ── PendingDeadline ───────────────────────────────────────────────────────────
45
46/// A deadline that a [`Workflow::handle`] function wishes to register,
47/// expressed without the process-identity fields that are only known to the
48/// engine's execution context.
49///
50/// The engine converts `PendingDeadline` into a fully-typed [`Deadline`] by
51/// injecting `stream_id`, `process_id`, `tenant_id`, and `workflow_id` from
52/// the active [`CommandContext`].  Workflows therefore stay pure (no I/O).
53///
54/// ## Usage
55///
56/// Return `PendingDeadline` inside [`WorkflowOutput`] when the command must
57/// register a regulatory deadline alongside its events and outbox messages:
58///
59/// ```rust,ignore
60/// use mako_engine::fristen::{APERAK_STROM_WINDOW_LABEL, aperak_strom_due_at};
61/// use mako_engine::workflow::PendingDeadline;
62///
63/// let due = aperak_strom_due_at(received_at);
64/// let dl = PendingDeadline::new(APERAK_STROM_WINDOW_LABEL, due);
65/// Ok(WorkflowOutput::with_outbox_and_deadline(events, outbox, dl))
66/// ```
67///
68/// [`Deadline`]: crate::deadline::Deadline
69/// [`CommandContext`]: crate::workflow::CommandContext
70#[derive(Debug, Clone)]
71pub struct PendingDeadline {
72    /// Deadline label (matches the `on_deadline` match arm in the workflow).
73    pub label: String,
74    /// Absolute UTC time at which the deadline fires.
75    pub due_at: time::OffsetDateTime,
76}
77
78impl PendingDeadline {
79    /// Create a new pending deadline with the given label and due time.
80    #[must_use]
81    pub fn new(label: impl Into<String>, due_at: time::OffsetDateTime) -> Self {
82        Self {
83            label: label.into(),
84            due_at,
85        }
86    }
87}
88
89// ── WorkflowOutput ────────────────────────────────────────────────────────────
90
91/// The combined output of [`Workflow::handle`]: domain events, optional
92/// outbox messages, and optional deadlines, all atomically co-persisted.
93///
94/// Use [`WorkflowOutput::events`] or `From<Vec<E>>` when the command produces
95/// only events (no outbox messages). This keeps existing `handle`
96/// implementations concise: `Ok(vec![event].into())`.
97///
98/// When the command must also send an EDIFACT message, add the corresponding
99/// [`PendingOutbox`] entries to `outbox`. The engine materialises them into
100/// fully-typed [`OutboxMessage`] values with correct `causation_event_id` links
101/// inside [`Process::execute_and_enqueue`].
102///
103/// When the command must register a regulatory deadline (e.g. APERAK 45-min
104/// sending window per APERAK AHB 1.0 §2.4.1), add a [`PendingDeadline`].
105/// The engine injects the process-identity fields from [`CommandContext`] and
106/// persists the deadline atomically with the events.
107///
108/// [`OutboxMessage`]: crate::outbox::OutboxMessage
109/// [`Process::execute_and_enqueue`]: crate::process::Process::execute_and_enqueue
110#[derive(Debug, Clone)]
111pub struct WorkflowOutput<E: EventPayload> {
112    /// Domain events to persist in the event stream.
113    pub events: Vec<E>,
114    /// Outbox messages to enqueue atomically alongside the events.
115    ///
116    /// Empty in the vast majority of commands. Only non-empty when the command
117    /// needs to trigger an outbound EDIFACT message (e.g. `DispatchAperak`).
118    pub outbox: Vec<PendingOutbox>,
119    /// Deadlines to register atomically alongside the events.
120    ///
121    /// Empty in most commands. Non-empty when the command starts a regulatory
122    /// monitoring window (e.g. APERAK 45-min sending deadline).
123    pub deadlines: Vec<PendingDeadline>,
124}
125
126impl<E: EventPayload> WorkflowOutput<E> {
127    /// Construct an output with events and no outbox messages or deadlines.
128    ///
129    /// Equivalent to `events.into()`.
130    #[must_use]
131    pub fn events(events: Vec<E>) -> Self {
132        Self {
133            events,
134            outbox: Vec::new(),
135            deadlines: Vec::new(),
136        }
137    }
138
139    /// Construct an output with both events and outbox messages.
140    #[must_use]
141    pub fn with_outbox(events: Vec<E>, outbox: Vec<PendingOutbox>) -> Self {
142        Self {
143            events,
144            outbox,
145            deadlines: Vec::new(),
146        }
147    }
148
149    /// Construct an output with events, outbox messages, and a single deadline.
150    #[must_use]
151    pub fn with_outbox_and_deadline(
152        events: Vec<E>,
153        outbox: Vec<PendingOutbox>,
154        deadline: PendingDeadline,
155    ) -> Self {
156        Self {
157            events,
158            outbox,
159            deadlines: vec![deadline],
160        }
161    }
162
163    /// Construct an output with events, outbox messages, and multiple deadlines.
164    #[must_use]
165    pub fn with_outbox_and_deadlines(
166        events: Vec<E>,
167        outbox: Vec<PendingOutbox>,
168        deadlines: Vec<PendingDeadline>,
169    ) -> Self {
170        Self {
171            events,
172            outbox,
173            deadlines,
174        }
175    }
176}
177
178impl<E: EventPayload> From<Vec<E>> for WorkflowOutput<E> {
179    /// Convert a plain event list into a `WorkflowOutput` with no outbox or deadlines.
180    ///
181    /// Allows `handle` implementations to write `Ok(vec![…].into())` without
182    /// constructing a `WorkflowOutput` explicitly.
183    fn from(events: Vec<E>) -> Self {
184        Self::events(events)
185    }
186}
187
188impl<E: EventPayload> std::ops::Deref for WorkflowOutput<E> {
189    type Target = [E];
190
191    /// Deref to the events slice so callers can use `.len()`, indexing, and
192    /// iteration on `WorkflowOutput` without destructuring.
193    fn deref(&self) -> &Self::Target {
194        &self.events
195    }
196}
197
198impl<E: EventPayload> IntoIterator for WorkflowOutput<E> {
199    type Item = E;
200    type IntoIter = std::vec::IntoIter<E>;
201
202    fn into_iter(self) -> Self::IntoIter {
203        self.events.into_iter()
204    }
205}
206
207impl<'a, E: EventPayload> IntoIterator for &'a WorkflowOutput<E> {
208    type Item = &'a E;
209    type IntoIter = std::slice::Iter<'a, E>;
210
211    fn into_iter(self) -> Self::IntoIter {
212        self.events.iter()
213    }
214}
215
216// ── EventPayload ──────────────────────────────────────────────────────────────
217
218/// Marker trait for domain event types.
219///
220/// Implementors must be JSON-serializable and carry a stable `event_type`
221/// string that the engine stores in [`EventEnvelope::event_type`] for
222/// projection routing and observability.
223///
224/// # Example
225///
226/// ```rust,ignore
227/// use mako_engine::workflow::EventPayload;
228///
229/// #[derive(serde::Serialize, serde::Deserialize)]
230/// enum MyEvent { Created { name: String }, Closed }
231///
232/// impl EventPayload for MyEvent {
233///     fn event_type(&self) -> &'static str {
234///         match self {
235///             Self::Created { .. } => "MyCreated",
236///             Self::Closed       => "MyClosed",
237///         }
238///     }
239/// }
240/// ```
241pub trait EventPayload:
242    serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static
243{
244    /// A stable, unique name for this event variant.
245    ///
246    /// Used in [`EventEnvelope::event_type`]. Choose names that survive
247    /// refactors (e.g. `"SupplierChangeInitiated"`, not `"Initiated"`).
248    fn event_type(&self) -> &'static str;
249
250    /// Schema version of this event's payload layout.
251    ///
252    /// Increment when the serialized payload structure changes in a
253    /// backward-incompatible way. The engine stamps this value into
254    /// [`EventEnvelope::schema_version`] so replay and upcasting tooling
255    /// can identify which decoder to use.
256    ///
257    /// Defaults to `1`.
258    fn schema_version(&self) -> u32 {
259        1
260    }
261}
262
263// ── CommandPayload ────────────────────────────────────────────────────────────
264
265/// Marker trait for domain command types.
266///
267/// Commands are transient — they are never persisted. Only `Send + 'static` is
268/// required.
269pub trait CommandPayload: Send + 'static {}
270
271// ── Workflow ──────────────────────────────────────────────────────────────────
272
273/// A versioned, deterministic domain workflow.
274///
275/// Workflows are the unit of business logic in the engine. Each BDEW process
276/// variant (e.g. GPKE Lieferbeginn, WiM Gerätewechsel) is a separate
277/// `Workflow` implementation in its domain crate.
278///
279/// ## State reconstruction
280///
281/// Before handling a command, the engine calls [`Workflow::apply`] on every
282/// event in the stream to reconstruct the current state. This is the only
283/// path to reading state — there is no "load current state" API.
284///
285/// ## Determinism
286///
287/// `handle` and `apply` must be deterministic and free of side effects. Do not
288/// access clocks, RNGs, network, or file system inside them.
289pub trait Workflow: Send + Sync + 'static {
290    /// Domain-specific process state, reconstructed by replaying events.
291    type State: Default + Clone + Send + Sync + 'static;
292
293    /// Domain event type emitted by this workflow.
294    type Event: EventPayload;
295
296    /// Command type handled by this workflow.
297    type Command: CommandPayload;
298
299    /// Fold a domain event into the current state.
300    ///
301    /// This function must be total (no panics, no errors) and must produce a
302    /// deterministic result.
303    fn apply(state: Self::State, event: &Self::Event) -> Self::State;
304
305    /// Validate `command` against `state` and return the events to emit.
306    ///
307    /// Return an empty [`WorkflowOutput`] (or `vec![].into()`) when the
308    /// command is a no-op (already processed).
309    ///
310    /// Outbox messages in the returned [`WorkflowOutput::outbox`] will be
311    /// atomically co-persisted with the events when the command is dispatched
312    /// via [`Process::execute_and_enqueue`]. If dispatched via
313    /// [`Process::execute`], the outbox field is silently ignored.
314    ///
315    /// # Errors
316    ///
317    /// Return a [`WorkflowError`] when the command is invalid for the current
318    /// state or when domain validation fails.
319    ///
320    /// [`Process::execute`]: crate::process::Process::execute
321    /// [`Process::execute_and_enqueue`]: crate::process::Process::execute_and_enqueue
322    fn handle(
323        state: &Self::State,
324        command: Self::Command,
325    ) -> Result<WorkflowOutput<Self::Event>, WorkflowError>;
326
327    /// Schema version for serialized `Workflow::State` payloads.
328    ///
329    /// The engine stores this value in every [`Snapshot`] taken via
330    /// [`Process::take_snapshot`]. Increment it when the serialized state
331    /// layout changes in a backward-incompatible way, and add a migration
332    /// arm to your snapshot loader.
333    ///
334    /// Defaults to `1`.
335    ///
336    /// [`Snapshot`]: crate::snapshot::Snapshot
337    /// [`Process::take_snapshot`]: crate::process::Process::take_snapshot
338    #[must_use]
339    fn state_schema_version() -> u32 {
340        1
341    }
342
343    /// Upcast a stored event payload from an older schema version.
344    ///
345    /// The engine calls this during state reconstruction for every loaded
346    /// event, *before* deserializing the payload into `Self::Event`. The
347    /// returned [`serde_json::Value`] is passed to the standard JSON
348    /// deserializer.
349    ///
350    /// Override this when you bump [`EventPayload::schema_version`] on a
351    /// variant — return a `Value` compatible with the new schema so old
352    /// events replay correctly without a data migration.
353    ///
354    /// # Example
355    ///
356    /// ```rust,ignore
357    /// fn upcast(
358    ///     event_type: &str,
359    ///     from_version: u32,
360    ///     mut payload: serde_json::Value,
361    /// ) -> Result<serde_json::Value, EngineError> {
362    ///     // v2 of SupplierChangeInitiated added a `document_type` field.
363    ///     if event_type == "SupplierChangeInitiated" && from_version == 1 {
364    ///         payload["document_type"] = serde_json::json!("E01");
365    ///     }
366    ///     Ok(payload)
367    /// }
368    /// ```
369    ///
370    /// # Errors
371    ///
372    /// Return [`EngineError::Deserialization`] when the payload cannot be
373    /// migrated to the current schema.
374    fn upcast(
375        _event_type: &str,
376        _from_version: u32,
377        payload: serde_json::Value,
378    ) -> Result<serde_json::Value, EngineError> {
379        Ok(payload)
380    }
381
382    /// Declares which BDEW format versions this workflow accepts for in-flight
383    /// processes.
384    ///
385    /// The engine uses this policy to validate that an incoming message's
386    /// format version is acceptable *before* constructing the command, surfacing
387    /// missing adapter coverage at dispatch time rather than during runtime
388    /// deserialization.
389    ///
390    /// The default returns [`WorkflowVersionPolicy::ForwardCompatible`] —
391    /// accept messages in any format version.  This is the safe default for
392    /// the majority of BDEW market-communication processes, which routinely
393    /// span annual release boundaries (e.g. a GPKE Lieferbeginn process
394    /// started in September may still receive APERAK replies in November under
395    /// the new October FV).
396    ///
397    /// Override to `Pinned` only for strictly short-lived workflows that are
398    /// guaranteed to complete within a single BDEW release cycle.
399    ///
400    /// # Example
401    ///
402    /// ```rust,ignore
403    /// use mako_engine::version::WorkflowVersionPolicy;
404    ///
405    /// // Override to Pinned for a workflow with a 24h wall-clock SLA:
406    /// fn version_policy() -> WorkflowVersionPolicy {
407    ///     WorkflowVersionPolicy::Pinned
408    /// }
409    /// ```
410    #[must_use]
411    fn version_policy() -> WorkflowVersionPolicy {
412        WorkflowVersionPolicy::ForwardCompatible
413    }
414
415    /// Map a fired deadline to a compensating command.
416    ///
417    /// Called by [`Process::execute_timeout`] when a registered deadline
418    /// for this workflow's process becomes overdue. Return `Some(command)`
419    /// to trigger a compensating action; return `None` to acknowledge the
420    /// deadline as a no-op.
421    ///
422    /// This method must be **pure**: no I/O, no clock access, no global state.
423    /// The same `(deadline, state)` must always produce the same `Option<Command>`.
424    ///
425    /// The full [`Deadline`] is provided (not just the label) so implementations
426    /// can construct commands that require `deadline_id` (e.g. `TimeoutExpired`).
427    ///
428    /// # Why a dedicated hook instead of a normal command?
429    ///
430    /// A synthetic `TimeoutFired` command variant works but couples the workflow
431    /// enum to infrastructure concerns. `on_deadline` keeps the domain command
432    /// type clean and makes compensation logic explicit and testable in isolation:
433    ///
434    /// ```rust,ignore
435    /// fn on_deadline(
436    ///     deadline: &Deadline,
437    ///     state: &Self::State,
438    /// ) -> Option<Self::Command> {
439    ///     match (deadline.label(), state) {
440    ///         ("aperak-window", SupplierChangeState::Initiated(_) | SupplierChangeState::ValidationPassed(_)) => {
441    ///             Some(SupplierChangeCommand::TimeoutExpired {
442    ///                 deadline_id: deadline.deadline_id(),
443    ///                 label: deadline.label().into(),
444    ///             })
445    ///         }
446    ///         _ => None,
447    ///     }
448    /// }
449    /// ```
450    ///
451    /// # Default
452    ///
453    /// Returns `None` for all deadlines — no automatic compensation. Override in
454    /// any workflow that has deadline-triggered compensation requirements.
455    ///
456    /// [`Process::execute_timeout`]: crate::process::Process::execute_timeout
457    fn on_deadline(_deadline: &Deadline, _state: &Self::State) -> Option<Self::Command> {
458        None
459    }
460}
461
462// ── CommandContext ─────────────────────────────────────────────────────────────
463
464/// Contextual metadata attached to every command dispatch.
465///
466/// The engine stamps this information onto every event produced by the command.
467/// Callers provide the process identity; the engine generates correlation IDs
468/// automatically unless provided explicitly.
469#[derive(Debug, Clone)]
470pub struct CommandContext {
471    /// See [`CorrelationId`].
472    pub correlation_id: CorrelationId,
473    /// See [`ConversationId`].
474    pub conversation_id: ConversationId,
475    /// The MaKo process instance this command targets.
476    pub process_id: ProcessId,
477    /// The tenant that issued this command.
478    pub tenant_id: TenantId,
479    /// The workflow version to use for processing.
480    pub workflow_id: WorkflowId,
481    /// The immediate cause of this command, if driven by a prior event.
482    pub causation_id: Option<CausationId>,
483}
484
485impl CommandContext {
486    /// Construct a context with auto-generated correlation and conversation IDs.
487    #[must_use]
488    pub fn new(tenant_id: TenantId, process_id: ProcessId, workflow_id: WorkflowId) -> Self {
489        Self {
490            correlation_id: CorrelationId::new(),
491            conversation_id: ConversationId::new(),
492            process_id,
493            tenant_id,
494            workflow_id,
495            causation_id: None,
496        }
497    }
498
499    /// Set an explicit causation ID (e.g. the ID of the event that triggered
500    /// this command).
501    #[must_use]
502    pub fn with_causation(mut self, id: CausationId) -> Self {
503        self.causation_id = Some(id);
504        self
505    }
506
507    /// Override the auto-generated correlation ID.
508    ///
509    /// Use this to propagate a correlation ID from an inbound EDIFACT message
510    /// so all resulting events share the same root correlation.
511    #[must_use]
512    pub fn with_correlation(mut self, id: CorrelationId) -> Self {
513        self.correlation_id = id;
514        self
515    }
516
517    /// Override the auto-generated conversation ID.
518    ///
519    /// Use this to link the outbound APERAK to the same conversation as the
520    /// UTILMD that triggered it, so the full message exchange is traceable as
521    /// a unit.
522    #[must_use]
523    pub fn with_conversation(mut self, id: ConversationId) -> Self {
524        self.conversation_id = id;
525        self
526    }
527
528    /// Build a context that is causally linked to a prior persisted event.
529    ///
530    /// Propagates `correlation_id`, `conversation_id`, `process_id`, and
531    /// `tenant_id` from the envelope and sets the envelope's `event_id` as
532    /// the `causation_id`. This is the canonical constructor for all commands
533    /// that are triggered by a prior event (e.g. dispatching an APERAK in
534    /// response to a received UTILMD).
535    ///
536    /// # Example
537    ///
538    /// ```rust,ignore
539    /// let ctx = CommandContext::from_envelope(&utilmd_envelope, workflow_id);
540    /// process.execute_with(DispatchAperak { positive: true, reason: None }, ctx).await?;
541    /// ```
542    #[must_use]
543    pub fn from_envelope(env: &EventEnvelope, workflow_id: WorkflowId) -> Self {
544        Self {
545            correlation_id: env.correlation_id,
546            conversation_id: env.conversation_id,
547            process_id: env.process_id,
548            tenant_id: env.tenant_id,
549            workflow_id,
550            causation_id: Some(env.event_id.into()),
551        }
552    }
553
554    /// Build a context for a deadline-triggered command.
555    ///
556    /// Propagates `process_id` and `tenant_id` from the deadline. Generates
557    /// fresh `correlation_id` and `conversation_id` (deadline firings start
558    /// a new tracing root).
559    ///
560    /// # Example
561    ///
562    /// ```rust,ignore
563    /// let ctx = CommandContext::from_deadline(&overdue_deadline, workflow_id);
564    /// process.execute_with(HandleTimeout { label: overdue_deadline.label().into() }, ctx).await?;
565    /// ```
566    #[must_use]
567    pub fn from_deadline(deadline: &crate::deadline::Deadline, workflow_id: WorkflowId) -> Self {
568        Self::new(deadline.tenant_id(), deadline.process_id(), workflow_id)
569    }
570
571    /// Build a [`NewEvent`] from this context and a domain event payload.
572    ///
573    /// This is the canonical way to construct a `NewEvent` inside a transport
574    /// adapter or test helper — it eliminates the nine-argument [`NewEvent::new`]
575    /// call and ensures that correlation metadata is always propagated correctly.
576    ///
577    /// # Errors
578    ///
579    /// Returns [`EngineError::Serialization`] when the event payload cannot be
580    /// serialized to JSON.
581    ///
582    /// # Example
583    ///
584    /// ```rust,ignore
585    /// // Inside a MessageAdapter or test:
586    /// let new_event = ctx.new_event(&SupplierChangeEvent::Activated)?;
587    /// store.append(&stream_id, ExpectedVersion::Any, &[new_event]).await?;
588    /// ```
589    pub fn new_event<E: EventPayload>(&self, event: &E) -> Result<NewEvent, EngineError> {
590        let payload =
591            serde_json::to_value(event).map_err(|e| EngineError::Serialization(e.to_string()))?;
592        Ok(NewEvent {
593            correlation_id: self.correlation_id,
594            causation_id: self.causation_id,
595            conversation_id: self.conversation_id,
596            process_id: self.process_id,
597            tenant_id: self.tenant_id,
598            workflow_id: self.workflow_id.clone(),
599            event_type: event.event_type().into(),
600            schema_version: event.schema_version(),
601            payload,
602        })
603    }
604}
605
606// ── EventEnvelope convenience ──────────────────────────────────────────────────
607
608impl EventEnvelope {
609    /// Build a [`NewEvent`] causally linked to this envelope.
610    ///
611    /// Propagates `correlation_id`, `conversation_id`, `process_id`, and
612    /// `tenant_id` from the envelope and sets `envelope.event_id` as the
613    /// `causation_id` of the new event. Useful when generating a follow-up
614    /// event (e.g. an APERAK trigger event) that must be traceable back to
615    /// the UTILMD envelope that caused it.
616    ///
617    /// # Errors
618    ///
619    /// Returns [`EngineError::Serialization`] when the event payload cannot be
620    /// serialized to JSON.
621    ///
622    /// # Example
623    ///
624    /// ```rust,ignore
625    /// // After persisting a UTILMD receive event, trigger an APERAK:
626    /// let aperak_new = utilmd_envelope.new_caused_event(
627    ///     workflow_id,
628    ///     &SupplierChangeEvent::AperakDispatched { positive: true, reason: None },
629    /// )?;
630    /// ```
631    pub fn new_caused_event<E: EventPayload>(
632        &self,
633        workflow_id: WorkflowId,
634        event: &E,
635    ) -> Result<NewEvent, EngineError> {
636        let payload =
637            serde_json::to_value(event).map_err(|e| EngineError::Serialization(e.to_string()))?;
638        Ok(NewEvent {
639            correlation_id: self.correlation_id,
640            causation_id: Some(self.event_id.into()),
641            conversation_id: self.conversation_id,
642            process_id: self.process_id,
643            tenant_id: self.tenant_id,
644            workflow_id,
645            event_type: event.event_type().into(),
646            schema_version: event.schema_version(),
647            payload,
648        })
649    }
650}
651
652// ── execute_command ───────────────────────────────────────────────────────────
653
654/// Dispatch a command through a workflow and persist the resulting events.
655///
656/// This is the crate-internal write-path entry point. The public API is
657/// [`Process::execute`] / [`Process::execute_with`].
658///
659/// It performs, in order:
660///
661/// 1. **Load** all events from `stream_id` via `store`.
662/// 2. **Reconstruct state** by folding events through [`Workflow::apply`].
663/// 3. **Handle** the command via [`Workflow::handle`] (pure, no I/O).
664/// 4. **Build** [`NewEvent`] values from each domain event + `ctx`.
665/// 5. **Append** atomically with optimistic concurrency
666///    (`ExpectedVersion::Exact(current_sequence)`).
667///
668/// Returns the persisted envelopes (with store-assigned IDs and sequence
669/// numbers). Returns an empty `Vec` when the workflow produced no events.
670///
671/// # Errors
672///
673/// - [`EngineError::VersionConflict`] when a concurrent writer raced ahead.
674/// - [`EngineError::Workflow`] when the workflow rejects the command.
675/// - [`EngineError::Deserialization`] when a stored event cannot be decoded.
676///
677/// [`Process::execute`]: crate::process::Process::execute
678/// [`Process::execute_with`]: crate::process::Process::execute_with
679pub(crate) async fn execute_command<W, S>(
680    store: &S,
681    stream_id: &crate::ids::StreamId,
682    command: W::Command,
683    ctx: &CommandContext,
684) -> Result<Vec<EventEnvelope>, EngineError>
685where
686    W: Workflow,
687    S: EventStore,
688{
689    execute_command_and_collect::<W, S>(store, stream_id, command, ctx)
690        .await
691        .map(|(envelopes, _outbox)| envelopes)
692}
693
694/// Like [`execute_command`] but also returns the [`PendingOutbox`] entries
695/// produced by [`Workflow::handle`].
696///
697/// Use this when the caller needs to inspect or render the outbox messages
698/// produced by the command — for example, in E2E tests that render EDIFACT
699/// wire bytes from the workflow's outbox.  Avoids calling `handle()` a second
700/// time just to recover the outbox that `execute_command` silently discards.
701///
702/// [`PendingOutbox`]: crate::outbox::PendingOutbox
703pub(crate) async fn execute_command_and_collect<W, S>(
704    store: &S,
705    stream_id: &crate::ids::StreamId,
706    command: W::Command,
707    ctx: &CommandContext,
708) -> Result<(Vec<EventEnvelope>, Vec<PendingOutbox>), EngineError>
709where
710    W: Workflow,
711    S: EventStore,
712{
713    // ── 1 + 2. Stream-fold: reconstruct state without materialising a Vec ─────
714    //
715    // `fold_stream` feeds `EventEnvelope` values one-at-a-time; the engine
716    // never holds more than one envelope in memory during replay.  Because
717    // the envelope is owned, `env.payload` is moved into `W::upcast` without
718    // a clone — no extra heap allocation per event.
719    let (state, current_sequence) = store
720        .fold_stream(
721            stream_id,
722            0,
723            (W::State::default(), 0u64),
724            |(acc, _), env| {
725                let seq = env.sequence_number;
726                // env.payload is moved here — no clone required.
727                let payload = W::upcast(&env.event_type, env.schema_version, env.payload)?;
728                let event: W::Event = serde_json::from_value(payload)
729                    .map_err(|e| EngineError::Deserialization(e.to_string()))?;
730                Ok((W::apply(acc, &event), seq))
731            },
732        )
733        .await?;
734
735    // ── 3. Handle command (pure) ──────────────────────────────────────────────
736    let output = W::handle(&state, command)?;
737
738    if output.events.is_empty() {
739        return Ok((Vec::new(), output.outbox));
740    }
741
742    // ── 4. Build NewEvent values (caller-known metadata) ──────────────────────
743    let new_events: Result<Vec<NewEvent>, EngineError> = output
744        .events
745        .iter()
746        .map(|event| ctx.new_event(event))
747        .collect();
748    let new_events = new_events?;
749
750    // ── 5. Persist with optimistic concurrency ────────────────────────────────
751    // The store assigns event_id, sequence_number, stream_id, and timestamp.
752    // Outbox messages (output.outbox) are intentionally ignored here — use
753    // execute_command_atomic when atomic dual-writes are required.
754    let result = store
755        .append(
756            stream_id,
757            ExpectedVersion::Exact(current_sequence),
758            &new_events,
759        )
760        .await?;
761
762    Ok((result.events, output.outbox))
763}
764
765/// Like [`execute_command`] but atomically co-persists any [`PendingOutbox`]
766/// messages produced by [`Workflow::handle`].
767///
768/// Requires `S: AtomicAppend`.  All internal logic is identical to
769/// `execute_command`; the only difference is the persistence call at the end.
770pub(crate) async fn execute_command_atomic<W, S>(
771    store: &S,
772    stream_id: &crate::ids::StreamId,
773    command: W::Command,
774    ctx: &CommandContext,
775) -> Result<Vec<EventEnvelope>, EngineError>
776where
777    W: Workflow,
778    S: crate::event_store::AtomicAppend,
779{
780    // ── 1 + 2. Stream-fold: reconstruct state without materialising a Vec ─────
781    let (state, current_sequence) = store
782        .fold_stream(
783            stream_id,
784            0,
785            (W::State::default(), 0u64),
786            |(acc, _), env| {
787                let seq = env.sequence_number;
788                let payload = W::upcast(&env.event_type, env.schema_version, env.payload)?;
789                let event: W::Event = serde_json::from_value(payload)
790                    .map_err(|e| EngineError::Deserialization(e.to_string()))?;
791                Ok((W::apply(acc, &event), seq))
792            },
793        )
794        .await?;
795
796    // ── 3. Handle command (pure) ──────────────────────────────────────────────
797    let output = W::handle(&state, command)?;
798
799    if output.events.is_empty() {
800        return Ok(Vec::new());
801    }
802
803    // ── 4. Build NewEvent values ──────────────────────────────────────────────
804    let new_events: Result<Vec<NewEvent>, EngineError> = output
805        .events
806        .iter()
807        .map(|event| ctx.new_event(event))
808        .collect();
809    let new_events = new_events?;
810
811    // ── 5. Persist events + outbox atomically ─────────────────────────────────
812    let result = store
813        .append_with_outbox(
814            stream_id,
815            ExpectedVersion::Exact(current_sequence),
816            &new_events,
817            &output.outbox,
818        )
819        .await?;
820
821    Ok(result.events)
822}
823
824/// Like [`execute_command_atomic`] but co-persists `deadlines` in the same
825/// atomic write as events and outbox entries.
826///
827/// On [`SlateDbStore`] all three sets of writes land in a single SSI
828/// transaction, eliminating the non-atomic window between event persistence
829/// and deadline registration. On stores that use the default
830/// [`AtomicAppend::append_with_outbox_and_deadlines`] fallback, deadlines are
831/// **not** persisted here — callers must register them separately via
832/// [`DeadlineStore::register`].
833///
834/// This is the canonical implementation path for commands that must register
835/// a regulatory deadline (GPKE 24h, WiM 5 WT, GeLi Gas / WiM Gas 10 WT,
836/// MABIS 1 WT).
837///
838/// [`SlateDbStore`]: crate::store_slatedb::SlateDbStore
839/// [`DeadlineStore::register`]: crate::deadline::DeadlineStore::register
840pub(crate) async fn execute_command_atomic_with_deadlines<W, S>(
841    store: &S,
842    stream_id: &crate::ids::StreamId,
843    command: W::Command,
844    ctx: &CommandContext,
845    deadlines: &[crate::deadline::Deadline],
846) -> Result<Vec<EventEnvelope>, EngineError>
847where
848    W: Workflow,
849    S: crate::event_store::AtomicAppend,
850{
851    let (state, current_sequence) = store
852        .fold_stream(
853            stream_id,
854            0,
855            (W::State::default(), 0u64),
856            |(acc, _), env| {
857                let seq = env.sequence_number;
858                let payload = W::upcast(&env.event_type, env.schema_version, env.payload)?;
859                let event: W::Event = serde_json::from_value(payload)
860                    .map_err(|e| EngineError::Deserialization(e.to_string()))?;
861                Ok((W::apply(acc, &event), seq))
862            },
863        )
864        .await?;
865
866    let output = W::handle(&state, command)?;
867
868    if output.events.is_empty() {
869        return Ok(Vec::new());
870    }
871
872    let new_events: Result<Vec<NewEvent>, EngineError> = output
873        .events
874        .iter()
875        .map(|event| ctx.new_event(event))
876        .collect();
877    let new_events = new_events?;
878
879    // Merge externally-supplied deadlines with any PendingDeadline values
880    // returned by the workflow's handle function.
881    let mut all_deadlines: Vec<crate::deadline::Deadline> = deadlines.to_vec();
882    for pd in &output.deadlines {
883        all_deadlines.push(crate::deadline::Deadline::new(
884            stream_id.clone(),
885            ctx.process_id,
886            ctx.tenant_id,
887            ctx.workflow_id.clone(),
888            pd.label.as_str(),
889            pd.due_at,
890        ));
891    }
892
893    let result = store
894        .append_with_outbox_and_deadlines(
895            stream_id,
896            ExpectedVersion::Exact(current_sequence),
897            &new_events,
898            &output.outbox,
899            &all_deadlines,
900        )
901        .await?;
902
903    Ok(result.events)
904}
905
906/// Reconstruct `(W::State, current_sequence)` using an optional snapshot as a
907/// starting point.
908///
909/// When a snapshot with matching schema version exists, replay starts from
910/// `snap.sequence_number` (O(k) tail scan). Otherwise falls back to full replay.
911async fn reconstruct_with_snapshot<W, S, Snap>(
912    store: &S,
913    snap_store: &Snap,
914    stream_id: &crate::ids::StreamId,
915) -> Result<(W::State, u64), EngineError>
916where
917    W: Workflow,
918    W::State: serde::de::DeserializeOwned,
919    S: EventStore,
920    Snap: crate::snapshot::SnapshotStore,
921{
922    let maybe_snap = snap_store.load(stream_id).await?;
923    let (initial_state, from_sequence) = match &maybe_snap {
924        Some(snap) if snap.state_schema_version == W::state_schema_version() => {
925            let state = serde_json::from_value::<W::State>(snap.state.clone())
926                .map_err(|e| EngineError::Deserialization(e.to_string()))?;
927            (state, snap.sequence_number)
928        }
929        #[allow(unused_variables)]
930        Some(snap) => {
931            #[cfg(feature = "tracing")]
932            tracing::warn!(
933                expected = W::state_schema_version(),
934                actual   = snap.state_schema_version,
935                stream_id = %stream_id,
936                "snapshot schema version mismatch; falling back to full replay"
937            );
938            (W::State::default(), 0)
939        }
940        None => (W::State::default(), 0),
941    };
942    store
943        .fold_stream(
944            stream_id,
945            from_sequence,
946            (initial_state, from_sequence),
947            |(acc, _), env| {
948                let seq = env.sequence_number;
949                let payload = W::upcast(&env.event_type, env.schema_version, env.payload)?;
950                let event: W::Event = serde_json::from_value(payload)
951                    .map_err(|e| EngineError::Deserialization(e.to_string()))?;
952                Ok((W::apply(acc, &event), seq))
953            },
954        )
955        .await
956}
957
958/// Like [`execute_command`] but uses a snapshot store to skip full replay.
959///
960/// When a valid snapshot exists, only tail events since the snapshot are
961/// replayed — O(k) instead of O(n). Falls back to full replay when no snapshot
962/// exists or the schema version has changed.
963pub(crate) async fn execute_command_with_snapshot<W, S, Snap>(
964    store: &S,
965    snap_store: &Snap,
966    stream_id: &crate::ids::StreamId,
967    command: W::Command,
968    ctx: &CommandContext,
969) -> Result<Vec<EventEnvelope>, EngineError>
970where
971    W: Workflow,
972    W::State: serde::de::DeserializeOwned,
973    S: EventStore,
974    Snap: crate::snapshot::SnapshotStore,
975{
976    let (state, current_sequence) =
977        reconstruct_with_snapshot::<W, S, Snap>(store, snap_store, stream_id).await?;
978
979    let output = W::handle(&state, command)?;
980    if output.events.is_empty() {
981        return Ok(Vec::new());
982    }
983    let new_events: Result<Vec<NewEvent>, EngineError> = output
984        .events
985        .iter()
986        .map(|event| ctx.new_event(event))
987        .collect();
988    let new_events = new_events?;
989    let result = store
990        .append(
991            stream_id,
992            ExpectedVersion::Exact(current_sequence),
993            &new_events,
994        )
995        .await?;
996    Ok(result.events)
997}
998
999/// Like [`execute_command_atomic`] but uses a snapshot store to skip full replay.
1000///
1001/// Atomically co-persists outbox messages alongside events while using a
1002/// snapshot as the starting point for state reconstruction.
1003pub(crate) async fn execute_command_atomic_with_snapshot<W, S, Snap>(
1004    store: &S,
1005    snap_store: &Snap,
1006    stream_id: &crate::ids::StreamId,
1007    command: W::Command,
1008    ctx: &CommandContext,
1009) -> Result<Vec<EventEnvelope>, EngineError>
1010where
1011    W: Workflow,
1012    W::State: serde::de::DeserializeOwned,
1013    S: crate::event_store::AtomicAppend,
1014    Snap: crate::snapshot::SnapshotStore,
1015{
1016    let (state, current_sequence) =
1017        reconstruct_with_snapshot::<W, S, Snap>(store, snap_store, stream_id).await?;
1018
1019    let output = W::handle(&state, command)?;
1020    if output.events.is_empty() {
1021        return Ok(Vec::new());
1022    }
1023    let new_events: Result<Vec<NewEvent>, EngineError> = output
1024        .events
1025        .iter()
1026        .map(|event| ctx.new_event(event))
1027        .collect();
1028    let new_events = new_events?;
1029    let result = store
1030        .append_with_outbox(
1031            stream_id,
1032            ExpectedVersion::Exact(current_sequence),
1033            &new_events,
1034            &output.outbox,
1035        )
1036        .await?;
1037    Ok(result.events)
1038}