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