Skip to main content

mur_common/coordination/
plan.rs

1//! Plan schema (§4) — TOML deserialization, validation, content hashing.
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use super::types::{DeterminismMode, Phase};
7
8/// Top-level plan — a directed acyclic graph of steps (§4).
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Plan {
11    pub plan: PlanHeader,
12}
13
14/// Plan-level metadata and settings, including the step list (§4).
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PlanHeader {
17    /// Protocol version. This spec = `"0"`.
18    pub version: String,
19    /// Unique identifier for this plan instance.
20    pub plan_id: Uuid,
21    /// Human-readable goal.
22    pub goal: String,
23    /// ISO 8601 creation timestamp.
24    pub created_at: String,
25    /// Publisher: `agent:<id>` or `human:<name>`.
26    pub created_by: String,
27    /// Total predicted LLM/compute cost in USD.
28    pub budget_estimate_usd: f64,
29    /// Determinism mode (§7).
30    #[serde(default)]
31    pub determinism: DeterminismMode,
32    /// SHA-256 of the canonical plan serialization (excluding this field and signature).
33    #[serde(default)]
34    pub content_sha256: String,
35    /// Optional Ed25519 signature over canonical bytes.
36    #[serde(default)]
37    pub signature: Option<String>,
38    /// Max escalation count before giving up (§8.4). Default 3.
39    #[serde(default = "default_max_escalations")]
40    pub max_escalations: u32,
41    /// Ordered list of steps forming the plan DAG.
42    #[serde(default)]
43    pub steps: Vec<Step>,
44}
45
46fn default_max_escalations() -> u32 {
47    3
48}
49
50/// A single step in the plan — one agent assignment (§4.1).
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Step {
53    /// Stable identifier within the plan.
54    pub step_id: String,
55    /// Human-readable description of what this step does.
56    pub description: String,
57    /// Preferred agent manifest name (e.g. "code-review").
58    pub agent_hint: String,
59    /// SDLC phases for this step, in execution order.
60    pub phases: Vec<Phase>,
61    /// Shell command or `verify://` URI for the Verify Gateway (§6).
62    pub verify_command: String,
63    /// Step ids that must complete before this step starts.
64    #[serde(default)]
65    pub depends_on: Vec<String>,
66
67    // ── Optional fields ──────────────────────────────────────────
68    /// Per-step budget override.
69    #[serde(default)]
70    pub budget_estimate_usd: Option<f64>,
71    /// Per-step timeout override (seconds).
72    #[serde(default)]
73    pub timeout_secs: Option<u64>,
74    /// Skill reference: `<name>@<version>` if this step executes a skill.
75    #[serde(default)]
76    pub skill_ref: Option<String>,
77    /// Per-step determinism override (inherits plan.determinism if absent).
78    #[serde(default)]
79    pub determinism: Option<DeterminismMode>,
80    /// Step-specific input variables (free-form).
81    #[serde(default)]
82    pub input: Option<toml::Table>,
83    /// Whether to run an independent judge on this step's result.
84    #[serde(default)]
85    pub judge: bool,
86}
87
88/// Validation error returned by [`Plan::validate`].
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum PlanValidationError {
91    UnknownDependency { step_id: String, referenced: String },
92    CycleDetected { cycle: Vec<String> },
93    EmptyPhases { step_id: String },
94    MissingVerifyCommand { step_id: String },
95    PhasesOutOfOrder { step_id: String, phases: Vec<Phase> },
96    MissingContentHash,
97}
98
99impl std::fmt::Display for PlanValidationError {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        match self {
102            PlanValidationError::UnknownDependency {
103                step_id,
104                referenced,
105            } => write!(
106                f,
107                "step '{}' depends on unknown step '{}'",
108                step_id, referenced
109            ),
110            PlanValidationError::CycleDetected { cycle } => {
111                write!(f, "dependency cycle detected: {}", cycle.join(" → "))
112            }
113            PlanValidationError::EmptyPhases { step_id } => {
114                write!(f, "step '{}' has no phases", step_id)
115            }
116            PlanValidationError::MissingVerifyCommand { step_id } => {
117                write!(f, "step '{}' has empty verify_command", step_id)
118            }
119            PlanValidationError::PhasesOutOfOrder { step_id, phases } => {
120                write!(
121                    f,
122                    "step '{}' phases not in SDLC order: {:?}",
123                    step_id, phases
124                )
125            }
126            PlanValidationError::MissingContentHash => {
127                write!(
128                    f,
129                    "content_sha256 is empty — call compute_content_sha256() first"
130                )
131            }
132        }
133    }
134}
135
136impl Plan {
137    /// Validate the plan structure. Returns a list of errors (empty = valid).
138    pub fn validate(&self) -> Vec<PlanValidationError> {
139        let mut errors = Vec::new();
140        let step_ids: std::collections::HashSet<&str> =
141            self.plan.steps.iter().map(|s| s.step_id.as_str()).collect();
142
143        // Check 1 + 2: dependency graph
144        for step in &self.plan.steps {
145            for dep in &step.depends_on {
146                if !step_ids.contains(dep.as_str()) {
147                    errors.push(PlanValidationError::UnknownDependency {
148                        step_id: step.step_id.clone(),
149                        referenced: dep.clone(),
150                    });
151                }
152            }
153        }
154        if let Some(cycle) = detect_cycle(&self.plan.steps) {
155            errors.push(PlanValidationError::CycleDetected { cycle });
156        }
157
158        // Check 3: non-empty phases
159        for step in &self.plan.steps {
160            if step.phases.is_empty() {
161                errors.push(PlanValidationError::EmptyPhases {
162                    step_id: step.step_id.clone(),
163                });
164            }
165        }
166
167        // Check 4: non-empty verify_command
168        for step in &self.plan.steps {
169            if step.verify_command.trim().is_empty() {
170                errors.push(PlanValidationError::MissingVerifyCommand {
171                    step_id: step.step_id.clone(),
172                });
173            }
174        }
175
176        // Check 5: phases in order
177        for step in &self.plan.steps {
178            let mut prev_idx: Option<u8> = None;
179            for phase in &step.phases {
180                let idx = phase.sdlc_index();
181                if let Some(p) = prev_idx
182                    && idx <= p
183                {
184                    errors.push(PlanValidationError::PhasesOutOfOrder {
185                        step_id: step.step_id.clone(),
186                        phases: step.phases.clone(),
187                    });
188                    break;
189                }
190                prev_idx = Some(idx);
191            }
192        }
193
194        // Check 6: content hash present
195        if self.plan.content_sha256.is_empty() {
196            errors.push(PlanValidationError::MissingContentHash);
197        }
198
199        errors
200    }
201
202    /// Compute the SHA-256 of the canonical TOML serialization.
203    pub fn compute_content_sha256(&self) -> String {
204        use sha2::{Digest, Sha256};
205        let canonical = self.canonical_toml_bytes();
206        hex::encode(Sha256::digest(&canonical))
207    }
208
209    /// Serialize the plan to canonical TOML bytes (for hashing).
210    /// This strips `content_sha256` and `signature`.
211    fn canonical_toml_bytes(&self) -> Vec<u8> {
212        let clean = CleanPlan {
213            plan: CleanPlanHeader {
214                version: self.plan.version.clone(),
215                plan_id: self.plan.plan_id,
216                goal: self.plan.goal.clone(),
217                created_at: self.plan.created_at.clone(),
218                created_by: self.plan.created_by.clone(),
219                budget_estimate_usd: self.plan.budget_estimate_usd,
220                determinism: self.plan.determinism,
221                max_escalations: self.plan.max_escalations,
222                steps: self
223                    .plan
224                    .steps
225                    .iter()
226                    .map(|s| CleanStep {
227                        step_id: s.step_id.clone(),
228                        description: s.description.clone(),
229                        agent_hint: s.agent_hint.clone(),
230                        phases: s.phases.clone(),
231                        verify_command: s.verify_command.clone(),
232                        depends_on: s.depends_on.clone(),
233                        budget_estimate_usd: s.budget_estimate_usd,
234                        timeout_secs: s.timeout_secs,
235                        skill_ref: s.skill_ref.clone(),
236                        determinism: s.determinism,
237                        input: s.input.clone(),
238                    })
239                    .collect(),
240            },
241        };
242        toml::to_string(&clean).unwrap().into_bytes()
243    }
244}
245
246// ── Internal clean types for canonical serialization ──────────────
247
248#[derive(Debug, Clone, Serialize)]
249struct CleanPlan {
250    plan: CleanPlanHeader,
251}
252
253#[derive(Debug, Clone, Serialize)]
254struct CleanPlanHeader {
255    version: String,
256    plan_id: Uuid,
257    goal: String,
258    created_at: String,
259    created_by: String,
260    budget_estimate_usd: f64,
261    determinism: DeterminismMode,
262    max_escalations: u32,
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    steps: Vec<CleanStep>,
265}
266
267#[derive(Debug, Clone, Serialize)]
268struct CleanStep {
269    step_id: String,
270    description: String,
271    agent_hint: String,
272    phases: Vec<Phase>,
273    verify_command: String,
274    #[serde(default, skip_serializing_if = "Vec::is_empty")]
275    depends_on: Vec<String>,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    budget_estimate_usd: Option<f64>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    timeout_secs: Option<u64>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    skill_ref: Option<String>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    determinism: Option<DeterminismMode>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    input: Option<toml::Table>,
286}
287
288// ── Cycle detection (Kahn's algorithm) ────────────────────────────
289
290fn detect_cycle(steps: &[Step]) -> Option<Vec<String>> {
291    use std::collections::{HashMap, VecDeque};
292
293    let step_ids: Vec<&str> = steps.iter().map(|s| s.step_id.as_str()).collect();
294    let id_to_idx: HashMap<&str, usize> = step_ids
295        .iter()
296        .enumerate()
297        .map(|(i, &id)| (id, i))
298        .collect();
299
300    let n = steps.len();
301    let mut in_degree = vec![0u32; n];
302    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
303
304    for (i, step) in steps.iter().enumerate() {
305        for dep in &step.depends_on {
306            if let Some(&j) = id_to_idx.get(dep.as_str()) {
307                adj[j].push(i);
308                in_degree[i] += 1;
309            }
310        }
311    }
312
313    let mut queue: VecDeque<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
314    let mut sorted = Vec::new();
315
316    while let Some(u) = queue.pop_front() {
317        sorted.push(u);
318        for &v in &adj[u] {
319            in_degree[v] -= 1;
320            if in_degree[v] == 0 {
321                queue.push_back(v);
322            }
323        }
324    }
325
326    if sorted.len() < n {
327        let cycle_nodes: Vec<String> = (0..n)
328            .filter(|i| in_degree[*i] > 0)
329            .map(|i| step_ids[i].to_string())
330            .collect();
331        Some(cycle_nodes)
332    } else {
333        None
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::coordination::types::*;
341    use uuid::Uuid;
342
343    #[test]
344    fn test_parse_minimal_plan() {
345        let toml = r#"
346[plan]
347version = "0"
348plan_id = "550e8400-e29b-41d4-a716-446655440000"
349goal = "Add Stripe webhook handler"
350created_at = "2026-05-24T12:00:00Z"
351created_by = "agent:commander-planner"
352budget_estimate_usd = 2.50
353determinism = "best-effort"
354content_sha256 = "abc123"
355
356[[plan.steps]]
357step_id = "step_001"
358description = "Implement webhook signature validator"
359agent_hint = "code-review"
360phases = ["plan", "design", "implement", "test", "verify"]
361verify_command = "cargo test --lib webhook_validator"
362depends_on = []
363
364[[plan.steps]]
365step_id = "step_002"
366description = "Deploy to staging"
367agent_hint = "generic"
368phases = ["plan", "implement", "verify"]
369verify_command = "curl -fsS https://staging.example.com/health"
370depends_on = ["step_001"]
371"#;
372        let plan: Plan = toml::from_str(toml).expect("parse valid plan");
373        assert_eq!(plan.plan.version, "0");
374        assert_eq!(
375            plan.plan.plan_id,
376            "550e8400-e29b-41d4-a716-446655440000"
377                .parse::<Uuid>()
378                .unwrap()
379        );
380        assert_eq!(plan.plan.goal, "Add Stripe webhook handler");
381        assert_eq!(plan.plan.determinism, DeterminismMode::BestEffort);
382        assert_eq!(plan.plan.steps.len(), 2);
383        assert_eq!(plan.plan.steps[0].step_id, "step_001");
384        assert_eq!(plan.plan.steps[0].agent_hint, "code-review");
385        assert_eq!(
386            plan.plan.steps[0].phases,
387            vec![
388                Phase::Plan,
389                Phase::Design,
390                Phase::Implement,
391                Phase::Test,
392                Phase::Verify,
393            ]
394        );
395        assert!(plan.plan.steps[0].depends_on.is_empty());
396        assert_eq!(plan.plan.steps[1].depends_on, vec!["step_001".to_string()]);
397    }
398
399    #[test]
400    fn test_parse_defaults() {
401        let toml = r#"
402[plan]
403version = "0"
404plan_id = "550e8400-e29b-41d4-a716-446655440000"
405goal = "test"
406created_at = "2026-05-24T12:00:00Z"
407created_by = "agent:test"
408budget_estimate_usd = 0.0
409determinism = "best-effort"
410content_sha256 = "abc"
411
412[[plan.steps]]
413step_id = "s1"
414description = "test step"
415agent_hint = "generic"
416phases = ["verify"]
417verify_command = "true"
418depends_on = []
419"#;
420        let plan: Plan = toml::from_str(toml).expect("parse defaults");
421        assert!(plan.plan.signature.is_none());
422        assert!(plan.plan.steps[0].skill_ref.is_none());
423        assert_eq!(plan.plan.steps[0].budget_estimate_usd, None);
424        assert_eq!(plan.plan.steps[0].timeout_secs, None);
425        assert_eq!(plan.plan.determinism, DeterminismMode::BestEffort);
426    }
427
428    #[test]
429    fn test_reject_unknown_phase() {
430        let toml = r#"
431[plan]
432version = "0"
433plan_id = "550e8400-e29b-41d4-a716-446655440000"
434goal = "test"
435created_at = "2026-05-24T12:00:00Z"
436created_by = "agent:test"
437budget_estimate_usd = 0.0
438determinism = "best-effort"
439content_sha256 = "abc"
440
441[[plan.steps]]
442step_id = "s1"
443description = "bad phase"
444agent_hint = "generic"
445phases = ["unknown_phase"]
446verify_command = "true"
447depends_on = []
448"#;
449        let result = toml::from_str::<Plan>(toml);
450        assert!(result.is_err(), "unknown phase must reject");
451    }
452
453    #[test]
454    fn test_reject_missing_verify_command() {
455        let toml = r#"
456[plan]
457version = "0"
458plan_id = "550e8400-e29b-41d4-a716-446655440000"
459goal = "test"
460created_at = "2026-05-24T12:00:00Z"
461created_by = "agent:test"
462budget_estimate_usd = 0.0
463determinism = "best-effort"
464content_sha256 = "abc"
465
466[[plan.steps]]
467step_id = "s1"
468description = "no verify"
469agent_hint = "generic"
470phases = ["plan"]
471verify_command = ""
472depends_on = []
473"#;
474        let plan = toml::from_str::<Plan>(toml).expect("parse ok");
475        let errors = plan.validate();
476        assert!(
477            !errors.is_empty(),
478            "empty verify_command must fail validation"
479        );
480    }
481
482    #[test]
483    fn test_reject_missing_phases() {
484        let toml = r#"
485[plan]
486version = "0"
487plan_id = "550e8400-e29b-41d4-a716-446655440000"
488goal = "test"
489created_at = "2026-05-24T12:00:00Z"
490created_by = "agent:test"
491budget_estimate_usd = 0.0
492determinism = "best-effort"
493content_sha256 = "abc"
494
495[[plan.steps]]
496step_id = "s1"
497description = "no phases"
498agent_hint = "generic"
499phases = []
500verify_command = "true"
501depends_on = []
502"#;
503        let plan = toml::from_str::<Plan>(toml).expect("parse ok");
504        let errors = plan.validate();
505        assert!(!errors.is_empty(), "empty phases must fail validation");
506    }
507
508    #[test]
509    fn test_reject_dependency_cycle() {
510        let toml = r#"
511[plan]
512version = "0"
513plan_id = "550e8400-e29b-41d4-a716-446655440000"
514goal = "test"
515created_at = "2026-05-24T12:00:00Z"
516created_by = "agent:test"
517budget_estimate_usd = 0.0
518determinism = "best-effort"
519content_sha256 = "abc"
520
521[[plan.steps]]
522step_id = "s1"
523description = "step 1"
524agent_hint = "generic"
525phases = ["verify"]
526verify_command = "true"
527depends_on = ["s2"]
528
529[[plan.steps]]
530step_id = "s2"
531description = "step 2"
532agent_hint = "generic"
533phases = ["verify"]
534verify_command = "true"
535depends_on = ["s1"]
536"#;
537        let plan = toml::from_str::<Plan>(toml).expect("parse ok");
538        let errors = plan.validate();
539        assert!(!errors.is_empty(), "cycle must fail validation");
540        let has_cycle = errors
541            .iter()
542            .any(|e| matches!(e, PlanValidationError::CycleDetected { .. }));
543        assert!(has_cycle, "error must mention cycle, got: {:?}", errors);
544    }
545
546    #[test]
547    fn test_validate_valid_plan() {
548        let toml = r#"
549[plan]
550version = "0"
551plan_id = "550e8400-e29b-41d4-a716-446655440000"
552goal = "valid plan"
553created_at = "2026-05-24T12:00:00Z"
554created_by = "agent:test"
555budget_estimate_usd = 0.0
556determinism = "best-effort"
557content_sha256 = "abc123"
558
559[[plan.steps]]
560step_id = "build"
561description = "build"
562agent_hint = "generic"
563phases = ["plan", "implement", "verify"]
564verify_command = "cargo build"
565depends_on = []
566
567[[plan.steps]]
568step_id = "test"
569description = "test"
570agent_hint = "code-review"
571phases = ["test", "verify"]
572verify_command = "cargo test"
573depends_on = ["build"]
574"#;
575        let plan: Plan = toml::from_str(toml).expect("parse valid plan");
576        let errors = plan.validate();
577        assert!(
578            errors.is_empty(),
579            "validation must pass for valid plan, got: {:?}",
580            errors
581        );
582    }
583
584    #[test]
585    fn test_validate_unknown_dependency() {
586        let toml = r#"
587[plan]
588version = "0"
589plan_id = "550e8400-e29b-41d4-a716-446655440000"
590goal = "test"
591created_at = "2026-05-24T12:00:00Z"
592created_by = "agent:test"
593budget_estimate_usd = 0.0
594determinism = "best-effort"
595content_sha256 = "abc"
596
597[[plan.steps]]
598step_id = "s1"
599description = "step 1"
600agent_hint = "generic"
601phases = ["verify"]
602verify_command = "true"
603depends_on = ["nonexistent_step"]
604"#;
605        let plan = toml::from_str::<Plan>(toml).expect("parse ok");
606        let errors = plan.validate();
607        assert!(!errors.is_empty(), "unknown dep must fail validation");
608    }
609
610    #[test]
611    fn test_content_sha256_computation() {
612        let toml = r#"
613[plan]
614version = "0"
615plan_id = "550e8400-e29b-41d4-a716-446655440000"
616goal = "test"
617created_at = "2026-05-24T12:00:00Z"
618created_by = "agent:test"
619budget_estimate_usd = 0.0
620determinism = "best-effort"
621
622[[plan.steps]]
623step_id = "s1"
624description = "step 1"
625agent_hint = "generic"
626phases = ["verify"]
627verify_command = "true"
628depends_on = []
629"#;
630        let plan = toml::from_str::<Plan>(toml).expect("parse ok");
631        let hash = plan.compute_content_sha256();
632        assert_eq!(hash.len(), 64);
633        let hash2 = plan.compute_content_sha256();
634        assert_eq!(hash, hash2);
635    }
636}