Skip to main content

camel_api/
runtime.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3
4use crate::CamelError;
5use crate::declarative::LanguageExpressionDef;
6use crate::splitter::StreamSplitConfig;
7
8pub const CANONICAL_CONTRACT_NAME: &str = "canonical-v1";
9pub const CANONICAL_CONTRACT_VERSION: u32 = 2;
10pub const CANONICAL_CONTRACT_SUPPORTED_STEPS: &[&str] = &[
11    "to",
12    "log",
13    "wire_tap",
14    "script",
15    "filter",
16    "choice",
17    "split",
18    "aggregate",
19    "stop",
20    "delay",
21    "cache",
22    "cache_invalidate",
23    "cache_peek_stale",
24];
25pub const CANONICAL_CONTRACT_DECLARATIVE_ONLY_STEPS: &[&str] =
26    &["script", "filter", "choice", "split"];
27pub const CANONICAL_CONTRACT_EXCLUDED_DECLARATIVE_STEPS: &[&str] = &[
28    "set_header",
29    "set_property",
30    "set_body",
31    "multicast",
32    "convert_body_to",
33    "bean",
34    "marshal",
35    "unmarshal",
36];
37pub const CANONICAL_CONTRACT_RUST_ONLY_STEPS: &[&str] = &[
38    "processor",
39    "process",
40    "process_fn",
41    "map_body",
42    "set_body_fn",
43    "set_header_fn",
44];
45
46pub fn canonical_contract_supports_step(step: &str) -> bool {
47    CANONICAL_CONTRACT_SUPPORTED_STEPS.contains(&step)
48}
49
50pub fn canonical_contract_rejection_reason(step: &str) -> Option<&'static str> {
51    if CANONICAL_CONTRACT_EXCLUDED_DECLARATIVE_STEPS.contains(&step) {
52        return Some(
53            "declared out-of-scope for canonical v2; use declarative route compilation path outside CQRS canonical commands",
54        );
55    }
56
57    if CANONICAL_CONTRACT_RUST_ONLY_STEPS.contains(&step) {
58        return Some("rust-only programmable step; not representable in canonical v2 contract");
59    }
60
61    if canonical_contract_supports_step(step)
62        && CANONICAL_CONTRACT_DECLARATIVE_ONLY_STEPS.contains(&step)
63    {
64        return Some(
65            "supported only as declarative/serializable expression form; closure/processor variants are outside canonical v2",
66        );
67    }
68
69    None
70}
71
72#[derive(
73    Debug,
74    Clone,
75    PartialEq,
76    Eq,
77    serde::Serialize,
78    serde::Deserialize,
79    schemars::JsonSchema,
80    ts_rs::TS,
81)]
82#[serde(rename_all = "snake_case")]
83#[ts(rename_all = "snake_case")]
84pub struct CanonicalRouteSpec {
85    /// Stable minimal route representation for runtime command registration.
86    ///
87    /// Scope notes:
88    /// - This is intentionally a partial model and does not mirror every `BuilderStep`.
89    /// - Version 2 adds: auto_startup, startup_order, concurrency.
90    /// - Still excluded: error_handler, unit_of_work. These are set to defaults
91    ///   when compiling from canonical.
92    /// - Round-trip (YAML → Canonical → YAML) loses these fields.
93    /// - Advanced EIPs continue to use the existing RouteDefinition/BuilderStep path.
94    pub route_id: String,
95    pub from: String,
96    pub steps: Vec<CanonicalStepSpec>,
97    pub circuit_breaker: Option<CanonicalCircuitBreakerSpec>,
98    pub auto_startup: Option<bool>,
99    pub startup_order: Option<i32>,
100    pub concurrency: Option<CanonicalConcurrencySpec>,
101    pub version: u32,
102}
103
104#[derive(
105    Debug,
106    Clone,
107    PartialEq,
108    Eq,
109    serde::Serialize,
110    serde::Deserialize,
111    schemars::JsonSchema,
112    ts_rs::TS,
113)]
114#[serde(tag = "step", content = "config", rename_all = "snake_case")]
115#[ts(rename_all = "snake_case")]
116#[non_exhaustive]
117pub enum CanonicalStepSpec {
118    To {
119        uri: String,
120    },
121    Log {
122        message: String,
123    },
124    WireTap {
125        uri: String,
126    },
127    Script {
128        expression: LanguageExpressionDef,
129    },
130    Filter {
131        predicate: LanguageExpressionDef,
132        steps: Vec<CanonicalStepSpec>,
133    },
134    Choice {
135        whens: Vec<CanonicalWhenSpec>,
136        otherwise: Option<Vec<CanonicalStepSpec>>,
137    },
138    Split {
139        expression: CanonicalSplitExpressionSpec,
140        aggregation: CanonicalSplitAggregationSpec,
141        parallel: bool,
142        parallel_limit: Option<usize>,
143        stop_on_exception: bool,
144        steps: Vec<CanonicalStepSpec>,
145    },
146    Aggregate(CanonicalAggregateSpec),
147    Stop,
148    Delay {
149        #[ts(type = "number")]
150        delay_ms: u64,
151        dynamic_header: Option<String>,
152    },
153    Cache {
154        repository: Option<String>,
155        key: String,
156        ttl: Option<String>,
157        max_entry_bytes: Option<usize>,
158        on_miss: Vec<CanonicalStepSpec>,
159    },
160    CacheInvalidate {
161        repository: Option<String>,
162        key: String,
163    },
164    CachePeekStale {
165        repository: Option<String>,
166        key: String,
167        /// On-miss policy: `"stop"` (default) or `"continue"`. Absent/null means `"stop"`.
168        #[serde(default, skip_serializing_if = "Option::is_none")]
169        on_miss: Option<String>,
170    },
171}
172
173#[derive(
174    Debug,
175    Clone,
176    PartialEq,
177    Eq,
178    serde::Serialize,
179    serde::Deserialize,
180    schemars::JsonSchema,
181    ts_rs::TS,
182)]
183#[serde(rename_all = "snake_case")]
184#[ts(rename_all = "snake_case")]
185pub struct CanonicalWhenSpec {
186    pub predicate: LanguageExpressionDef,
187    pub steps: Vec<CanonicalStepSpec>,
188}
189
190#[derive(
191    Debug,
192    Clone,
193    PartialEq,
194    Eq,
195    serde::Serialize,
196    serde::Deserialize,
197    schemars::JsonSchema,
198    ts_rs::TS,
199)]
200#[serde(rename_all = "snake_case")]
201#[ts(rename_all = "snake_case")]
202#[non_exhaustive]
203pub enum CanonicalSplitExpressionSpec {
204    BodyLines,
205    BodyJsonArray,
206    Language(LanguageExpressionDef),
207    Stream(StreamSplitConfig),
208}
209
210#[derive(
211    Debug,
212    Clone,
213    PartialEq,
214    Eq,
215    serde::Serialize,
216    serde::Deserialize,
217    schemars::JsonSchema,
218    ts_rs::TS,
219)]
220#[serde(rename_all = "snake_case")]
221#[ts(rename_all = "snake_case")]
222#[non_exhaustive]
223pub enum CanonicalSplitAggregationSpec {
224    LastWins,
225    CollectAll,
226    Original,
227}
228
229#[derive(
230    Debug,
231    Clone,
232    PartialEq,
233    Eq,
234    serde::Serialize,
235    serde::Deserialize,
236    schemars::JsonSchema,
237    ts_rs::TS,
238)]
239#[serde(rename_all = "snake_case")]
240#[ts(rename_all = "snake_case")]
241#[non_exhaustive]
242pub enum CanonicalAggregateStrategySpec {
243    CollectAll,
244}
245
246#[derive(
247    Debug,
248    Clone,
249    PartialEq,
250    Eq,
251    serde::Serialize,
252    serde::Deserialize,
253    schemars::JsonSchema,
254    ts_rs::TS,
255)]
256#[serde(rename_all = "snake_case")]
257#[ts(rename_all = "snake_case")]
258pub struct CanonicalAggregateSpec {
259    pub header: String,
260    pub completion_size: Option<usize>,
261    #[ts(type = "number")]
262    pub completion_timeout_ms: Option<u64>,
263    pub correlation_key: Option<String>,
264    pub force_completion_on_stop: Option<bool>,
265    pub discard_on_timeout: Option<bool>,
266    pub strategy: CanonicalAggregateStrategySpec,
267    pub max_buckets: Option<usize>,
268    #[ts(type = "number")]
269    pub bucket_ttl_ms: Option<u64>,
270    /// Language expression predicate; completes the bucket when it evaluates
271    /// true against the incoming exchange (runtime-resolved via the language
272    /// registry). Mirrors `CorrelationStrategy::Expression`.
273    #[serde(default)]
274    pub completion_predicate: Option<LanguageExpressionDef>,
275}
276
277#[derive(
278    Debug,
279    Clone,
280    PartialEq,
281    Eq,
282    serde::Serialize,
283    serde::Deserialize,
284    schemars::JsonSchema,
285    ts_rs::TS,
286)]
287#[serde(rename_all = "snake_case")]
288#[ts(rename_all = "snake_case")]
289pub struct CanonicalCircuitBreakerSpec {
290    pub failure_threshold: u32,
291    #[ts(type = "number")]
292    pub open_duration_ms: u64,
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub fallback: Vec<CanonicalStepSpec>,
295}
296
297#[derive(
298    Debug,
299    Clone,
300    PartialEq,
301    Eq,
302    serde::Serialize,
303    serde::Deserialize,
304    schemars::JsonSchema,
305    ts_rs::TS,
306)]
307#[serde(tag = "mode", rename_all = "snake_case")]
308#[non_exhaustive]
309pub enum CanonicalConcurrencySpec {
310    Sequential,
311    Concurrent { max: usize },
312}
313
314impl CanonicalRouteSpec {
315    pub fn new(route_id: impl Into<String>, from: impl Into<String>) -> Self {
316        Self {
317            route_id: route_id.into(),
318            from: from.into(),
319            steps: Vec::new(),
320            circuit_breaker: None,
321            auto_startup: None,
322            startup_order: None,
323            concurrency: None,
324            version: CANONICAL_CONTRACT_VERSION,
325        }
326    }
327
328    pub fn with_auto_startup(mut self, auto: bool) -> Self {
329        self.auto_startup = Some(auto);
330        self
331    }
332
333    pub fn with_startup_order(mut self, order: i32) -> Self {
334        self.startup_order = Some(order);
335        self
336    }
337
338    pub fn with_concurrency(mut self, concurrency: CanonicalConcurrencySpec) -> Self {
339        self.concurrency = Some(concurrency);
340        self
341    }
342
343    pub fn validate_contract(&self) -> Result<(), CamelError> {
344        if self.route_id.trim().is_empty() {
345            return Err(CamelError::RouteError(
346                "canonical contract violation: route_id cannot be empty".to_string(),
347            ));
348        }
349        if self.from.trim().is_empty() {
350            return Err(CamelError::RouteError(
351                "canonical contract violation: from cannot be empty".to_string(),
352            ));
353        }
354        if self.version == 0 || self.version > CANONICAL_CONTRACT_VERSION {
355            return Err(CamelError::RouteError(format!(
356                "canonical contract violation: expected version {}, got {}",
357                CANONICAL_CONTRACT_VERSION, self.version
358            )));
359        }
360        validate_steps(&self.steps)?;
361        if let Some(cb) = &self.circuit_breaker {
362            if cb.failure_threshold == 0 {
363                return Err(CamelError::RouteError(
364                    "canonical contract violation: circuit_breaker.failure_threshold must be > 0"
365                        .to_string(),
366                ));
367            }
368            if cb.open_duration_ms == 0 {
369                return Err(CamelError::RouteError(
370                    "canonical contract violation: circuit_breaker.open_duration_ms must be > 0"
371                        .to_string(),
372                ));
373            }
374            validate_steps(&cb.fallback)?;
375        }
376        if let Some(CanonicalConcurrencySpec::Concurrent { max: 0 }) = &self.concurrency {
377            return Err(CamelError::RouteError(
378                "canonical contract violation: concurrency max must be > 0".to_string(),
379            ));
380        }
381        Ok(())
382    }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
386pub struct CanonicalFieldLoss {
387    pub field: &'static str,
388    pub reason: String,
389    pub target_version: u32,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
393pub struct CanonicalLossReport {
394    pub dropped_fields: Vec<CanonicalFieldLoss>,
395}
396
397impl CanonicalLossReport {
398    pub fn from_field(field: &'static str, reason: &str, target_version: u32) -> Self {
399        Self {
400            dropped_fields: vec![CanonicalFieldLoss {
401                field,
402                reason: reason.to_string(),
403                target_version,
404            }],
405        }
406    }
407
408    pub fn is_empty(&self) -> bool {
409        self.dropped_fields.is_empty()
410    }
411}
412
413fn validate_steps(steps: &[CanonicalStepSpec]) -> Result<(), CamelError> {
414    for step in steps {
415        match step {
416            CanonicalStepSpec::To { uri } | CanonicalStepSpec::WireTap { uri } => {
417                if uri.trim().is_empty() {
418                    return Err(CamelError::RouteError(
419                        "canonical contract violation: endpoint uri cannot be empty".to_string(),
420                    ));
421                }
422            }
423            CanonicalStepSpec::Filter { steps, .. } => validate_steps(steps)?,
424            CanonicalStepSpec::Choice { whens, otherwise } => {
425                for when in whens {
426                    validate_steps(&when.steps)?;
427                }
428                if let Some(otherwise) = otherwise {
429                    validate_steps(otherwise)?;
430                }
431            }
432            CanonicalStepSpec::Split {
433                parallel_limit,
434                steps,
435                ..
436            } => {
437                if let Some(limit) = parallel_limit
438                    && *limit == 0
439                {
440                    return Err(CamelError::RouteError(
441                        "canonical contract violation: split.parallel_limit must be > 0"
442                            .to_string(),
443                    ));
444                }
445                validate_steps(steps)?;
446            }
447            CanonicalStepSpec::Aggregate(config) => {
448                if config.header.trim().is_empty() {
449                    return Err(CamelError::RouteError(
450                        "canonical contract violation: aggregate.header cannot be empty"
451                            .to_string(),
452                    ));
453                }
454                if let Some(size) = config.completion_size
455                    && size == 0
456                {
457                    return Err(CamelError::RouteError(
458                        "canonical contract violation: aggregate.completion_size must be > 0"
459                            .to_string(),
460                    ));
461                }
462            }
463            CanonicalStepSpec::Cache { on_miss, .. } => {
464                validate_steps(on_miss)?;
465            }
466            CanonicalStepSpec::Log { .. }
467            | CanonicalStepSpec::Script { .. }
468            | CanonicalStepSpec::Stop
469            | CanonicalStepSpec::Delay { .. }
470            | CanonicalStepSpec::CacheInvalidate { .. }
471            | CanonicalStepSpec::CachePeekStale { .. } => {}
472        }
473    }
474    Ok(())
475}
476
477#[derive(Debug, Clone, PartialEq, Eq)]
478#[non_exhaustive]
479pub enum RuntimeCommand {
480    RegisterRoute {
481        spec: CanonicalRouteSpec,
482        command_id: String,
483        causation_id: Option<String>,
484    },
485    StartRoute {
486        route_id: String,
487        command_id: String,
488        causation_id: Option<String>,
489    },
490    StopRoute {
491        route_id: String,
492        command_id: String,
493        causation_id: Option<String>,
494    },
495    SuspendRoute {
496        route_id: String,
497        command_id: String,
498        causation_id: Option<String>,
499    },
500    ResumeRoute {
501        route_id: String,
502        command_id: String,
503        causation_id: Option<String>,
504    },
505    ReloadRoute {
506        route_id: String,
507        command_id: String,
508        causation_id: Option<String>,
509    },
510    /// Internal lifecycle command emitted by runtime adapters when a route crashes at runtime.
511    ///
512    /// This keeps aggregate/projection state aligned with controller-observed failures.
513    FailRoute {
514        route_id: String,
515        error: String,
516        command_id: String,
517        causation_id: Option<String>,
518    },
519    RemoveRoute {
520        route_id: String,
521        command_id: String,
522        causation_id: Option<String>,
523    },
524    ReloadTlsCerts {
525        scheme: String,
526        host: String,
527        port: u16,
528        command_id: String,
529        causation_id: Option<String>,
530    },
531    /// Reload template sources for a route (infrastructure command).
532    ///
533    /// Intercepted in `RuntimeBus::execute` BEFORE journal recovery + dedup,
534    /// exactly like `ReloadTlsCerts`: it is idempotent, NOT journaled, and does
535    /// not mutate `RouteStatus`. Dispatches to
536    /// `TemplateReloadRegistry::reload_route`.
537    ReloadTemplates {
538        route_id: String,
539        command_id: String,
540        causation_id: Option<String>,
541    },
542}
543
544impl RuntimeCommand {
545    pub fn command_id(&self) -> &str {
546        match self {
547            RuntimeCommand::RegisterRoute { command_id, .. }
548            | RuntimeCommand::StartRoute { command_id, .. }
549            | RuntimeCommand::StopRoute { command_id, .. }
550            | RuntimeCommand::SuspendRoute { command_id, .. }
551            | RuntimeCommand::ResumeRoute { command_id, .. }
552            | RuntimeCommand::ReloadRoute { command_id, .. }
553            | RuntimeCommand::FailRoute { command_id, .. }
554            | RuntimeCommand::RemoveRoute { command_id, .. }
555            | RuntimeCommand::ReloadTlsCerts { command_id, .. }
556            | RuntimeCommand::ReloadTemplates { command_id, .. } => command_id,
557        }
558    }
559
560    pub fn causation_id(&self) -> Option<&str> {
561        match self {
562            RuntimeCommand::RegisterRoute { causation_id, .. }
563            | RuntimeCommand::StartRoute { causation_id, .. }
564            | RuntimeCommand::StopRoute { causation_id, .. }
565            | RuntimeCommand::SuspendRoute { causation_id, .. }
566            | RuntimeCommand::ResumeRoute { causation_id, .. }
567            | RuntimeCommand::ReloadRoute { causation_id, .. }
568            | RuntimeCommand::FailRoute { causation_id, .. }
569            | RuntimeCommand::RemoveRoute { causation_id, .. }
570            | RuntimeCommand::ReloadTlsCerts { causation_id, .. }
571            | RuntimeCommand::ReloadTemplates { causation_id, .. } => causation_id.as_deref(),
572        }
573    }
574}
575
576#[derive(Debug, Clone, PartialEq, Eq)]
577#[non_exhaustive]
578pub enum RuntimeCommandResult {
579    Accepted,
580    Duplicate {
581        command_id: String,
582    },
583    RouteRegistered {
584        route_id: String,
585    },
586    RouteStateChanged {
587        route_id: String,
588        status: String,
589    },
590    TlsCertsReloaded {
591        scheme: String,
592        host: String,
593        port: u16,
594    },
595    TemplatesReloaded {
596        route_id: String,
597    },
598}
599
600#[derive(Debug, Clone, PartialEq, Eq)]
601#[non_exhaustive]
602pub enum RuntimeQuery {
603    GetRouteStatus {
604        route_id: String,
605    },
606    /// **Note:** This variant is intercepted by `RuntimeBus::ask` *before* reaching
607    /// `execute_query`. Do not handle it in `execute_query` — it has no access to
608    /// the in-flight counter. See `runtime_bus.rs` for the intercept.
609    InFlightCount {
610        route_id: String,
611    },
612    ListRoutes,
613}
614
615#[derive(Debug, Clone, PartialEq, Eq)]
616#[non_exhaustive]
617pub enum RuntimeQueryResult {
618    InFlightCount { route_id: String, count: u64 },
619    RouteNotFound { route_id: String },
620    RouteStatus { route_id: String, status: String },
621    Routes { route_ids: Vec<String> },
622}
623
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
625#[non_exhaustive]
626pub enum RuntimeEvent {
627    RouteRegistered { route_id: String },
628    RouteStartRequested { route_id: String },
629    RouteStarted { route_id: String },
630    RouteFailed { route_id: String, error: String },
631    RouteStopped { route_id: String },
632    RouteSuspended { route_id: String },
633    RouteResumed { route_id: String },
634    RouteReloaded { route_id: String },
635    RouteRemoved { route_id: String },
636}
637
638#[async_trait]
639pub trait RuntimeCommandBus: Send + Sync {
640    async fn execute(&self, cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError>;
641}
642
643#[async_trait]
644pub trait RuntimeQueryBus: Send + Sync {
645    async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError>;
646}
647
648pub trait RuntimeHandle: RuntimeCommandBus + RuntimeQueryBus {}
649
650impl<T> RuntimeHandle for T where T: RuntimeCommandBus + RuntimeQueryBus {}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use async_trait::async_trait;
656    use futures::executor::block_on;
657
658    struct NoopRuntime;
659
660    #[async_trait]
661    impl RuntimeCommandBus for NoopRuntime {
662        async fn execute(&self, cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError> {
663            Ok(match cmd {
664                RuntimeCommand::RegisterRoute { spec, .. } => {
665                    RuntimeCommandResult::RouteRegistered {
666                        route_id: spec.route_id,
667                    }
668                }
669                RuntimeCommand::StartRoute { route_id, .. }
670                | RuntimeCommand::StopRoute { route_id, .. }
671                | RuntimeCommand::SuspendRoute { route_id, .. }
672                | RuntimeCommand::ResumeRoute { route_id, .. }
673                | RuntimeCommand::ReloadRoute { route_id, .. }
674                | RuntimeCommand::FailRoute { route_id, .. }
675                | RuntimeCommand::RemoveRoute { route_id, .. } => {
676                    RuntimeCommandResult::RouteStateChanged {
677                        route_id,
678                        status: "ok".to_string(),
679                    }
680                }
681                RuntimeCommand::ReloadTlsCerts {
682                    scheme, host, port, ..
683                } => RuntimeCommandResult::TlsCertsReloaded { scheme, host, port },
684                RuntimeCommand::ReloadTemplates { route_id, .. } => {
685                    RuntimeCommandResult::TemplatesReloaded { route_id }
686                }
687            })
688        }
689    }
690
691    #[async_trait]
692    impl RuntimeQueryBus for NoopRuntime {
693        async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError> {
694            Ok(match query {
695                RuntimeQuery::GetRouteStatus { route_id } => RuntimeQueryResult::RouteStatus {
696                    route_id,
697                    status: "Started".to_string(),
698                },
699                RuntimeQuery::InFlightCount { route_id } => {
700                    RuntimeQueryResult::InFlightCount { route_id, count: 0 }
701                }
702                RuntimeQuery::ListRoutes => RuntimeQueryResult::Routes {
703                    route_ids: vec!["r1".to_string()],
704                },
705            })
706        }
707    }
708
709    #[test]
710    fn command_and_query_ids_are_exposed() {
711        let cmd = RuntimeCommand::StartRoute {
712            route_id: "r1".into(),
713            command_id: "c1".into(),
714            causation_id: None,
715        };
716        assert_eq!(cmd.command_id(), "c1");
717    }
718
719    #[test]
720    fn canonical_spec_requires_route_id_and_from() {
721        let spec = CanonicalRouteSpec::new("r1", "timer:tick");
722        assert_eq!(spec.route_id, "r1");
723        assert_eq!(spec.from, "timer:tick");
724        assert_eq!(spec.version, CANONICAL_CONTRACT_VERSION);
725        assert!(spec.steps.is_empty());
726        assert!(spec.circuit_breaker.is_none());
727    }
728
729    #[test]
730    fn canonical_contract_rejects_invalid_version() {
731        let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
732        spec.version = 3;
733        let err = spec.validate_contract().unwrap_err().to_string();
734        assert!(err.contains("expected version"));
735    }
736
737    #[test]
738    fn canonical_contract_declares_subset_scope() {
739        assert!(canonical_contract_supports_step("to"));
740        assert!(canonical_contract_supports_step("split"));
741        assert!(!canonical_contract_supports_step("set_header"));
742        assert!(!canonical_contract_supports_step("set_property"));
743
744        assert!(CANONICAL_CONTRACT_DECLARATIVE_ONLY_STEPS.contains(&"split"));
745        assert!(CANONICAL_CONTRACT_EXCLUDED_DECLARATIVE_STEPS.contains(&"set_header"));
746        assert!(CANONICAL_CONTRACT_EXCLUDED_DECLARATIVE_STEPS.contains(&"set_property"));
747        assert!(CANONICAL_CONTRACT_RUST_ONLY_STEPS.contains(&"processor"));
748    }
749
750    #[test]
751    fn canonical_contract_rejection_reason_is_explicit() {
752        let set_header_reason = canonical_contract_rejection_reason("set_header")
753            .expect("set_header should have explicit reason");
754        assert!(set_header_reason.contains("out-of-scope"));
755
756        let set_property_reason = canonical_contract_rejection_reason("set_property")
757            .expect("set_property should have explicit reason");
758        assert!(set_property_reason.contains("out-of-scope"));
759
760        let processor_reason = canonical_contract_rejection_reason("processor")
761            .expect("processor should be rust-only");
762        assert!(processor_reason.contains("rust-only"));
763
764        let split_reason = canonical_contract_rejection_reason("split")
765            .expect("split should require declarative form");
766        assert!(split_reason.contains("declarative"));
767    }
768
769    #[test]
770    fn command_causation_id_is_exposed() {
771        let cmd = RuntimeCommand::StopRoute {
772            route_id: "r1".into(),
773            command_id: "c2".into(),
774            causation_id: Some("c1".into()),
775        };
776        assert_eq!(cmd.command_id(), "c2");
777        assert_eq!(cmd.causation_id(), Some("c1"));
778    }
779
780    #[test]
781    fn canonical_contract_rejects_empty_route_id_and_from() {
782        let spec = CanonicalRouteSpec::new("   ", "timer:tick");
783        let err = spec.validate_contract().unwrap_err().to_string();
784        assert!(err.contains("route_id cannot be empty"));
785
786        let spec = CanonicalRouteSpec::new("r1", "  ");
787        let err = spec.validate_contract().unwrap_err().to_string();
788        assert!(err.contains("from cannot be empty"));
789    }
790
791    #[test]
792    fn canonical_contract_rejects_invalid_nested_steps() {
793        let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
794        spec.steps = vec![CanonicalStepSpec::Split {
795            expression: CanonicalSplitExpressionSpec::BodyLines,
796            aggregation: CanonicalSplitAggregationSpec::CollectAll,
797            parallel: true,
798            parallel_limit: Some(0),
799            stop_on_exception: false,
800            steps: vec![CanonicalStepSpec::To {
801                uri: "log:ok".to_string(),
802            }],
803        }];
804        let err = spec.validate_contract().unwrap_err().to_string();
805        assert!(err.contains("split.parallel_limit must be > 0"));
806
807        spec.steps = vec![CanonicalStepSpec::To {
808            uri: "   ".to_string(),
809        }];
810        let err = spec.validate_contract().unwrap_err().to_string();
811        assert!(err.contains("endpoint uri cannot be empty"));
812    }
813
814    #[test]
815    fn canonical_contract_rejects_invalid_aggregate_and_circuit_breaker() {
816        let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
817        spec.steps = vec![CanonicalStepSpec::Aggregate(CanonicalAggregateSpec {
818            header: " ".to_string(),
819            completion_size: Some(1),
820            completion_timeout_ms: None,
821            correlation_key: None,
822            force_completion_on_stop: None,
823            discard_on_timeout: None,
824            strategy: CanonicalAggregateStrategySpec::CollectAll,
825            max_buckets: None,
826            bucket_ttl_ms: None,
827            completion_predicate: None,
828        })];
829        let err = spec.validate_contract().unwrap_err().to_string();
830        assert!(err.contains("aggregate.header cannot be empty"));
831
832        spec.steps = vec![CanonicalStepSpec::Aggregate(CanonicalAggregateSpec {
833            header: "k".to_string(),
834            completion_size: Some(0),
835            completion_timeout_ms: None,
836            correlation_key: None,
837            force_completion_on_stop: None,
838            discard_on_timeout: None,
839            strategy: CanonicalAggregateStrategySpec::CollectAll,
840            max_buckets: None,
841            bucket_ttl_ms: None,
842            completion_predicate: None,
843        })];
844        let err = spec.validate_contract().unwrap_err().to_string();
845        assert!(err.contains("aggregate.completion_size must be > 0"));
846
847        spec.steps = vec![];
848        spec.circuit_breaker = Some(CanonicalCircuitBreakerSpec {
849            failure_threshold: 0,
850            open_duration_ms: 10,
851            fallback: vec![],
852        });
853        let err = spec.validate_contract().unwrap_err().to_string();
854        assert!(err.contains("failure_threshold must be > 0"));
855
856        spec.circuit_breaker = Some(CanonicalCircuitBreakerSpec {
857            failure_threshold: 1,
858            open_duration_ms: 0,
859            fallback: vec![],
860        });
861        let err = spec.validate_contract().unwrap_err().to_string();
862        assert!(err.contains("open_duration_ms must be > 0"));
863    }
864
865    #[test]
866    fn canonical_contract_rejects_invalid_fallback_step() {
867        let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
868        spec.circuit_breaker = Some(CanonicalCircuitBreakerSpec {
869            failure_threshold: 1,
870            open_duration_ms: 10,
871            fallback: vec![CanonicalStepSpec::To {
872                uri: "   ".to_string(),
873            }],
874        });
875        let err = spec.validate_contract().unwrap_err().to_string();
876        assert!(err.contains("endpoint uri cannot be empty"));
877    }
878
879    #[test]
880    fn canonical_contract_rejection_reason_none_for_regular_steps() {
881        assert!(canonical_contract_rejection_reason("to").is_none());
882        assert!(canonical_contract_rejection_reason("unknown-step").is_none());
883    }
884
885    #[test]
886    fn command_helpers_cover_all_variants() {
887        let spec = CanonicalRouteSpec::new("r1", "timer:tick");
888        let cmds = [
889            RuntimeCommand::RegisterRoute {
890                spec,
891                command_id: "c1".into(),
892                causation_id: Some("root".into()),
893            },
894            RuntimeCommand::StartRoute {
895                route_id: "r1".into(),
896                command_id: "c2".into(),
897                causation_id: None,
898            },
899            RuntimeCommand::StopRoute {
900                route_id: "r1".into(),
901                command_id: "c3".into(),
902                causation_id: None,
903            },
904            RuntimeCommand::SuspendRoute {
905                route_id: "r1".into(),
906                command_id: "c4".into(),
907                causation_id: None,
908            },
909            RuntimeCommand::ResumeRoute {
910                route_id: "r1".into(),
911                command_id: "c5".into(),
912                causation_id: None,
913            },
914            RuntimeCommand::ReloadRoute {
915                route_id: "r1".into(),
916                command_id: "c6".into(),
917                causation_id: None,
918            },
919            RuntimeCommand::FailRoute {
920                route_id: "r1".into(),
921                error: "boom".into(),
922                command_id: "c7".into(),
923                causation_id: None,
924            },
925            RuntimeCommand::RemoveRoute {
926                route_id: "r1".into(),
927                command_id: "c8".into(),
928                causation_id: None,
929            },
930            RuntimeCommand::ReloadTlsCerts {
931                scheme: "https".into(),
932                host: "example.com".into(),
933                port: 8443,
934                command_id: "c9".into(),
935                causation_id: None,
936            },
937        ];
938
939        let ids: Vec<&str> = cmds.iter().map(RuntimeCommand::command_id).collect();
940        assert_eq!(
941            ids,
942            vec!["c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c9"]
943        );
944        assert_eq!(cmds[0].causation_id(), Some("root"));
945        assert_eq!(cmds[1].causation_id(), None);
946    }
947
948    #[test]
949    fn canonical_route_spec_serde_roundtrip() {
950        let mut spec = CanonicalRouteSpec::new("test-route", "timer:tick?period=1000");
951        spec.steps.push(CanonicalStepSpec::Log {
952            message: "Hello".into(),
953        });
954        spec.steps.push(CanonicalStepSpec::To {
955            uri: "log:info".into(),
956        });
957        spec.steps.push(CanonicalStepSpec::Stop);
958
959        let json = serde_json::to_string(&spec).unwrap();
960        let deserialized: CanonicalRouteSpec = serde_json::from_str(&json).unwrap();
961        assert_eq!(spec, deserialized);
962    }
963
964    #[test]
965    fn canonical_step_spec_serde_variants() {
966        let steps = vec![
967            CanonicalStepSpec::To {
968                uri: "direct:a".into(),
969            },
970            CanonicalStepSpec::Log {
971                message: "msg".into(),
972            },
973            CanonicalStepSpec::WireTap {
974                uri: "direct:audit".into(),
975            },
976            CanonicalStepSpec::Stop,
977            CanonicalStepSpec::Delay {
978                delay_ms: 100,
979                dynamic_header: None,
980            },
981        ];
982        let json = serde_json::to_string_pretty(&steps).unwrap();
983        let back: Vec<CanonicalStepSpec> = serde_json::from_str(&json).unwrap();
984        assert_eq!(steps, back);
985    }
986
987    #[test]
988    fn canonical_cache_peek_stale_on_miss_round_trip() {
989        let some = CanonicalStepSpec::CachePeekStale {
990            repository: None,
991            key: "k".into(),
992            on_miss: Some("continue".into()),
993        };
994        let json = serde_json::to_string(&some).unwrap();
995        assert!(json.contains("continue"));
996        let back: CanonicalStepSpec = serde_json::from_str(&json).unwrap();
997        assert_eq!(some, back);
998
999        let none = CanonicalStepSpec::CachePeekStale {
1000            repository: None,
1001            key: "k".into(),
1002            on_miss: None,
1003        };
1004        let json = serde_json::to_string(&none).unwrap();
1005        assert!(
1006            !json.contains("on_miss"),
1007            "on_miss: None must omit the key from JSON, got: {json}"
1008        );
1009        let back: CanonicalStepSpec = serde_json::from_str(&json).unwrap();
1010        assert_eq!(none, back);
1011    }
1012
1013    #[test]
1014    fn canonical_circuit_breaker_fallback_roundtrip() {
1015        let mut spec = CanonicalRouteSpec::new("cb-fallback", "direct:start");
1016        spec.circuit_breaker = Some(CanonicalCircuitBreakerSpec {
1017            failure_threshold: 1,
1018            open_duration_ms: 60000,
1019            fallback: vec![CanonicalStepSpec::CachePeekStale {
1020                repository: Some("persistent".into()),
1021                key: "tile-xyz".into(),
1022                on_miss: None,
1023            }],
1024        });
1025
1026        let json = serde_json::to_string(&spec).unwrap();
1027        let back: CanonicalRouteSpec = serde_json::from_str(&json).unwrap();
1028        assert_eq!(spec, back);
1029        assert_eq!(back.circuit_breaker.as_ref().unwrap().fallback.len(), 1);
1030
1031        // A spec serialized without the fallback key deserializes with empty fallback.
1032        let without = CanonicalCircuitBreakerSpec {
1033            failure_threshold: 1,
1034            open_duration_ms: 60000,
1035            fallback: vec![],
1036        };
1037        let json = serde_json::to_string(&without).unwrap();
1038        assert!(
1039            !json.contains("fallback"),
1040            "empty fallback must omit the key from JSON, got: {json}"
1041        );
1042        let back: CanonicalCircuitBreakerSpec = serde_json::from_str(&json).unwrap();
1043        assert!(back.fallback.is_empty());
1044    }
1045
1046    #[test]
1047    fn canonical_route_spec_json_schema_generates() {
1048        let schema = schemars::schema_for!(CanonicalRouteSpec);
1049        let json = serde_json::to_string(&schema).unwrap();
1050        assert!(json.contains("CanonicalRouteSpec"));
1051        assert!(json.contains("route_id"));
1052    }
1053
1054    #[test]
1055    fn canonical_json_schema_has_no_function_step() {
1056        let schema = schemars::schema_for!(CanonicalRouteSpec);
1057        let json = serde_json::to_string(&schema).unwrap();
1058        assert!(
1059            !json.contains("\"function\""),
1060            "canonical JSON schema must not contain 'function' step"
1061        );
1062    }
1063
1064    #[test]
1065    fn canonical_contract_does_not_support_function() {
1066        assert!(
1067            !canonical_contract_supports_step("function"),
1068            "function must not be in CANONICAL_CONTRACT_SUPPORTED_STEPS"
1069        );
1070    }
1071
1072    #[test]
1073    fn runtime_command_result_all_variants_are_distinct() {
1074        let accepted = RuntimeCommandResult::Accepted;
1075        let dup = RuntimeCommandResult::Duplicate {
1076            command_id: "c1".into(),
1077        };
1078        let registered = RuntimeCommandResult::RouteRegistered {
1079            route_id: "r1".into(),
1080        };
1081        let changed = RuntimeCommandResult::RouteStateChanged {
1082            route_id: "r1".into(),
1083            status: "Started".into(),
1084        };
1085
1086        assert_ne!(accepted, dup);
1087        assert_ne!(dup, registered);
1088        assert_ne!(registered, changed);
1089
1090        let dup2 = RuntimeCommandResult::Duplicate {
1091            command_id: "c1".into(),
1092        };
1093        assert_eq!(dup, dup2);
1094    }
1095
1096    #[test]
1097    fn runtime_event_serialization_round_trip() {
1098        let event = RuntimeEvent::RouteFailed {
1099            route_id: "route-a".to_string(),
1100            error: "boom".to_string(),
1101        };
1102        let json = serde_json::to_string(&event).unwrap();
1103        let back: RuntimeEvent = serde_json::from_str(&json).unwrap();
1104        assert_eq!(event, back);
1105    }
1106
1107    #[test]
1108    fn noop_runtime_execute_and_ask_return_expected_shapes() {
1109        let rt = NoopRuntime;
1110        let cmd = RuntimeCommand::RegisterRoute {
1111            spec: CanonicalRouteSpec::new("r2", "timer:tick"),
1112            command_id: "c1".into(),
1113            causation_id: None,
1114        };
1115        let cmd_result = block_on(rt.execute(cmd)).unwrap();
1116        assert_eq!(
1117            cmd_result,
1118            RuntimeCommandResult::RouteRegistered {
1119                route_id: "r2".into()
1120            }
1121        );
1122
1123        let query_result = block_on(rt.ask(RuntimeQuery::GetRouteStatus {
1124            route_id: "r2".into(),
1125        }))
1126        .unwrap();
1127        assert_eq!(
1128            query_result,
1129            RuntimeQueryResult::RouteStatus {
1130                route_id: "r2".into(),
1131                status: "Started".into()
1132            }
1133        );
1134    }
1135
1136    #[test]
1137    fn canonical_contract_name_and_version_constants_match() {
1138        assert_eq!(CANONICAL_CONTRACT_NAME, "canonical-v1");
1139        assert_eq!(CANONICAL_CONTRACT_VERSION, 2);
1140    }
1141
1142    #[test]
1143    fn canonical_concurrency_spec_rejects_zero_max() {
1144        let spec = CanonicalRouteSpec::new("r1", "timer:tick")
1145            .with_concurrency(CanonicalConcurrencySpec::Concurrent { max: 0 });
1146        let err = spec.validate_contract().unwrap_err().to_string();
1147        assert!(err.contains("concurrency max must be > 0"), "{err}");
1148    }
1149
1150    #[test]
1151    fn canonical_v2_round_trip() {
1152        let spec = CanonicalRouteSpec::new("r1", "timer:tick")
1153            .with_auto_startup(false)
1154            .with_startup_order(42)
1155            .with_concurrency(CanonicalConcurrencySpec::Concurrent { max: 8 });
1156        spec.validate_contract().unwrap();
1157    }
1158
1159    #[test]
1160    fn canonical_v2_version_is_2() {
1161        assert_eq!(CANONICAL_CONTRACT_VERSION, 2);
1162    }
1163
1164    #[test]
1165    fn canonical_loss_report_builder() {
1166        let report =
1167            CanonicalLossReport::from_field("error_handler", "not supported by canonical path", 2);
1168        assert_eq!(report.dropped_fields.len(), 1);
1169        assert_eq!(report.dropped_fields[0].field, "error_handler");
1170    }
1171
1172    #[test]
1173    fn canonical_v1_json_deserializes_in_v2() {
1174        let json = r#"{"route_id":"r1","from":"timer:tick","steps":[],"version":1}"#;
1175        let spec: CanonicalRouteSpec = serde_json::from_str(json).unwrap();
1176        assert_eq!(spec.route_id, "r1");
1177        assert!(spec.auto_startup.is_none());
1178        assert!(spec.startup_order.is_none());
1179        assert!(spec.concurrency.is_none());
1180        // CRITICAL: v1 specs must pass validation in v2 runtime (backward compat)
1181        spec.validate_contract().unwrap();
1182    }
1183}