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}