Skip to main content

mako_engine/
process.rs

1//! [`Process`] — ergonomic typed handle for a single MaKo process instance.
2//!
3//! Instead of threading `stream_id`, `workflow_id`, `tenant_id`, and a store
4//! reference through every call to the write path, bind them once into a
5//! `Process<W, S>` and call [`execute`] / [`state`] directly.
6//!
7//! # Starting a new process
8//!
9//! ```rust,ignore
10//! use mako_engine::{
11//!     event_store::InMemoryEventStore,
12//!     ids::TenantId,
13//!     process::Process,
14//!     version::WorkflowId,
15//! };
16//!
17//! let store = InMemoryEventStore::new();
18//! let process = Process::<MyWorkflow, _>::new(
19//!     store,
20//!     TenantId::new(),
21//!     WorkflowId::new("my-workflow", "FV2024-10-01"),
22//! );
23//!
24//! let envelopes = process.execute(my_command).await?;
25//! let current   = process.state().await?;
26//! ```
27//!
28//! # Resuming an existing process
29//!
30//! ```rust,ignore
31//! let process = Process::<MyWorkflow, _>::from_stream(
32//!     store, stream_id, process_id, tenant_id, workflow_id,
33//! );
34//! ```
35//!
36//! [`execute`]: Process::execute
37//! [`state`]: Process::state
38
39use std::marker::PhantomData;
40
41use crate::{
42    envelope::EventEnvelope,
43    error::EngineError,
44    event_store::EventStore,
45    ids::{ProcessId, ProcessIdentity, StreamId, TenantId},
46    snapshot::{Snapshot, SnapshotStore},
47    version::WorkflowId,
48    workflow::{
49        CommandContext, Workflow, execute_command, execute_command_and_collect,
50        execute_command_with_snapshot,
51    },
52};
53
54// ── Process ───────────────────────────────────────────────────────────────────
55
56/// An ergonomic typed handle for a single MaKo process instance.
57///
58/// `Process` bundles the [`StreamId`], [`ProcessId`], [`TenantId`],
59/// [`WorkflowId`], and event store into a single owned value so callers do not
60/// need to pass them on every command dispatch.
61///
62/// ## Generic parameters
63///
64/// - `W` — the [`Workflow`] implementation. In practice this is a zero-size
65///   marker struct; the type parameter carries the domain logic as associated
66///   types.
67/// - `S` — the [`EventStore`] backend. `InMemoryEventStore` (requires `testing` feature) is the default
68///   for tests; production deployments wrap a persistent backend in
69///   [`Arc`][std::sync::Arc] and use `Process<W, Arc<MyStore>>`.
70///
71/// ## Clone semantics
72///
73/// If `S: Clone` (e.g. `InMemoryEventStore` (requires `testing` feature) or `Arc<…>`), `Process` is also
74/// `Clone` and all clones share the same underlying storage.
75#[expect(clippy::struct_field_names)] // `process_id` and `stream_id` are intentional: they
76// describe engine-layer concepts, not redundant prefixes.
77pub struct Process<W: Workflow, S: EventStore> {
78    stream_id: StreamId,
79    process_id: ProcessId,
80    tenant_id: TenantId,
81    workflow_id: WorkflowId,
82    store: S,
83    _phantom: PhantomData<fn() -> W>,
84}
85
86impl<W: Workflow, S: EventStore> Process<W, S> {
87    /// Create a fresh process instance.
88    ///
89    /// Generates a new [`ProcessId`] and derives the [`StreamId`] from
90    /// `tenant_id` and `process_id` (`process/{tenant_id}/{process_id}`).
91    /// Use this when starting a new MaKo process
92    /// (e.g. on receipt of the first inbound UTILMD Lieferbeginn).
93    #[must_use]
94    pub fn new(store: S, tenant_id: TenantId, workflow_id: WorkflowId) -> Self {
95        let process_id = ProcessId::new();
96        let stream_id = StreamId::for_process(tenant_id, &process_id);
97        Self {
98            stream_id,
99            process_id,
100            tenant_id,
101            workflow_id,
102            store,
103            _phantom: PhantomData,
104        }
105    }
106
107    /// Attach to an existing process stream.
108    ///
109    /// Use this on service restart or when routing an inbound message to an
110    /// already-running process whose identifiers were previously persisted.
111    #[must_use]
112    pub fn from_stream(
113        store: S,
114        stream_id: StreamId,
115        process_id: ProcessId,
116        tenant_id: TenantId,
117        workflow_id: WorkflowId,
118    ) -> Self {
119        Self {
120            stream_id,
121            process_id,
122            tenant_id,
123            workflow_id,
124            store,
125            _phantom: PhantomData,
126        }
127    }
128
129    /// The event stream identifier for this process.
130    #[must_use]
131    pub fn stream_id(&self) -> &StreamId {
132        &self.stream_id
133    }
134
135    /// The stable process identifier.
136    #[must_use]
137    pub fn process_id(&self) -> ProcessId {
138        self.process_id
139    }
140
141    /// The tenant that owns this process.
142    #[must_use]
143    pub fn tenant_id(&self) -> TenantId {
144        self.tenant_id
145    }
146
147    /// The workflow version under which this process was created.
148    #[must_use]
149    pub fn workflow_id(&self) -> &WorkflowId {
150        &self.workflow_id
151    }
152
153    /// Return a serializable value bundle of all four process identifiers.
154    ///
155    /// Persist this to a routing table (e.g. keyed by `conversation_id` or
156    /// `correlation_id`) so inbound messages can be routed to the correct
157    /// running process without the caller needing to manage four separate
158    /// fields.
159    ///
160    /// Use [`Process::from_identity`] to re-attach to the same process stream
161    /// on a subsequent request.
162    ///
163    /// ```rust,ignore
164    /// let id = process.identity();
165    /// routing_table.insert(conv_id, id.clone());
166    ///
167    /// // Later, on a subsequent inbound message:
168    /// let id = routing_table.get(&conv_id)?;
169    /// let process = Process::<MyWorkflow, _>::from_identity(store, id);
170    /// ```
171    #[must_use]
172    pub fn identity(&self) -> ProcessIdentity {
173        ProcessIdentity::new(self.process_id, self.tenant_id, self.workflow_id.clone())
174    }
175
176    /// Build a [`CommandContext`] for an inbound EDIFACT message dispatch.
177    ///
178    /// Derives a **deterministic** [`CorrelationId`] from `interchange_ref`
179    /// (UUID v5) so repeated dispatches of the same EDIFACT message — e.g.
180    /// AS4 retransmissions or idempotent REST replays — produce the same
181    /// correlation root. This makes EDIFACT-level idempotency observable in
182    /// distributed traces without any extra dedup logic at the engine level.
183    ///
184    /// Use this instead of [`Process::execute`] when you need to propagate
185    /// EDIFACT correlation metadata into the event stream. The returned
186    /// context is passed to [`Process::execute_with`].
187    ///
188    /// # Example
189    ///
190    /// ```rust,ignore
191    /// let process = ctx.resume::<GpkeSupplierChangeWorkflow>(identity);
192    /// let cmd_ctx = process.context_for_inbound(&utilmd_interchange_ref);
193    /// process.execute_with(command, cmd_ctx).await?;
194    /// ```
195    ///
196    /// [`CorrelationId`]: crate::ids::CorrelationId
197    #[must_use]
198    pub fn context_for_inbound(&self, interchange_ref: &str) -> CommandContext {
199        CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone())
200            .with_correlation(crate::ids::CorrelationId::from_interchange_ref(
201                interchange_ref,
202            ))
203    }
204
205    /// Attach to an existing process stream from a previously persisted
206    /// [`ProcessIdentity`].
207    ///
208    /// This is the companion to [`Process::identity`]: look up the identity
209    /// from your routing table and call `from_identity` to get a live
210    /// `Process` handle bound to `store`.
211    #[must_use]
212    pub fn from_identity(store: S, identity: ProcessIdentity) -> Self {
213        Self {
214            stream_id: identity.stream_id().clone(),
215            process_id: identity.process_id,
216            tenant_id: identity.tenant_id,
217            workflow_id: identity.workflow_id,
218            store,
219            _phantom: PhantomData,
220        }
221    }
222
223    /// Return the number of events currently in the stream.
224    ///
225    /// Uses [`EventStore::stream_version`] for an efficient O(1) metadata
226    /// query on backends that override it. Falls back to loading all events
227    /// on stores that use the default implementation.
228    ///
229    /// Use this to decide whether to take a snapshot — e.g. with
230    /// [`Snapshot::should_take`]:
231    ///
232    /// ```rust,ignore
233    /// if Snapshot::should_take(process.event_count().await?, 100) {
234    ///     process.take_snapshot(&snap_store, 100).await?;
235    /// }
236    /// ```
237    ///
238    /// # Errors
239    ///
240    /// Returns [`EngineError::Store`] on storage failures.
241    ///
242    /// [`Snapshot::should_take`]: crate::snapshot::Snapshot::should_take
243    pub async fn event_count(&self) -> Result<u64, EngineError> {
244        self.store.stream_version(&self.stream_id).await
245    }
246
247    /// Dispatch `command` using a freshly generated [`CommandContext`].
248    ///
249    /// A new [`CorrelationId`] and [`ConversationId`] are auto-generated for
250    /// each call. To propagate tracing IDs from an inbound EDIFACT message
251    /// across a multi-step command chain, use [`execute_with`].
252    ///
253    /// # Errors
254    ///
255    /// - [`EngineError::VersionConflict`] when a concurrent writer raced ahead;
256    ///   retry by calling `execute` again.
257    /// - [`EngineError::Workflow`] when the workflow rejects the command.
258    /// - [`EngineError::Deserialization`] when a stored event cannot be decoded.
259    ///
260    /// [`CorrelationId`]: crate::ids::CorrelationId
261    /// [`ConversationId`]: crate::ids::ConversationId
262    /// [`execute_with`]: Process::execute_with
263    #[cfg_attr(
264        feature = "tracing",
265        tracing::instrument(skip(self, command), fields(
266            workflow = %self.workflow_id,
267            process_id = %self.process_id,
268            stream_id = %self.stream_id,
269        ))
270    )]
271    pub async fn execute(&self, command: W::Command) -> Result<Vec<EventEnvelope>, EngineError> {
272        let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
273        execute_command::<W, S>(&self.store, &self.stream_id, command, &ctx).await
274    }
275
276    /// Like [`execute`] but also returns the outbox messages produced by
277    /// [`Workflow::handle`], fully stamped with the real IDs from the persisted
278    /// event.
279    ///
280    /// The returned [`OutboxMessage`] entries have their `causation_event_id`
281    /// set to the `event_id` of the first persisted event — identical to what
282    /// `execute_and_enqueue` writes into the [`OutboxStore`] atomically.  This
283    /// makes the messages ready to pass directly to the EDIFACT renderer
284    /// without any manual ID stitching.
285    ///
286    /// Use this in E2E and integration tests that need to inspect or render
287    /// outbox messages after a command is persisted, without the awkward
288    /// `handle()` + `execute()` double invocation.
289    ///
290    /// [`execute`]: Process::execute
291    /// [`OutboxMessage`]: crate::outbox::OutboxMessage
292    /// [`OutboxStore`]: crate::outbox::OutboxStore
293    ///
294    /// # Errors
295    ///
296    /// Returns [`EngineError`] on storage or command handling failure.
297    pub async fn execute_and_collect(
298        &self,
299        command: W::Command,
300    ) -> Result<(Vec<EventEnvelope>, Vec<crate::outbox::OutboxMessage>), EngineError> {
301        let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
302        let (events, pending) =
303            execute_command_and_collect::<W, S>(&self.store, &self.stream_id, command, &ctx)
304                .await?;
305
306        // Stamp each PendingOutbox with the real IDs from the persisted event.
307        // Using the first event's event_id as causation_event_id mirrors what
308        // execute_and_enqueue writes into the OutboxStore atomically.
309        let causation_event_id = events
310            .first()
311            .map_or_else(crate::ids::EventId::new, |e| e.event_id);
312
313        let outbox = pending
314            .into_iter()
315            .map(|p| {
316                crate::outbox::OutboxMessage::new(
317                    self.stream_id.clone(),
318                    self.process_id,
319                    self.tenant_id,
320                    ctx.correlation_id,
321                    ctx.conversation_id,
322                    causation_event_id,
323                    p.message_type,
324                    p.recipient,
325                    p.payload,
326                )
327            })
328            .collect();
329
330        Ok((events, outbox))
331    }
332
333    /// Dispatch `command` with a caller-supplied [`CommandContext`].
334    ///
335    /// Use this when you need to thread a specific `correlation_id`,
336    /// `conversation_id`, or `causation_id` through the command. For example,
337    /// when dispatching an APERAK in response to a UTILMD, pass the
338    /// `conversation_id` from the UTILMD envelope so both exchanges are
339    /// traceable as a single business conversation.
340    ///
341    /// Build a context with:
342    ///
343    /// ```rust,ignore
344    /// let ctx = CommandContext::new(tenant_id, process_id, workflow_id)
345    ///     .with_causation(utilmd_event_id.into())  // From<EventId> for CausationId
346    ///     .with_conversation(utilmd_conversation_id);
347    /// process.execute_with(DispatchAperak { .. }, ctx).await?;
348    /// ```
349    ///
350    /// # Errors
351    ///
352    /// See [`Process::execute`] for the error contract.
353    ///
354    /// [`Process::execute`]: Process::execute
355    #[cfg_attr(
356        feature = "tracing",
357        tracing::instrument(skip(self, command, ctx), fields(
358            workflow = %self.workflow_id,
359            process_id = %self.process_id,
360            correlation_id = %ctx.correlation_id,
361        ))
362    )]
363    pub async fn execute_with(
364        &self,
365        command: W::Command,
366        ctx: CommandContext,
367    ) -> Result<Vec<EventEnvelope>, EngineError> {
368        execute_command::<W, S>(&self.store, &self.stream_id, command, &ctx).await
369    }
370
371    /// Dispatch `command` using a snapshot store to accelerate state reconstruction.
372    ///
373    /// Equivalent to [`Process::execute`] but starts replay from the most recent
374    /// snapshot rather than from sequence 0. For streams with thousands of events
375    /// and a snapshot within the last 100 events, this reduces replay cost from
376    /// O(n) to O(k) where k is the tail length since the last snapshot.
377    ///
378    /// When no snapshot exists or the schema version has changed, falls back to
379    /// full O(n) replay — identical in cost to [`Process::execute`].
380    ///
381    /// # Errors
382    ///
383    /// Same contract as [`Process::execute`].
384    pub async fn execute_snapshot<Snap>(
385        &self,
386        command: W::Command,
387        snap_store: &Snap,
388    ) -> Result<Vec<EventEnvelope>, EngineError>
389    where
390        W::State: serde::de::DeserializeOwned,
391        Snap: SnapshotStore,
392    {
393        let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
394        execute_command_with_snapshot::<W, S, Snap>(
395            &self.store,
396            snap_store,
397            &self.stream_id,
398            command,
399            &ctx,
400        )
401        .await
402    }
403
404    /// Reconstruct the current workflow state by replaying all persisted events.
405    ///
406    /// This is a **read-only** operation — it loads events but does not
407    /// acquire any write lock or check optimistic concurrency. Use it to:
408    ///
409    /// - Inspect process status in tests without dispatching a command.
410    /// - Build a diagnostic snapshot for observability or health checks.
411    /// - Implement query-side read models that need the full typed state.
412    ///
413    /// For production read models, prefer a [`Projection`] that is updated
414    /// incrementally rather than replaying the full stream on every query.
415    ///
416    /// To accelerate replay for long-lived streams, use
417    /// [`Process::state_with_snapshot`] instead.
418    ///
419    /// # Errors
420    ///
421    /// - [`EngineError::Store`] on storage failures.
422    /// - [`EngineError::Deserialization`] when a stored event cannot be decoded
423    ///   into `W::Event` (schema migration required).
424    ///
425    /// [`Projection`]: crate::projection::Projection
426    #[cfg_attr(
427        feature = "tracing",
428        tracing::instrument(skip(self), fields(
429            workflow = %self.workflow_id,
430            stream_id = %self.stream_id,
431        ))
432    )]
433    pub async fn state(&self) -> Result<W::State, EngineError> {
434        self.store
435            .fold_stream(&self.stream_id, 0, W::State::default(), |acc, env| {
436                let payload = W::upcast(&env.event_type, env.schema_version, env.payload)?;
437                let event: W::Event = serde_json::from_value(payload)
438                    .map_err(|e| EngineError::Deserialization(e.to_string()))?;
439                Ok(W::apply(acc, &event))
440            })
441            .await
442    }
443
444    // ── Snapshot-aware state reconstruction ──────────────────────────────────
445
446    /// Reconstruct current state using a snapshot as the starting point.
447    ///
448    /// Loads the most recent snapshot for this stream from `snap_store`. If
449    /// one exists, deserializes it into `W::State` and then replays only
450    /// events appended **after** the snapshot's `sequence_number`
451    /// (O(k) instead of O(n)). Falls back to full replay when no snapshot
452    /// exists.
453    ///
454    /// ## When to use
455    ///
456    /// Use this instead of [`Process::state`] for long-lived processes where
457    /// the event count grows large. Pair it with [`Process::take_snapshot`]
458    /// to keep the snapshot store current after each command.
459    ///
460    /// ## Schema version compatibility
461    ///
462    /// Snapshots whose `state` field cannot be deserialized into `W::State`
463    /// (e.g. after a breaking state schema change) will return
464    /// [`EngineError::Deserialization`]. In that case, fall back to
465    /// [`Process::state`] (full replay) and take a fresh snapshot.
466    ///
467    /// # Errors
468    ///
469    /// - [`EngineError::Store`] on snapshot or event storage failures.
470    /// - [`EngineError::Deserialization`] when the snapshot state or a tail
471    ///   event cannot be decoded.
472    #[cfg_attr(
473        feature = "tracing",
474        tracing::instrument(skip(self, snap_store), fields(
475            workflow = %self.workflow_id,
476            stream_id = %self.stream_id,
477        ))
478    )]
479    pub async fn state_with_snapshot<Snap: SnapshotStore>(
480        &self,
481        snap_store: &Snap,
482    ) -> Result<W::State, EngineError>
483    where
484        W::State: serde::de::DeserializeOwned,
485    {
486        let maybe_snap = snap_store.load(&self.stream_id).await?;
487
488        let (initial_state, from_sequence) = match maybe_snap {
489            Some(snap) => {
490                if snap.state_schema_version == W::state_schema_version() {
491                    let state = serde_json::from_value::<W::State>(snap.state)
492                        .map_err(|e| EngineError::Deserialization(e.to_string()))?;
493                    (state, snap.sequence_number)
494                } else {
495                    // Schema version mismatch: discard the stale snapshot and
496                    // fall back to full replay. The caller should take a fresh
497                    // snapshot after this reconstruction completes.
498                    tracing::warn!(
499                        expected = W::state_schema_version(),
500                        actual   = snap.state_schema_version,
501                        stream_id = %self.stream_id,
502                        "snapshot schema version mismatch; falling back to full replay"
503                    );
504                    (W::State::default(), 0)
505                }
506            }
507            None => (W::State::default(), 0),
508        };
509
510        let tail = self
511            .store
512            .fold_stream(&self.stream_id, from_sequence, initial_state, |acc, env| {
513                let payload = W::upcast(&env.event_type, env.schema_version, env.payload)?;
514                let event: W::Event = serde_json::from_value(payload)
515                    .map_err(|e| EngineError::Deserialization(e.to_string()))?;
516                Ok(W::apply(acc, &event))
517            })
518            .await?;
519        Ok(tail)
520    }
521
522    /// Reconstruct current state and save a snapshot if the event-count
523    /// threshold is reached.
524    ///
525    /// Checks [`Snapshot::should_take`] with `interval`. When at least
526    /// `interval` new events have accumulated since the last snapshot,
527    /// reconstructs state via full replay, serializes it, and calls
528    /// [`SnapshotStore::save`].
529    ///
530    /// Returns `true` when a snapshot was taken, `false` when the threshold
531    /// was not reached or `interval` is `0`.
532    ///
533    /// ## Integration pattern
534    ///
535    /// ```rust,ignore
536    /// // After every successful command:
537    /// process.execute(command).await?;
538    /// process.take_snapshot(&snap_store, 100).await?;
539    ///
540    /// // On the read path — O(k) instead of O(n):
541    /// let state = process.state_with_snapshot(&snap_store).await?;
542    /// ```
543    ///
544    /// # Errors
545    ///
546    /// - [`EngineError::Store`] on snapshot storage failures.
547    /// - [`EngineError::Serialization`] when the state cannot be JSON-encoded.
548    /// - [`EngineError::Deserialization`] when a stored event cannot be decoded.
549    ///
550    /// [`Snapshot::should_take`]: crate::snapshot::Snapshot::should_take
551    pub async fn take_snapshot<Snap: SnapshotStore>(
552        &self,
553        snap_store: &Snap,
554        interval: u64,
555    ) -> Result<bool, EngineError>
556    where
557        W::State: serde::Serialize,
558    {
559        let count = self.event_count().await?;
560        // Load the last snapshot (if any) to get its sequence number.
561        let last_snap_seq = snap_store
562            .load(&self.stream_id)
563            .await?
564            .map_or(0, |s| s.sequence_number);
565        if !Snapshot::should_take(count, last_snap_seq, interval) {
566            return Ok(false);
567        }
568        let state = self.state().await?;
569        let payload =
570            serde_json::to_value(&state).map_err(|e| EngineError::Serialization(e.to_string()))?;
571        let snap = Snapshot::new(
572            self.stream_id.clone(),
573            count,
574            W::state_schema_version(),
575            payload,
576        );
577        snap_store.save(&snap).await?;
578        Ok(true)
579    }
580
581    // ── Retry ─────────────────────────────────────────────────────────────────
582
583    /// Dispatch `command` with automatic retry on [`EngineError::VersionConflict`].
584    ///
585    /// A version conflict occurs when a concurrent writer appended events
586    /// between this process's read and its append attempt. On each conflict,
587    /// the engine **reloads the complete event stream from the store and
588    /// replays all events** to rebuild fresh state before re-handling the
589    /// command. Stale in-memory state from a previous attempt is never
590    /// carried forward — each retry always starts from a fully-rebuilt snapshot.
591    ///
592    /// Non-conflict errors (storage failures, workflow rejections) are
593    /// returned immediately without retrying.
594    ///
595    /// A freshly-generated [`CommandContext`] is pinned before the first
596    /// attempt and reused across all retries so all events share the same
597    /// correlation root regardless of retry count. Use
598    /// [`execute_with_retry_ctx`] to supply a specific context (e.g. one
599    /// derived from an inbound EDIFACT envelope).
600    ///
601    /// ## When to use
602    ///
603    /// Use for commands where two inbound EDIFACT messages for the same
604    /// process may arrive concurrently — e.g. a UTILMD and its APERAK
605    /// processed on separate async tasks.
606    ///
607    /// ## Command cloning
608    ///
609    /// `W::Command` must implement [`Clone`] so it can be resubmitted on
610    /// each retry without reconstructing it from scratch.
611    ///
612    /// # Errors
613    ///
614    /// - [`EngineError::VersionConflict`] when all `max_attempts` are
615    ///   exhausted without a successful append.
616    /// - Any non-conflict [`EngineError`] returned by the workflow or storage.
617    /// - [`EngineError::Store`] when `max_attempts` is `0`.
618    ///
619    /// [`execute_with_retry_ctx`]: Process::execute_with_retry_ctx
620    pub async fn execute_with_retry(
621        &self,
622        command: W::Command,
623        max_attempts: u32,
624    ) -> Result<Vec<EventEnvelope>, EngineError>
625    where
626        W::Command: Clone,
627    {
628        if max_attempts == 0 {
629            return Err(EngineError::store("max_attempts must be >= 1"));
630        }
631        // Pin context before the loop — all retry attempts share the same
632        // correlation root for consistent distributed tracing.
633        let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
634        self.execute_with_retry_ctx(command, ctx, max_attempts)
635            .await
636    }
637
638    /// Dispatch `command` with a caller-supplied [`CommandContext`] and
639    /// automatic retry on [`EngineError::VersionConflict`].
640    ///
641    /// Identical to [`execute_with_retry`] but threads the provided `ctx`
642    /// (including its `correlation_id`, `conversation_id`, and `causation_id`)
643    /// through every retry attempt. Use this when you need to propagate
644    /// tracing IDs from an inbound EDIFACT envelope across a retried command.
645    ///
646    /// # Example
647    ///
648    /// ```rust,ignore
649    /// let ctx = CommandContext::from_envelope(&utilmd_envelope, workflow_id);
650    /// process.execute_with_retry_ctx(HandleAperak { .. }, ctx, 3).await?;
651    /// ```
652    ///
653    /// # Errors
654    ///
655    /// See [`execute_with_retry`] for the error contract.
656    ///
657    /// # Panics
658    ///
659    /// Panics if `max_attempts` is 0 and the guard at the top of the function
660    /// is somehow bypassed (unreachable in practice).
661    ///
662    /// [`execute_with_retry`]: Process::execute_with_retry
663    pub async fn execute_with_retry_ctx(
664        &self,
665        command: W::Command,
666        ctx: CommandContext,
667        max_attempts: u32,
668    ) -> Result<Vec<EventEnvelope>, EngineError>
669    where
670        W::Command: Clone,
671    {
672        if max_attempts == 0 {
673            return Err(EngineError::store("max_attempts must be >= 1"));
674        }
675        let mut conflict_err: Option<EngineError> = None;
676        for attempt in 0..max_attempts {
677            // Each call to `execute_with` internally calls `fold_stream` from
678            // sequence 0 (or from the most recent snapshot if one is available).
679            // State is always freshly reconstructed from the event log on every
680            // attempt — there is no stale state carried forward between retries.
681            // Do NOT "optimise" this by caching state across attempts; doing so
682            // would allow a winning concurrent writer's events to be invisible
683            // to the retry, producing incorrect decisions and duplicate events.
684            match self.execute_with(command.clone(), ctx.clone()).await {
685                Ok(envs) => return Ok(envs),
686                Err(e) if e.is_version_conflict() => {
687                    conflict_err = Some(e);
688                    // Brief jittered sleep to reduce thundering-herd under
689                    // concurrent ERP commands targeting the same stream.
690                    // Delay = uniform random in [0, 10ms * attempt], capped at 80ms.
691                    // Uses the OS CSPRNG via rand so every retry gets independent
692                    // entropy regardless of stream-ID prefix.
693                    if attempt + 1 < max_attempts {
694                        let entropy: u64 = rand::random();
695                        let window_ms: u64 = (10 * (u64::from(attempt) + 1)).min(80);
696                        let jitter_ms = if window_ms == 0 {
697                            0
698                        } else {
699                            entropy % window_ms
700                        };
701                        tokio::time::sleep(std::time::Duration::from_millis(jitter_ms)).await;
702                    }
703                }
704                Err(e) => return Err(e), // non-retriable — propagate immediately
705            }
706        }
707        // At least one attempt ran (max_attempts >= 1), so conflict_err is Some.
708        Err(conflict_err.expect("loop ran at least once"))
709    }
710
711    /// Execute `command` and atomically co-persist any [`PendingOutbox`] messages
712    /// produced by [`Workflow::handle`].
713    ///
714    /// Like [`execute`], but requires `S: AtomicAppend`. When the workflow's
715    /// `handle` returns outbox messages alongside events, both are written to
716    /// storage in a single `WriteBatch`, eliminating the silent message-loss
717    /// window that would exist with separate writes.
718    ///
719    /// When the handle returns no outbox messages, this degenerates to a plain
720    /// `EventStore::append` (no performance cost).
721    ///
722    /// **Use this method instead of [`execute`] in all production code** that
723    /// needs outbox delivery guarantees. Plain `execute` silently drops any
724    /// outbox entries produced by the workflow handler — a crash between
725    /// `execute` and a subsequent manual `OutboxStore::enqueue` call would
726    /// lose the APERAK or UTILMD response permanently.
727    ///
728    /// For long event streams with periodic snapshots use
729    /// [`execute_and_enqueue_snapshot`] to reduce O(n) replay cost to O(k).
730    /// In concurrent environments where `VersionConflict` is expected, use
731    /// [`execute_and_enqueue_with_retry`] to retry automatically.
732    ///
733    /// # Example
734    ///
735    /// ```rust,ignore
736    /// use std::sync::Arc;
737    /// use mako_engine::process::Process;
738    /// use mako_engine::version::WorkflowId;
739    /// use mako_engine::ids::TenantId;
740    ///
741    /// // SlateDbStore implements AtomicAppend — required for execute_and_enqueue.
742    /// let store = Arc::new(SlateDbStore::open_in_memory().await?);
743    /// let tenant_id = TenantId::from_party_id("9904231000007");
744    /// let workflow_id = WorkflowId::new("gpke-supplier-change", fv);
745    ///
746    /// let process = Process::<GpkeSupplierChangeWorkflow, _>::new(
747    ///     Arc::clone(&store),
748    ///     tenant_id,
749    ///     workflow_id,
750    /// );
751    ///
752    /// // The workflow handle emits a PendingOutbox APERAK entry alongside the event.
753    /// // execute_and_enqueue writes both in one WriteBatch — no partial-write window.
754    /// let events = process
755    ///     .execute_and_enqueue(GpkeCommand::ReceiveUtilmd { pid: 55001, payload })
756    ///     .await?;
757    ///
758    /// assert!(!events.is_empty(), "at least one event was persisted");
759    ///
760    /// // The APERAK outbox entry is now visible to the outbox worker:
761    /// let pending = store.peek_outbox(tenant_id, 10).await?;
762    /// assert_eq!(pending.len(), 1, "APERAK enqueued atomically with the event");
763    /// ```
764    ///
765    /// # Errors
766    ///
767    /// - [`EngineError::VersionConflict`] — stream was modified concurrently;
768    ///   retry with [`execute_and_enqueue_with_retry`].
769    /// - [`EngineError::Workflow`] — the command was rejected by the workflow.
770    /// - [`EngineError::Store`] / [`EngineError::Outbox`] — storage failure.
771    ///
772    /// [`PendingOutbox`]: crate::outbox::PendingOutbox
773    /// [`execute`]: Process::execute
774    /// [`execute_and_enqueue_snapshot`]: Process::execute_and_enqueue_snapshot
775    /// [`execute_and_enqueue_with_retry`]: Process::execute_and_enqueue_with_retry
776    pub async fn execute_and_enqueue(
777        &self,
778        command: W::Command,
779    ) -> Result<Vec<EventEnvelope>, EngineError>
780    where
781        S: crate::event_store::AtomicAppend,
782    {
783        let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
784        crate::workflow::execute_command_atomic::<W, S>(&self.store, &self.stream_id, command, &ctx)
785            .await
786    }
787
788    /// Like [`execute_and_enqueue`] but co-persists `deadlines` in the same
789    /// atomic write as events and outbox entries.
790    ///
791    /// On `SlateDbStore` (requires `slatedb` feature) this writes events, outbox entries, **and** deadlines
792    /// in a single SSI transaction.  On in-memory test stores the default
793    /// fallback is used: events and outbox are written atomically, deadlines are
794    /// **not** persisted here and must be registered separately.
795    ///
796    /// Use this method for commands that must register a regulatory deadline
797    /// (GPKE 24h APERAK, WiM 5 WT, GeLi Gas / WiM Gas 10 WT, MABIS 1 WT).
798    ///
799    /// [`execute_and_enqueue`]: Process::execute_and_enqueue
800    ///
801    /// # Errors
802    ///
803    /// Returns [`EngineError`] on storage or command handling failure.
804    pub async fn execute_and_enqueue_with_deadlines(
805        &self,
806        command: W::Command,
807        deadlines: &[crate::deadline::Deadline],
808    ) -> Result<Vec<EventEnvelope>, EngineError>
809    where
810        S: crate::event_store::AtomicAppend,
811    {
812        let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
813        crate::workflow::execute_command_atomic_with_deadlines::<W, S>(
814            &self.store,
815            &self.stream_id,
816            command,
817            &ctx,
818            deadlines,
819        )
820        .await
821    }
822
823    /// Like [`execute_and_enqueue`] but uses a snapshot to accelerate replay.
824    ///
825    /// Atomically persists events and outbox entries while starting state
826    /// reconstruction from the most recent snapshot. For long streams with
827    /// periodic snapshots this reduces replay cost from O(n) to O(k).
828    ///
829    /// [`execute_and_enqueue`]: Process::execute_and_enqueue
830    ///
831    /// # Errors
832    ///
833    /// Returns [`EngineError`] on storage or command handling failure.
834    pub async fn execute_and_enqueue_snapshot<Snap>(
835        &self,
836        command: W::Command,
837        snap_store: &Snap,
838    ) -> Result<Vec<EventEnvelope>, EngineError>
839    where
840        W::State: serde::de::DeserializeOwned,
841        S: crate::event_store::AtomicAppend,
842        Snap: SnapshotStore,
843    {
844        let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
845        crate::workflow::execute_command_atomic_with_snapshot::<W, S, Snap>(
846            &self.store,
847            snap_store,
848            &self.stream_id,
849            command,
850            &ctx,
851        )
852        .await
853    }
854
855    /// Dispatch the compensation command returned by [`Workflow::on_deadline`].
856    ///
857    /// Reconstructs the current process state, calls
858    /// `W::on_deadline(deadline, &state)`, and — if the hook returns
859    /// `Some(command)` — executes it via [`Process::execute_and_enqueue`],
860    /// which atomically persists events **and** any outbox entries (e.g.
861    /// APERAK Ablehnung) produced by the compensation handler.
862    ///
863    /// Returns `Ok(Some(events))` when compensation fired, `Ok(None)` when
864    /// the hook returned `None` (deadline acknowledged as no-op).
865    ///
866    /// This is the canonical way to wire deadline firings to workflow
867    /// compensation logic.  Any [`WorkflowOutput::with_outbox`] entries
868    /// returned by `on_deadline` are guaranteed to be persisted atomically —
869    /// there is no window where the event is stored but the outbox entry is
870    /// lost.
871    ///
872    /// # Example
873    ///
874    /// ```rust,ignore
875    /// // In the deadline worker:
876    /// let overdue = ctx.deadline_store().due_now(50).await?;
877    /// for deadline in overdue {
878    ///     let identity = ctx.registry()
879    ///         .lookup(deadline.tenant_id(), &RegistryKey::from_process(deadline.process_id()))
880    ///         .await?
881    ///         .expect("process must be registered");
882    ///     let process = ctx.resume::<GpkeSupplierChangeWorkflow>(identity);
883    ///     if let Some(events) = process.execute_timeout(&deadline).await? {
884    ///         // compensation command was dispatched — APERAK Ablehnung enqueued
885    ///         tracing::info!(events = events.len(), "timeout compensation applied");
886    ///     }
887    ///     ctx.deadline_store().cancel(deadline.deadline_id()).await?;
888    /// }
889    /// ```
890    ///
891    /// # Errors
892    ///
893    /// Propagates [`EngineError::VersionConflict`], [`EngineError::Workflow`],
894    /// and storage errors from `execute_and_enqueue`. Use
895    /// [`execute_timeout_with_retry`] when `VersionConflict` retries are
896    /// required.
897    ///
898    /// [`Workflow::on_deadline`]: crate::workflow::Workflow::on_deadline
899    /// [`WorkflowOutput::with_outbox`]: crate::workflow::WorkflowOutput::with_outbox
900    /// [`execute_timeout_with_retry`]: Process::execute_timeout_with_retry
901    pub async fn execute_timeout(
902        &self,
903        deadline: &crate::deadline::Deadline,
904    ) -> Result<Option<Vec<EventEnvelope>>, EngineError>
905    where
906        S: crate::event_store::AtomicAppend,
907    {
908        let state = self.state().await?;
909        match W::on_deadline(deadline, &state) {
910            None => Ok(None),
911            Some(command) => self.execute_and_enqueue(command).await.map(Some),
912        }
913    }
914
915    /// Like [`execute_timeout`] but retries on [`VersionConflict`] up to
916    /// `max_attempts` times.
917    ///
918    /// Use this in production deadline workers where concurrent event appends
919    /// are expected.  Outbox entries (e.g. APERAK Ablehnung) produced by the
920    /// compensation handler are persisted atomically on every attempt.
921    ///
922    /// [`execute_timeout`]: Process::execute_timeout
923    /// [`VersionConflict`]: crate::error::EngineError::VersionConflict
924    ///
925    /// # Errors
926    ///
927    /// Returns [`EngineError`] on storage or command handling failure.
928    ///
929    /// # Panics
930    ///
931    /// Panics if the deadline produces a command but the retry loop somehow
932    /// exhausts without capturing an error (unreachable in practice).
933    pub async fn execute_timeout_with_retry(
934        &self,
935        deadline: &crate::deadline::Deadline,
936        max_attempts: u32,
937    ) -> Result<Option<Vec<EventEnvelope>>, EngineError>
938    where
939        S: crate::event_store::AtomicAppend,
940        W::Command: Clone,
941    {
942        let state = self.state().await?;
943        match W::on_deadline(deadline, &state) {
944            None => Ok(None),
945            Some(command) => self
946                .execute_and_enqueue_with_retry(command, max_attempts)
947                .await
948                .map(Some),
949        }
950    }
951
952    /// Like [`execute_and_enqueue`] but retries on [`crate::error::EngineError::VersionConflict`] up to
953    /// `max_attempts` times.
954    ///
955    /// [`execute_and_enqueue`]: Process::execute_and_enqueue
956    ///
957    /// # Errors
958    ///
959    /// Returns [`EngineError`] on storage or command handling failure.
960    ///
961    /// # Panics
962    ///
963    /// Panics if `max_attempts` is 0 and the guard is bypassed (unreachable).
964    pub async fn execute_and_enqueue_with_retry(
965        &self,
966        command: W::Command,
967        max_attempts: u32,
968    ) -> Result<Vec<EventEnvelope>, EngineError>
969    where
970        S: crate::event_store::AtomicAppend,
971        W::Command: Clone,
972    {
973        if max_attempts == 0 {
974            return Err(EngineError::store("max_attempts must be >= 1"));
975        }
976        let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
977        let mut conflict_err: Option<EngineError> = None;
978        for _ in 0..max_attempts {
979            match crate::workflow::execute_command_atomic::<W, S>(
980                &self.store,
981                &self.stream_id,
982                command.clone(),
983                &ctx,
984            )
985            .await
986            {
987                Ok(envs) => return Ok(envs),
988                Err(e) if e.is_version_conflict() => conflict_err = Some(e),
989                Err(e) => return Err(e),
990            }
991        }
992        Err(conflict_err.expect("loop ran at least once"))
993    }
994
995    /// Execute `command` atomically with outbox, then automatically snapshot
996    /// if the event-count threshold is reached.
997    ///
998    /// Combines [`execute_and_enqueue`] with [`take_snapshot`]: after a
999    /// successful write, checks whether `event_count % snapshot_interval == 0`
1000    /// and, if so, serialises and saves a snapshot via `snap_store`.
1001    ///
1002    /// Pass `snapshot_interval = 0` to disable auto-snapshotting; the call
1003    /// then behaves identically to [`execute_and_enqueue`].
1004    ///
1005    /// Returns `(events, snapshot_taken)` where `snapshot_taken` is `true` when
1006    /// a snapshot was written this call.
1007    ///
1008    /// # Errors
1009    ///
1010    /// - [`EngineError::VersionConflict`] — stream was modified concurrently;
1011    ///   retry with [`execute_and_enqueue_with_retry`].
1012    /// - [`EngineError::Workflow`] — the command was rejected by the workflow.
1013    /// - [`EngineError::Store`] / [`EngineError::Outbox`] — storage failure.
1014    /// - [`EngineError::Serialization`] — state serialisation failed during snapshot.
1015    ///
1016    /// [`execute_and_enqueue`]: Process::execute_and_enqueue
1017    /// [`take_snapshot`]: Process::take_snapshot
1018    /// [`execute_and_enqueue_with_retry`]: Process::execute_and_enqueue_with_retry
1019    pub async fn execute_and_enqueue_with_snapshot<Snap>(
1020        &self,
1021        command: W::Command,
1022        snap_store: &Snap,
1023        snapshot_interval: u64,
1024    ) -> Result<(Vec<EventEnvelope>, bool), EngineError>
1025    where
1026        S: crate::event_store::AtomicAppend,
1027        Snap: crate::snapshot::SnapshotStore,
1028        W::State: serde::Serialize,
1029    {
1030        let events = self.execute_and_enqueue(command).await?;
1031        let snapped = if snapshot_interval > 0 {
1032            self.take_snapshot(snap_store, snapshot_interval).await?
1033        } else {
1034            false
1035        };
1036        Ok((events, snapped))
1037    }
1038
1039    /// Like [`execute_and_enqueue_with_snapshot`] but retries on
1040    /// [`crate::error::EngineError::VersionConflict`] up to `max_attempts` times.
1041    ///
1042    /// [`execute_and_enqueue_with_retry`]: Process::execute_and_enqueue_with_retry
1043    ///
1044    /// # Errors
1045    ///
1046    /// - [`EngineError::VersionConflict`] — stream was modified concurrently;
1047    ///   retry with [`execute_and_enqueue_with_snapshot_and_retry`].
1048    /// - [`EngineError::Workflow`] — the command was rejected by the workflow.
1049    /// - [`EngineError::Store`] / [`EngineError::Outbox`] — storage failure.
1050    /// - [`EngineError::Serialization`] — state serialisation failed during snapshot.
1051    ///
1052    /// # Panics
1053    ///
1054    /// Panics if `max_attempts` is 0 and the loop guard is bypassed (unreachable).
1055    ///
1056    /// [`execute_and_enqueue_with_snapshot`]: Process::execute_and_enqueue_with_snapshot
1057    /// [`execute_and_enqueue_with_snapshot_and_retry`]: Process::execute_and_enqueue_with_snapshot_and_retry
1058    pub async fn execute_and_enqueue_with_snapshot_and_retry<Snap>(
1059        &self,
1060        command: W::Command,
1061        max_attempts: u32,
1062        snap_store: &Snap,
1063        snapshot_interval: u64,
1064    ) -> Result<(Vec<EventEnvelope>, bool), EngineError>
1065    where
1066        S: crate::event_store::AtomicAppend,
1067        W::Command: Clone,
1068        Snap: crate::snapshot::SnapshotStore,
1069        W::State: serde::Serialize,
1070    {
1071        let events = self
1072            .execute_and_enqueue_with_retry(command, max_attempts)
1073            .await?;
1074        let snapped = if snapshot_interval > 0 {
1075            self.take_snapshot(snap_store, snapshot_interval).await?
1076        } else {
1077            false
1078        };
1079        Ok((events, snapped))
1080    }
1081}
1082
1083impl<W: Workflow, S: EventStore + Clone> Clone for Process<W, S> {
1084    fn clone(&self) -> Self {
1085        Self {
1086            stream_id: self.stream_id.clone(),
1087            process_id: self.process_id,
1088            tenant_id: self.tenant_id,
1089            workflow_id: self.workflow_id.clone(),
1090            store: self.store.clone(),
1091            _phantom: PhantomData,
1092        }
1093    }
1094}
1095
1096impl<W: Workflow, S: EventStore + std::fmt::Debug> std::fmt::Debug for Process<W, S> {
1097    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1098        f.debug_struct("Process")
1099            .field("stream_id", &self.stream_id)
1100            .field("process_id", &self.process_id)
1101            .field("workflow_id", &self.workflow_id)
1102            .finish_non_exhaustive()
1103    }
1104}
1105
1106// ── Unit tests ────────────────────────────────────────────────────────────────
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111    use crate::{
1112        envelope::NewEvent,
1113        error::WorkflowError,
1114        event_store::{EventStore, ExpectedVersion, InMemoryEventStore},
1115        ids::{ConversationId, CorrelationId, TenantId},
1116        snapshot::{InMemorySnapshotStore, NoopSnapshotStore},
1117        version::WorkflowId,
1118        workflow::{CommandPayload, EventPayload},
1119    };
1120
1121    // ── Minimal test workflow ─────────────────────────────────────────────────
1122
1123    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1124    enum CounterEvent {
1125        Incremented { by: u32 },
1126        Reset,
1127    }
1128
1129    impl EventPayload for CounterEvent {
1130        fn event_type(&self) -> &'static str {
1131            match self {
1132                Self::Incremented { .. } => "Incremented",
1133                Self::Reset => "Reset",
1134            }
1135        }
1136    }
1137
1138    #[derive(Debug, Clone)]
1139    enum CounterCommand {
1140        Increment { by: u32 },
1141        Reset,
1142    }
1143
1144    impl CommandPayload for CounterCommand {}
1145
1146    #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1147    struct CounterState {
1148        value: u32,
1149    }
1150
1151    struct CounterWorkflow;
1152
1153    impl Workflow for CounterWorkflow {
1154        type State = CounterState;
1155        type Event = CounterEvent;
1156        type Command = CounterCommand;
1157
1158        fn apply(mut state: CounterState, event: &CounterEvent) -> CounterState {
1159            match event {
1160                CounterEvent::Incremented { by } => state.value += by,
1161                CounterEvent::Reset => state.value = 0,
1162            }
1163            state
1164        }
1165
1166        fn handle(
1167            _state: &CounterState,
1168            command: CounterCommand,
1169        ) -> Result<crate::workflow::WorkflowOutput<CounterEvent>, WorkflowError> {
1170            Ok(match command {
1171                CounterCommand::Increment { by } => vec![CounterEvent::Incremented { by }].into(),
1172                CounterCommand::Reset => vec![CounterEvent::Reset].into(),
1173            })
1174        }
1175    }
1176
1177    fn make_process() -> Process<CounterWorkflow, InMemoryEventStore> {
1178        Process::new(
1179            InMemoryEventStore::new(),
1180            TenantId::new(),
1181            WorkflowId::new("counter", "FV2024-10-01"),
1182        )
1183    }
1184
1185    // ── execute + state round-trip ────────────────────────────────────────────
1186
1187    #[tokio::test]
1188    async fn execute_then_state_round_trip() {
1189        let p = make_process();
1190
1191        p.execute(CounterCommand::Increment { by: 3 })
1192            .await
1193            .unwrap();
1194        p.execute(CounterCommand::Increment { by: 7 })
1195            .await
1196            .unwrap();
1197
1198        let state = p.state().await.unwrap();
1199        assert_eq!(state.value, 10);
1200    }
1201
1202    #[tokio::test]
1203    async fn event_count_matches_dispatched_commands() {
1204        let p = make_process();
1205
1206        assert_eq!(p.event_count().await.unwrap(), 0);
1207        p.execute(CounterCommand::Increment { by: 1 })
1208            .await
1209            .unwrap();
1210        assert_eq!(p.event_count().await.unwrap(), 1);
1211        p.execute(CounterCommand::Reset).await.unwrap();
1212        assert_eq!(p.event_count().await.unwrap(), 2);
1213    }
1214
1215    // ── identity round-trip ───────────────────────────────────────────────────
1216
1217    #[tokio::test]
1218    async fn identity_round_trip_via_from_identity() {
1219        let store = InMemoryEventStore::new();
1220        let p1 = Process::<CounterWorkflow, _>::new(
1221            store.clone(),
1222            TenantId::new(),
1223            WorkflowId::new("counter", "FV2024-10-01"),
1224        );
1225
1226        p1.execute(CounterCommand::Increment { by: 5 })
1227            .await
1228            .unwrap();
1229
1230        let identity = p1.identity();
1231        assert_eq!(*identity.stream_id(), *p1.stream_id());
1232        assert_eq!(identity.process_id, p1.process_id());
1233
1234        // Re-attach from identity and confirm state is visible.
1235        let p2 = Process::<CounterWorkflow, _>::from_identity(store, identity);
1236        let state = p2.state().await.unwrap();
1237        assert_eq!(state.value, 5);
1238    }
1239
1240    #[test]
1241    fn process_identity_is_serializable() {
1242        let p = make_process();
1243        let id = p.identity();
1244        let json = serde_json::to_string(&id).expect("ProcessIdentity must be serializable");
1245        let back: ProcessIdentity = serde_json::from_str(&json).unwrap();
1246        assert_eq!(*back.stream_id(), *id.stream_id());
1247        assert_eq!(back.process_id, id.process_id);
1248    }
1249
1250    // ── snapshot-accelerated state reconstruction ─────────────────────────────
1251
1252    #[tokio::test]
1253    async fn take_snapshot_and_state_with_snapshot() {
1254        let snap_store = InMemorySnapshotStore::new();
1255        let p = make_process();
1256
1257        // Dispatch 4 commands; the interval is 4.
1258        for i in 1u32..=4 {
1259            p.execute(CounterCommand::Increment { by: i })
1260                .await
1261                .unwrap();
1262        }
1263
1264        let took = p.take_snapshot(&snap_store, 4).await.unwrap();
1265        assert!(took, "snapshot must be taken at event_count = 4");
1266
1267        // Dispatch one more command after the snapshot.
1268        p.execute(CounterCommand::Increment { by: 10 })
1269            .await
1270            .unwrap();
1271
1272        let state = p.state_with_snapshot(&snap_store).await.unwrap();
1273        // 1+2+3+4 = 10, plus the final +10 = 20.
1274        assert_eq!(state.value, 20);
1275    }
1276
1277    #[tokio::test]
1278    async fn state_with_snapshot_falls_back_to_full_replay() {
1279        let p = make_process();
1280        p.execute(CounterCommand::Increment { by: 42 })
1281            .await
1282            .unwrap();
1283
1284        // NoopSnapshotStore always returns None → full replay.
1285        let state = p.state_with_snapshot(&NoopSnapshotStore).await.unwrap();
1286        assert_eq!(state.value, 42);
1287    }
1288
1289    #[tokio::test]
1290    async fn take_snapshot_skipped_between_intervals() {
1291        let snap_store = InMemorySnapshotStore::new();
1292        let p = make_process();
1293
1294        p.execute(CounterCommand::Increment { by: 1 })
1295            .await
1296            .unwrap();
1297        p.execute(CounterCommand::Increment { by: 1 })
1298            .await
1299            .unwrap();
1300        p.execute(CounterCommand::Increment { by: 1 })
1301            .await
1302            .unwrap();
1303
1304        // 3 events, interval = 4 → must not take.
1305        let took = p.take_snapshot(&snap_store, 4).await.unwrap();
1306        assert!(!took);
1307        assert!(snap_store.is_empty().await);
1308    }
1309
1310    /// Regression test for when a persisted snapshot carries a
1311    /// `state_schema_version` that does not match the current workflow's
1312    /// `state_schema_version()`, `state_with_snapshot` must silently discard
1313    /// the stale snapshot and fall back to full event replay.
1314    ///
1315    /// This guards against silent data corruption when state layout changes
1316    /// incompatibly — e.g. after adding a new required field to `CounterState`.
1317    #[tokio::test]
1318    async fn stale_snapshot_schema_version_falls_back_to_full_replay() {
1319        // CounterWorkflow uses state_schema_version() == 1 (the default).
1320        // We simulate a "migrated" workflow by injecting a snapshot whose
1321        // state_schema_version is bumped to 99, representing a schema that the
1322        // current workflow code does not understand.
1323        let snap_store = InMemorySnapshotStore::new();
1324        let p = make_process();
1325
1326        // Dispatch some events so there is something to replay.
1327        p.execute(CounterCommand::Increment { by: 5 })
1328            .await
1329            .unwrap();
1330        p.execute(CounterCommand::Increment { by: 3 })
1331            .await
1332            .unwrap();
1333
1334        // Manually save a stale snapshot with schema_version = 99.
1335        // The state payload is intentionally wrong — it should never be used.
1336        let stale = crate::snapshot::Snapshot::new(
1337            p.stream_id().clone(),
1338            2,                                    // sequence_number after 2 events
1339            99,                                   // ← unknown schema version
1340            serde_json::json!({ "value": 9999 }), // ← wrong value; must not be read
1341        );
1342        snap_store.save(&stale).await.unwrap();
1343
1344        // state_with_snapshot must discard the stale snapshot and replay all
1345        // events from sequence 0, producing the correct state (5+3=8).
1346        let current_state = p.state_with_snapshot(&snap_store).await.unwrap();
1347        assert_eq!(
1348            current_state.value, 8,
1349            "stale snapshot must be discarded; full replay must yield correct state"
1350        );
1351    }
1352
1353    // ── execute_with_retry ────────────────────────────────────────────────────
1354
1355    #[tokio::test]
1356    async fn execute_with_retry_succeeds_on_first_attempt() {
1357        let p = make_process();
1358        let envs = p
1359            .execute_with_retry(CounterCommand::Increment { by: 99 }, 3)
1360            .await
1361            .unwrap();
1362        assert_eq!(envs.len(), 1);
1363        assert_eq!(p.state().await.unwrap().value, 99);
1364    }
1365
1366    #[tokio::test]
1367    async fn execute_with_retry_returns_err_on_zero_attempts() {
1368        let p = make_process();
1369        let err = p
1370            .execute_with_retry(CounterCommand::Increment { by: 1 }, 0)
1371            .await
1372            .unwrap_err();
1373        assert!(
1374            matches!(err, EngineError::Store { ref message, .. } if message.contains("max_attempts")),
1375            "expected Store error about max_attempts, got: {err:?}",
1376        );
1377    }
1378
1379    // ── execute_with (explicit context) ──────────────────────────────────────
1380
1381    #[tokio::test]
1382    async fn execute_with_explicit_context_propagates_ids() {
1383        use crate::ids::{ConversationId, CorrelationId};
1384        let p = make_process();
1385
1386        let corr = CorrelationId::new();
1387        let conv = ConversationId::new();
1388        let ctx = CommandContext::new(p.tenant_id(), p.process_id(), p.workflow_id().clone())
1389            .with_correlation(corr)
1390            .with_conversation(conv);
1391
1392        let envs = p
1393            .execute_with(CounterCommand::Increment { by: 1 }, ctx)
1394            .await
1395            .unwrap();
1396        assert_eq!(envs.len(), 1);
1397        assert_eq!(envs[0].correlation_id, corr);
1398        assert_eq!(envs[0].conversation_id, conv);
1399    }
1400
1401    // ── upcast / schema-migration ─────────────────────────────────────────────
1402    //
1403    // A v2 workflow adds a `label: String` field to its single event.
1404    // Old (v1) events stored without `label` must be migrated by `upcast`.
1405    //
1406    // `#[serde(untagged)]` is used so the serialized payload is the flat
1407    // inner struct `{"count": N, "label": "..."}` rather than the externally-
1408    // tagged `{"Tagged": {"count": N}}` form.  This matches the common
1409    // real-world pattern where each `EventPayload::event_type()` discriminant
1410    // IS the variant selector stored in the envelope, and the payload holds
1411    // only the fields.
1412
1413    #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1414    struct TagState {
1415        total: u32,
1416        last_label: String,
1417    }
1418
1419    /// v1 schema (legacy): `{ "count": u32 }` — `label` field absent.
1420    /// v2 schema: `{ "count": u32, "label": String }`.
1421    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1422    #[serde(untagged)]
1423    enum TagEvent {
1424        Tagged { count: u32, label: String },
1425    }
1426
1427    impl EventPayload for TagEvent {
1428        fn event_type(&self) -> &'static str {
1429            "Tagged"
1430        }
1431        fn schema_version(&self) -> u32 {
1432            2
1433        }
1434    }
1435
1436    #[derive(Debug, Clone)]
1437    struct TagCommand {
1438        count: u32,
1439        label: String,
1440    }
1441    impl CommandPayload for TagCommand {}
1442
1443    struct TagWorkflow;
1444
1445    impl Workflow for TagWorkflow {
1446        type State = TagState;
1447        type Event = TagEvent;
1448        type Command = TagCommand;
1449
1450        fn apply(mut state: TagState, event: &TagEvent) -> TagState {
1451            let TagEvent::Tagged { count, label } = event;
1452            state.total += count;
1453            state.last_label = label.clone();
1454            state
1455        }
1456
1457        fn handle(
1458            _state: &TagState,
1459            cmd: TagCommand,
1460        ) -> Result<crate::workflow::WorkflowOutput<TagEvent>, WorkflowError> {
1461            Ok(vec![TagEvent::Tagged {
1462                count: cmd.count,
1463                label: cmd.label,
1464            }]
1465            .into())
1466        }
1467
1468        /// Migrate v1 `Tagged` events (missing `label`) to v2.
1469        ///
1470        /// v1 payload: `{"count": N}` (no `label` field)
1471        /// v2 payload: `{"count": N, "label": ""}` (default empty string)
1472        ///
1473        /// Because the event uses `#[serde(untagged)]`, the envelope payload
1474        /// is the flat struct — variant discrimination comes from `event_type`.
1475        fn upcast(
1476            event_type: &str,
1477            from_version: u32,
1478            mut payload: serde_json::Value,
1479        ) -> Result<serde_json::Value, EngineError> {
1480            if event_type == "Tagged"
1481                && from_version == 1
1482                && let Some(obj) = payload.as_object_mut()
1483            {
1484                obj.entry("label")
1485                    .or_insert_with(|| serde_json::Value::String(String::new()));
1486            }
1487            Ok(payload)
1488        }
1489    }
1490
1491    /// Inject a raw v1 event (no `label` field) directly into the store and
1492    /// confirm that `state()` replays it correctly via `upcast`.
1493    #[tokio::test]
1494    async fn upcast_v1_event_adds_default_label() {
1495        let store = InMemoryEventStore::new();
1496        let p = Process::<TagWorkflow, _>::new(
1497            store.clone(), // shares the underlying Arc<RwLock<_>>
1498            TenantId::new(),
1499            WorkflowId::new("tag", "FV2025-10-01"),
1500        );
1501
1502        // v1 payload: flat struct fields, no `label` (untagged serde repr).
1503        let v1_payload = serde_json::json!({ "count": 7 });
1504        let raw = NewEvent {
1505            correlation_id: CorrelationId::new(),
1506            causation_id: None,
1507            conversation_id: ConversationId::new(),
1508            process_id: p.process_id(),
1509            tenant_id: p.tenant_id(),
1510            workflow_id: p.workflow_id().clone(),
1511            event_type: "Tagged".into(),
1512            schema_version: 1, // ← schema_version 1 (old format)
1513            payload: v1_payload,
1514        };
1515        store
1516            .append(p.stream_id(), ExpectedVersion::Any, &[raw])
1517            .await
1518            .expect("inject v1 event");
1519
1520        // Replay via the v2 workflow — `upcast` must fill in `label: ""`.
1521        let state = p.state().await.expect("state must replay without error");
1522        assert_eq!(state.total, 7, "count must be accumulated");
1523        assert_eq!(
1524            state.last_label, "",
1525            "missing v1 label must default to empty string"
1526        );
1527
1528        // Also verify that a normally-executed v2 event round-trips correctly.
1529        p.execute(TagCommand {
1530            count: 3,
1531            label: "hello".into(),
1532        })
1533        .await
1534        .unwrap();
1535        let state2 = p.state().await.unwrap();
1536        assert_eq!(state2.total, 10);
1537        assert_eq!(state2.last_label, "hello");
1538    }
1539}