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_stream(A::aggregate_type(), 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
263                    .event_store
264                    .load_snapshot_for(A::aggregate_type(), &aggregate_id)
265                    .await
266                {
267                    Ok(Some(snap)) => match A::from_snapshot(snap.state.clone()) {
268                        Some(mut agg) => {
269                            let tail = self
270                                .event_store
271                                .load_stream_from(
272                                    A::aggregate_type(),
273                                    &aggregate_id,
274                                    snap.version + 1,
275                                )
276                                .await
277                                .map_err(|source| CommandBusError::LoadFailed {
278                                    aggregate_id: aggregate_id.clone(),
279                                    source,
280                                })?;
281                            let current_version =
282                                tail.last().map(|e| e.sequence).unwrap_or(snap.version);
283                            for event in &tail {
284                                agg.apply(event);
285                            }
286                            loaded_snapshot_version = snap.version;
287                            (agg, current_version)
288                        }
289                        // Snapshot present but its state no longer decodes (e.g. the
290                        // aggregate's shape drifted): replay the immutable log.
291                        None => self.full_replay(&aggregate_id).await?,
292                    },
293                    // No snapshot yet, or the store failed to read one — the log
294                    // replay is always a correct (if slower) substitute.
295                    Ok(None) | Err(_) => self.full_replay(&aggregate_id).await?,
296                }
297            }
298        };
299
300        // Step 3: Handle
301        let new_events = aggregate
302            .handle(command)
303            .await
304            .map_err(|e| CommandBusError::handle_failed(&aggregate_id, e.to_string()))?;
305
306        if new_events.is_empty() {
307            return Ok(vec![]);
308        }
309
310        // Step 4: Stamp audit (one validated stamp shared across all produced events)
311        let audit = context
312            .to_audit()
313            .map_err(|source| CommandBusError::InvalidAudit {
314                aggregate_id: aggregate_id.clone(),
315                source,
316            })?;
317        let new_events: Vec<Event> = new_events
318            .into_iter()
319            .map(|e| e.with_audit(audit.clone()))
320            .collect();
321
322        // Step 5: Append (store re-validates audit defense-in-depth)
323        let version_check = if current_version == 0 {
324            VersionCheck::New
325        } else {
326            VersionCheck::Expected(current_version)
327        };
328
329        self.event_store
330            .append_to(
331                A::aggregate_type(),
332                &aggregate_id,
333                version_check,
334                new_events.clone(),
335            )
336            .await
337            .map_err(|source| CommandBusError::AppendFailed {
338                aggregate_id: aggregate_id.clone(),
339                source,
340            })?;
341
342        // Step 6: Publish
343        self.event_bus
344            .publish(new_events.clone())
345            .await
346            .map_err(|source| CommandBusError::PublishFailed {
347                aggregate_id: aggregate_id.clone(),
348                source,
349            })?;
350
351        // Step 7: Snapshot (best-effort cache write). The command's events are
352        // already durably appended and published; the snapshot is a rebuildable
353        // read cache, so a failure here must never fail the dispatch.
354        if let SnapshotPolicy::EveryNEvents(n) = self.snapshot_policy {
355            let new_version = new_events
356                .last()
357                .map(|e| e.sequence)
358                .unwrap_or(current_version);
359            if new_version - loaded_snapshot_version >= n {
360                // Fold the just-appended events onto the rehydrated state to get
361                // the post-append aggregate, then let it serialize itself. An
362                // aggregate that opts out (`to_snapshot` -> None) simply gets no
363                // snapshot, and we skip silently.
364                let mut post = aggregate;
365                for event in &new_events {
366                    post.apply(event);
367                }
368                if let Some(state) = post.to_snapshot() {
369                    let snapshot = Snapshot::new(
370                        aggregate_id.clone(),
371                        A::aggregate_type(),
372                        new_version,
373                        state,
374                    );
375                    let _ = self.event_store.save_snapshot(&snapshot).await;
376                }
377            }
378        }
379
380        Ok(new_events)
381    }
382
383    pub fn event_store(&self) -> &dyn EventStore {
384        self.event_store.as_ref()
385    }
386
387    pub fn event_bus(&self) -> &dyn EventBus {
388        self.event_bus.as_ref()
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::aggregate::Aggregate;
396    use crate::event::Event;
397    use crate::event_bus::{EventHandler, InProcessEventBus};
398    use crate::event_store::{
399        EventStore, EventStoreError, EventStoreResult, InMemoryEventStore, VersionCheck,
400    };
401    use async_trait::async_trait;
402    use serde::{Deserialize, Serialize};
403    use serde_json::json;
404    use std::sync::Arc;
405    use tokio::sync::Mutex as TokioMutex;
406
407    #[derive(Debug, Clone, PartialEq)]
408    struct CounterCommand {
409        id: String,
410        increment: i64,
411    }
412
413    impl Command for CounterCommand {
414        fn aggregate_id(&self) -> &str {
415            &self.id
416        }
417    }
418
419    // Serialize/Deserialize so this aggregate opts into snapshotting, exercising
420    // the snapshot create + load paths through the bus.
421    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
422    struct CounterAggregate {
423        id: Option<String>,
424        value: i64,
425        version: i64,
426    }
427
428    #[derive(Debug, thiserror::Error)]
429    enum CounterError {
430        #[error("Negative increment not allowed")]
431        NegativeIncrement,
432    }
433
434    #[async_trait]
435    impl Aggregate for CounterAggregate {
436        type Command = CounterCommand;
437        type Event = ();
438        type Error = CounterError;
439
440        fn aggregate_type() -> &'static str {
441            "Counter"
442        }
443
444        fn version(&self) -> i64 {
445            self.version
446        }
447
448        async fn handle(&self, command: Self::Command) -> Result<Vec<Event>, Self::Error> {
449            if command.increment < 0 {
450                return Err(CounterError::NegativeIncrement);
451            }
452            Ok(vec![Event::new(
453                "Counter",
454                &command.id,
455                self.version + 1,
456                "CounterIncremented",
457                json!({ "increment": command.increment }),
458            )])
459        }
460
461        fn apply(&mut self, event: &Event) {
462            if event.event_type == "CounterIncremented" {
463                self.id = Some(event.aggregate_id.clone());
464                self.value += event.payload["increment"].as_i64().unwrap_or(0);
465                self.version = event.sequence;
466            }
467        }
468
469        fn to_snapshot(&self) -> Option<serde_json::Value> {
470            serde_json::to_value(self).ok()
471        }
472
473        fn from_snapshot(state: serde_json::Value) -> Option<Self> {
474            serde_json::from_value(state).ok()
475        }
476    }
477
478    fn ctx() -> CommandContext {
479        CommandContext::for_actor("test-actor")
480    }
481
482    #[tokio::test]
483    async fn test_command_bus_new() {
484        let _bus = CommandBus::<CounterAggregate>::new(
485            Box::new(InMemoryEventStore::new()),
486            Box::new(InProcessEventBus::new()),
487        );
488    }
489
490    #[tokio::test]
491    async fn test_dispatch_first_command() {
492        let bus = CommandBus::<CounterAggregate>::new(
493            Box::new(InMemoryEventStore::new()),
494            Box::new(InProcessEventBus::new()),
495        );
496        let cmd = CounterCommand {
497            id: "counter-1".into(),
498            increment: 5,
499        };
500        let events = bus.dispatch(cmd, ctx()).await.unwrap();
501        assert_eq!(events.len(), 1);
502        assert_eq!(events[0].event_type, "CounterIncremented");
503        assert_eq!(events[0].sequence, 1);
504        assert!(!events[0].audit.is_pending());
505        assert_eq!(events[0].audit.actor_id, "test-actor");
506    }
507
508    #[tokio::test]
509    async fn test_dispatch_multiple_commands() {
510        let bus = CommandBus::<CounterAggregate>::new(
511            Box::new(InMemoryEventStore::new()),
512            Box::new(InProcessEventBus::new()),
513        );
514        bus.dispatch(
515            CounterCommand {
516                id: "counter-1".into(),
517                increment: 5,
518            },
519            ctx(),
520        )
521        .await
522        .unwrap();
523        let events = bus
524            .dispatch(
525                CounterCommand {
526                    id: "counter-1".into(),
527                    increment: 3,
528                },
529                ctx(),
530            )
531            .await
532            .unwrap();
533        assert_eq!(events[0].sequence, 2);
534    }
535
536    #[tokio::test]
537    async fn test_dispatch_validates_command() {
538        let bus = CommandBus::<CounterAggregate>::new(
539            Box::new(InMemoryEventStore::new()),
540            Box::new(InProcessEventBus::new()),
541        );
542        let result = bus
543            .dispatch(
544                CounterCommand {
545                    id: "c1".into(),
546                    increment: -5,
547                },
548                ctx(),
549            )
550            .await;
551        match result.unwrap_err() {
552            CommandBusError::HandleFailed { message, .. } => {
553                assert!(message.contains("Negative increment"));
554            }
555            other => panic!("expected HandleFailed, got {:?}", other),
556        }
557    }
558
559    #[tokio::test]
560    async fn test_dispatch_publishes_events() {
561        let mut event_bus = InProcessEventBus::new();
562        let published = Arc::new(TokioMutex::new(Vec::new()));
563        let captured = published.clone();
564
565        struct H {
566            captured: Arc<TokioMutex<Vec<String>>>,
567        }
568        #[async_trait]
569        impl EventHandler for H {
570            fn handles(&self) -> Vec<String> {
571                vec!["CounterIncremented".to_string()]
572            }
573            async fn handle(
574                &self,
575                event: &Event,
576            ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
577                self.captured.lock().await.push(event.event_type.clone());
578                Ok(())
579            }
580        }
581        event_bus.subscribe(Box::new(H { captured })).await.unwrap();
582
583        let bus = CommandBus::<CounterAggregate>::new(
584            Box::new(InMemoryEventStore::new()),
585            Box::new(event_bus),
586        );
587        bus.dispatch(
588            CounterCommand {
589                id: "c1".into(),
590                increment: 5,
591            },
592            ctx(),
593        )
594        .await
595        .unwrap();
596        assert_eq!(published.lock().await.len(), 1);
597    }
598
599    #[tokio::test]
600    async fn test_dispatch_empty_events() {
601        #[derive(Default)]
602        struct NoOpAggregate {
603            version: i64,
604        }
605        struct NoOpCommand {
606            id: String,
607        }
608        impl Command for NoOpCommand {
609            fn aggregate_id(&self) -> &str {
610                &self.id
611            }
612        }
613        #[derive(Debug, thiserror::Error)]
614        #[error("noop")]
615        struct NoOpErr;
616        #[async_trait]
617        impl Aggregate for NoOpAggregate {
618            type Command = NoOpCommand;
619            type Event = ();
620            type Error = NoOpErr;
621            fn aggregate_type() -> &'static str {
622                "NoOp"
623            }
624            fn version(&self) -> i64 {
625                self.version
626            }
627            async fn handle(&self, _: Self::Command) -> Result<Vec<Event>, Self::Error> {
628                Ok(vec![])
629            }
630            fn apply(&mut self, _: &Event) {}
631        }
632        let bus = CommandBus::<NoOpAggregate>::new(
633            Box::new(InMemoryEventStore::new()),
634            Box::new(InProcessEventBus::new()),
635        );
636        let events = bus
637            .dispatch(NoOpCommand { id: "n1".into() }, ctx())
638            .await
639            .unwrap();
640        assert!(events.is_empty());
641    }
642
643    #[tokio::test]
644    async fn test_aggregate_state_reconstruction() {
645        let bus = CommandBus::<CounterAggregate>::new(
646            Box::new(InMemoryEventStore::new()),
647            Box::new(InProcessEventBus::new()),
648        );
649        bus.dispatch(
650            CounterCommand {
651                id: "c1".into(),
652                increment: 5,
653            },
654            ctx(),
655        )
656        .await
657        .unwrap();
658        bus.dispatch(
659            CounterCommand {
660                id: "c1".into(),
661                increment: 3,
662            },
663            ctx(),
664        )
665        .await
666        .unwrap();
667        let events = bus.event_store().load("c1").await.unwrap();
668        let agg = CounterAggregate::from_events(events);
669        assert_eq!(agg.value, 8);
670        assert_eq!(agg.version, 2);
671    }
672
673    #[tokio::test]
674    async fn test_optimistic_concurrency() {
675        struct ConflictingStore;
676        #[async_trait]
677        impl EventStore for ConflictingStore {
678            async fn append(
679                &self,
680                aggregate_id: &str,
681                version_check: VersionCheck,
682                _events: Vec<Event>,
683            ) -> EventStoreResult<()> {
684                if let Some(expected) = version_check.version() {
685                    Err(EventStoreError::ConcurrencyConflict {
686                        aggregate_id: aggregate_id.to_string(),
687                        expected,
688                        actual: expected + 1,
689                    })
690                } else {
691                    Ok(())
692                }
693            }
694            async fn load(&self, _: &str) -> EventStoreResult<Vec<Event>> {
695                Ok(vec![])
696            }
697            async fn load_from(&self, _: &str, _: i64) -> EventStoreResult<Vec<Event>> {
698                Ok(vec![])
699            }
700            async fn stream_all(&self, _: i64) -> EventStoreResult<Vec<Event>> {
701                Ok(vec![])
702            }
703            async fn get_version(&self, _: &str) -> EventStoreResult<i64> {
704                Ok(0)
705            }
706        }
707        let bus = CommandBus::<CounterAggregate>::new(
708            Box::new(ConflictingStore),
709            Box::new(InProcessEventBus::new()),
710        );
711        let err = bus
712            .dispatch(
713                CounterCommand {
714                    id: "c1".into(),
715                    increment: 5,
716                },
717                ctx(),
718            )
719            .await
720            .unwrap_err();
721        assert!(matches!(
722            err,
723            CommandBusError::AppendFailed {
724                source: EventStoreError::ConcurrencyConflict { .. },
725                ..
726            }
727        ));
728    }
729
730    #[tokio::test]
731    async fn test_dispatch_stamps_audit_on_every_event() {
732        let bus = CommandBus::<CounterAggregate>::new(
733            Box::new(InMemoryEventStore::new()),
734            Box::new(InProcessEventBus::new()),
735        );
736        let ctx = CommandContext {
737            actor_id: "alice-uuid".into(),
738            session_id: Some("sess-1".into()),
739            source_ip: Some("10.0.0.1".into()),
740            user_agent: Some("test-agent".into()),
741            correlation_id: Uuid::new_v4(),
742            causation_id: None,
743        };
744        let corr = ctx.correlation_id;
745        let events = bus
746            .dispatch(
747                CounterCommand {
748                    id: "c1".into(),
749                    increment: 5,
750                },
751                ctx,
752            )
753            .await
754            .unwrap();
755        assert_eq!(events[0].audit.actor_id, "alice-uuid");
756        assert_eq!(events[0].audit.actor_session_id.as_deref(), Some("sess-1"));
757        assert_eq!(events[0].audit.source_ip.as_deref(), Some("10.0.0.1"));
758        assert_eq!(events[0].audit.user_agent.as_deref(), Some("test-agent"));
759        assert_eq!(events[0].audit.correlation_id, corr);
760        assert!(events[0].audit.timestamp_utc_us > 0);
761    }
762
763    #[tokio::test]
764    async fn test_dispatch_rejects_invalid_actor() {
765        let bus = CommandBus::<CounterAggregate>::new(
766            Box::new(InMemoryEventStore::new()),
767            Box::new(InProcessEventBus::new()),
768        );
769        let bad_ctx = CommandContext {
770            actor_id: "".into(), // empty
771            session_id: None,
772            source_ip: None,
773            user_agent: None,
774            correlation_id: Uuid::new_v4(),
775            causation_id: None,
776        };
777        let err = bus
778            .dispatch(
779                CounterCommand {
780                    id: "c1".into(),
781                    increment: 5,
782                },
783                bad_ctx,
784            )
785            .await
786            .unwrap_err();
787        assert!(matches!(err, CommandBusError::InvalidAudit { .. }));
788    }
789
790    #[tokio::test]
791    async fn test_concurrent_dispatches_keep_distinct_correlation_ids() {
792        let bus = Arc::new(CommandBus::<CounterAggregate>::new(
793            Box::new(InMemoryEventStore::new()),
794            Box::new(InProcessEventBus::new()),
795        ));
796
797        let ctx_a = CommandContext::for_actor("alice");
798        let ctx_b = CommandContext::for_actor("bob");
799        let corr_a = ctx_a.correlation_id;
800        let corr_b = ctx_b.correlation_id;
801        assert_ne!(corr_a, corr_b);
802
803        let bus_a = bus.clone();
804        let bus_b = bus.clone();
805        let h_a = tokio::spawn(async move {
806            bus_a
807                .dispatch(
808                    CounterCommand {
809                        id: "agg-a".into(),
810                        increment: 1,
811                    },
812                    ctx_a,
813                )
814                .await
815        });
816        let h_b = tokio::spawn(async move {
817            bus_b
818                .dispatch(
819                    CounterCommand {
820                        id: "agg-b".into(),
821                        increment: 1,
822                    },
823                    ctx_b,
824                )
825                .await
826        });
827        let res_a = h_a.await.unwrap().unwrap();
828        let res_b = h_b.await.unwrap().unwrap();
829
830        assert_eq!(res_a[0].audit.correlation_id, corr_a);
831        assert_eq!(res_a[0].audit.actor_id, "alice");
832        assert_eq!(res_b[0].audit.correlation_id, corr_b);
833        assert_eq!(res_b[0].audit.actor_id, "bob");
834    }
835
836    #[tokio::test]
837    async fn test_caused_by_inherits_correlation() {
838        let bus = CommandBus::<CounterAggregate>::new(
839            Box::new(InMemoryEventStore::new()),
840            Box::new(InProcessEventBus::new()),
841        );
842        let first_ctx = CommandContext::for_actor("alice");
843        let trigger_corr = first_ctx.correlation_id;
844        let triggers = bus
845            .dispatch(
846                CounterCommand {
847                    id: "c1".into(),
848                    increment: 5,
849                },
850                first_ctx,
851            )
852            .await
853            .unwrap();
854
855        let follow_ctx = CommandContext::caused_by("projection-worker", &triggers[0]);
856        let follow = bus
857            .dispatch(
858                CounterCommand {
859                    id: "c2".into(),
860                    increment: 1,
861                },
862                follow_ctx,
863            )
864            .await
865            .unwrap();
866
867        assert_eq!(follow[0].audit.correlation_id, trigger_corr);
868        assert_eq!(follow[0].audit.causation_id, Some(triggers[0].event_id));
869    }
870
871    #[test]
872    fn test_error_messages() {
873        let e = CommandBusError::handle_failed("user-123", "Invalid email");
874        assert!(e.to_string().contains("user-123"));
875        assert!(e.to_string().contains("Invalid email"));
876        assert!(CommandBusError::other("X").to_string().contains("X"));
877    }
878
879    // A default-constructed bus (Disabled) must never read or write snapshots, so
880    // existing behavior is preserved byte-for-byte.
881    #[tokio::test]
882    async fn test_snapshot_disabled_by_default_writes_nothing() {
883        let bus = CommandBus::<CounterAggregate>::new(
884            Box::new(InMemoryEventStore::new()),
885            Box::new(InProcessEventBus::new()),
886        );
887        for _ in 0..5 {
888            bus.dispatch(
889                CounterCommand {
890                    id: "c1".into(),
891                    increment: 1,
892                },
893                ctx(),
894            )
895            .await
896            .unwrap();
897        }
898        assert!(bus
899            .event_store()
900            .load_snapshot("c1")
901            .await
902            .unwrap()
903            .is_none());
904    }
905
906    // Crossing the per-aggregate event threshold creates a snapshot stamped at the
907    // last appended sequence.
908    #[tokio::test]
909    async fn test_snapshot_created_when_threshold_crossed() {
910        let bus = CommandBus::<CounterAggregate>::new(
911            Box::new(InMemoryEventStore::new()),
912            Box::new(InProcessEventBus::new()),
913        )
914        .with_snapshot_policy(SnapshotPolicy::EveryNEvents(3));
915
916        // Versions 1 and 2 sit below the threshold of 3 — no snapshot yet.
917        for _ in 0..2 {
918            bus.dispatch(
919                CounterCommand {
920                    id: "c1".into(),
921                    increment: 1,
922                },
923                ctx(),
924            )
925            .await
926            .unwrap();
927        }
928        assert!(bus
929            .event_store()
930            .load_snapshot("c1")
931            .await
932            .unwrap()
933            .is_none());
934
935        // Version 3: 3 - 0 >= 3, so a snapshot is written at version 3.
936        bus.dispatch(
937            CounterCommand {
938                id: "c1".into(),
939                increment: 1,
940            },
941            ctx(),
942        )
943        .await
944        .unwrap();
945        let snap = bus
946            .event_store()
947            .load_snapshot("c1")
948            .await
949            .unwrap()
950            .expect("snapshot at threshold");
951        assert_eq!(snap.version, 3);
952        assert_eq!(snap.aggregate_type, "Counter");
953    }
954
955    // With a snapshot present, the snapshot+tail rehydrate path must produce the
956    // exact same stream and final state as a Disabled bus over identical commands.
957    #[tokio::test]
958    async fn test_snapshot_load_path_matches_full_replay() {
959        let enabled = CommandBus::<CounterAggregate>::new(
960            Box::new(InMemoryEventStore::new()),
961            Box::new(InProcessEventBus::new()),
962        )
963        .with_snapshot_policy(SnapshotPolicy::EveryNEvents(2));
964        let disabled = CommandBus::<CounterAggregate>::new(
965            Box::new(InMemoryEventStore::new()),
966            Box::new(InProcessEventBus::new()),
967        );
968
969        for inc in [3, 4, 5, 6, 7] {
970            enabled
971                .dispatch(
972                    CounterCommand {
973                        id: "c1".into(),
974                        increment: inc,
975                    },
976                    ctx(),
977                )
978                .await
979                .unwrap();
980            disabled
981                .dispatch(
982                    CounterCommand {
983                        id: "c1".into(),
984                        increment: inc,
985                    },
986                    ctx(),
987                )
988                .await
989                .unwrap();
990        }
991
992        // A snapshot exists, so later dispatches rehydrated through it.
993        assert!(enabled
994            .event_store()
995            .load_snapshot("c1")
996            .await
997            .unwrap()
998            .is_some());
999
1000        let enabled_state =
1001            CounterAggregate::from_events(enabled.event_store().load("c1").await.unwrap());
1002        let disabled_state =
1003            CounterAggregate::from_events(disabled.event_store().load("c1").await.unwrap());
1004        assert_eq!(enabled_state.value, disabled_state.value);
1005        assert_eq!(enabled_state.version, disabled_state.version);
1006        assert_eq!(enabled_state.value, 25);
1007        assert_eq!(enabled_state.version, 5);
1008    }
1009
1010    // An enabled bus with no snapshot present must still dispatch correctly via the
1011    // from-zero fallback (and across multiple commands).
1012    #[tokio::test]
1013    async fn test_enabled_falls_back_to_replay_without_snapshot() {
1014        // Threshold high enough that a snapshot is never written, so every
1015        // rehydrate exercises the fallback replay path.
1016        let bus = CommandBus::<CounterAggregate>::new(
1017            Box::new(InMemoryEventStore::new()),
1018            Box::new(InProcessEventBus::new()),
1019        )
1020        .with_snapshot_policy(SnapshotPolicy::EveryNEvents(100));
1021
1022        let e1 = bus
1023            .dispatch(
1024                CounterCommand {
1025                    id: "c1".into(),
1026                    increment: 5,
1027                },
1028                ctx(),
1029            )
1030            .await
1031            .unwrap();
1032        assert_eq!(e1[0].sequence, 1);
1033
1034        let e2 = bus
1035            .dispatch(
1036                CounterCommand {
1037                    id: "c1".into(),
1038                    increment: 3,
1039                },
1040                ctx(),
1041            )
1042            .await
1043            .unwrap();
1044        assert_eq!(e2[0].sequence, 2);
1045
1046        assert!(bus
1047            .event_store()
1048            .load_snapshot("c1")
1049            .await
1050            .unwrap()
1051            .is_none());
1052        let state = CounterAggregate::from_events(bus.event_store().load("c1").await.unwrap());
1053        assert_eq!(state.value, 8);
1054        assert_eq!(state.version, 2);
1055    }
1056}