Skip to main content

arc_core/
command_bus.rs

1//! # Command Bus Module
2//!
3//! Coordinates command handling through aggregates with event persistence and publishing.
4//!
5//! ## Flow
6//!
7//! 1. Load events from `EventStore` for the target aggregate
8//! 2. Reconstruct aggregate state via `Aggregate::from_events()`
9//! 3. Handle command through `Aggregate::handle()` to produce new events
10//!    (events leave `handle()` with `audit = AuditMetadata::pending()`)
11//! 4. **Stamp** each event with a fully-validated [`AuditMetadata`] derived from
12//!    the request-scoped [`CommandContext`]
13//! 5. Append events to `EventStore` with optimistic concurrency check; the
14//!    store re-validates audit (defense-in-depth)
15//! 6. Publish events to `EventBus` for projections and side effects
16//!
17//! ## Audit invariant
18//!
19//! Every persisted event carries [`AuditMetadata`] (HIPAA §164.312(b)).
20//! `dispatch` requires a [`CommandContext`] argument; production code cannot
21//! omit it. Internal jobs use [`CommandContext::system`].
22
23use crate::aggregate::{Aggregate, Command};
24use crate::audit::{AuditError, AuditMetadata, SYSTEM_ACTOR};
25use crate::event::Event;
26use crate::event_bus::{EventBus, EventBusError};
27use crate::event_store::{EventStore, EventStoreError, VersionCheck};
28use crate::snapshot::Snapshot;
29use std::marker::PhantomData;
30use thiserror::Error;
31use uuid::Uuid;
32
33/// Request-scoped context for command dispatch.
34///
35/// Constructed once per HTTP request (or by [`CommandContext::system`] for
36/// internal jobs). Carries the data needed to build [`AuditMetadata`] for every
37/// event the command produces.
38#[derive(Debug, Clone)]
39pub struct CommandContext {
40    /// Required. Aggregate UUID, `"system"`, `"anonymous"`, or
41    /// `"legacy-pre-hipaa"`. Must be non-empty.
42    pub actor_id: String,
43
44    /// Optional session id (paired with HIPAA-4 server-side session store).
45    pub session_id: Option<String>,
46
47    /// Source IP. `None` for system jobs.
48    pub source_ip: Option<String>,
49
50    /// `User-Agent` header.
51    pub user_agent: Option<String>,
52
53    /// Required. Groups every event from one logical request together.
54    pub correlation_id: Uuid,
55
56    /// Optional event id that triggered this command (saga / projection follow-up).
57    pub causation_id: Option<Uuid>,
58}
59
60impl CommandContext {
61    /// Convenience for an authenticated HTTP request. Synthesizes
62    /// `correlation_id` if not supplied by the caller.
63    pub fn for_actor(actor_id: impl Into<String>) -> Self {
64        Self {
65            actor_id: actor_id.into(),
66            session_id: None,
67            source_ip: None,
68            user_agent: None,
69            correlation_id: Uuid::new_v4(),
70            causation_id: None,
71        }
72    }
73
74    /// System-internal context (cron, seeders, migrations).
75    pub fn system() -> Self {
76        Self::for_actor(SYSTEM_ACTOR)
77    }
78
79    /// Build a context whose causation chains from a triggering event. Inherit
80    /// the upstream `correlation_id` so the saga is traceable end-to-end.
81    pub fn caused_by(actor_id: impl Into<String>, triggering: &Event) -> Self {
82        Self {
83            actor_id: actor_id.into(),
84            session_id: None,
85            source_ip: None,
86            user_agent: None,
87            correlation_id: triggering.audit.correlation_id,
88            causation_id: Some(triggering.event_id),
89        }
90    }
91
92    /// Convert into the [`AuditMetadata`] that will stamp produced events.
93    /// Sets `timestamp_utc_us = now`. Validates before returning.
94    pub fn to_audit(&self) -> Result<AuditMetadata, AuditError> {
95        let m = AuditMetadata {
96            actor_id: self.actor_id.clone(),
97            actor_session_id: self.session_id.clone(),
98            source_ip: self.source_ip.clone(),
99            user_agent: self.user_agent.clone(),
100            timestamp_utc_us: crate::audit::now_us(),
101            causation_id: self.causation_id,
102            correlation_id: self.correlation_id,
103        };
104        m.validate()?;
105        Ok(m)
106    }
107}
108
109#[cfg(any(test, feature = "test-utils"))]
110impl Default for CommandContext {
111    fn default() -> Self {
112        Self::for_actor("test")
113    }
114}
115
116/// Errors that can occur during command bus operations.
117#[derive(Debug, Error)]
118pub enum CommandBusError {
119    #[error("Failed to load aggregate '{aggregate_id}': {source}")]
120    LoadFailed {
121        aggregate_id: String,
122        #[source]
123        source: EventStoreError,
124    },
125
126    #[error("Command handling failed for aggregate '{aggregate_id}': {message}")]
127    HandleFailed {
128        aggregate_id: String,
129        message: String,
130    },
131
132    #[error("Failed to append events for aggregate '{aggregate_id}': {source}")]
133    AppendFailed {
134        aggregate_id: String,
135        #[source]
136        source: EventStoreError,
137    },
138
139    #[error("Failed to publish events for aggregate '{aggregate_id}': {source}")]
140    PublishFailed {
141        aggregate_id: String,
142        #[source]
143        source: EventBusError,
144    },
145
146    #[error("Audit metadata validation failed for aggregate '{aggregate_id}': {source}")]
147    InvalidAudit {
148        aggregate_id: String,
149        #[source]
150        source: AuditError,
151    },
152
153    #[error("Command bus error: {message}")]
154    Other { message: String },
155}
156
157impl CommandBusError {
158    pub fn handle_failed(aggregate_id: impl Into<String>, message: impl Into<String>) -> Self {
159        CommandBusError::HandleFailed {
160            aggregate_id: aggregate_id.into(),
161            message: message.into(),
162        }
163    }
164
165    pub fn other(message: impl Into<String>) -> Self {
166        CommandBusError::Other {
167            message: message.into(),
168        }
169    }
170}
171
172pub type CommandBusResult<T> = Result<T, CommandBusError>;
173
174/// Controls whether [`CommandBus`] maintains snapshots on the command/write
175/// rehydrate path.
176///
177/// `Disabled` is the default and reproduces the original behavior exactly: every
178/// dispatch replays the full event stream and no snapshot is ever read or
179/// written. The event log stays the immutable source of truth in both modes —
180/// a snapshot only short-circuits the rehydrate read, so enabling it can never
181/// change correctness, only the cost of loading a long-lived aggregate.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
183pub enum SnapshotPolicy {
184    /// No snapshot reads or writes; full from-zero replay on every dispatch.
185    #[default]
186    Disabled,
187    /// Snapshot an aggregate once it has accumulated at least this many events
188    /// since its last snapshot, and rehydrate from snapshot + event tail.
189    EveryNEvents(i64),
190}
191
192/// Command bus for dispatching commands to aggregates.
193pub struct CommandBus<A: Aggregate> {
194    event_store: Box<dyn EventStore>,
195    event_bus: Box<dyn EventBus>,
196    snapshot_policy: SnapshotPolicy,
197    _phantom: PhantomData<A>,
198}
199
200impl<A: Aggregate> CommandBus<A> {
201    pub fn new(event_store: Box<dyn EventStore>, event_bus: Box<dyn EventBus>) -> Self {
202        Self {
203            event_store,
204            event_bus,
205            // Off by default: a freshly constructed bus behaves exactly as it did
206            // before snapshots existed.
207            snapshot_policy: SnapshotPolicy::Disabled,
208            _phantom: PhantomData,
209        }
210    }
211
212    /// Enable (or change) the snapshot policy for this bus. Snapshots are a
213    /// rehydrate-path cache only; toggling this never alters command results.
214    pub fn with_snapshot_policy(mut self, policy: SnapshotPolicy) -> Self {
215        self.snapshot_policy = policy;
216        self
217    }
218
219    /// Reconstruct an aggregate by replaying its entire stream from sequence 0.
220    /// Always correct; this is the only path when snapshots are disabled and the
221    /// fallback whenever a snapshot is missing, undecodable, or unreadable.
222    async fn full_replay(&self, aggregate_id: &str) -> CommandBusResult<(A, i64)> {
223        let events = self
224            .event_store
225            .load(aggregate_id)
226            .await
227            .map_err(|source| CommandBusError::LoadFailed {
228                aggregate_id: aggregate_id.to_string(),
229                source,
230            })?;
231        let current_version = events.last().map(|e| e.sequence).unwrap_or(0);
232        Ok((A::from_events(events), current_version))
233    }
234
235    /// Dispatch a command with its request-scoped [`CommandContext`].
236    ///
237    /// Steps: load → reconstruct → handle → **stamp audit** → append → publish.
238    /// The aggregate's `handle()` returns events with placeholder audit; this
239    /// method overwrites it with a single validated [`AuditMetadata`] per
240    /// dispatch (all events from one command share the same audit stamp).
241    pub async fn dispatch(
242        &self,
243        command: A::Command,
244        context: CommandContext,
245    ) -> CommandBusResult<Vec<Event>> {
246        let aggregate_id = command.aggregate_id().to_string();
247
248        // Steps 1-2: Load existing events and reconstruct aggregate state.
249        //
250        // With snapshots disabled this is a full from-zero replay — byte-for-byte
251        // the original behavior. When a policy is enabled we resume from the
252        // latest snapshot plus the event tail, but the event log is the source of
253        // truth: any missing, undecodable, or unreadable snapshot falls back to a
254        // full replay. Both paths yield identical final state and version.
255        //
256        // `loaded_snapshot_version` is the version of the snapshot we rehydrated
257        // from (0 when none was used); the create path measures growth against it.
258        let mut loaded_snapshot_version = 0i64;
259        let (aggregate, current_version) = match self.snapshot_policy {
260            SnapshotPolicy::Disabled => self.full_replay(&aggregate_id).await?,
261            SnapshotPolicy::EveryNEvents(_) => {
262                match self.event_store.load_snapshot(&aggregate_id).await {
263                    Ok(Some(snap)) => match A::from_snapshot(snap.state.clone()) {
264                        Some(mut agg) => {
265                            let tail = self
266                                .event_store
267                                .load_from(&aggregate_id, snap.version + 1)
268                                .await
269                                .map_err(|source| CommandBusError::LoadFailed {
270                                    aggregate_id: aggregate_id.clone(),
271                                    source,
272                                })?;
273                            let current_version =
274                                tail.last().map(|e| e.sequence).unwrap_or(snap.version);
275                            for event in &tail {
276                                agg.apply(event);
277                            }
278                            loaded_snapshot_version = snap.version;
279                            (agg, current_version)
280                        }
281                        // Snapshot present but its state no longer decodes (e.g. the
282                        // aggregate's shape drifted): replay the immutable log.
283                        None => self.full_replay(&aggregate_id).await?,
284                    },
285                    // No snapshot yet, or the store failed to read one — the log
286                    // replay is always a correct (if slower) substitute.
287                    Ok(None) | Err(_) => self.full_replay(&aggregate_id).await?,
288                }
289            }
290        };
291
292        // Step 3: Handle
293        let new_events = aggregate
294            .handle(command)
295            .await
296            .map_err(|e| CommandBusError::handle_failed(&aggregate_id, e.to_string()))?;
297
298        if new_events.is_empty() {
299            return Ok(vec![]);
300        }
301
302        // Step 4: Stamp audit (one validated stamp shared across all produced events)
303        let audit = context
304            .to_audit()
305            .map_err(|source| CommandBusError::InvalidAudit {
306                aggregate_id: aggregate_id.clone(),
307                source,
308            })?;
309        let new_events: Vec<Event> = new_events
310            .into_iter()
311            .map(|e| e.with_audit(audit.clone()))
312            .collect();
313
314        // Step 5: Append (store re-validates audit defense-in-depth)
315        let version_check = if current_version == 0 {
316            VersionCheck::New
317        } else {
318            VersionCheck::Expected(current_version)
319        };
320
321        self.event_store
322            .append(&aggregate_id, version_check, new_events.clone())
323            .await
324            .map_err(|source| CommandBusError::AppendFailed {
325                aggregate_id: aggregate_id.clone(),
326                source,
327            })?;
328
329        // Step 6: Publish
330        self.event_bus
331            .publish(new_events.clone())
332            .await
333            .map_err(|source| CommandBusError::PublishFailed {
334                aggregate_id: aggregate_id.clone(),
335                source,
336            })?;
337
338        // Step 7: Snapshot (best-effort cache write). The command's events are
339        // already durably appended and published; the snapshot is a rebuildable
340        // read cache, so a failure here must never fail the dispatch.
341        if let SnapshotPolicy::EveryNEvents(n) = self.snapshot_policy {
342            let new_version = new_events
343                .last()
344                .map(|e| e.sequence)
345                .unwrap_or(current_version);
346            if new_version - loaded_snapshot_version >= n {
347                // Fold the just-appended events onto the rehydrated state to get
348                // the post-append aggregate, then let it serialize itself. An
349                // aggregate that opts out (`to_snapshot` -> None) simply gets no
350                // snapshot, and we skip silently.
351                let mut post = aggregate;
352                for event in &new_events {
353                    post.apply(event);
354                }
355                if let Some(state) = post.to_snapshot() {
356                    let snapshot = Snapshot::new(
357                        aggregate_id.clone(),
358                        A::aggregate_type(),
359                        new_version,
360                        state,
361                    );
362                    let _ = self.event_store.save_snapshot(&snapshot).await;
363                }
364            }
365        }
366
367        Ok(new_events)
368    }
369
370    pub fn event_store(&self) -> &dyn EventStore {
371        self.event_store.as_ref()
372    }
373
374    pub fn event_bus(&self) -> &dyn EventBus {
375        self.event_bus.as_ref()
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::aggregate::Aggregate;
383    use crate::event::Event;
384    use crate::event_bus::{EventHandler, InProcessEventBus};
385    use crate::event_store::{
386        EventStore, EventStoreError, EventStoreResult, InMemoryEventStore, VersionCheck,
387    };
388    use async_trait::async_trait;
389    use serde::{Deserialize, Serialize};
390    use serde_json::json;
391    use std::sync::Arc;
392    use tokio::sync::Mutex as TokioMutex;
393
394    #[derive(Debug, Clone, PartialEq)]
395    struct CounterCommand {
396        id: String,
397        increment: i64,
398    }
399
400    impl Command for CounterCommand {
401        fn aggregate_id(&self) -> &str {
402            &self.id
403        }
404    }
405
406    // Serialize/Deserialize so this aggregate opts into snapshotting, exercising
407    // the snapshot create + load paths through the bus.
408    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
409    struct CounterAggregate {
410        id: Option<String>,
411        value: i64,
412        version: i64,
413    }
414
415    #[derive(Debug, thiserror::Error)]
416    enum CounterError {
417        #[error("Negative increment not allowed")]
418        NegativeIncrement,
419    }
420
421    #[async_trait]
422    impl Aggregate for CounterAggregate {
423        type Command = CounterCommand;
424        type Event = ();
425        type Error = CounterError;
426
427        fn aggregate_type() -> &'static str {
428            "Counter"
429        }
430
431        fn version(&self) -> i64 {
432            self.version
433        }
434
435        async fn handle(&self, command: Self::Command) -> Result<Vec<Event>, Self::Error> {
436            if command.increment < 0 {
437                return Err(CounterError::NegativeIncrement);
438            }
439            Ok(vec![Event::new(
440                "Counter",
441                &command.id,
442                self.version + 1,
443                "CounterIncremented",
444                json!({ "increment": command.increment }),
445            )])
446        }
447
448        fn apply(&mut self, event: &Event) {
449            if event.event_type == "CounterIncremented" {
450                self.id = Some(event.aggregate_id.clone());
451                self.value += event.payload["increment"].as_i64().unwrap_or(0);
452                self.version = event.sequence;
453            }
454        }
455
456        fn to_snapshot(&self) -> Option<serde_json::Value> {
457            serde_json::to_value(self).ok()
458        }
459
460        fn from_snapshot(state: serde_json::Value) -> Option<Self> {
461            serde_json::from_value(state).ok()
462        }
463    }
464
465    fn ctx() -> CommandContext {
466        CommandContext::for_actor("test-actor")
467    }
468
469    #[tokio::test]
470    async fn test_command_bus_new() {
471        let _bus = CommandBus::<CounterAggregate>::new(
472            Box::new(InMemoryEventStore::new()),
473            Box::new(InProcessEventBus::new()),
474        );
475    }
476
477    #[tokio::test]
478    async fn test_dispatch_first_command() {
479        let bus = CommandBus::<CounterAggregate>::new(
480            Box::new(InMemoryEventStore::new()),
481            Box::new(InProcessEventBus::new()),
482        );
483        let cmd = CounterCommand {
484            id: "counter-1".into(),
485            increment: 5,
486        };
487        let events = bus.dispatch(cmd, ctx()).await.unwrap();
488        assert_eq!(events.len(), 1);
489        assert_eq!(events[0].event_type, "CounterIncremented");
490        assert_eq!(events[0].sequence, 1);
491        assert!(!events[0].audit.is_pending());
492        assert_eq!(events[0].audit.actor_id, "test-actor");
493    }
494
495    #[tokio::test]
496    async fn test_dispatch_multiple_commands() {
497        let bus = CommandBus::<CounterAggregate>::new(
498            Box::new(InMemoryEventStore::new()),
499            Box::new(InProcessEventBus::new()),
500        );
501        bus.dispatch(
502            CounterCommand {
503                id: "counter-1".into(),
504                increment: 5,
505            },
506            ctx(),
507        )
508        .await
509        .unwrap();
510        let events = bus
511            .dispatch(
512                CounterCommand {
513                    id: "counter-1".into(),
514                    increment: 3,
515                },
516                ctx(),
517            )
518            .await
519            .unwrap();
520        assert_eq!(events[0].sequence, 2);
521    }
522
523    #[tokio::test]
524    async fn test_dispatch_validates_command() {
525        let bus = CommandBus::<CounterAggregate>::new(
526            Box::new(InMemoryEventStore::new()),
527            Box::new(InProcessEventBus::new()),
528        );
529        let result = bus
530            .dispatch(
531                CounterCommand {
532                    id: "c1".into(),
533                    increment: -5,
534                },
535                ctx(),
536            )
537            .await;
538        match result.unwrap_err() {
539            CommandBusError::HandleFailed { message, .. } => {
540                assert!(message.contains("Negative increment"));
541            }
542            other => panic!("expected HandleFailed, got {:?}", other),
543        }
544    }
545
546    #[tokio::test]
547    async fn test_dispatch_publishes_events() {
548        let mut event_bus = InProcessEventBus::new();
549        let published = Arc::new(TokioMutex::new(Vec::new()));
550        let captured = published.clone();
551
552        struct H {
553            captured: Arc<TokioMutex<Vec<String>>>,
554        }
555        #[async_trait]
556        impl EventHandler for H {
557            fn handles(&self) -> Vec<String> {
558                vec!["CounterIncremented".to_string()]
559            }
560            async fn handle(
561                &self,
562                event: &Event,
563            ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
564                self.captured.lock().await.push(event.event_type.clone());
565                Ok(())
566            }
567        }
568        event_bus.subscribe(Box::new(H { captured })).await.unwrap();
569
570        let bus = CommandBus::<CounterAggregate>::new(
571            Box::new(InMemoryEventStore::new()),
572            Box::new(event_bus),
573        );
574        bus.dispatch(
575            CounterCommand {
576                id: "c1".into(),
577                increment: 5,
578            },
579            ctx(),
580        )
581        .await
582        .unwrap();
583        assert_eq!(published.lock().await.len(), 1);
584    }
585
586    #[tokio::test]
587    async fn test_dispatch_empty_events() {
588        #[derive(Default)]
589        struct NoOpAggregate {
590            version: i64,
591        }
592        struct NoOpCommand {
593            id: String,
594        }
595        impl Command for NoOpCommand {
596            fn aggregate_id(&self) -> &str {
597                &self.id
598            }
599        }
600        #[derive(Debug, thiserror::Error)]
601        #[error("noop")]
602        struct NoOpErr;
603        #[async_trait]
604        impl Aggregate for NoOpAggregate {
605            type Command = NoOpCommand;
606            type Event = ();
607            type Error = NoOpErr;
608            fn aggregate_type() -> &'static str {
609                "NoOp"
610            }
611            fn version(&self) -> i64 {
612                self.version
613            }
614            async fn handle(&self, _: Self::Command) -> Result<Vec<Event>, Self::Error> {
615                Ok(vec![])
616            }
617            fn apply(&mut self, _: &Event) {}
618        }
619        let bus = CommandBus::<NoOpAggregate>::new(
620            Box::new(InMemoryEventStore::new()),
621            Box::new(InProcessEventBus::new()),
622        );
623        let events = bus
624            .dispatch(NoOpCommand { id: "n1".into() }, ctx())
625            .await
626            .unwrap();
627        assert!(events.is_empty());
628    }
629
630    #[tokio::test]
631    async fn test_aggregate_state_reconstruction() {
632        let bus = CommandBus::<CounterAggregate>::new(
633            Box::new(InMemoryEventStore::new()),
634            Box::new(InProcessEventBus::new()),
635        );
636        bus.dispatch(
637            CounterCommand {
638                id: "c1".into(),
639                increment: 5,
640            },
641            ctx(),
642        )
643        .await
644        .unwrap();
645        bus.dispatch(
646            CounterCommand {
647                id: "c1".into(),
648                increment: 3,
649            },
650            ctx(),
651        )
652        .await
653        .unwrap();
654        let events = bus.event_store().load("c1").await.unwrap();
655        let agg = CounterAggregate::from_events(events);
656        assert_eq!(agg.value, 8);
657        assert_eq!(agg.version, 2);
658    }
659
660    #[tokio::test]
661    async fn test_optimistic_concurrency() {
662        struct ConflictingStore;
663        #[async_trait]
664        impl EventStore for ConflictingStore {
665            async fn append(
666                &self,
667                aggregate_id: &str,
668                version_check: VersionCheck,
669                _events: Vec<Event>,
670            ) -> EventStoreResult<()> {
671                if let Some(expected) = version_check.version() {
672                    Err(EventStoreError::ConcurrencyConflict {
673                        aggregate_id: aggregate_id.to_string(),
674                        expected,
675                        actual: expected + 1,
676                    })
677                } else {
678                    Ok(())
679                }
680            }
681            async fn load(&self, _: &str) -> EventStoreResult<Vec<Event>> {
682                Ok(vec![])
683            }
684            async fn load_from(&self, _: &str, _: i64) -> EventStoreResult<Vec<Event>> {
685                Ok(vec![])
686            }
687            async fn stream_all(&self, _: i64) -> EventStoreResult<Vec<Event>> {
688                Ok(vec![])
689            }
690            async fn get_version(&self, _: &str) -> EventStoreResult<i64> {
691                Ok(0)
692            }
693        }
694        let bus = CommandBus::<CounterAggregate>::new(
695            Box::new(ConflictingStore),
696            Box::new(InProcessEventBus::new()),
697        );
698        let err = bus
699            .dispatch(
700                CounterCommand {
701                    id: "c1".into(),
702                    increment: 5,
703                },
704                ctx(),
705            )
706            .await
707            .unwrap_err();
708        assert!(matches!(
709            err,
710            CommandBusError::AppendFailed {
711                source: EventStoreError::ConcurrencyConflict { .. },
712                ..
713            }
714        ));
715    }
716
717    #[tokio::test]
718    async fn test_dispatch_stamps_audit_on_every_event() {
719        let bus = CommandBus::<CounterAggregate>::new(
720            Box::new(InMemoryEventStore::new()),
721            Box::new(InProcessEventBus::new()),
722        );
723        let ctx = CommandContext {
724            actor_id: "alice-uuid".into(),
725            session_id: Some("sess-1".into()),
726            source_ip: Some("10.0.0.1".into()),
727            user_agent: Some("test-agent".into()),
728            correlation_id: Uuid::new_v4(),
729            causation_id: None,
730        };
731        let corr = ctx.correlation_id;
732        let events = bus
733            .dispatch(
734                CounterCommand {
735                    id: "c1".into(),
736                    increment: 5,
737                },
738                ctx,
739            )
740            .await
741            .unwrap();
742        assert_eq!(events[0].audit.actor_id, "alice-uuid");
743        assert_eq!(events[0].audit.actor_session_id.as_deref(), Some("sess-1"));
744        assert_eq!(events[0].audit.source_ip.as_deref(), Some("10.0.0.1"));
745        assert_eq!(events[0].audit.user_agent.as_deref(), Some("test-agent"));
746        assert_eq!(events[0].audit.correlation_id, corr);
747        assert!(events[0].audit.timestamp_utc_us > 0);
748    }
749
750    #[tokio::test]
751    async fn test_dispatch_rejects_invalid_actor() {
752        let bus = CommandBus::<CounterAggregate>::new(
753            Box::new(InMemoryEventStore::new()),
754            Box::new(InProcessEventBus::new()),
755        );
756        let bad_ctx = CommandContext {
757            actor_id: "".into(), // empty
758            session_id: None,
759            source_ip: None,
760            user_agent: None,
761            correlation_id: Uuid::new_v4(),
762            causation_id: None,
763        };
764        let err = bus
765            .dispatch(
766                CounterCommand {
767                    id: "c1".into(),
768                    increment: 5,
769                },
770                bad_ctx,
771            )
772            .await
773            .unwrap_err();
774        assert!(matches!(err, CommandBusError::InvalidAudit { .. }));
775    }
776
777    #[tokio::test]
778    async fn test_concurrent_dispatches_keep_distinct_correlation_ids() {
779        let bus = Arc::new(CommandBus::<CounterAggregate>::new(
780            Box::new(InMemoryEventStore::new()),
781            Box::new(InProcessEventBus::new()),
782        ));
783
784        let ctx_a = CommandContext::for_actor("alice");
785        let ctx_b = CommandContext::for_actor("bob");
786        let corr_a = ctx_a.correlation_id;
787        let corr_b = ctx_b.correlation_id;
788        assert_ne!(corr_a, corr_b);
789
790        let bus_a = bus.clone();
791        let bus_b = bus.clone();
792        let h_a = tokio::spawn(async move {
793            bus_a
794                .dispatch(
795                    CounterCommand {
796                        id: "agg-a".into(),
797                        increment: 1,
798                    },
799                    ctx_a,
800                )
801                .await
802        });
803        let h_b = tokio::spawn(async move {
804            bus_b
805                .dispatch(
806                    CounterCommand {
807                        id: "agg-b".into(),
808                        increment: 1,
809                    },
810                    ctx_b,
811                )
812                .await
813        });
814        let res_a = h_a.await.unwrap().unwrap();
815        let res_b = h_b.await.unwrap().unwrap();
816
817        assert_eq!(res_a[0].audit.correlation_id, corr_a);
818        assert_eq!(res_a[0].audit.actor_id, "alice");
819        assert_eq!(res_b[0].audit.correlation_id, corr_b);
820        assert_eq!(res_b[0].audit.actor_id, "bob");
821    }
822
823    #[tokio::test]
824    async fn test_caused_by_inherits_correlation() {
825        let bus = CommandBus::<CounterAggregate>::new(
826            Box::new(InMemoryEventStore::new()),
827            Box::new(InProcessEventBus::new()),
828        );
829        let first_ctx = CommandContext::for_actor("alice");
830        let trigger_corr = first_ctx.correlation_id;
831        let triggers = bus
832            .dispatch(
833                CounterCommand {
834                    id: "c1".into(),
835                    increment: 5,
836                },
837                first_ctx,
838            )
839            .await
840            .unwrap();
841
842        let follow_ctx = CommandContext::caused_by("projection-worker", &triggers[0]);
843        let follow = bus
844            .dispatch(
845                CounterCommand {
846                    id: "c2".into(),
847                    increment: 1,
848                },
849                follow_ctx,
850            )
851            .await
852            .unwrap();
853
854        assert_eq!(follow[0].audit.correlation_id, trigger_corr);
855        assert_eq!(follow[0].audit.causation_id, Some(triggers[0].event_id));
856    }
857
858    #[test]
859    fn test_error_messages() {
860        let e = CommandBusError::handle_failed("user-123", "Invalid email");
861        assert!(e.to_string().contains("user-123"));
862        assert!(e.to_string().contains("Invalid email"));
863        assert!(CommandBusError::other("X").to_string().contains("X"));
864    }
865
866    // A default-constructed bus (Disabled) must never read or write snapshots, so
867    // existing behavior is preserved byte-for-byte.
868    #[tokio::test]
869    async fn test_snapshot_disabled_by_default_writes_nothing() {
870        let bus = CommandBus::<CounterAggregate>::new(
871            Box::new(InMemoryEventStore::new()),
872            Box::new(InProcessEventBus::new()),
873        );
874        for _ in 0..5 {
875            bus.dispatch(
876                CounterCommand {
877                    id: "c1".into(),
878                    increment: 1,
879                },
880                ctx(),
881            )
882            .await
883            .unwrap();
884        }
885        assert!(bus
886            .event_store()
887            .load_snapshot("c1")
888            .await
889            .unwrap()
890            .is_none());
891    }
892
893    // Crossing the per-aggregate event threshold creates a snapshot stamped at the
894    // last appended sequence.
895    #[tokio::test]
896    async fn test_snapshot_created_when_threshold_crossed() {
897        let bus = CommandBus::<CounterAggregate>::new(
898            Box::new(InMemoryEventStore::new()),
899            Box::new(InProcessEventBus::new()),
900        )
901        .with_snapshot_policy(SnapshotPolicy::EveryNEvents(3));
902
903        // Versions 1 and 2 sit below the threshold of 3 — no snapshot yet.
904        for _ in 0..2 {
905            bus.dispatch(
906                CounterCommand {
907                    id: "c1".into(),
908                    increment: 1,
909                },
910                ctx(),
911            )
912            .await
913            .unwrap();
914        }
915        assert!(bus
916            .event_store()
917            .load_snapshot("c1")
918            .await
919            .unwrap()
920            .is_none());
921
922        // Version 3: 3 - 0 >= 3, so a snapshot is written at version 3.
923        bus.dispatch(
924            CounterCommand {
925                id: "c1".into(),
926                increment: 1,
927            },
928            ctx(),
929        )
930        .await
931        .unwrap();
932        let snap = bus
933            .event_store()
934            .load_snapshot("c1")
935            .await
936            .unwrap()
937            .expect("snapshot at threshold");
938        assert_eq!(snap.version, 3);
939        assert_eq!(snap.aggregate_type, "Counter");
940    }
941
942    // With a snapshot present, the snapshot+tail rehydrate path must produce the
943    // exact same stream and final state as a Disabled bus over identical commands.
944    #[tokio::test]
945    async fn test_snapshot_load_path_matches_full_replay() {
946        let enabled = CommandBus::<CounterAggregate>::new(
947            Box::new(InMemoryEventStore::new()),
948            Box::new(InProcessEventBus::new()),
949        )
950        .with_snapshot_policy(SnapshotPolicy::EveryNEvents(2));
951        let disabled = CommandBus::<CounterAggregate>::new(
952            Box::new(InMemoryEventStore::new()),
953            Box::new(InProcessEventBus::new()),
954        );
955
956        for inc in [3, 4, 5, 6, 7] {
957            enabled
958                .dispatch(
959                    CounterCommand {
960                        id: "c1".into(),
961                        increment: inc,
962                    },
963                    ctx(),
964                )
965                .await
966                .unwrap();
967            disabled
968                .dispatch(
969                    CounterCommand {
970                        id: "c1".into(),
971                        increment: inc,
972                    },
973                    ctx(),
974                )
975                .await
976                .unwrap();
977        }
978
979        // A snapshot exists, so later dispatches rehydrated through it.
980        assert!(enabled
981            .event_store()
982            .load_snapshot("c1")
983            .await
984            .unwrap()
985            .is_some());
986
987        let enabled_state =
988            CounterAggregate::from_events(enabled.event_store().load("c1").await.unwrap());
989        let disabled_state =
990            CounterAggregate::from_events(disabled.event_store().load("c1").await.unwrap());
991        assert_eq!(enabled_state.value, disabled_state.value);
992        assert_eq!(enabled_state.version, disabled_state.version);
993        assert_eq!(enabled_state.value, 25);
994        assert_eq!(enabled_state.version, 5);
995    }
996
997    // An enabled bus with no snapshot present must still dispatch correctly via the
998    // from-zero fallback (and across multiple commands).
999    #[tokio::test]
1000    async fn test_enabled_falls_back_to_replay_without_snapshot() {
1001        // Threshold high enough that a snapshot is never written, so every
1002        // rehydrate exercises the fallback replay path.
1003        let bus = CommandBus::<CounterAggregate>::new(
1004            Box::new(InMemoryEventStore::new()),
1005            Box::new(InProcessEventBus::new()),
1006        )
1007        .with_snapshot_policy(SnapshotPolicy::EveryNEvents(100));
1008
1009        let e1 = bus
1010            .dispatch(
1011                CounterCommand {
1012                    id: "c1".into(),
1013                    increment: 5,
1014                },
1015                ctx(),
1016            )
1017            .await
1018            .unwrap();
1019        assert_eq!(e1[0].sequence, 1);
1020
1021        let e2 = bus
1022            .dispatch(
1023                CounterCommand {
1024                    id: "c1".into(),
1025                    increment: 3,
1026                },
1027                ctx(),
1028            )
1029            .await
1030            .unwrap();
1031        assert_eq!(e2[0].sequence, 2);
1032
1033        assert!(bus
1034            .event_store()
1035            .load_snapshot("c1")
1036            .await
1037            .unwrap()
1038            .is_none());
1039        let state = CounterAggregate::from_events(bus.event_store().load("c1").await.unwrap());
1040        assert_eq!(state.value, 8);
1041        assert_eq!(state.version, 2);
1042    }
1043}