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