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