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