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)]
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)]
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)]
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 #[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)]
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)]
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(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#[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#[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#[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 pub name: String,
608 pub version: u16,
610 pub queue: String,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub input_schema: Option<String>,
615 #[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 pub name: String,
633 pub function_name: String,
635 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}