Skip to main content

lenso_contracts/
runtime.rs

1//! Pure-data runtime declarations for module manifests.
2//!
3//! These declarations describe runtime behavior a module can provide without
4//! carrying executable handlers. Loading sources decide how to bind them later.
5
6use 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)]
14pub struct RuntimeSurface {
15    #[serde(default)]
16    pub functions: Vec<RuntimeFunctionDeclaration>,
17    #[serde(default)]
18    pub schedules: Vec<ScheduledFunctionDeclaration>,
19    /// Engine-neutral Durable Workflow definitions owned by this Module.
20    /// Autonomous Service composition decides how to execute them; Host-managed
21    /// Provider and Runtime Function behavior does not consume this metadata.
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub workflows: Vec<WorkflowDefinition>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
27#[serde(rename_all = "camelCase", deny_unknown_fields)]
28pub struct WorkflowDefinition {
29    /// Versioned declaration protocol, currently `lenso.workflow-definition.v1`.
30    pub protocol: String,
31    /// Stable Module identity that owns the workflow behavior.
32    pub owner: String,
33    /// Stable workflow name within the owning Module.
34    pub name: String,
35    /// Definition version pinned by every started instance.
36    pub version: String,
37    pub input_contract: WorkflowDataContract,
38    pub result_contract: WorkflowDataContract,
39    /// Ordered step metadata. Position in this vector is contract-significant.
40    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)]
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)]
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    /// Optional durable retry schedule. `max_attempts` includes the original
89    /// attempt, while each delay schedules the following attempt.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub retry_policy: Option<WorkflowRetryPolicyDeclaration>,
92    /// Optional durable timeout applied independently to every attempt.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub timeout_ms: Option<u64>,
95    /// Optional compensation for the completed business effect produced by
96    /// this step. The explicit order is evaluated across all completed effects
97    /// when compensation is selected; it is independent from completion time.
98    #[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)]
140#[serde(rename_all = "camelCase", deny_unknown_fields)]
141pub struct WorkflowCompensationDeclaration {
142    /// Stable compensation behavior identity within this Workflow Definition.
143    pub name: String,
144    /// Explicit deterministic execution order. Lower values execute first.
145    pub order: u32,
146    /// Versioned Event Contract used to request the compensation effect.
147    pub contract: WorkflowDataContract,
148    /// Versioned Event Contract that confirms the remote business effect was reversed.
149    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)]
171#[serde(rename_all = "camelCase", deny_unknown_fields)]
172pub struct WorkflowRetryPolicyDeclaration {
173    /// Total attempts including the original execution.
174    pub max_attempts: u32,
175    /// Delay before attempts 2..N. The length must equal `max_attempts` - 1.
176    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(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, ToSchema)]
190#[serde(rename_all = "kebab-case")]
191pub enum WorkflowCompatibilityCategory {
192    Safe,
193    NeedsAttention,
194    Breaking,
195    Blocked,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
199#[serde(rename_all = "camelCase", deny_unknown_fields)]
200pub struct WorkflowDefinitionReference {
201    pub owner: String,
202    pub name: String,
203    pub version: String,
204}
205
206impl From<&WorkflowDefinition> for WorkflowDefinitionReference {
207    fn from(definition: &WorkflowDefinition) -> Self {
208        Self {
209            owner: definition.owner.clone(),
210            name: definition.name.clone(),
211            version: definition.version.clone(),
212        }
213    }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, ToSchema)]
217#[serde(rename_all = "camelCase", deny_unknown_fields)]
218pub struct WorkflowCompatibilityReason {
219    pub code: String,
220    pub path: String,
221    pub message: String,
222    pub next_action: String,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
226#[serde(rename_all = "camelCase", deny_unknown_fields)]
227pub struct WorkflowCompatibilityResult {
228    pub protocol: String,
229    pub category: WorkflowCompatibilityCategory,
230    pub before: WorkflowDefinitionReference,
231    pub after: WorkflowDefinitionReference,
232    pub reasons: Vec<WorkflowCompatibilityReason>,
233}
234
235/// Compares two versions of one engine-neutral Workflow Definition.
236///
237/// The evaluator is deliberately conservative: changes to execution policy or
238/// the ordered step graph never become `safe` merely because both declarations
239/// deserialize. Reusing one version for different content is always blocked.
240#[must_use]
241pub fn evaluate_workflow_compatibility(
242    before: &WorkflowDefinition,
243    after: &WorkflowDefinition,
244) -> WorkflowCompatibilityResult {
245    let mut result = WorkflowCompatibilityResult {
246        protocol: WORKFLOW_COMPATIBILITY_PROTOCOL.to_owned(),
247        category: WorkflowCompatibilityCategory::Safe,
248        before: before.into(),
249        after: after.into(),
250        reasons: Vec::new(),
251    };
252    if before.protocol != WORKFLOW_DEFINITION_PROTOCOL {
253        add_workflow_compatibility_reason(
254            &mut result,
255            WorkflowCompatibilityCategory::Blocked,
256            "workflow_before_protocol_unsupported",
257            "$.before.protocol",
258            "The source Workflow Definition must use the supported declaration protocol.",
259            "Regenerate the source definition with the supported Workflow Definition protocol.",
260        );
261    }
262    if after.protocol != WORKFLOW_DEFINITION_PROTOCOL {
263        add_workflow_compatibility_reason(
264            &mut result,
265            WorkflowCompatibilityCategory::Blocked,
266            "workflow_after_protocol_unsupported",
267            "$.after.protocol",
268            "The target Workflow Definition must use the supported declaration protocol.",
269            "Regenerate the target definition with the supported Workflow Definition protocol.",
270        );
271    }
272    if before.owner != after.owner || before.name != after.name {
273        add_workflow_compatibility_reason(
274            &mut result,
275            WorkflowCompatibilityCategory::Blocked,
276            "workflow_identity_changed",
277            "$.after",
278            "Compatibility can only be evaluated between versions of one Workflow Definition.",
279            "Compare definitions with the same owner and stable workflow name.",
280        );
281    }
282    let before_version_missing = before.version.trim().is_empty();
283    let after_version_missing = after.version.trim().is_empty();
284    if before_version_missing {
285        add_workflow_compatibility_reason(
286            &mut result,
287            WorkflowCompatibilityCategory::Blocked,
288            "workflow_before_version_missing",
289            "$.before.version",
290            "The source Workflow Definition must have an explicit version.",
291            "Restore the source definition's immutable version identifier.",
292        );
293    }
294    if after_version_missing {
295        add_workflow_compatibility_reason(
296            &mut result,
297            WorkflowCompatibilityCategory::Blocked,
298            "workflow_after_version_missing",
299            "$.after.version",
300            "The target Workflow Definition must have an explicit version.",
301            "Publish the target definition with a new explicit version identifier.",
302        );
303    }
304    if before_version_missing || after_version_missing {
305        finish_workflow_compatibility(&mut result);
306        return result;
307    }
308    if before.version == after.version {
309        if before == after {
310            finish_workflow_compatibility(&mut result);
311            return result;
312        }
313        add_workflow_compatibility_reason(
314            &mut result,
315            WorkflowCompatibilityCategory::Blocked,
316            "workflow_version_not_immutable",
317            "$.after.version",
318            "Changed Workflow Definition content must use a new explicit version.",
319            "Restore the original version artifact and publish the changed definition under a new version.",
320        );
321        finish_workflow_compatibility(&mut result);
322        return result;
323    }
324
325    compare_workflow_data_contract(
326        &mut result,
327        "inputContract",
328        &before.input_contract,
329        &after.input_contract,
330    );
331    compare_workflow_data_contract(
332        &mut result,
333        "resultContract",
334        &before.result_contract,
335        &after.result_contract,
336    );
337    for (old_index, old_step) in before.steps.iter().enumerate() {
338        let Some(new_index) = after
339            .steps
340            .iter()
341            .position(|candidate| candidate.name == old_step.name)
342        else {
343            add_workflow_compatibility_reason(
344                &mut result,
345                WorkflowCompatibilityCategory::Breaking,
346                "workflow_step_removed",
347                &format!("$.before.steps[{old_index}]"),
348                "An existing ordered Workflow step was removed.",
349                "Preserve the existing step or provide an explicit in-flight state mapping.",
350            );
351            continue;
352        };
353        let new_step = &after.steps[new_index];
354        if old_index != new_index {
355            add_workflow_compatibility_reason(
356                &mut result,
357                WorkflowCompatibilityCategory::Breaking,
358                "workflow_step_moved",
359                &format!("$.after.steps[{new_index}].name"),
360                "An existing ordered Workflow step moved to a different position.",
361                "Preserve step order or provide an explicit in-flight state mapping.",
362            );
363        }
364        if old_step.retry_policy != new_step.retry_policy {
365            add_workflow_compatibility_reason(
366                &mut result,
367                WorkflowCompatibilityCategory::NeedsAttention,
368                "workflow_retry_policy_changed",
369                &format!("$.after.steps[{new_index}].retryPolicy"),
370                "The retry schedule for an existing Workflow step changed.",
371                "Review retry and exhaustion effects for new instances and keep in-flight instances pinned.",
372            );
373        }
374        if old_step.timeout_ms != new_step.timeout_ms {
375            add_workflow_compatibility_reason(
376                &mut result,
377                WorkflowCompatibilityCategory::NeedsAttention,
378                "workflow_timeout_changed",
379                &format!("$.after.steps[{new_index}].timeoutMs"),
380                "The timeout for an existing Workflow step changed.",
381                "Review timer effects for new instances and keep in-flight timers pinned.",
382            );
383        }
384        if old_step.display_name != new_step.display_name {
385            add_workflow_compatibility_reason(
386                &mut result,
387                WorkflowCompatibilityCategory::Safe,
388                "workflow_display_name_changed",
389                &format!("$.after.steps[{new_index}].displayName"),
390                "Only operator-facing Workflow step display metadata changed.",
391                "Regenerate the version artifact and retain the old artifact for in-flight instances.",
392            );
393        }
394    }
395    for (new_index, new_step) in after.steps.iter().enumerate() {
396        if before
397            .steps
398            .iter()
399            .any(|candidate| candidate.name == new_step.name)
400        {
401            continue;
402        }
403        add_workflow_compatibility_reason(
404            &mut result,
405            WorkflowCompatibilityCategory::NeedsAttention,
406            "workflow_step_added",
407            &format!("$.after.steps[{new_index}]"),
408            "A new ordered Workflow step was added.",
409            "Review the new business effect and start it only through the new definition version.",
410        );
411    }
412    finish_workflow_compatibility(&mut result);
413    result
414}
415
416fn compare_workflow_data_contract(
417    result: &mut WorkflowCompatibilityResult,
418    field: &str,
419    before: &WorkflowDataContract,
420    after: &WorkflowDataContract,
421) {
422    if before.contract_id != after.contract_id {
423        add_workflow_compatibility_reason(
424            result,
425            WorkflowCompatibilityCategory::Breaking,
426            "workflow_data_contract_identity_changed",
427            &format!("$.after.{field}.contractId"),
428            "The stable Workflow data contract identity changed.",
429            "Preserve the contract identity or coordinate an explicit state and payload migration.",
430        );
431    } else if before.version != after.version {
432        add_workflow_compatibility_reason(
433            result,
434            WorkflowCompatibilityCategory::NeedsAttention,
435            "workflow_data_contract_version_changed",
436            &format!("$.after.{field}.version"),
437            "A versioned Workflow data contract changed.",
438            "Review payload compatibility and retain evidence for the selected contract version.",
439        );
440    }
441}
442
443fn add_workflow_compatibility_reason(
444    result: &mut WorkflowCompatibilityResult,
445    category: WorkflowCompatibilityCategory,
446    code: &str,
447    path: &str,
448    message: &str,
449    next_action: &str,
450) {
451    result.category = result.category.max(category);
452    result.reasons.push(WorkflowCompatibilityReason {
453        code: code.to_owned(),
454        path: path.to_owned(),
455        message: message.to_owned(),
456        next_action: next_action.to_owned(),
457    });
458}
459
460fn finish_workflow_compatibility(result: &mut WorkflowCompatibilityResult) {
461    if result.reasons.is_empty() {
462        add_workflow_compatibility_reason(
463            result,
464            WorkflowCompatibilityCategory::Safe,
465            "workflow_definition_compatible",
466            "$.after.version",
467            "The new version preserves the existing Workflow execution contract.",
468            "Publish the new immutable version and select it explicitly for new instances.",
469        );
470    }
471    result.reasons.sort();
472    result.reasons.dedup();
473}
474
475/// Stable generated examples for every public Workflow compatibility category.
476#[must_use]
477pub fn workflow_compatibility_artifact() -> Value {
478    let before = WorkflowDefinition::new(
479        "support-sla",
480        "ticket_sla",
481        "v1",
482        WorkflowDataContract::new("support.sla.start", "v1"),
483        WorkflowDataContract::new("support.sla.result", "v1"),
484        vec![
485            WorkflowStepDeclaration::new("acknowledge_ticket"),
486            WorkflowStepDeclaration::new("await_resolution"),
487        ],
488    );
489    let mut safe = before.clone();
490    safe.version = "v2".to_owned();
491    let mut needs_attention = safe.clone();
492    needs_attention.steps[0].timeout_ms = Some(5_000);
493    let mut breaking = safe.clone();
494    breaking.steps.remove(0);
495    let mut blocked = before.clone();
496    blocked.steps[0].timeout_ms = Some(5_000);
497    json!({
498        "protocol": WORKFLOW_COMPATIBILITY_PROTOCOL,
499        "cases": [
500            {"name": "safe", "result": evaluate_workflow_compatibility(&before, &safe)},
501            {"name": "needs_attention", "result": evaluate_workflow_compatibility(&before, &needs_attention)},
502            {"name": "breaking", "result": evaluate_workflow_compatibility(&before, &breaking)},
503            {"name": "blocked", "result": evaluate_workflow_compatibility(&before, &blocked)}
504        ]
505    })
506}
507
508/// Deterministic JSON Schema published for engine-neutral Workflow Definitions.
509#[must_use]
510pub fn workflow_definition_schema() -> Value {
511    json!({
512        "$schema": "https://json-schema.org/draft/2020-12/schema",
513        "$id": "https://contracts.lenso.local/workflows/lenso.workflow-definition.v1.schema.json",
514        "title": "LensoWorkflowDefinition",
515        "type": "object",
516        "additionalProperties": false,
517        "required": [
518            "protocol",
519            "owner",
520            "name",
521            "version",
522            "inputContract",
523            "resultContract",
524            "steps"
525        ],
526        "properties": {
527            "protocol": { "const": WORKFLOW_DEFINITION_PROTOCOL },
528            "owner": { "type": "string", "minLength": 1 },
529            "name": { "type": "string", "minLength": 1 },
530            "version": { "type": "string", "minLength": 1 },
531            "inputContract": { "$ref": "#/$defs/dataContract" },
532            "resultContract": { "$ref": "#/$defs/dataContract" },
533            "steps": {
534                "type": "array",
535                "minItems": 1,
536                "items": { "$ref": "#/$defs/step" }
537            }
538        },
539        "$defs": {
540            "dataContract": {
541                "type": "object",
542                "additionalProperties": false,
543                "required": ["contractId", "version"],
544                "properties": {
545                    "contractId": { "type": "string", "minLength": 1 },
546                    "version": { "type": "string", "minLength": 1 }
547                }
548            },
549            "step": {
550                "type": "object",
551                "additionalProperties": false,
552                "required": ["name"],
553                "properties": {
554                    "name": { "type": "string", "minLength": 1 },
555                    "displayName": { "type": "string", "minLength": 1 },
556                    "retryPolicy": { "$ref": "#/$defs/retryPolicy" },
557                    "timeoutMs": {
558                        "type": "integer",
559                        "minimum": 1,
560                        "maximum": 9223372036854775807_i64
561                    },
562                    "compensation": { "$ref": "#/$defs/compensation" }
563                }
564            },
565            "compensation": {
566                "type": "object",
567                "additionalProperties": false,
568                "required": ["name", "order", "contract", "completionContract"],
569                "properties": {
570                    "name": { "type": "string", "minLength": 1 },
571                    "order": {
572                        "type": "integer",
573                        "minimum": 1,
574                        "maximum": 2147483647
575                    },
576                    "contract": { "$ref": "#/$defs/dataContract" },
577                    "completionContract": { "$ref": "#/$defs/dataContract" }
578                }
579            },
580            "retryPolicy": {
581                "type": "object",
582                "additionalProperties": false,
583                "required": ["maxAttempts", "delaysMs"],
584                "properties": {
585                    "maxAttempts": {
586                        "type": "integer",
587                        "minimum": 1,
588                        "maximum": 2147483647
589                    },
590                    "delaysMs": {
591                        "type": "array",
592                        "items": {
593                            "type": "integer",
594                            "minimum": 0,
595                            "maximum": 9223372036854775807_i64
596                        }
597                    }
598                }
599            }
600        }
601    })
602}
603
604#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
605pub struct RuntimeFunctionDeclaration {
606    /// Stable function name used by `runtime.function_runs`.
607    pub name: String,
608    /// Version for the declared function contract.
609    pub version: u16,
610    /// Queue name the host should use when registering the function.
611    pub queue: String,
612    /// Optional schema identifier for the function input payload.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub input_schema: Option<String>,
615    /// Optional retry policy requested by the module. The host still owns
616    /// enforcement and may clamp values when behavior registration is added.
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub retry_policy: Option<RuntimeRetryPolicyDeclaration>,
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub operation: Option<crate::ServiceOperationMetadata>,
621}
622
623#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
624pub struct RuntimeRetryPolicyDeclaration {
625    pub max_attempts: u32,
626    pub initial_delay_ms: u64,
627}
628
629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
630pub struct ScheduledFunctionDeclaration {
631    /// Stable schedule name scoped to the owning module.
632    pub name: String,
633    /// Runtime function enqueued when this schedule is due.
634    pub function_name: String,
635    /// Standard 5-field cron expression in UTC.
636    pub cron: String,
637    #[serde(default)]
638    pub input: Value,
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    fn definition(version: &str) -> WorkflowDefinition {
646        WorkflowDefinition::new(
647            "support-sla",
648            "ticket_sla",
649            version,
650            WorkflowDataContract::new("support.sla.start", "v1"),
651            WorkflowDataContract::new("support.sla.result", "v1"),
652            vec![
653                WorkflowStepDeclaration::new("acknowledge_ticket"),
654                WorkflowStepDeclaration::new("await_resolution"),
655            ],
656        )
657    }
658
659    #[test]
660    fn workflow_compatibility_categories_are_deterministic_and_actionable() {
661        let before = definition("v1");
662        let safe = definition("v2");
663        let mut needs_attention = safe.clone();
664        needs_attention.steps[0].timeout_ms = Some(5_000);
665        assert_eq!(
666            serde_json::to_value(evaluate_workflow_compatibility(&before, &needs_attention))
667                .unwrap()["category"],
668            "needs-attention"
669        );
670        let mut breaking = safe.clone();
671        breaking.steps.remove(0);
672        let mut blocked = before.clone();
673        blocked.steps[0].timeout_ms = Some(5_000);
674
675        for (expected, after) in [
676            (WorkflowCompatibilityCategory::Safe, safe),
677            (
678                WorkflowCompatibilityCategory::NeedsAttention,
679                needs_attention,
680            ),
681            (WorkflowCompatibilityCategory::Breaking, breaking),
682            (WorkflowCompatibilityCategory::Blocked, blocked),
683        ] {
684            let first = evaluate_workflow_compatibility(&before, &after);
685            let second = evaluate_workflow_compatibility(&before, &after);
686            assert_eq!(first, second);
687            assert_eq!(first.category, expected);
688            assert!(first.reasons.iter().all(|reason| {
689                !reason.code.is_empty()
690                    && reason.path.starts_with('$')
691                    && !reason.next_action.is_empty()
692            }));
693        }
694    }
695
696    #[test]
697    fn workflow_compatibility_paths_identify_real_source_and_target_steps() {
698        let before = definition("v1");
699        let mut removed = definition("v2");
700        removed.steps.pop();
701        let removed_result = evaluate_workflow_compatibility(&before, &removed);
702        assert!(removed_result.reasons.iter().any(|reason| {
703            reason.code == "workflow_step_removed" && reason.path == "$.before.steps[1]"
704        }));
705
706        let mut inserted = definition("v2");
707        inserted
708            .steps
709            .insert(0, WorkflowStepDeclaration::new("triage_ticket"));
710        let inserted_result = evaluate_workflow_compatibility(&before, &inserted);
711        assert!(inserted_result.reasons.iter().any(|reason| {
712            reason.code == "workflow_step_added" && reason.path == "$.after.steps[0]"
713        }));
714    }
715
716    #[test]
717    fn workflow_compatibility_invalid_source_paths_point_to_the_source() {
718        let mut before = definition("v1");
719        let after = definition("v2");
720        before.protocol = "unsupported.workflow-definition".to_owned();
721        let unsupported = evaluate_workflow_compatibility(&before, &after);
722        assert!(unsupported.reasons.iter().any(|reason| {
723            reason.code == "workflow_before_protocol_unsupported"
724                && reason.path == "$.before.protocol"
725        }));
726        assert!(
727            !unsupported
728                .reasons
729                .iter()
730                .any(|reason| reason.code == "workflow_after_protocol_unsupported")
731        );
732
733        before.protocol = WORKFLOW_DEFINITION_PROTOCOL.to_owned();
734        before.version.clear();
735        let missing = evaluate_workflow_compatibility(&before, &after);
736        assert!(missing.reasons.iter().any(|reason| {
737            reason.code == "workflow_before_version_missing" && reason.path == "$.before.version"
738        }));
739        assert!(
740            !missing
741                .reasons
742                .iter()
743                .any(|reason| reason.code == "workflow_after_version_missing")
744        );
745    }
746}