1use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8use utoipa::ToSchema;
9
10pub const WORKFLOW_DEFINITION_PROTOCOL: &str = "lenso.workflow-definition.v1";
11pub const WORKFLOW_COMPATIBILITY_PROTOCOL: &str = "lenso.workflow-compatibility.v1";
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
14pub struct RuntimeSurface {
15 #[serde(default)]
16 pub functions: Vec<RuntimeFunctionDeclaration>,
17 #[serde(default)]
18 pub schedules: Vec<ScheduledFunctionDeclaration>,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
23 pub workflows: Vec<WorkflowDefinition>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
27#[serde(rename_all = "camelCase", deny_unknown_fields)]
28pub struct WorkflowDefinition {
29 pub protocol: String,
31 pub owner: String,
33 pub name: String,
35 pub version: String,
37 pub input_contract: WorkflowDataContract,
38 pub result_contract: WorkflowDataContract,
39 pub steps: Vec<WorkflowStepDeclaration>,
41}
42
43impl WorkflowDefinition {
44 #[must_use]
45 pub fn new(
46 owner: impl Into<String>,
47 name: impl Into<String>,
48 version: impl Into<String>,
49 input_contract: WorkflowDataContract,
50 result_contract: WorkflowDataContract,
51 steps: Vec<WorkflowStepDeclaration>,
52 ) -> Self {
53 Self {
54 protocol: WORKFLOW_DEFINITION_PROTOCOL.to_owned(),
55 owner: owner.into(),
56 name: name.into(),
57 version: version.into(),
58 input_contract,
59 result_contract,
60 steps,
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
66#[serde(rename_all = "camelCase", deny_unknown_fields)]
67pub struct WorkflowDataContract {
68 pub contract_id: String,
69 pub version: String,
70}
71
72impl WorkflowDataContract {
73 #[must_use]
74 pub fn new(contract_id: impl Into<String>, version: impl Into<String>) -> Self {
75 Self {
76 contract_id: contract_id.into(),
77 version: version.into(),
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct WorkflowStepDeclaration {
85 pub name: String,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub display_name: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub retry_policy: Option<WorkflowRetryPolicyDeclaration>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub timeout_ms: Option<u64>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub compensation: Option<WorkflowCompensationDeclaration>,
100}
101
102impl WorkflowStepDeclaration {
103 #[must_use]
104 pub fn new(name: impl Into<String>) -> Self {
105 Self {
106 name: name.into(),
107 display_name: None,
108 retry_policy: None,
109 timeout_ms: None,
110 compensation: None,
111 }
112 }
113
114 #[must_use]
115 pub fn with_display_name(mut self, display_name: impl Into<String>) -> Self {
116 self.display_name = Some(display_name.into());
117 self
118 }
119
120 #[must_use]
121 pub fn with_retry_policy(mut self, retry_policy: WorkflowRetryPolicyDeclaration) -> Self {
122 self.retry_policy = Some(retry_policy);
123 self
124 }
125
126 #[must_use]
127 pub const fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
128 self.timeout_ms = Some(timeout_ms);
129 self
130 }
131
132 #[must_use]
133 pub fn with_compensation(mut self, compensation: WorkflowCompensationDeclaration) -> Self {
134 self.compensation = Some(compensation);
135 self
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
140#[serde(rename_all = "camelCase", deny_unknown_fields)]
141pub struct WorkflowCompensationDeclaration {
142 pub name: String,
144 pub order: u32,
146 pub contract: WorkflowDataContract,
148 pub completion_contract: WorkflowDataContract,
150}
151
152impl WorkflowCompensationDeclaration {
153 #[must_use]
154 pub fn new(name: impl Into<String>, order: u32, contract: WorkflowDataContract) -> Self {
155 Self {
156 name: name.into(),
157 order,
158 completion_contract: contract.clone(),
159 contract,
160 }
161 }
162
163 #[must_use]
164 pub fn with_completion_contract(mut self, contract: WorkflowDataContract) -> Self {
165 self.completion_contract = contract;
166 self
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
171#[serde(rename_all = "camelCase", deny_unknown_fields)]
172pub struct WorkflowRetryPolicyDeclaration {
173 pub max_attempts: u32,
175 pub delays_ms: Vec<u64>,
177}
178
179impl WorkflowRetryPolicyDeclaration {
180 #[must_use]
181 pub const fn new(max_attempts: u32, delays_ms: Vec<u64>) -> Self {
182 Self {
183 max_attempts,
184 delays_ms,
185 }
186 }
187}
188
189#[derive(
190 Debug,
191 Clone,
192 Copy,
193 PartialEq,
194 Eq,
195 PartialOrd,
196 Ord,
197 Serialize,
198 Deserialize,
199 ToSchema,
200 schemars::JsonSchema,
201)]
202#[serde(rename_all = "kebab-case")]
203pub enum WorkflowCompatibilityCategory {
204 Safe,
205 NeedsAttention,
206 Breaking,
207 Blocked,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct WorkflowDefinitionReference {
213 pub owner: String,
214 pub name: String,
215 pub version: String,
216}
217
218impl From<&WorkflowDefinition> for WorkflowDefinitionReference {
219 fn from(definition: &WorkflowDefinition) -> Self {
220 Self {
221 owner: definition.owner.clone(),
222 name: definition.name.clone(),
223 version: definition.version.clone(),
224 }
225 }
226}
227
228#[derive(
229 Debug,
230 Clone,
231 PartialEq,
232 Eq,
233 PartialOrd,
234 Ord,
235 Serialize,
236 Deserialize,
237 ToSchema,
238 schemars::JsonSchema,
239)]
240#[serde(rename_all = "camelCase", deny_unknown_fields)]
241pub struct WorkflowCompatibilityReason {
242 pub code: String,
243 pub path: String,
244 pub message: String,
245 pub next_action: String,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
249#[serde(rename_all = "camelCase", deny_unknown_fields)]
250pub struct WorkflowCompatibilityResult {
251 pub protocol: String,
252 pub category: WorkflowCompatibilityCategory,
253 pub before: WorkflowDefinitionReference,
254 pub after: WorkflowDefinitionReference,
255 pub reasons: Vec<WorkflowCompatibilityReason>,
256}
257
258#[must_use]
264pub fn evaluate_workflow_compatibility(
265 before: &WorkflowDefinition,
266 after: &WorkflowDefinition,
267) -> WorkflowCompatibilityResult {
268 let mut result = WorkflowCompatibilityResult {
269 protocol: WORKFLOW_COMPATIBILITY_PROTOCOL.to_owned(),
270 category: WorkflowCompatibilityCategory::Safe,
271 before: before.into(),
272 after: after.into(),
273 reasons: Vec::new(),
274 };
275 if before.protocol != WORKFLOW_DEFINITION_PROTOCOL {
276 add_workflow_compatibility_reason(
277 &mut result,
278 WorkflowCompatibilityCategory::Blocked,
279 "workflow_before_protocol_unsupported",
280 "$.before.protocol",
281 "The source Workflow Definition must use the supported declaration protocol.",
282 "Regenerate the source definition with the supported Workflow Definition protocol.",
283 );
284 }
285 if after.protocol != WORKFLOW_DEFINITION_PROTOCOL {
286 add_workflow_compatibility_reason(
287 &mut result,
288 WorkflowCompatibilityCategory::Blocked,
289 "workflow_after_protocol_unsupported",
290 "$.after.protocol",
291 "The target Workflow Definition must use the supported declaration protocol.",
292 "Regenerate the target definition with the supported Workflow Definition protocol.",
293 );
294 }
295 if before.owner != after.owner || before.name != after.name {
296 add_workflow_compatibility_reason(
297 &mut result,
298 WorkflowCompatibilityCategory::Blocked,
299 "workflow_identity_changed",
300 "$.after",
301 "Compatibility can only be evaluated between versions of one Workflow Definition.",
302 "Compare definitions with the same owner and stable workflow name.",
303 );
304 }
305 let before_version_missing = before.version.trim().is_empty();
306 let after_version_missing = after.version.trim().is_empty();
307 if before_version_missing {
308 add_workflow_compatibility_reason(
309 &mut result,
310 WorkflowCompatibilityCategory::Blocked,
311 "workflow_before_version_missing",
312 "$.before.version",
313 "The source Workflow Definition must have an explicit version.",
314 "Restore the source definition's immutable version identifier.",
315 );
316 }
317 if after_version_missing {
318 add_workflow_compatibility_reason(
319 &mut result,
320 WorkflowCompatibilityCategory::Blocked,
321 "workflow_after_version_missing",
322 "$.after.version",
323 "The target Workflow Definition must have an explicit version.",
324 "Publish the target definition with a new explicit version identifier.",
325 );
326 }
327 if before_version_missing || after_version_missing {
328 finish_workflow_compatibility(&mut result);
329 return result;
330 }
331 if before.version == after.version {
332 if before == after {
333 finish_workflow_compatibility(&mut result);
334 return result;
335 }
336 add_workflow_compatibility_reason(
337 &mut result,
338 WorkflowCompatibilityCategory::Blocked,
339 "workflow_version_not_immutable",
340 "$.after.version",
341 "Changed Workflow Definition content must use a new explicit version.",
342 "Restore the original version artifact and publish the changed definition under a new version.",
343 );
344 finish_workflow_compatibility(&mut result);
345 return result;
346 }
347
348 compare_workflow_data_contract(
349 &mut result,
350 "inputContract",
351 &before.input_contract,
352 &after.input_contract,
353 );
354 compare_workflow_data_contract(
355 &mut result,
356 "resultContract",
357 &before.result_contract,
358 &after.result_contract,
359 );
360 for (old_index, old_step) in before.steps.iter().enumerate() {
361 let Some(new_index) = after
362 .steps
363 .iter()
364 .position(|candidate| candidate.name == old_step.name)
365 else {
366 add_workflow_compatibility_reason(
367 &mut result,
368 WorkflowCompatibilityCategory::Breaking,
369 "workflow_step_removed",
370 &format!("$.before.steps[{old_index}]"),
371 "An existing ordered Workflow step was removed.",
372 "Preserve the existing step or provide an explicit in-flight state mapping.",
373 );
374 continue;
375 };
376 let new_step = &after.steps[new_index];
377 if old_index != new_index {
378 add_workflow_compatibility_reason(
379 &mut result,
380 WorkflowCompatibilityCategory::Breaking,
381 "workflow_step_moved",
382 &format!("$.after.steps[{new_index}].name"),
383 "An existing ordered Workflow step moved to a different position.",
384 "Preserve step order or provide an explicit in-flight state mapping.",
385 );
386 }
387 if old_step.retry_policy != new_step.retry_policy {
388 add_workflow_compatibility_reason(
389 &mut result,
390 WorkflowCompatibilityCategory::NeedsAttention,
391 "workflow_retry_policy_changed",
392 &format!("$.after.steps[{new_index}].retryPolicy"),
393 "The retry schedule for an existing Workflow step changed.",
394 "Review retry and exhaustion effects for new instances and keep in-flight instances pinned.",
395 );
396 }
397 if old_step.timeout_ms != new_step.timeout_ms {
398 add_workflow_compatibility_reason(
399 &mut result,
400 WorkflowCompatibilityCategory::NeedsAttention,
401 "workflow_timeout_changed",
402 &format!("$.after.steps[{new_index}].timeoutMs"),
403 "The timeout for an existing Workflow step changed.",
404 "Review timer effects for new instances and keep in-flight timers pinned.",
405 );
406 }
407 if old_step.display_name != new_step.display_name {
408 add_workflow_compatibility_reason(
409 &mut result,
410 WorkflowCompatibilityCategory::Safe,
411 "workflow_display_name_changed",
412 &format!("$.after.steps[{new_index}].displayName"),
413 "Only operator-facing Workflow step display metadata changed.",
414 "Regenerate the version artifact and retain the old artifact for in-flight instances.",
415 );
416 }
417 }
418 for (new_index, new_step) in after.steps.iter().enumerate() {
419 if before
420 .steps
421 .iter()
422 .any(|candidate| candidate.name == new_step.name)
423 {
424 continue;
425 }
426 add_workflow_compatibility_reason(
427 &mut result,
428 WorkflowCompatibilityCategory::NeedsAttention,
429 "workflow_step_added",
430 &format!("$.after.steps[{new_index}]"),
431 "A new ordered Workflow step was added.",
432 "Review the new business effect and start it only through the new definition version.",
433 );
434 }
435 finish_workflow_compatibility(&mut result);
436 result
437}
438
439fn compare_workflow_data_contract(
440 result: &mut WorkflowCompatibilityResult,
441 field: &str,
442 before: &WorkflowDataContract,
443 after: &WorkflowDataContract,
444) {
445 if before.contract_id != after.contract_id {
446 add_workflow_compatibility_reason(
447 result,
448 WorkflowCompatibilityCategory::Breaking,
449 "workflow_data_contract_identity_changed",
450 &format!("$.after.{field}.contractId"),
451 "The stable Workflow data contract identity changed.",
452 "Preserve the contract identity or coordinate an explicit state and payload migration.",
453 );
454 } else if before.version != after.version {
455 add_workflow_compatibility_reason(
456 result,
457 WorkflowCompatibilityCategory::NeedsAttention,
458 "workflow_data_contract_version_changed",
459 &format!("$.after.{field}.version"),
460 "A versioned Workflow data contract changed.",
461 "Review payload compatibility and retain evidence for the selected contract version.",
462 );
463 }
464}
465
466fn add_workflow_compatibility_reason(
467 result: &mut WorkflowCompatibilityResult,
468 category: WorkflowCompatibilityCategory,
469 code: &str,
470 path: &str,
471 message: &str,
472 next_action: &str,
473) {
474 result.category = result.category.max(category);
475 result.reasons.push(WorkflowCompatibilityReason {
476 code: code.to_owned(),
477 path: path.to_owned(),
478 message: message.to_owned(),
479 next_action: next_action.to_owned(),
480 });
481}
482
483fn finish_workflow_compatibility(result: &mut WorkflowCompatibilityResult) {
484 if result.reasons.is_empty() {
485 add_workflow_compatibility_reason(
486 result,
487 WorkflowCompatibilityCategory::Safe,
488 "workflow_definition_compatible",
489 "$.after.version",
490 "The new version preserves the existing Workflow execution contract.",
491 "Publish the new immutable version and select it explicitly for new instances.",
492 );
493 }
494 result.reasons.sort();
495 result.reasons.dedup();
496}
497
498#[must_use]
500pub fn workflow_compatibility_artifact() -> Value {
501 let before = WorkflowDefinition::new(
502 "support-sla",
503 "ticket_sla",
504 "v1",
505 WorkflowDataContract::new("support.sla.start", "v1"),
506 WorkflowDataContract::new("support.sla.result", "v1"),
507 vec![
508 WorkflowStepDeclaration::new("acknowledge_ticket"),
509 WorkflowStepDeclaration::new("await_resolution"),
510 ],
511 );
512 let mut safe = before.clone();
513 safe.version = "v2".to_owned();
514 let mut needs_attention = safe.clone();
515 needs_attention.steps[0].timeout_ms = Some(5_000);
516 let mut breaking = safe.clone();
517 breaking.steps.remove(0);
518 let mut blocked = before.clone();
519 blocked.steps[0].timeout_ms = Some(5_000);
520 json!({
521 "protocol": WORKFLOW_COMPATIBILITY_PROTOCOL,
522 "cases": [
523 {"name": "safe", "result": evaluate_workflow_compatibility(&before, &safe)},
524 {"name": "needs_attention", "result": evaluate_workflow_compatibility(&before, &needs_attention)},
525 {"name": "breaking", "result": evaluate_workflow_compatibility(&before, &breaking)},
526 {"name": "blocked", "result": evaluate_workflow_compatibility(&before, &blocked)}
527 ]
528 })
529}
530
531#[must_use]
533pub fn workflow_definition_schema() -> Value {
534 json!({
535 "$schema": "https://json-schema.org/draft/2020-12/schema",
536 "$id": "https://contracts.lenso.local/workflows/lenso.workflow-definition.v1.schema.json",
537 "title": "LensoWorkflowDefinition",
538 "type": "object",
539 "additionalProperties": false,
540 "required": [
541 "protocol",
542 "owner",
543 "name",
544 "version",
545 "inputContract",
546 "resultContract",
547 "steps"
548 ],
549 "properties": {
550 "protocol": { "const": WORKFLOW_DEFINITION_PROTOCOL },
551 "owner": { "type": "string", "minLength": 1 },
552 "name": { "type": "string", "minLength": 1 },
553 "version": { "type": "string", "minLength": 1 },
554 "inputContract": { "$ref": "#/$defs/dataContract" },
555 "resultContract": { "$ref": "#/$defs/dataContract" },
556 "steps": {
557 "type": "array",
558 "minItems": 1,
559 "items": { "$ref": "#/$defs/step" }
560 }
561 },
562 "$defs": {
563 "dataContract": {
564 "type": "object",
565 "additionalProperties": false,
566 "required": ["contractId", "version"],
567 "properties": {
568 "contractId": { "type": "string", "minLength": 1 },
569 "version": { "type": "string", "minLength": 1 }
570 }
571 },
572 "step": {
573 "type": "object",
574 "additionalProperties": false,
575 "required": ["name"],
576 "properties": {
577 "name": { "type": "string", "minLength": 1 },
578 "displayName": { "type": "string", "minLength": 1 },
579 "retryPolicy": { "$ref": "#/$defs/retryPolicy" },
580 "timeoutMs": {
581 "type": "integer",
582 "minimum": 1,
583 "maximum": 9223372036854775807_i64
584 },
585 "compensation": { "$ref": "#/$defs/compensation" }
586 }
587 },
588 "compensation": {
589 "type": "object",
590 "additionalProperties": false,
591 "required": ["name", "order", "contract", "completionContract"],
592 "properties": {
593 "name": { "type": "string", "minLength": 1 },
594 "order": {
595 "type": "integer",
596 "minimum": 1,
597 "maximum": 2147483647
598 },
599 "contract": { "$ref": "#/$defs/dataContract" },
600 "completionContract": { "$ref": "#/$defs/dataContract" }
601 }
602 },
603 "retryPolicy": {
604 "type": "object",
605 "additionalProperties": false,
606 "required": ["maxAttempts", "delaysMs"],
607 "properties": {
608 "maxAttempts": {
609 "type": "integer",
610 "minimum": 1,
611 "maximum": 2147483647
612 },
613 "delaysMs": {
614 "type": "array",
615 "items": {
616 "type": "integer",
617 "minimum": 0,
618 "maximum": 9223372036854775807_i64
619 }
620 }
621 }
622 }
623 }
624 })
625}
626
627#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
628pub struct RuntimeFunctionDeclaration {
629 pub name: String,
631 pub version: u16,
633 pub queue: String,
635 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub input_schema: Option<String>,
638 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub retry_policy: Option<RuntimeRetryPolicyDeclaration>,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub operation: Option<crate::ServiceOperationMetadata>,
644}
645
646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
647pub struct RuntimeRetryPolicyDeclaration {
648 pub max_attempts: u32,
649 pub initial_delay_ms: u64,
650}
651
652#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, schemars::JsonSchema)]
653pub struct ScheduledFunctionDeclaration {
654 pub name: String,
656 pub function_name: String,
658 pub cron: String,
660 #[serde(default)]
661 pub input: Value,
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 fn definition(version: &str) -> WorkflowDefinition {
669 WorkflowDefinition::new(
670 "support-sla",
671 "ticket_sla",
672 version,
673 WorkflowDataContract::new("support.sla.start", "v1"),
674 WorkflowDataContract::new("support.sla.result", "v1"),
675 vec![
676 WorkflowStepDeclaration::new("acknowledge_ticket"),
677 WorkflowStepDeclaration::new("await_resolution"),
678 ],
679 )
680 }
681
682 #[test]
683 fn workflow_compatibility_categories_are_deterministic_and_actionable() {
684 let before = definition("v1");
685 let safe = definition("v2");
686 let mut needs_attention = safe.clone();
687 needs_attention.steps[0].timeout_ms = Some(5_000);
688 assert_eq!(
689 serde_json::to_value(evaluate_workflow_compatibility(&before, &needs_attention))
690 .unwrap()["category"],
691 "needs-attention"
692 );
693 let mut breaking = safe.clone();
694 breaking.steps.remove(0);
695 let mut blocked = before.clone();
696 blocked.steps[0].timeout_ms = Some(5_000);
697
698 for (expected, after) in [
699 (WorkflowCompatibilityCategory::Safe, safe),
700 (
701 WorkflowCompatibilityCategory::NeedsAttention,
702 needs_attention,
703 ),
704 (WorkflowCompatibilityCategory::Breaking, breaking),
705 (WorkflowCompatibilityCategory::Blocked, blocked),
706 ] {
707 let first = evaluate_workflow_compatibility(&before, &after);
708 let second = evaluate_workflow_compatibility(&before, &after);
709 assert_eq!(first, second);
710 assert_eq!(first.category, expected);
711 assert!(first.reasons.iter().all(|reason| {
712 !reason.code.is_empty()
713 && reason.path.starts_with('$')
714 && !reason.next_action.is_empty()
715 }));
716 }
717 }
718
719 #[test]
720 fn workflow_compatibility_paths_identify_real_source_and_target_steps() {
721 let before = definition("v1");
722 let mut removed = definition("v2");
723 removed.steps.pop();
724 let removed_result = evaluate_workflow_compatibility(&before, &removed);
725 assert!(removed_result.reasons.iter().any(|reason| {
726 reason.code == "workflow_step_removed" && reason.path == "$.before.steps[1]"
727 }));
728
729 let mut inserted = definition("v2");
730 inserted
731 .steps
732 .insert(0, WorkflowStepDeclaration::new("triage_ticket"));
733 let inserted_result = evaluate_workflow_compatibility(&before, &inserted);
734 assert!(inserted_result.reasons.iter().any(|reason| {
735 reason.code == "workflow_step_added" && reason.path == "$.after.steps[0]"
736 }));
737 }
738
739 #[test]
740 fn workflow_compatibility_invalid_source_paths_point_to_the_source() {
741 let mut before = definition("v1");
742 let after = definition("v2");
743 before.protocol = "unsupported.workflow-definition".to_owned();
744 let unsupported = evaluate_workflow_compatibility(&before, &after);
745 assert!(unsupported.reasons.iter().any(|reason| {
746 reason.code == "workflow_before_protocol_unsupported"
747 && reason.path == "$.before.protocol"
748 }));
749 assert!(
750 !unsupported
751 .reasons
752 .iter()
753 .any(|reason| reason.code == "workflow_after_protocol_unsupported")
754 );
755
756 before.protocol = WORKFLOW_DEFINITION_PROTOCOL.to_owned();
757 before.version.clear();
758 let missing = evaluate_workflow_compatibility(&before, &after);
759 assert!(missing.reasons.iter().any(|reason| {
760 reason.code == "workflow_before_version_missing" && reason.path == "$.before.version"
761 }));
762 assert!(
763 !missing
764 .reasons
765 .iter()
766 .any(|reason| reason.code == "workflow_after_version_missing")
767 );
768 }
769}