Skip to main content

tandem_workflows/
mission_builder.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::{HashMap, HashSet};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6#[serde(rename_all = "snake_case")]
7pub enum ReviewStageKind {
8    Review,
9    Test,
10    Approval,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum ApprovalDecision {
16    Approve,
17    Rework,
18    Cancel,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "snake_case")]
23pub enum ValidationSeverity {
24    Info,
25    Warning,
26    Error,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30pub struct ValidationMessage {
31    pub severity: ValidationSeverity,
32    pub code: String,
33    pub message: String,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub subject_id: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
39pub struct MissionTeamBlueprint {
40    #[serde(default)]
41    pub allowed_template_ids: Vec<String>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub default_model_policy: Option<Value>,
44    #[serde(default)]
45    pub allowed_mcp_servers: Vec<String>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub max_parallel_agents: Option<u32>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub mission_budget: Option<Value>,
50    #[serde(default)]
51    pub orchestrator_only_tool_calls: bool,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55pub struct OutputContractBlueprint {
56    pub kind: String,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub schema: Option<Value>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub summary_guidance: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64pub struct InputRefBlueprint {
65    pub from_step_id: String,
66    pub alias: String,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "snake_case")]
71pub enum MissionPhaseExecutionMode {
72    Soft,
73    Barrier,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct MissionPhaseBlueprint {
78    pub phase_id: String,
79    pub title: String,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub description: Option<String>,
82    #[serde(default)]
83    pub execution_mode: Option<MissionPhaseExecutionMode>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
87pub struct MissionMilestoneBlueprint {
88    pub milestone_id: String,
89    pub title: String,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub description: Option<String>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub phase_id: Option<String>,
94    #[serde(default)]
95    pub required_stage_ids: Vec<String>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99pub struct WorkstreamBlueprint {
100    pub workstream_id: String,
101    pub title: String,
102    pub objective: String,
103    pub role: String,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub priority: Option<i32>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub phase_id: Option<String>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub lane: Option<String>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub milestone: Option<String>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub template_id: Option<String>,
114    pub prompt: String,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub model_override: Option<Value>,
117    #[serde(default)]
118    pub tool_allowlist_override: Vec<String>,
119    #[serde(default)]
120    pub mcp_servers_override: Vec<String>,
121    #[serde(default)]
122    pub depends_on: Vec<String>,
123    #[serde(default)]
124    pub input_refs: Vec<InputRefBlueprint>,
125    pub output_contract: OutputContractBlueprint,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub retry_policy: Option<Value>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub timeout_ms: Option<u64>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub metadata: Option<Value>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
135pub struct HumanApprovalGate {
136    #[serde(default)]
137    pub required: bool,
138    #[serde(default)]
139    pub decisions: Vec<ApprovalDecision>,
140    #[serde(default)]
141    pub rework_targets: Vec<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub instructions: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct ReviewStage {
148    pub stage_id: String,
149    pub stage_kind: ReviewStageKind,
150    pub title: String,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub priority: Option<i32>,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub phase_id: Option<String>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub lane: Option<String>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub milestone: Option<String>,
159    #[serde(default)]
160    pub target_ids: Vec<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub role: Option<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub template_id: Option<String>,
165    pub prompt: String,
166    #[serde(default)]
167    pub checklist: Vec<String>,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub model_override: Option<Value>,
170    #[serde(default)]
171    pub tool_allowlist_override: Vec<String>,
172    #[serde(default)]
173    pub mcp_servers_override: Vec<String>,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub gate: Option<HumanApprovalGate>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
179pub struct MissionBlueprint {
180    pub mission_id: String,
181    pub title: String,
182    pub goal: String,
183    #[serde(default)]
184    pub success_criteria: Vec<String>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub shared_context: Option<String>,
187    pub workspace_root: String,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub orchestrator_template_id: Option<String>,
190    #[serde(default)]
191    pub phases: Vec<MissionPhaseBlueprint>,
192    #[serde(default)]
193    pub milestones: Vec<MissionMilestoneBlueprint>,
194    #[serde(default)]
195    pub team: MissionTeamBlueprint,
196    #[serde(default)]
197    pub workstreams: Vec<WorkstreamBlueprint>,
198    #[serde(default)]
199    pub review_stages: Vec<ReviewStage>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub metadata: Option<Value>,
202}
203
204pub fn validate_mission_blueprint(blueprint: &MissionBlueprint) -> Vec<ValidationMessage> {
205    let mut messages = Vec::new();
206    if blueprint.title.trim().is_empty() {
207        messages.push(error(
208            "MISSION_TITLE_REQUIRED",
209            "mission title is required",
210            None,
211        ));
212    }
213    if blueprint.goal.trim().is_empty() {
214        messages.push(error(
215            "MISSION_GOAL_REQUIRED",
216            "mission goal is required",
217            None,
218        ));
219    }
220    if blueprint.workspace_root.trim().is_empty() {
221        messages.push(error(
222            "MISSION_WORKSPACE_REQUIRED",
223            "mission workspace_root is required",
224            None,
225        ));
226    }
227    if blueprint.workstreams.is_empty() {
228        messages.push(error(
229            "MISSION_WORKSTREAMS_REQUIRED",
230            "mission must include at least one workstream",
231            None,
232        ));
233    }
234
235    let mut phase_ids = HashSet::new();
236    for phase in &blueprint.phases {
237        let id = phase.phase_id.trim();
238        if id.is_empty() {
239            messages.push(error(
240                "MISSION_PHASE_ID_REQUIRED",
241                "mission phase_id is required",
242                None,
243            ));
244            continue;
245        }
246        if !phase_ids.insert(id.to_string()) {
247            messages.push(error(
248                "MISSION_PHASE_DUPLICATE",
249                "duplicate mission phase_id",
250                Some(id.to_string()),
251            ));
252        }
253        if phase.title.trim().is_empty() {
254            messages.push(error(
255                "MISSION_PHASE_TITLE_REQUIRED",
256                "mission phase title is required",
257                Some(id.to_string()),
258            ));
259        }
260    }
261
262    let mut milestone_ids = HashSet::new();
263    for milestone in &blueprint.milestones {
264        let id = milestone.milestone_id.trim();
265        if id.is_empty() {
266            messages.push(error(
267                "MISSION_MILESTONE_ID_REQUIRED",
268                "mission milestone_id is required",
269                None,
270            ));
271            continue;
272        }
273        if !milestone_ids.insert(id.to_string()) {
274            messages.push(error(
275                "MISSION_MILESTONE_DUPLICATE",
276                "duplicate mission milestone_id",
277                Some(id.to_string()),
278            ));
279        }
280        if milestone.title.trim().is_empty() {
281            messages.push(error(
282                "MISSION_MILESTONE_TITLE_REQUIRED",
283                "mission milestone title is required",
284                Some(id.to_string()),
285            ));
286        }
287        if let Some(phase_id) = milestone.phase_id.as_deref() {
288            if !phase_id.trim().is_empty() && !phase_ids.contains(phase_id.trim()) {
289                messages.push(error(
290                    "MISSION_MILESTONE_PHASE_UNKNOWN",
291                    "mission milestone references unknown phase_id",
292                    Some(id.to_string()),
293                ));
294            }
295        }
296    }
297
298    let mut stage_ids = HashSet::new();
299    let mut workstream_ids = HashSet::new();
300    for workstream in &blueprint.workstreams {
301        let id = workstream.workstream_id.trim();
302        if id.is_empty() {
303            messages.push(error(
304                "WORKSTREAM_ID_REQUIRED",
305                "workstream_id is required",
306                None,
307            ));
308            continue;
309        }
310        if !stage_ids.insert(id.to_string()) {
311            messages.push(error(
312                "DUPLICATE_STAGE_ID",
313                "duplicate stage/workstream id",
314                Some(id.to_string()),
315            ));
316        }
317        workstream_ids.insert(id.to_string());
318        if workstream.title.trim().is_empty() {
319            messages.push(error(
320                "WORKSTREAM_TITLE_REQUIRED",
321                "workstream title is required",
322                Some(id.to_string()),
323            ));
324        }
325        if workstream.objective.trim().is_empty() {
326            messages.push(error(
327                "WORKSTREAM_OBJECTIVE_REQUIRED",
328                "workstream objective is required",
329                Some(id.to_string()),
330            ));
331        }
332        if workstream.role.trim().is_empty() {
333            messages.push(error(
334                "WORKSTREAM_ROLE_REQUIRED",
335                "workstream role is required",
336                Some(id.to_string()),
337            ));
338        }
339        if workstream.prompt.trim().is_empty() {
340            messages.push(error(
341                "WORKSTREAM_PROMPT_REQUIRED",
342                "workstream prompt is required",
343                Some(id.to_string()),
344            ));
345        }
346        if workstream.output_contract.kind.trim().is_empty() {
347            messages.push(error(
348                "WORKSTREAM_OUTPUT_REQUIRED",
349                "workstream output_contract.kind is required",
350                Some(id.to_string()),
351            ));
352        }
353        if let Some(phase_id) = workstream.phase_id.as_deref() {
354            if !phase_id.trim().is_empty() && !phase_ids.contains(phase_id.trim()) {
355                messages.push(error(
356                    "WORKSTREAM_PHASE_UNKNOWN",
357                    "workstream phase_id references unknown mission phase",
358                    Some(id.to_string()),
359                ));
360            }
361        }
362        if let Some(milestone) = workstream.milestone.as_deref() {
363            if !milestone.trim().is_empty() && !milestone_ids.contains(milestone.trim()) {
364                messages.push(error(
365                    "WORKSTREAM_MILESTONE_UNKNOWN",
366                    "workstream milestone references unknown mission milestone",
367                    Some(id.to_string()),
368                ));
369            }
370        }
371    }
372
373    for stage in &blueprint.review_stages {
374        let id = stage.stage_id.trim();
375        if id.is_empty() {
376            messages.push(error(
377                "REVIEW_STAGE_ID_REQUIRED",
378                "stage_id is required",
379                None,
380            ));
381            continue;
382        }
383        if !stage_ids.insert(id.to_string()) {
384            messages.push(error(
385                "DUPLICATE_STAGE_ID",
386                "duplicate stage/workstream id",
387                Some(id.to_string()),
388            ));
389        }
390        if stage.title.trim().is_empty() {
391            messages.push(error(
392                "REVIEW_STAGE_TITLE_REQUIRED",
393                "review stage title is required",
394                Some(id.to_string()),
395            ));
396        }
397        if stage.prompt.trim().is_empty() && stage.stage_kind != ReviewStageKind::Approval {
398            messages.push(error(
399                "REVIEW_STAGE_PROMPT_REQUIRED",
400                "review/test stage prompt is required",
401                Some(id.to_string()),
402            ));
403        }
404        if stage.target_ids.is_empty() {
405            messages.push(error(
406                "REVIEW_STAGE_TARGETS_REQUIRED",
407                "review stage must target at least one upstream stage",
408                Some(id.to_string()),
409            ));
410        }
411        if let Some(phase_id) = stage.phase_id.as_deref() {
412            if !phase_id.trim().is_empty() && !phase_ids.contains(phase_id.trim()) {
413                messages.push(error(
414                    "REVIEW_STAGE_PHASE_UNKNOWN",
415                    "review stage phase_id references unknown mission phase",
416                    Some(id.to_string()),
417                ));
418            }
419        }
420        if let Some(milestone) = stage.milestone.as_deref() {
421            if !milestone.trim().is_empty() && !milestone_ids.contains(milestone.trim()) {
422                messages.push(error(
423                    "REVIEW_STAGE_MILESTONE_UNKNOWN",
424                    "review stage milestone references unknown mission milestone",
425                    Some(id.to_string()),
426                ));
427            }
428        }
429        if stage.stage_kind == ReviewStageKind::Approval {
430            let gate = stage.gate.as_ref();
431            if !gate.map(|value| value.required).unwrap_or(false) {
432                messages.push(error(
433                    "APPROVAL_GATE_REQUIRED",
434                    "approval stage must include a required gate",
435                    Some(id.to_string()),
436                ));
437            }
438            let decisions = gate.map(|value| value.decisions.as_slice()).unwrap_or(&[]);
439            if !decisions.contains(&ApprovalDecision::Approve)
440                || !decisions.contains(&ApprovalDecision::Rework)
441                || !decisions.contains(&ApprovalDecision::Cancel)
442            {
443                messages.push(error(
444                    "APPROVAL_GATE_DECISIONS_INVALID",
445                    "approval stage must support approve, rework, and cancel",
446                    Some(id.to_string()),
447                ));
448            }
449        }
450    }
451
452    for workstream in &blueprint.workstreams {
453        for dep in &workstream.depends_on {
454            if !workstream_ids.contains(dep.trim()) {
455                messages.push(error(
456                    "WORKSTREAM_DEPENDENCY_UNKNOWN",
457                    "workstream depends_on references unknown workstream",
458                    Some(workstream.workstream_id.clone()),
459                ));
460            }
461        }
462        for input_ref in &workstream.input_refs {
463            if !workstream_ids.contains(input_ref.from_step_id.trim())
464                && !stage_ids.contains(input_ref.from_step_id.trim())
465            {
466                messages.push(error(
467                    "WORKSTREAM_INPUT_UNKNOWN",
468                    "workstream input_refs references unknown upstream stage",
469                    Some(workstream.workstream_id.clone()),
470                ));
471            }
472        }
473    }
474
475    for stage in &blueprint.review_stages {
476        for target in &stage.target_ids {
477            if !stage_ids.contains(target.trim()) {
478                messages.push(error(
479                    "REVIEW_STAGE_TARGET_UNKNOWN",
480                    "review stage target_ids references unknown stage",
481                    Some(stage.stage_id.clone()),
482                ));
483            }
484        }
485        if let Some(gate) = stage.gate.as_ref() {
486            for target in &gate.rework_targets {
487                if !stage_ids.contains(target.trim()) {
488                    messages.push(error(
489                        "APPROVAL_GATE_REWORK_UNKNOWN",
490                        "approval gate rework_targets references unknown stage",
491                        Some(stage.stage_id.clone()),
492                    ));
493                }
494            }
495        }
496    }
497
498    for milestone in &blueprint.milestones {
499        if milestone.required_stage_ids.is_empty() {
500            messages.push(warning(
501                "MISSION_MILESTONE_EMPTY",
502                "mission milestone does not currently reference any required stages",
503                Some(milestone.milestone_id.clone()),
504            ));
505        }
506        for stage_id in &milestone.required_stage_ids {
507            if !stage_ids.contains(stage_id.trim()) {
508                messages.push(error(
509                    "MISSION_MILESTONE_STAGE_UNKNOWN",
510                    "mission milestone required_stage_ids references unknown stage",
511                    Some(milestone.milestone_id.clone()),
512                ));
513            }
514        }
515    }
516
517    if messages
518        .iter()
519        .all(|message| message.code != "WORKSTREAM_DEPENDENCY_UNKNOWN")
520    {
521        messages.extend(validate_cycles(blueprint));
522    }
523
524    messages.extend(validate_phase_barriers(blueprint));
525    messages.extend(validate_graph_warnings(blueprint));
526
527    messages
528}
529
530fn validate_cycles(blueprint: &MissionBlueprint) -> Vec<ValidationMessage> {
531    let mut graph = HashMap::<String, Vec<String>>::new();
532    for workstream in &blueprint.workstreams {
533        graph.insert(
534            workstream.workstream_id.clone(),
535            workstream.depends_on.clone(),
536        );
537    }
538    for stage in &blueprint.review_stages {
539        graph.insert(stage.stage_id.clone(), stage.target_ids.clone());
540    }
541    let mut visiting = HashSet::new();
542    let mut visited = HashSet::new();
543    let mut messages = Vec::new();
544    for node in graph.keys() {
545        if has_cycle(node, &graph, &mut visiting, &mut visited) {
546            messages.push(error(
547                "MISSION_GRAPH_CYCLE",
548                "mission graph contains a dependency cycle",
549                Some(node.clone()),
550            ));
551            break;
552        }
553    }
554    messages
555}
556
557fn has_cycle(
558    node: &str,
559    graph: &HashMap<String, Vec<String>>,
560    visiting: &mut HashSet<String>,
561    visited: &mut HashSet<String>,
562) -> bool {
563    if visited.contains(node) {
564        return false;
565    }
566    if !visiting.insert(node.to_string()) {
567        return true;
568    }
569    if let Some(deps) = graph.get(node) {
570        for dep in deps {
571            if graph.contains_key(dep) && has_cycle(dep, graph, visiting, visited) {
572                return true;
573            }
574        }
575    }
576    visiting.remove(node);
577    visited.insert(node.to_string());
578    false
579}
580
581fn error(code: &str, message: &str, subject_id: Option<String>) -> ValidationMessage {
582    ValidationMessage {
583        severity: ValidationSeverity::Error,
584        code: code.to_string(),
585        message: message.to_string(),
586        subject_id,
587    }
588}
589
590fn warning(code: &str, message: &str, subject_id: Option<String>) -> ValidationMessage {
591    ValidationMessage {
592        severity: ValidationSeverity::Warning,
593        code: code.to_string(),
594        message: message.to_string(),
595        subject_id,
596    }
597}
598
599fn phase_rank_map(blueprint: &MissionBlueprint) -> HashMap<String, usize> {
600    blueprint
601        .phases
602        .iter()
603        .enumerate()
604        .map(|(index, phase)| (phase.phase_id.clone(), index))
605        .collect()
606}
607
608fn validate_phase_barriers(blueprint: &MissionBlueprint) -> Vec<ValidationMessage> {
609    let phase_rank = phase_rank_map(blueprint);
610    let barrier_phases = blueprint
611        .phases
612        .iter()
613        .filter_map(|phase| {
614            (phase.execution_mode == Some(MissionPhaseExecutionMode::Barrier))
615                .then_some(phase.phase_id.clone())
616        })
617        .collect::<HashSet<_>>();
618    let stage_phase = blueprint
619        .workstreams
620        .iter()
621        .map(|workstream| {
622            (
623                workstream.workstream_id.clone(),
624                workstream.phase_id.clone().unwrap_or_default(),
625            )
626        })
627        .chain(blueprint.review_stages.iter().map(|stage| {
628            (
629                stage.stage_id.clone(),
630                stage.phase_id.clone().unwrap_or_default(),
631            )
632        }))
633        .collect::<HashMap<_, _>>();
634    let mut messages = Vec::new();
635    for workstream in &blueprint.workstreams {
636        if let Some(phase_id) = workstream.phase_id.as_deref() {
637            if let Some(&rank) = phase_rank.get(phase_id) {
638                for dep in &workstream.depends_on {
639                    if let Some(dep_phase) = stage_phase.get(dep) {
640                        if let Some(&dep_rank) = phase_rank.get(dep_phase) {
641                            if dep_rank > rank {
642                                messages.push(error(
643                                    "WORKSTREAM_PHASE_ORDER_INVALID",
644                                    "workstream depends on a later phase",
645                                    Some(workstream.workstream_id.clone()),
646                                ));
647                            }
648                        }
649                    }
650                }
651            }
652        }
653    }
654    for stage in &blueprint.review_stages {
655        if let Some(phase_id) = stage.phase_id.as_deref() {
656            if let Some(&rank) = phase_rank.get(phase_id) {
657                for target in &stage.target_ids {
658                    if let Some(dep_phase) = stage_phase.get(target) {
659                        if let Some(&dep_rank) = phase_rank.get(dep_phase) {
660                            if dep_rank > rank {
661                                messages.push(error(
662                                    "REVIEW_STAGE_PHASE_ORDER_INVALID",
663                                    "review stage targets a later phase",
664                                    Some(stage.stage_id.clone()),
665                                ));
666                            }
667                        }
668                    }
669                }
670            }
671        }
672    }
673    for phase in &blueprint.phases {
674        if phase.execution_mode != Some(MissionPhaseExecutionMode::Barrier) {
675            continue;
676        }
677        let Some(&rank) = phase_rank.get(&phase.phase_id) else {
678            continue;
679        };
680        let has_prior = rank > 0;
681        let stage_count = blueprint
682            .workstreams
683            .iter()
684            .filter(|workstream| workstream.phase_id.as_deref() == Some(phase.phase_id.as_str()))
685            .count()
686            + blueprint
687                .review_stages
688                .iter()
689                .filter(|stage| stage.phase_id.as_deref() == Some(phase.phase_id.as_str()))
690                .count();
691        if has_prior && stage_count == 0 {
692            messages.push(warning(
693                "MISSION_PHASE_BARRIER_EMPTY",
694                "barrier phase is defined but currently has no stages assigned",
695                Some(phase.phase_id.clone()),
696            ));
697        }
698        if !has_prior {
699            continue;
700        }
701        let prior_barrier =
702            blueprint.phases.iter().take(rank).any(|candidate| {
703                candidate.execution_mode == Some(MissionPhaseExecutionMode::Barrier)
704            });
705        if !prior_barrier {
706            messages.push(warning(
707                "MISSION_PHASE_BARRIER_SOFT_PREFIX",
708                "barrier phase will compile as a full dependency barrier across all earlier phases",
709                Some(phase.phase_id.clone()),
710            ));
711        }
712    }
713    if !blueprint.phases.is_empty() {
714        for workstream in &blueprint.workstreams {
715            if workstream
716                .phase_id
717                .as_deref()
718                .unwrap_or("")
719                .trim()
720                .is_empty()
721            {
722                messages.push(warning(
723                    "WORKSTREAM_PHASE_UNSET",
724                    "workstream has no phase_id even though mission phases are defined",
725                    Some(workstream.workstream_id.clone()),
726                ));
727            }
728        }
729    }
730    let _ = barrier_phases;
731    messages
732}
733
734fn validate_graph_warnings(blueprint: &MissionBlueprint) -> Vec<ValidationMessage> {
735    let mut messages = Vec::new();
736    let all_stage_ids = blueprint
737        .workstreams
738        .iter()
739        .map(|workstream| workstream.workstream_id.clone())
740        .chain(
741            blueprint
742                .review_stages
743                .iter()
744                .map(|stage| stage.stage_id.clone()),
745        )
746        .collect::<HashSet<_>>();
747    let milestone_targets = blueprint
748        .milestones
749        .iter()
750        .flat_map(|milestone| milestone.required_stage_ids.iter().cloned())
751        .collect::<HashSet<_>>();
752    let mut downstream_counts = HashMap::<String, usize>::new();
753    for workstream in &blueprint.workstreams {
754        if !workstream.depends_on.is_empty() && workstream.input_refs.is_empty() {
755            messages.push(warning(
756                "WORKSTREAM_DEPENDENCY_INPUT_IMPLICIT",
757                "workstream depends on upstream stages but has no explicit input_refs",
758                Some(workstream.workstream_id.clone()),
759            ));
760        }
761        let mut seen_input_refs = HashSet::new();
762        for input_ref in &workstream.input_refs {
763            if !seen_input_refs.insert(input_ref.from_step_id.clone()) {
764                messages.push(warning(
765                    "WORKSTREAM_INPUT_REF_DUPLICATE",
766                    "workstream has duplicate input_refs for the same upstream stage",
767                    Some(workstream.workstream_id.clone()),
768                ));
769            }
770        }
771        if workstream.depends_on.len() >= 4 {
772            messages.push(warning(
773                "WORKSTREAM_FAN_IN_HIGH",
774                "workstream has a high fan-in dependency count",
775                Some(workstream.workstream_id.clone()),
776            ));
777        }
778        if let Some(template_id) = workstream.template_id.as_ref() {
779            if !blueprint.team.allowed_template_ids.is_empty()
780                && !blueprint
781                    .team
782                    .allowed_template_ids
783                    .iter()
784                    .any(|row| row == template_id)
785            {
786                messages.push(warning(
787                    "WORKSTREAM_TEMPLATE_NOT_ALLOWED",
788                    "workstream template_id is outside the mission allowed_template_ids set",
789                    Some(workstream.workstream_id.clone()),
790                ));
791            }
792        }
793        if let Some(model_override) = workstream.model_override.as_ref() {
794            let default_model = model_override
795                .get("default_model")
796                .or_else(|| model_override.get("defaultModel"));
797            let provider_id = default_model
798                .and_then(|value| value.get("provider_id").or_else(|| value.get("providerId")))
799                .and_then(Value::as_str)
800                .unwrap_or_default();
801            let model_id = default_model
802                .and_then(|value| value.get("model_id").or_else(|| value.get("modelId")))
803                .and_then(Value::as_str)
804                .unwrap_or_default();
805            if provider_id.is_empty() != model_id.is_empty() {
806                messages.push(warning(
807                    "WORKSTREAM_MODEL_OVERRIDE_PARTIAL",
808                    "workstream model_override must specify both provider_id and model_id",
809                    Some(workstream.workstream_id.clone()),
810                ));
811            }
812        }
813        for dep in &workstream.depends_on {
814            *downstream_counts.entry(dep.clone()).or_insert(0) += 1;
815        }
816    }
817    for stage in &blueprint.review_stages {
818        if stage.target_ids.len() >= 4 {
819            messages.push(warning(
820                "REVIEW_STAGE_FAN_IN_HIGH",
821                "review stage has a high fan-in dependency count",
822                Some(stage.stage_id.clone()),
823            ));
824        }
825        if let Some(template_id) = stage.template_id.as_ref() {
826            if !blueprint.team.allowed_template_ids.is_empty()
827                && !blueprint
828                    .team
829                    .allowed_template_ids
830                    .iter()
831                    .any(|row| row == template_id)
832            {
833                messages.push(warning(
834                    "REVIEW_STAGE_TEMPLATE_NOT_ALLOWED",
835                    "review stage template_id is outside the mission allowed_template_ids set",
836                    Some(stage.stage_id.clone()),
837                ));
838            }
839        }
840        for target in &stage.target_ids {
841            *downstream_counts.entry(target.clone()).or_insert(0) += 1;
842        }
843    }
844    for stage_id in &all_stage_ids {
845        let downstream = downstream_counts.get(stage_id).copied().unwrap_or(0);
846        if downstream >= 4 {
847            messages.push(warning(
848                "STAGE_FAN_OUT_HIGH",
849                "stage fans out to many downstream stages",
850                Some(stage_id.clone()),
851            ));
852        }
853        let terminal = downstream == 0;
854        let is_milestone_target = milestone_targets.contains(stage_id);
855        let is_approval_stage = blueprint.review_stages.iter().any(|stage| {
856            stage.stage_id == *stage_id && stage.stage_kind == ReviewStageKind::Approval
857        });
858        if terminal && !is_milestone_target && !is_approval_stage {
859            messages.push(warning(
860                "STAGE_TERMINAL_UNPROMOTED",
861                "stage has no downstream dependents and is not captured by a milestone or approval stage",
862                Some(stage_id.clone()),
863            ));
864        }
865    }
866    messages
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872
873    fn sample_blueprint() -> MissionBlueprint {
874        MissionBlueprint {
875            mission_id: "mission-demo".to_string(),
876            title: "Mission".to_string(),
877            goal: "Produce a useful deliverable".to_string(),
878            success_criteria: vec!["Artifact delivered".to_string()],
879            shared_context: Some("Shared context".to_string()),
880            workspace_root: "/tmp/workspace".to_string(),
881            orchestrator_template_id: Some("orchestrator-default".to_string()),
882            phases: vec![
883                MissionPhaseBlueprint {
884                    phase_id: "discover".to_string(),
885                    title: "Discover".to_string(),
886                    description: None,
887                    execution_mode: Some(MissionPhaseExecutionMode::Soft),
888                },
889                MissionPhaseBlueprint {
890                    phase_id: "synthesize".to_string(),
891                    title: "Synthesize".to_string(),
892                    description: None,
893                    execution_mode: Some(MissionPhaseExecutionMode::Barrier),
894                },
895            ],
896            milestones: vec![MissionMilestoneBlueprint {
897                milestone_id: "draft_ready".to_string(),
898                title: "Draft ready".to_string(),
899                description: None,
900                phase_id: Some("synthesize".to_string()),
901                required_stage_ids: vec!["synthesis".to_string(), "approval".to_string()],
902            }],
903            team: MissionTeamBlueprint::default(),
904            workstreams: vec![
905                WorkstreamBlueprint {
906                    workstream_id: "research".to_string(),
907                    title: "Research".to_string(),
908                    objective: "Collect inputs".to_string(),
909                    role: "researcher".to_string(),
910                    priority: Some(10),
911                    phase_id: Some("discover".to_string()),
912                    lane: Some("research".to_string()),
913                    milestone: None,
914                    template_id: None,
915                    prompt: "Research the topic".to_string(),
916                    model_override: None,
917                    tool_allowlist_override: Vec::new(),
918                    mcp_servers_override: Vec::new(),
919                    depends_on: Vec::new(),
920                    input_refs: Vec::new(),
921                    output_contract: OutputContractBlueprint {
922                        kind: "report_markdown".to_string(),
923                        schema: None,
924                        summary_guidance: None,
925                    },
926                    retry_policy: None,
927                    timeout_ms: None,
928                    metadata: None,
929                },
930                WorkstreamBlueprint {
931                    workstream_id: "synthesis".to_string(),
932                    title: "Synthesis".to_string(),
933                    objective: "Combine research".to_string(),
934                    role: "analyst".to_string(),
935                    priority: Some(5),
936                    phase_id: Some("synthesize".to_string()),
937                    lane: Some("analysis".to_string()),
938                    milestone: Some("draft_ready".to_string()),
939                    template_id: None,
940                    prompt: "Synthesize the report".to_string(),
941                    model_override: None,
942                    tool_allowlist_override: Vec::new(),
943                    mcp_servers_override: Vec::new(),
944                    depends_on: vec!["research".to_string()],
945                    input_refs: vec![InputRefBlueprint {
946                        from_step_id: "research".to_string(),
947                        alias: "research_report".to_string(),
948                    }],
949                    output_contract: OutputContractBlueprint {
950                        kind: "report_markdown".to_string(),
951                        schema: None,
952                        summary_guidance: None,
953                    },
954                    retry_policy: None,
955                    timeout_ms: None,
956                    metadata: None,
957                },
958            ],
959            review_stages: vec![ReviewStage {
960                stage_id: "approval".to_string(),
961                stage_kind: ReviewStageKind::Approval,
962                title: "Approve".to_string(),
963                priority: Some(1),
964                phase_id: Some("synthesize".to_string()),
965                lane: Some("governance".to_string()),
966                milestone: Some("draft_ready".to_string()),
967                target_ids: vec!["synthesis".to_string()],
968                role: None,
969                template_id: None,
970                prompt: String::new(),
971                checklist: Vec::new(),
972                model_override: None,
973                tool_allowlist_override: Vec::new(),
974                mcp_servers_override: Vec::new(),
975                gate: Some(HumanApprovalGate {
976                    required: true,
977                    decisions: vec![
978                        ApprovalDecision::Approve,
979                        ApprovalDecision::Rework,
980                        ApprovalDecision::Cancel,
981                    ],
982                    rework_targets: vec!["synthesis".to_string()],
983                    instructions: None,
984                }),
985            }],
986            metadata: None,
987        }
988    }
989
990    #[test]
991    fn sample_blueprint_validates_cleanly() {
992        let messages = validate_mission_blueprint(&sample_blueprint());
993        assert!(messages
994            .iter()
995            .all(|message| message.severity != ValidationSeverity::Error));
996    }
997
998    #[test]
999    fn cycle_is_reported() {
1000        let mut blueprint = sample_blueprint();
1001        blueprint.workstreams[0]
1002            .depends_on
1003            .push("synthesis".to_string());
1004        let messages = validate_mission_blueprint(&blueprint);
1005        assert!(messages
1006            .iter()
1007            .any(|message| message.code == "MISSION_GRAPH_CYCLE"));
1008    }
1009
1010    #[test]
1011    fn invalid_phase_reference_is_reported() {
1012        let mut blueprint = sample_blueprint();
1013        blueprint.workstreams[0].phase_id = Some("missing".to_string());
1014        let messages = validate_mission_blueprint(&blueprint);
1015        assert!(messages
1016            .iter()
1017            .any(|message| message.code == "WORKSTREAM_PHASE_UNKNOWN"));
1018    }
1019
1020    #[test]
1021    fn later_phase_dependency_is_reported() {
1022        let mut blueprint = sample_blueprint();
1023        blueprint.workstreams[0]
1024            .depends_on
1025            .push("synthesis".to_string());
1026        let messages = validate_mission_blueprint(&blueprint);
1027        assert!(messages
1028            .iter()
1029            .any(|message| message.code == "WORKSTREAM_PHASE_ORDER_INVALID"));
1030    }
1031
1032    #[test]
1033    fn duplicate_input_ref_warning_is_reported() {
1034        let mut blueprint = sample_blueprint();
1035        blueprint.workstreams[1].input_refs.push(InputRefBlueprint {
1036            from_step_id: "research".to_string(),
1037            alias: "duplicate".to_string(),
1038        });
1039        let messages = validate_mission_blueprint(&blueprint);
1040        assert!(messages
1041            .iter()
1042            .any(|message| message.code == "WORKSTREAM_INPUT_REF_DUPLICATE"));
1043    }
1044
1045    #[test]
1046    fn terminal_stage_without_milestone_warning_is_reported() {
1047        let mut blueprint = sample_blueprint();
1048        blueprint.milestones.clear();
1049        blueprint.review_stages.clear();
1050        let messages = validate_mission_blueprint(&blueprint);
1051        assert!(messages
1052            .iter()
1053            .any(|message| message.code == "STAGE_TERMINAL_UNPROMOTED"));
1054    }
1055}