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