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}