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, schemars::JsonSchema)]
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, schemars::JsonSchema)]
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, 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    /// 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, schemars::JsonSchema)]
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, schemars::JsonSchema)]
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(
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/// Compares two versions of one engine-neutral Workflow Definition.
259///
260/// The evaluator is deliberately conservative: changes to execution policy or
261/// the ordered step graph never become `safe` merely because both declarations
262/// deserialize. Reusing one version for different content is always blocked.
263#[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/// Stable generated examples for every public Workflow compatibility category.
499#[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/// Deterministic JSON Schema published for engine-neutral Workflow Definitions.
532#[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    /// Stable function name used by `runtime.function_runs`.
630    pub name: String,
631    /// Version for the declared function contract.
632    pub version: u16,
633    /// Queue name the host should use when registering the function.
634    pub queue: String,
635    /// Optional schema identifier for the function input payload.
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub input_schema: Option<String>,
638    /// Optional retry policy requested by the module. The host still owns
639    /// enforcement and may clamp values when behavior registration is added.
640    #[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    /// Stable schedule name scoped to the owning module.
655    pub name: String,
656    /// Runtime function enqueued when this schedule is due.
657    pub function_name: String,
658    /// Standard 5-field cron expression in UTC.
659    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}