1use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10use thiserror::Error;
11use uuid::Uuid;
12
13pub use crate::evaluation::{
17 AcceptanceCriterion, ArtifactRef, CompletionClaim, CriterionEvaluation, CriterionKind,
18 CriterionVerdict, Evaluation, EvaluationError, EvaluationFinding, EvaluationReport,
19 EvaluationState, EvaluationSubject, EvaluationVerdict, EvidenceRef, FindingSeverity,
20 WorkspaceRevision,
21};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25pub struct MissionEvaluation {
26 pub mission: WorkIdentity,
28 pub verdict: EvaluationVerdict,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
34#[serde(rename_all = "snake_case")]
35pub enum DeliveryBlockReason {
36 MissingGoalEvaluation,
38 StaleGoalEvaluation,
40 GoalNotPassed,
42 ConflictingGoalEvaluations,
44 MissingMissionEvaluation,
46 StaleMissionEvaluation,
48 MissionNotPassed,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "snake_case", tag = "state")]
55pub enum DeliveryEligibility {
56 Eligible,
58 Blocked { reason: DeliveryBlockReason },
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
64#[serde(rename_all = "snake_case", tag = "kind")]
65pub enum WorkProjectionEvent {
66 GoalObserved { goal_id: Uuid, revision: u64 },
68 MissionObserved { mission_id: Uuid, revision: u64 },
70 DeliveryEligibilityChanged { eligible: bool },
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
76pub struct MissionGateResult {
77 pub mission: WorkIdentity,
79 pub delivery: DeliveryEligibility,
81 pub events: Vec<WorkProjectionEvent>,
83}
84
85#[derive(Debug, Clone, Copy)]
87pub struct MissionGate<'a> {
88 pub mission: WorkIdentity,
90 pub required_goals: &'a [WorkIdentity],
92 pub goal_evaluations: &'a [Evaluation],
94 pub mission_evaluation: Option<MissionEvaluation>,
96}
97
98impl MissionGate<'_> {
99 #[must_use]
101 pub fn evaluate(&self) -> MissionGateResult {
102 let mut events = Vec::with_capacity(self.required_goals.len() + 2);
103 let mut blocked = None;
104 for goal in self.required_goals {
105 let evaluations: Vec<&Evaluation> = self
106 .goal_evaluations
107 .iter()
108 .filter(|evaluation| {
109 evaluation.claim.subject.goal.id == goal.id
110 && evaluation.claim.subject.goal.kind == goal.kind
111 })
112 .collect();
113 match evaluations.as_slice() {
114 [] => {
115 blocked.get_or_insert(DeliveryBlockReason::MissingGoalEvaluation);
116 }
117 [evaluation] => {
118 events.push(WorkProjectionEvent::GoalObserved {
119 goal_id: goal.id,
120 revision: goal.revision,
121 });
122 if evaluation.claim.subject.goal != *goal
123 || evaluation.claim.subject.mission != self.mission
124 {
125 blocked.get_or_insert(DeliveryBlockReason::StaleGoalEvaluation);
126 } else if !evaluation.has_valid_pass() {
127 blocked.get_or_insert(DeliveryBlockReason::GoalNotPassed);
128 }
129 }
130 _ => {
131 blocked.get_or_insert(DeliveryBlockReason::ConflictingGoalEvaluations);
132 }
133 };
134 }
135
136 match self.mission_evaluation {
137 None => {
138 blocked.get_or_insert(DeliveryBlockReason::MissingMissionEvaluation);
139 }
140 Some(evaluation) => {
141 events.push(WorkProjectionEvent::MissionObserved {
142 mission_id: evaluation.mission.id,
143 revision: evaluation.mission.revision,
144 });
145 if evaluation.mission != self.mission {
146 blocked.get_or_insert(DeliveryBlockReason::StaleMissionEvaluation);
147 } else if evaluation.verdict != EvaluationVerdict::Pass {
148 blocked.get_or_insert(DeliveryBlockReason::MissionNotPassed);
149 }
150 }
151 }
152
153 let delivery = blocked.map_or(DeliveryEligibility::Eligible, |reason| {
154 DeliveryEligibility::Blocked { reason }
155 });
156 events.push(WorkProjectionEvent::DeliveryEligibilityChanged {
157 eligible: matches!(delivery, DeliveryEligibility::Eligible),
158 });
159 MissionGateResult {
160 mission: self.mission,
161 delivery,
162 events,
163 }
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
169#[serde(rename_all = "snake_case")]
170pub enum WorkKind {
171 Mission,
173 Goal,
175 WorkUnit,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
181pub struct WorkIdentity {
182 pub id: Uuid,
184 pub kind: WorkKind,
186 pub revision: u64,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
192pub struct WorkNode {
193 pub identity: WorkIdentity,
195 pub parent_id: Option<Uuid>,
197 pub title: String,
199 pub description: Option<String>,
201 pub status: WorkStatus,
203 pub priority: WorkPriority,
205 pub tags: Vec<String>,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
211#[serde(rename_all = "snake_case")]
212pub enum WorkStatus {
213 Todo,
215 InProgress,
217 Completed,
219 Blocked,
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
225#[serde(rename_all = "snake_case")]
226pub enum WorkPriority {
227 Low,
229 Medium,
231 High,
233 Critical,
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
239pub struct WorkEdge {
240 pub identity: WorkEdgeIdentity,
242 pub parent_id: Uuid,
244 pub child_id: Uuid,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
250pub struct WorkEdgeIdentity {
251 pub id: Uuid,
253 pub revision: u64,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
259pub enum WorkGraphError {
260 #[error("work node cannot depend on itself: {0}")]
262 SelfDependency(Uuid),
263 #[error("work dependency would create a cycle: {parent_id} -> {child_id}")]
265 Cycle { parent_id: Uuid, child_id: Uuid },
266 #[error("work edge references an unknown node")]
268 UnknownNode,
269 #[error("work graph contains a duplicate node identity: {0}")]
271 DuplicateNode(Uuid),
272 #[error("work graph contains a duplicate edge")]
274 DuplicateEdge,
275 #[error("invalid work containment")]
277 InvalidContainment,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
282pub struct WorkGraph {
283 pub nodes: Vec<WorkNode>,
285 pub edges: Vec<WorkEdge>,
287}
288
289impl WorkGraph {
290 pub fn new(nodes: Vec<WorkNode>, edges: Vec<WorkEdge>) -> Result<Self, WorkGraphError> {
292 let kinds: HashMap<_, _> = nodes
293 .iter()
294 .map(|node| (node.identity.id, node.identity.kind))
295 .collect();
296 if kinds.len() != nodes.len() {
297 let mut seen = HashSet::new();
298 let duplicate = nodes
299 .iter()
300 .find_map(|node| (!seen.insert(node.identity.id)).then_some(node.identity.id))
301 .unwrap_or(Uuid::nil());
302 return Err(WorkGraphError::DuplicateNode(duplicate));
303 }
304 if nodes.iter().any(|node| match node.identity.kind {
305 WorkKind::Mission => node.parent_id.is_some(),
306 WorkKind::Goal => node
307 .parent_id
308 .is_none_or(|parent| kinds.get(&parent) != Some(&WorkKind::Mission)),
309 WorkKind::WorkUnit => node
312 .parent_id
313 .is_some_and(|parent| kinds.get(&parent) != Some(&WorkKind::Goal)),
314 }) {
315 return Err(WorkGraphError::InvalidContainment);
316 }
317 if edges
318 .iter()
319 .any(|edge| !kinds.contains_key(&edge.parent_id) || !kinds.contains_key(&edge.child_id))
320 {
321 return Err(WorkGraphError::UnknownNode);
322 }
323 let mut edge_ids = HashSet::new();
324 let mut endpoint_pairs = HashSet::new();
325 if edges.iter().any(|edge| {
326 !edge_ids.insert(edge.identity.id)
327 || !endpoint_pairs.insert((edge.parent_id, edge.child_id))
328 }) {
329 return Err(WorkGraphError::DuplicateEdge);
330 }
331 let mut accepted = Vec::with_capacity(edges.len());
332 for edge in edges {
333 validate_edge(accepted.iter().copied(), edge)?;
334 accepted.push(edge);
335 }
336 Ok(Self {
337 nodes,
338 edges: accepted,
339 })
340 }
341
342 pub fn with_node(&self, node: WorkNode) -> Result<Self, WorkGraphError> {
344 let mut nodes = self.nodes.clone();
345 nodes.push(node);
346 Self::new(nodes, self.edges.clone())
347 }
348
349 pub fn with_edge(&self, edge: WorkEdge) -> Result<Self, WorkGraphError> {
351 let mut edges = self.edges.clone();
352 edges.push(edge);
353 Self::new(self.nodes.clone(), edges)
354 }
355
356 #[must_use]
358 pub fn without_edge(&self, edge_id: Uuid) -> Self {
359 Self {
360 nodes: self.nodes.clone(),
361 edges: self
362 .edges
363 .iter()
364 .copied()
365 .filter(|edge| edge.identity.id != edge_id)
366 .collect(),
367 }
368 }
369
370 #[must_use]
372 pub fn node(&self, id: Uuid) -> Option<&WorkNode> {
373 self.nodes.iter().find(|node| node.identity.id == id)
374 }
375}
376
377pub fn validate_edge(
379 edges: impl IntoIterator<Item = WorkEdge>,
380 edge: WorkEdge,
381) -> Result<(), WorkGraphError> {
382 if edge.parent_id == edge.child_id {
383 return Err(WorkGraphError::SelfDependency(edge.parent_id));
384 }
385 let mut adjacency: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
386 for existing in edges {
387 adjacency
388 .entry(existing.parent_id)
389 .or_default()
390 .push(existing.child_id);
391 }
392 let mut stack = vec![edge.child_id];
393 let mut visited = HashSet::new();
394 while let Some(node) = stack.pop() {
395 if node == edge.parent_id {
396 return Err(WorkGraphError::Cycle {
397 parent_id: edge.parent_id,
398 child_id: edge.child_id,
399 });
400 }
401 if visited.insert(node)
402 && let Some(children) = adjacency.get(&node)
403 {
404 stack.extend(children.iter().copied());
405 }
406 }
407 Ok(())
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 fn passing_evaluation(mission: WorkIdentity, goal: WorkIdentity) -> Evaluation {
415 let subject = EvaluationSubject {
416 mission,
417 goal,
418 workspace: WorkspaceRevision {
419 id: Uuid::new_v4(),
420 revision: 1,
421 },
422 };
423 let criterion = AcceptanceCriterion {
424 id: Uuid::new_v4(),
425 kind: CriterionKind::Technical,
426 statement: "works".into(),
427 required: true,
428 };
429 let claim = CompletionClaim::new(subject, vec![criterion.clone()], vec![], vec![], "done")
430 .expect("claim");
431 let mut evaluation = claim.evaluation();
432 evaluation.begin().expect("begin");
433 let report = EvaluationReport::new(
434 &claim,
435 subject,
436 vec![CriterionEvaluation {
437 criterion_id: criterion.id,
438 verdict: CriterionVerdict::Pass,
439 evidence: vec![],
440 finding_ids: vec![],
441 }],
442 vec![],
443 )
444 .expect("report");
445 evaluation.accept_report(report).expect("accept");
446 evaluation
447 }
448
449 #[test]
450 fn rejects_self_dependency_and_cycles() {
451 let a = Uuid::new_v4();
452 let b = Uuid::new_v4();
453 assert_eq!(
454 validate_edge(
455 [],
456 WorkEdge {
457 identity: WorkEdgeIdentity {
458 id: Uuid::new_v4(),
459 revision: 1
460 },
461 parent_id: a,
462 child_id: a
463 }
464 ),
465 Err(WorkGraphError::SelfDependency(a))
466 );
467 assert_eq!(
468 validate_edge(
469 [WorkEdge {
470 identity: WorkEdgeIdentity {
471 id: Uuid::new_v4(),
472 revision: 1
473 },
474 parent_id: a,
475 child_id: b
476 }],
477 WorkEdge {
478 identity: WorkEdgeIdentity {
479 id: Uuid::new_v4(),
480 revision: 1
481 },
482 parent_id: b,
483 child_id: a
484 }
485 ),
486 Err(WorkGraphError::Cycle {
487 parent_id: b,
488 child_id: a
489 })
490 );
491 }
492
493 #[test]
494 fn accepts_disconnected_edge() {
495 let a = Uuid::new_v4();
496 let b = Uuid::new_v4();
497 assert!(
498 validate_edge(
499 [],
500 WorkEdge {
501 identity: WorkEdgeIdentity {
502 id: Uuid::new_v4(),
503 revision: 1
504 },
505 parent_id: a,
506 child_id: b
507 }
508 )
509 .is_ok()
510 );
511 }
512
513 #[test]
514 fn mission_gate_requires_independent_mission_pass() {
515 let mission = WorkIdentity {
516 id: Uuid::new_v4(),
517 kind: WorkKind::Mission,
518 revision: 4,
519 };
520 let goal = WorkIdentity {
521 id: Uuid::new_v4(),
522 kind: WorkKind::Goal,
523 revision: 2,
524 };
525 let result = MissionGate {
526 mission,
527 required_goals: &[goal],
528 goal_evaluations: &[passing_evaluation(mission, goal)],
529 mission_evaluation: None,
530 }
531 .evaluate();
532 assert_eq!(
533 result.delivery,
534 DeliveryEligibility::Blocked {
535 reason: DeliveryBlockReason::MissingMissionEvaluation
536 }
537 );
538 assert!(matches!(
539 result.events.last(),
540 Some(WorkProjectionEvent::DeliveryEligibilityChanged { eligible: false })
541 ));
542 }
543
544 #[test]
545 fn mission_gate_emits_deterministic_eligible_projection() {
546 let mission = WorkIdentity {
547 id: Uuid::new_v4(),
548 kind: WorkKind::Mission,
549 revision: 1,
550 };
551 let goal = WorkIdentity {
552 id: Uuid::new_v4(),
553 kind: WorkKind::Goal,
554 revision: 1,
555 };
556 let result = MissionGate {
557 mission,
558 required_goals: &[goal],
559 goal_evaluations: &[passing_evaluation(mission, goal)],
560 mission_evaluation: Some(MissionEvaluation {
561 mission,
562 verdict: EvaluationVerdict::Pass,
563 }),
564 }
565 .evaluate();
566 assert_eq!(result.delivery, DeliveryEligibility::Eligible);
567 assert_eq!(result.events.len(), 3);
568 assert!(matches!(
569 result.events.last(),
570 Some(WorkProjectionEvent::DeliveryEligibilityChanged { eligible: true })
571 ));
572 }
573
574 #[test]
575 fn mission_gate_rejects_stale_goal_revision() {
576 let mission = WorkIdentity {
577 id: Uuid::new_v4(),
578 kind: WorkKind::Mission,
579 revision: 1,
580 };
581 let required_goal = WorkIdentity {
582 id: Uuid::new_v4(),
583 kind: WorkKind::Goal,
584 revision: 2,
585 };
586 let old_goal = WorkIdentity {
587 revision: 1,
588 ..required_goal
589 };
590 let result = MissionGate {
591 mission,
592 required_goals: &[required_goal],
593 goal_evaluations: &[passing_evaluation(mission, old_goal)],
594 mission_evaluation: Some(MissionEvaluation {
595 mission,
596 verdict: EvaluationVerdict::Pass,
597 }),
598 }
599 .evaluate();
600 assert_eq!(
601 result.delivery,
602 DeliveryEligibility::Blocked {
603 reason: DeliveryBlockReason::StaleGoalEvaluation
604 }
605 );
606 }
607
608 #[test]
609 fn p4_non_persistent_fixture_covers_claim_staleness_gate_and_delivery_projection() {
610 let mission = WorkIdentity {
611 id: Uuid::new_v4(),
612 kind: WorkKind::Mission,
613 revision: 1,
614 };
615 let goal = WorkIdentity {
616 id: Uuid::new_v4(),
617 kind: WorkKind::Goal,
618 revision: 1,
619 };
620
621 let work_unit = WorkNode {
622 identity: WorkIdentity {
623 id: Uuid::new_v4(),
624 kind: WorkKind::WorkUnit,
625 revision: 1,
626 },
627 parent_id: Some(goal.id),
628 title: "fixture work unit".into(),
629 description: None,
630 status: WorkStatus::Completed,
631 priority: WorkPriority::Medium,
632 tags: vec![],
633 };
634 assert_eq!(work_unit.status, WorkStatus::Completed);
635
636 let mut goal_evaluation = passing_evaluation(mission, goal);
639 let mut changed_subject = goal_evaluation.claim.subject;
640 changed_subject.goal.revision = 2;
641 goal_evaluation
642 .observe_subject(changed_subject)
643 .expect("revision change marks the prior evaluation stale");
644 goal_evaluation
645 .request_rework()
646 .expect("stale evaluation requires rework");
647 assert_eq!(goal_evaluation.state, EvaluationState::Rework);
648
649 let stale = MissionGate {
650 mission,
651 required_goals: &[changed_subject.goal],
652 goal_evaluations: &[goal_evaluation.clone()],
653 mission_evaluation: Some(MissionEvaluation {
654 mission,
655 verdict: EvaluationVerdict::Pass,
656 }),
657 }
658 .evaluate();
659 assert_eq!(
660 stale.delivery,
661 DeliveryEligibility::Blocked {
662 reason: DeliveryBlockReason::StaleGoalEvaluation
663 }
664 );
665
666 let refreshed = passing_evaluation(mission, changed_subject.goal);
667 let eligible = MissionGate {
668 mission,
669 required_goals: &[changed_subject.goal],
670 goal_evaluations: &[refreshed],
671 mission_evaluation: Some(MissionEvaluation {
672 mission,
673 verdict: EvaluationVerdict::Pass,
674 }),
675 }
676 .evaluate();
677 assert_eq!(eligible.delivery, DeliveryEligibility::Eligible);
678 assert_eq!(eligible.events.len(), 3);
679 assert!(serde_json::to_value(&eligible).is_ok());
680 }
681
682 #[test]
683 fn mission_gate_rejects_duplicate_goal_evaluations() {
684 let mission = WorkIdentity {
685 id: Uuid::new_v4(),
686 kind: WorkKind::Mission,
687 revision: 1,
688 };
689 let goal = WorkIdentity {
690 id: Uuid::new_v4(),
691 kind: WorkKind::Goal,
692 revision: 1,
693 };
694 let first = passing_evaluation(mission, goal);
695 let second = passing_evaluation(mission, goal);
696 let result = MissionGate {
697 mission,
698 required_goals: &[goal],
699 goal_evaluations: &[first, second],
700 mission_evaluation: Some(MissionEvaluation {
701 mission,
702 verdict: EvaluationVerdict::Pass,
703 }),
704 }
705 .evaluate();
706 assert_eq!(
707 result.delivery,
708 DeliveryEligibility::Blocked {
709 reason: DeliveryBlockReason::ConflictingGoalEvaluations
710 }
711 );
712 }
713
714 #[test]
715 fn mission_gate_rejects_forged_passing_evaluation_without_report() {
716 let mission = WorkIdentity {
717 id: Uuid::new_v4(),
718 kind: WorkKind::Mission,
719 revision: 1,
720 };
721 let goal = WorkIdentity {
722 id: Uuid::new_v4(),
723 kind: WorkKind::Goal,
724 revision: 1,
725 };
726 let mut forged = passing_evaluation(mission, goal);
727 forged.report = None;
728 let result = MissionGate {
729 mission,
730 required_goals: &[goal],
731 goal_evaluations: &[forged],
732 mission_evaluation: Some(MissionEvaluation {
733 mission,
734 verdict: EvaluationVerdict::Pass,
735 }),
736 }
737 .evaluate();
738 assert_eq!(
739 result.delivery,
740 DeliveryEligibility::Blocked {
741 reason: DeliveryBlockReason::GoalNotPassed
742 }
743 );
744 }
745
746 #[test]
747 fn mission_gate_rejects_passing_state_with_valid_fail_report() {
748 let mission = WorkIdentity {
749 id: Uuid::new_v4(),
750 kind: WorkKind::Mission,
751 revision: 1,
752 };
753 let goal = WorkIdentity {
754 id: Uuid::new_v4(),
755 kind: WorkKind::Goal,
756 revision: 1,
757 };
758 let mut forged = passing_evaluation(mission, goal);
759 let claim = forged.claim.clone();
760 forged.report = Some(
761 EvaluationReport::new(
762 &claim,
763 claim.subject,
764 vec![CriterionEvaluation {
765 criterion_id: claim.criteria[0].id,
766 verdict: CriterionVerdict::Fail,
767 evidence: vec![],
768 finding_ids: vec![],
769 }],
770 vec![],
771 )
772 .expect("valid fail report"),
773 );
774 let result = MissionGate {
775 mission,
776 required_goals: &[goal],
777 goal_evaluations: &[forged],
778 mission_evaluation: Some(MissionEvaluation {
779 mission,
780 verdict: EvaluationVerdict::Pass,
781 }),
782 }
783 .evaluate();
784 assert_eq!(
785 result.delivery,
786 DeliveryEligibility::Blocked {
787 reason: DeliveryBlockReason::GoalNotPassed
788 }
789 );
790 }
791}