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    /// (the per-PID business Antwortfrist from `mako_fristen::antwort`).
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        self.execute_and_enqueue_with_deadlines_and_correlations(command, deadlines, &[])
813            .await
814    }
815
816    /// Like [`execute_and_enqueue_with_deadlines`] but also writes
817    /// correlation-index entries in the same atomic batch.
818    ///
819    /// This is the spawn path. A process is not usable when its events are
820    /// durable — it is usable when its business key resolves to it. Until then
821    /// the counterparty's reply finds no process and is skipped, and the next
822    /// thing to happen is the process's own Frist expiring as a false timeout.
823    /// Writing the key separately left exactly that window open.
824    ///
825    /// # Errors
826    ///
827    /// Returns [`EngineError`] on storage or command handling failure. A
828    /// malformed business key is rejected before anything is written.
829    ///
830    /// [`execute_and_enqueue_with_deadlines`]: Process::execute_and_enqueue_with_deadlines
831    pub async fn execute_and_enqueue_with_deadlines_and_correlations(
832        &self,
833        command: W::Command,
834        deadlines: &[crate::deadline::Deadline],
835        correlations: &[crate::event_store::CorrelationEntry],
836    ) -> Result<Vec<EventEnvelope>, EngineError>
837    where
838        S: crate::event_store::AtomicAppend,
839    {
840        let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
841        crate::workflow::execute_command_atomic_with_deadlines::<W, S>(
842            &self.store,
843            &self.stream_id,
844            command,
845            &ctx,
846            deadlines,
847            correlations,
848        )
849        .await
850    }
851
852    /// Like [`execute_and_enqueue`] but uses a snapshot to accelerate replay.
853    ///
854    /// Atomically persists events and outbox entries while starting state
855    /// reconstruction from the most recent snapshot. For long streams with
856    /// periodic snapshots this reduces replay cost from O(n) to O(k).
857    ///
858    /// [`execute_and_enqueue`]: Process::execute_and_enqueue
859    ///
860    /// # Errors
861    ///
862    /// Returns [`EngineError`] on storage or command handling failure.
863    pub async fn execute_and_enqueue_snapshot<Snap>(
864        &self,
865        command: W::Command,
866        snap_store: &Snap,
867    ) -> Result<Vec<EventEnvelope>, EngineError>
868    where
869        W::State: serde::de::DeserializeOwned,
870        S: crate::event_store::AtomicAppend,
871        Snap: SnapshotStore,
872    {
873        let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
874        crate::workflow::execute_command_atomic_with_snapshot::<W, S, Snap>(
875            &self.store,
876            snap_store,
877            &self.stream_id,
878            command,
879            &ctx,
880        )
881        .await
882    }
883
884    /// Dispatch the compensation command returned by [`Workflow::on_deadline`].
885    ///
886    /// Reconstructs the current process state, calls
887    /// `W::on_deadline(deadline, &state)`, and — if the hook returns
888    /// `Some(command)` — executes it via [`Process::execute_and_enqueue`],
889    /// which atomically persists events **and** any outbox entries (e.g.
890    /// APERAK Ablehnung) produced by the compensation handler.
891    ///
892    /// Returns `Ok(Some(events))` when compensation fired, `Ok(None)` when
893    /// the hook returned `None` (deadline acknowledged as no-op).
894    ///
895    /// This is the canonical way to wire deadline firings to workflow
896    /// compensation logic.  Any [`WorkflowOutput::with_outbox`] entries
897    /// returned by `on_deadline` are guaranteed to be persisted atomically —
898    /// there is no window where the event is stored but the outbox entry is
899    /// lost.
900    ///
901    /// # Example
902    ///
903    /// ```rust,ignore
904    /// // In the deadline worker:
905    /// let overdue = ctx.deadline_store().due_now(50).await?;
906    /// for deadline in overdue {
907    ///     let identity = ctx.registry()
908    ///         .lookup(deadline.tenant_id(), &RegistryKey::from_process(deadline.process_id()))
909    ///         .await?
910    ///         .expect("process must be registered");
911    ///     let process = ctx.resume::<GpkeSupplierChangeWorkflow>(identity);
912    ///     if let Some(events) = process.execute_timeout(&deadline).await? {
913    ///         // compensation command was dispatched — APERAK Ablehnung enqueued
914    ///         tracing::info!(events = events.len(), "timeout compensation applied");
915    ///     }
916    ///     ctx.deadline_store().cancel(deadline.deadline_id()).await?;
917    /// }
918    /// ```
919    ///
920    /// # Errors
921    ///
922    /// Propagates [`EngineError::VersionConflict`], [`EngineError::Workflow`],
923    /// and storage errors from `execute_and_enqueue`. Use
924    /// [`execute_timeout_with_retry`] when `VersionConflict` retries are
925    /// required.
926    ///
927    /// [`Workflow::on_deadline`]: crate::workflow::Workflow::on_deadline
928    /// [`WorkflowOutput::with_outbox`]: crate::workflow::WorkflowOutput::with_outbox
929    /// [`execute_timeout_with_retry`]: Process::execute_timeout_with_retry
930    pub async fn execute_timeout(
931        &self,
932        deadline: &crate::deadline::Deadline,
933    ) -> Result<Option<Vec<EventEnvelope>>, EngineError>
934    where
935        S: crate::event_store::AtomicAppend,
936    {
937        let state = self.state().await?;
938        match W::on_deadline(deadline, &state) {
939            None => Ok(None),
940            Some(command) => self.execute_and_enqueue(command).await.map(Some),
941        }
942    }
943
944    /// Like [`execute_timeout`] but retries on [`VersionConflict`] up to
945    /// `max_attempts` times.
946    ///
947    /// Use this in production deadline workers where concurrent event appends
948    /// are expected.  Outbox entries (e.g. APERAK Ablehnung) produced by the
949    /// compensation handler are persisted atomically on every attempt.
950    ///
951    /// [`execute_timeout`]: Process::execute_timeout
952    /// [`VersionConflict`]: crate::error::EngineError::VersionConflict
953    ///
954    /// # Errors
955    ///
956    /// Returns [`EngineError`] on storage or command handling failure.
957    ///
958    /// # Panics
959    ///
960    /// Panics if the deadline produces a command but the retry loop somehow
961    /// exhausts without capturing an error (unreachable in practice).
962    pub async fn execute_timeout_with_retry(
963        &self,
964        deadline: &crate::deadline::Deadline,
965        max_attempts: u32,
966    ) -> Result<Option<Vec<EventEnvelope>>, EngineError>
967    where
968        S: crate::event_store::AtomicAppend,
969        W::Command: Clone,
970    {
971        let state = self.state().await?;
972        match W::on_deadline(deadline, &state) {
973            None => Ok(None),
974            Some(command) => self
975                .execute_and_enqueue_with_retry(command, max_attempts)
976                .await
977                .map(Some),
978        }
979    }
980
981    /// Like [`execute_and_enqueue`] but retries on [`crate::error::EngineError::VersionConflict`] up to
982    /// `max_attempts` times.
983    ///
984    /// [`execute_and_enqueue`]: Process::execute_and_enqueue
985    ///
986    /// # Errors
987    ///
988    /// Returns [`EngineError`] on storage or command handling failure.
989    ///
990    /// # Panics
991    ///
992    /// Panics if `max_attempts` is 0 and the guard is bypassed (unreachable).
993    pub async fn execute_and_enqueue_with_retry(
994        &self,
995        command: W::Command,
996        max_attempts: u32,
997    ) -> Result<Vec<EventEnvelope>, EngineError>
998    where
999        S: crate::event_store::AtomicAppend,
1000        W::Command: Clone,
1001    {
1002        if max_attempts == 0 {
1003            return Err(EngineError::store("max_attempts must be >= 1"));
1004        }
1005        let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
1006        let mut conflict_err: Option<EngineError> = None;
1007        for _ in 0..max_attempts {
1008            match crate::workflow::execute_command_atomic::<W, S>(
1009                &self.store,
1010                &self.stream_id,
1011                command.clone(),
1012                &ctx,
1013            )
1014            .await
1015            {
1016                Ok(envs) => return Ok(envs),
1017                Err(e) if e.is_version_conflict() => conflict_err = Some(e),
1018                Err(e) => return Err(e),
1019            }
1020        }
1021        Err(conflict_err.expect("loop ran at least once"))
1022    }
1023
1024    /// Execute `command` atomically with outbox, then automatically snapshot
1025    /// if the event-count threshold is reached.
1026    ///
1027    /// Combines [`execute_and_enqueue`] with [`take_snapshot`]: after a
1028    /// successful write, checks whether `event_count % snapshot_interval == 0`
1029    /// and, if so, serialises and saves a snapshot via `snap_store`.
1030    ///
1031    /// Pass `snapshot_interval = 0` to disable auto-snapshotting; the call
1032    /// then behaves identically to [`execute_and_enqueue`].
1033    ///
1034    /// Returns `(events, snapshot_taken)` where `snapshot_taken` is `true` when
1035    /// a snapshot was written this call.
1036    ///
1037    /// # Errors
1038    ///
1039    /// - [`EngineError::VersionConflict`] — stream was modified concurrently;
1040    ///   retry with [`execute_and_enqueue_with_retry`].
1041    /// - [`EngineError::Workflow`] — the command was rejected by the workflow.
1042    /// - [`EngineError::Store`] / [`EngineError::Outbox`] — storage failure.
1043    /// - [`EngineError::Serialization`] — state serialisation failed during snapshot.
1044    ///
1045    /// [`execute_and_enqueue`]: Process::execute_and_enqueue
1046    /// [`take_snapshot`]: Process::take_snapshot
1047    /// [`execute_and_enqueue_with_retry`]: Process::execute_and_enqueue_with_retry
1048    pub async fn execute_and_enqueue_with_snapshot<Snap>(
1049        &self,
1050        command: W::Command,
1051        snap_store: &Snap,
1052        snapshot_interval: u64,
1053    ) -> Result<(Vec<EventEnvelope>, bool), EngineError>
1054    where
1055        S: crate::event_store::AtomicAppend,
1056        Snap: crate::snapshot::SnapshotStore,
1057        W::State: serde::Serialize,
1058    {
1059        let events = self.execute_and_enqueue(command).await?;
1060        let snapped = if snapshot_interval > 0 {
1061            self.take_snapshot(snap_store, snapshot_interval).await?
1062        } else {
1063            false
1064        };
1065        Ok((events, snapped))
1066    }
1067
1068    /// Like [`execute_and_enqueue_with_snapshot`] but retries on
1069    /// [`crate::error::EngineError::VersionConflict`] up to `max_attempts` times.
1070    ///
1071    /// [`execute_and_enqueue_with_retry`]: Process::execute_and_enqueue_with_retry
1072    ///
1073    /// # Errors
1074    ///
1075    /// - [`EngineError::VersionConflict`] — stream was modified concurrently;
1076    ///   retry with [`execute_and_enqueue_with_snapshot_and_retry`].
1077    /// - [`EngineError::Workflow`] — the command was rejected by the workflow.
1078    /// - [`EngineError::Store`] / [`EngineError::Outbox`] — storage failure.
1079    /// - [`EngineError::Serialization`] — state serialisation failed during snapshot.
1080    ///
1081    /// # Panics
1082    ///
1083    /// Panics if `max_attempts` is 0 and the loop guard is bypassed (unreachable).
1084    ///
1085    /// [`execute_and_enqueue_with_snapshot`]: Process::execute_and_enqueue_with_snapshot
1086    /// [`execute_and_enqueue_with_snapshot_and_retry`]: Process::execute_and_enqueue_with_snapshot_and_retry
1087    pub async fn execute_and_enqueue_with_snapshot_and_retry<Snap>(
1088        &self,
1089        command: W::Command,
1090        max_attempts: u32,
1091        snap_store: &Snap,
1092        snapshot_interval: u64,
1093    ) -> Result<(Vec<EventEnvelope>, bool), EngineError>
1094    where
1095        S: crate::event_store::AtomicAppend,
1096        W::Command: Clone,
1097        Snap: crate::snapshot::SnapshotStore,
1098        W::State: serde::Serialize,
1099    {
1100        let events = self
1101            .execute_and_enqueue_with_retry(command, max_attempts)
1102            .await?;
1103        let snapped = if snapshot_interval > 0 {
1104            self.take_snapshot(snap_store, snapshot_interval).await?
1105        } else {
1106            false
1107        };
1108        Ok((events, snapped))
1109    }
1110}
1111
1112impl<W: Workflow, S: EventStore + Clone> Clone for Process<W, S> {
1113    fn clone(&self) -> Self {
1114        Self {
1115            stream_id: self.stream_id.clone(),
1116            process_id: self.process_id,
1117            tenant_id: self.tenant_id,
1118            workflow_id: self.workflow_id.clone(),
1119            store: self.store.clone(),
1120            _phantom: PhantomData,
1121        }
1122    }
1123}
1124
1125impl<W: Workflow, S: EventStore + std::fmt::Debug> std::fmt::Debug for Process<W, S> {
1126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1127        f.debug_struct("Process")
1128            .field("stream_id", &self.stream_id)
1129            .field("process_id", &self.process_id)
1130            .field("workflow_id", &self.workflow_id)
1131            .finish_non_exhaustive()
1132    }
1133}
1134
1135// ── Unit tests ────────────────────────────────────────────────────────────────
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140    use crate::{
1141        envelope::NewEvent,
1142        error::WorkflowError,
1143        event_store::{EventStore, ExpectedVersion, InMemoryEventStore},
1144        ids::{ConversationId, CorrelationId, TenantId},
1145        snapshot::{InMemorySnapshotStore, NoopSnapshotStore},
1146        version::WorkflowId,
1147        workflow::{CommandPayload, EventPayload},
1148    };
1149
1150    // ── Minimal test workflow ─────────────────────────────────────────────────
1151
1152    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1153    enum CounterEvent {
1154        Incremented { by: u32 },
1155        Reset,
1156    }
1157
1158    impl EventPayload for CounterEvent {
1159        fn event_type(&self) -> &'static str {
1160            match self {
1161                Self::Incremented { .. } => "Incremented",
1162                Self::Reset => "Reset",
1163            }
1164        }
1165    }
1166
1167    #[derive(Debug, Clone)]
1168    enum CounterCommand {
1169        Increment { by: u32 },
1170        Reset,
1171    }
1172
1173    impl CommandPayload for CounterCommand {}
1174
1175    #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1176    struct CounterState {
1177        value: u32,
1178    }
1179
1180    struct CounterWorkflow;
1181
1182    impl Workflow for CounterWorkflow {
1183        type State = CounterState;
1184        type Event = CounterEvent;
1185        type Command = CounterCommand;
1186
1187        fn apply(mut state: CounterState, event: &CounterEvent) -> CounterState {
1188            match event {
1189                CounterEvent::Incremented { by } => state.value += by,
1190                CounterEvent::Reset => state.value = 0,
1191            }
1192            state
1193        }
1194
1195        fn handle(
1196            _state: &CounterState,
1197            command: CounterCommand,
1198        ) -> Result<crate::workflow::WorkflowOutput<CounterEvent>, WorkflowError> {
1199            Ok(match command {
1200                CounterCommand::Increment { by } => vec![CounterEvent::Incremented { by }].into(),
1201                CounterCommand::Reset => vec![CounterEvent::Reset].into(),
1202            })
1203        }
1204    }
1205
1206    fn make_process() -> Process<CounterWorkflow, InMemoryEventStore> {
1207        Process::new(
1208            InMemoryEventStore::new(),
1209            TenantId::new(),
1210            WorkflowId::new("counter", "FV2024-10-01"),
1211        )
1212    }
1213
1214    // ── execute + state round-trip ────────────────────────────────────────────
1215
1216    #[tokio::test]
1217    async fn execute_then_state_round_trip() {
1218        let p = make_process();
1219
1220        p.execute(CounterCommand::Increment { by: 3 })
1221            .await
1222            .unwrap();
1223        p.execute(CounterCommand::Increment { by: 7 })
1224            .await
1225            .unwrap();
1226
1227        let state = p.state().await.unwrap();
1228        assert_eq!(state.value, 10);
1229    }
1230
1231    #[tokio::test]
1232    async fn event_count_matches_dispatched_commands() {
1233        let p = make_process();
1234
1235        assert_eq!(p.event_count().await.unwrap(), 0);
1236        p.execute(CounterCommand::Increment { by: 1 })
1237            .await
1238            .unwrap();
1239        assert_eq!(p.event_count().await.unwrap(), 1);
1240        p.execute(CounterCommand::Reset).await.unwrap();
1241        assert_eq!(p.event_count().await.unwrap(), 2);
1242    }
1243
1244    // ── identity round-trip ───────────────────────────────────────────────────
1245
1246    #[tokio::test]
1247    async fn identity_round_trip_via_from_identity() {
1248        let store = InMemoryEventStore::new();
1249        let p1 = Process::<CounterWorkflow, _>::new(
1250            store.clone(),
1251            TenantId::new(),
1252            WorkflowId::new("counter", "FV2024-10-01"),
1253        );
1254
1255        p1.execute(CounterCommand::Increment { by: 5 })
1256            .await
1257            .unwrap();
1258
1259        let identity = p1.identity();
1260        assert_eq!(*identity.stream_id(), *p1.stream_id());
1261        assert_eq!(identity.process_id, p1.process_id());
1262
1263        // Re-attach from identity and confirm state is visible.
1264        let p2 = Process::<CounterWorkflow, _>::from_identity(store, identity);
1265        let state = p2.state().await.unwrap();
1266        assert_eq!(state.value, 5);
1267    }
1268
1269    #[test]
1270    fn process_identity_is_serializable() {
1271        let p = make_process();
1272        let id = p.identity();
1273        let json = serde_json::to_string(&id).expect("ProcessIdentity must be serializable");
1274        let back: ProcessIdentity = serde_json::from_str(&json).unwrap();
1275        assert_eq!(*back.stream_id(), *id.stream_id());
1276        assert_eq!(back.process_id, id.process_id);
1277    }
1278
1279    // ── snapshot-accelerated state reconstruction ─────────────────────────────
1280
1281    #[tokio::test]
1282    async fn take_snapshot_and_state_with_snapshot() {
1283        let snap_store = InMemorySnapshotStore::new();
1284        let p = make_process();
1285
1286        // Dispatch 4 commands; the interval is 4.
1287        for i in 1u32..=4 {
1288            p.execute(CounterCommand::Increment { by: i })
1289                .await
1290                .unwrap();
1291        }
1292
1293        let took = p.take_snapshot(&snap_store, 4).await.unwrap();
1294        assert!(took, "snapshot must be taken at event_count = 4");
1295
1296        // Dispatch one more command after the snapshot.
1297        p.execute(CounterCommand::Increment { by: 10 })
1298            .await
1299            .unwrap();
1300
1301        let state = p.state_with_snapshot(&snap_store).await.unwrap();
1302        // 1+2+3+4 = 10, plus the final +10 = 20.
1303        assert_eq!(state.value, 20);
1304    }
1305
1306    #[tokio::test]
1307    async fn state_with_snapshot_falls_back_to_full_replay() {
1308        let p = make_process();
1309        p.execute(CounterCommand::Increment { by: 42 })
1310            .await
1311            .unwrap();
1312
1313        // NoopSnapshotStore always returns None → full replay.
1314        let state = p.state_with_snapshot(&NoopSnapshotStore).await.unwrap();
1315        assert_eq!(state.value, 42);
1316    }
1317
1318    #[tokio::test]
1319    async fn take_snapshot_skipped_between_intervals() {
1320        let snap_store = InMemorySnapshotStore::new();
1321        let p = make_process();
1322
1323        p.execute(CounterCommand::Increment { by: 1 })
1324            .await
1325            .unwrap();
1326        p.execute(CounterCommand::Increment { by: 1 })
1327            .await
1328            .unwrap();
1329        p.execute(CounterCommand::Increment { by: 1 })
1330            .await
1331            .unwrap();
1332
1333        // 3 events, interval = 4 → must not take.
1334        let took = p.take_snapshot(&snap_store, 4).await.unwrap();
1335        assert!(!took);
1336        assert!(snap_store.is_empty().await);
1337    }
1338
1339    /// Regression test for when a persisted snapshot carries a
1340    /// `state_schema_version` that does not match the current workflow's
1341    /// `state_schema_version()`, `state_with_snapshot` must silently discard
1342    /// the stale snapshot and fall back to full event replay.
1343    ///
1344    /// This guards against silent data corruption when state layout changes
1345    /// incompatibly — e.g. after adding a new required field to `CounterState`.
1346    #[tokio::test]
1347    async fn stale_snapshot_schema_version_falls_back_to_full_replay() {
1348        // CounterWorkflow uses state_schema_version() == 1 (the default).
1349        // We simulate a "migrated" workflow by injecting a snapshot whose
1350        // state_schema_version is bumped to 99, representing a schema that the
1351        // current workflow code does not understand.
1352        let snap_store = InMemorySnapshotStore::new();
1353        let p = make_process();
1354
1355        // Dispatch some events so there is something to replay.
1356        p.execute(CounterCommand::Increment { by: 5 })
1357            .await
1358            .unwrap();
1359        p.execute(CounterCommand::Increment { by: 3 })
1360            .await
1361            .unwrap();
1362
1363        // Manually save a stale snapshot with schema_version = 99.
1364        // The state payload is intentionally wrong — it should never be used.
1365        let stale = crate::snapshot::Snapshot::new(
1366            p.stream_id().clone(),
1367            2,                                    // sequence_number after 2 events
1368            99,                                   // ← unknown schema version
1369            serde_json::json!({ "value": 9999 }), // ← wrong value; must not be read
1370        );
1371        snap_store.save(&stale).await.unwrap();
1372
1373        // state_with_snapshot must discard the stale snapshot and replay all
1374        // events from sequence 0, producing the correct state (5+3=8).
1375        let current_state = p.state_with_snapshot(&snap_store).await.unwrap();
1376        assert_eq!(
1377            current_state.value, 8,
1378            "stale snapshot must be discarded; full replay must yield correct state"
1379        );
1380    }
1381
1382    // ── execute_with_retry ────────────────────────────────────────────────────
1383
1384    #[tokio::test]
1385    async fn execute_with_retry_succeeds_on_first_attempt() {
1386        let p = make_process();
1387        let envs = p
1388            .execute_with_retry(CounterCommand::Increment { by: 99 }, 3)
1389            .await
1390            .unwrap();
1391        assert_eq!(envs.len(), 1);
1392        assert_eq!(p.state().await.unwrap().value, 99);
1393    }
1394
1395    #[tokio::test]
1396    async fn execute_with_retry_returns_err_on_zero_attempts() {
1397        let p = make_process();
1398        let err = p
1399            .execute_with_retry(CounterCommand::Increment { by: 1 }, 0)
1400            .await
1401            .unwrap_err();
1402        assert!(
1403            matches!(err, EngineError::Store { ref message, .. } if message.contains("max_attempts")),
1404            "expected Store error about max_attempts, got: {err:?}",
1405        );
1406    }
1407
1408    // ── execute_with (explicit context) ──────────────────────────────────────
1409
1410    #[tokio::test]
1411    async fn execute_with_explicit_context_propagates_ids() {
1412        use crate::ids::{ConversationId, CorrelationId};
1413        let p = make_process();
1414
1415        let corr = CorrelationId::new();
1416        let conv = ConversationId::new();
1417        let ctx = CommandContext::new(p.tenant_id(), p.process_id(), p.workflow_id().clone())
1418            .with_correlation(corr)
1419            .with_conversation(conv);
1420
1421        let envs = p
1422            .execute_with(CounterCommand::Increment { by: 1 }, ctx)
1423            .await
1424            .unwrap();
1425        assert_eq!(envs.len(), 1);
1426        assert_eq!(envs[0].correlation_id, corr);
1427        assert_eq!(envs[0].conversation_id, conv);
1428    }
1429
1430    // ── upcast / schema-migration ─────────────────────────────────────────────
1431    //
1432    // A v2 workflow adds a `label: String` field to its single event.
1433    // Old (v1) events stored without `label` must be migrated by `upcast`.
1434    //
1435    // `#[serde(untagged)]` is used so the serialized payload is the flat
1436    // inner struct `{"count": N, "label": "..."}` rather than the externally-
1437    // tagged `{"Tagged": {"count": N}}` form.  This matches the common
1438    // real-world pattern where each `EventPayload::event_type()` discriminant
1439    // IS the variant selector stored in the envelope, and the payload holds
1440    // only the fields.
1441
1442    #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1443    struct TagState {
1444        total: u32,
1445        last_label: String,
1446    }
1447
1448    /// v1 schema (legacy): `{ "count": u32 }` — `label` field absent.
1449    /// v2 schema: `{ "count": u32, "label": String }`.
1450    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1451    #[serde(untagged)]
1452    enum TagEvent {
1453        Tagged { count: u32, label: String },
1454    }
1455
1456    impl EventPayload for TagEvent {
1457        fn event_type(&self) -> &'static str {
1458            "Tagged"
1459        }
1460        fn schema_version(&self) -> u32 {
1461            2
1462        }
1463    }
1464
1465    #[derive(Debug, Clone)]
1466    struct TagCommand {
1467        count: u32,
1468        label: String,
1469    }
1470    impl CommandPayload for TagCommand {}
1471
1472    struct TagWorkflow;
1473
1474    impl Workflow for TagWorkflow {
1475        type State = TagState;
1476        type Event = TagEvent;
1477        type Command = TagCommand;
1478
1479        fn apply(mut state: TagState, event: &TagEvent) -> TagState {
1480            let TagEvent::Tagged { count, label } = event;
1481            state.total += count;
1482            state.last_label = label.clone();
1483            state
1484        }
1485
1486        fn handle(
1487            _state: &TagState,
1488            cmd: TagCommand,
1489        ) -> Result<crate::workflow::WorkflowOutput<TagEvent>, WorkflowError> {
1490            Ok(vec![TagEvent::Tagged {
1491                count: cmd.count,
1492                label: cmd.label,
1493            }]
1494            .into())
1495        }
1496
1497        /// Migrate v1 `Tagged` events (missing `label`) to v2.
1498        ///
1499        /// v1 payload: `{"count": N}` (no `label` field)
1500        /// v2 payload: `{"count": N, "label": ""}` (default empty string)
1501        ///
1502        /// Because the event uses `#[serde(untagged)]`, the envelope payload
1503        /// is the flat struct — variant discrimination comes from `event_type`.
1504        fn upcast(
1505            event_type: &str,
1506            from_version: u32,
1507            mut payload: serde_json::Value,
1508        ) -> Result<serde_json::Value, EngineError> {
1509            if event_type == "Tagged"
1510                && from_version == 1
1511                && let Some(obj) = payload.as_object_mut()
1512            {
1513                obj.entry("label")
1514                    .or_insert_with(|| serde_json::Value::String(String::new()));
1515            }
1516            Ok(payload)
1517        }
1518    }
1519
1520    /// Inject a raw v1 event (no `label` field) directly into the store and
1521    /// confirm that `state()` replays it correctly via `upcast`.
1522    #[tokio::test]
1523    async fn upcast_v1_event_adds_default_label() {
1524        let store = InMemoryEventStore::new();
1525        let p = Process::<TagWorkflow, _>::new(
1526            store.clone(), // shares the underlying Arc<RwLock<_>>
1527            TenantId::new(),
1528            WorkflowId::new("tag", "FV2025-10-01"),
1529        );
1530
1531        // v1 payload: flat struct fields, no `label` (untagged serde repr).
1532        let v1_payload = serde_json::json!({ "count": 7 });
1533        let raw = NewEvent {
1534            correlation_id: CorrelationId::new(),
1535            causation_id: None,
1536            conversation_id: ConversationId::new(),
1537            process_id: p.process_id(),
1538            tenant_id: p.tenant_id(),
1539            workflow_id: p.workflow_id().clone(),
1540            event_type: "Tagged".into(),
1541            schema_version: 1, // ← schema_version 1 (old format)
1542            payload: v1_payload,
1543        };
1544        store
1545            .append(p.stream_id(), ExpectedVersion::Any, &[raw])
1546            .await
1547            .expect("inject v1 event");
1548
1549        // Replay via the v2 workflow — `upcast` must fill in `label: ""`.
1550        let state = p.state().await.expect("state must replay without error");
1551        assert_eq!(state.total, 7, "count must be accumulated");
1552        assert_eq!(
1553            state.last_label, "",
1554            "missing v1 label must default to empty string"
1555        );
1556
1557        // Also verify that a normally-executed v2 event round-trips correctly.
1558        p.execute(TagCommand {
1559            count: 3,
1560            label: "hello".into(),
1561        })
1562        .await
1563        .unwrap();
1564        let state2 = p.state().await.unwrap();
1565        assert_eq!(state2.total, 10);
1566        assert_eq!(state2.last_label, "hello");
1567    }
1568}