Skip to main content

talos_core/
work.rs

1//! Canonical, storage-neutral work-domain values.
2//!
3//! The domain is deliberately independent of persistence and presentation. During the Todo
4//! compatibility window, `talos-session` remains the durable authority and projects records into
5//! these values rather than maintaining a second repository.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10use thiserror::Error;
11use uuid::Uuid;
12
13// Re-export completion/evaluation contracts from the canonical work-domain path.  The
14// implementation lives in its own module to keep the graph model readable while allowing
15// consumers to import all work-domain values from `talos_core::work`.
16pub 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/// A Mission-level evaluation result used by the final Delivery gate.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25pub struct MissionEvaluation {
26    /// Exact Mission identity and revision that was evaluated.
27    pub mission: WorkIdentity,
28    /// Independent evaluator verdict for the integrated Mission outcome.
29    pub verdict: EvaluationVerdict,
30}
31
32/// Why a Mission is not eligible for Delivery.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
34#[serde(rename_all = "snake_case")]
35pub enum DeliveryBlockReason {
36    /// A required Goal has no evaluation result.
37    MissingGoalEvaluation,
38    /// A Goal evaluation does not match the required Mission revision.
39    StaleGoalEvaluation,
40    /// A required Goal evaluation is not a passing verdict.
41    GoalNotPassed,
42    /// More than one result was supplied for the same required Goal.
43    ConflictingGoalEvaluations,
44    /// The independent Mission evaluation is absent.
45    MissingMissionEvaluation,
46    /// The Mission evaluation targets another identity or revision.
47    StaleMissionEvaluation,
48    /// The independent Mission evaluation did not pass.
49    MissionNotPassed,
50}
51
52/// Delivery eligibility produced by the Mission final gate.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "snake_case", tag = "state")]
55pub enum DeliveryEligibility {
56    /// All required Goal and Mission evaluations passed at the current revisions.
57    Eligible,
58    /// Delivery is denied until the reported blocker is resolved.
59    Blocked { reason: DeliveryBlockReason },
60}
61
62/// Presentation-neutral event emitted by a Mission gate evaluation.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
64#[serde(rename_all = "snake_case", tag = "kind")]
65pub enum WorkProjectionEvent {
66    /// A required Goal was inspected by the gate.
67    GoalObserved { goal_id: Uuid, revision: u64 },
68    /// The Mission-level evaluator was inspected by the gate.
69    MissionObserved { mission_id: Uuid, revision: u64 },
70    /// The final Delivery eligibility was determined.
71    DeliveryEligibilityChanged { eligible: bool },
72}
73
74/// Result of evaluating one Mission against its required Goal evaluations.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
76pub struct MissionGateResult {
77    /// Mission identity evaluated by the gate.
78    pub mission: WorkIdentity,
79    /// Delivery eligibility; blocked results never mutate work state.
80    pub delivery: DeliveryEligibility,
81    /// Deterministically ordered events for UI-neutral consumers.
82    pub events: Vec<WorkProjectionEvent>,
83}
84
85/// Storage-neutral final gate for Mission Delivery.
86#[derive(Debug, Clone, Copy)]
87pub struct MissionGate<'a> {
88    /// Mission identity and revision to evaluate.
89    pub mission: WorkIdentity,
90    /// Required Goal identities in deterministic order.
91    pub required_goals: &'a [WorkIdentity],
92    /// Existing revision-bound Goal evaluations.
93    pub goal_evaluations: &'a [Evaluation],
94    /// Independent Mission-level evaluation, if available.
95    pub mission_evaluation: Option<MissionEvaluation>,
96}
97
98impl MissionGate<'_> {
99    /// Evaluate required Goal and Mission results without mutating any input state.
100    #[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/// A durable node role in the canonical work graph.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
169#[serde(rename_all = "snake_case")]
170pub enum WorkKind {
171    /// A top-level mission containing goals and work units.
172    Mission,
173    /// A goal within a mission.
174    Goal,
175    /// An executable unit of work.
176    WorkUnit,
177}
178
179/// Stable graph identity and revision for one work node.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
181pub struct WorkIdentity {
182    /// Stable UUID identity.
183    pub id: Uuid,
184    /// Node role.
185    pub kind: WorkKind,
186    /// Monotonically increasing persisted revision.
187    pub revision: u64,
188}
189
190/// A storage-neutral canonical work node.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
192pub struct WorkNode {
193    /// Stable identity and revision.
194    pub identity: WorkIdentity,
195    /// Optional containing node identity.
196    pub parent_id: Option<Uuid>,
197    /// User-visible title.
198    pub title: String,
199    /// Optional description.
200    pub description: Option<String>,
201    /// Existing Todo-compatible status value.
202    pub status: WorkStatus,
203    /// Existing Todo-compatible priority value.
204    pub priority: WorkPriority,
205    /// Filterable tags.
206    pub tags: Vec<String>,
207}
208
209/// Stable status values shared by all work-domain projections.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
211#[serde(rename_all = "snake_case")]
212pub enum WorkStatus {
213    /// Not started.
214    Todo,
215    /// Currently executing.
216    InProgress,
217    /// Completed.
218    Completed,
219    /// Blocked by a prerequisite.
220    Blocked,
221}
222
223/// Stable priority values shared by all work-domain projections.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
225#[serde(rename_all = "snake_case")]
226pub enum WorkPriority {
227    /// Low priority.
228    Low,
229    /// Normal priority.
230    Medium,
231    /// High priority.
232    High,
233    /// Critical priority.
234    Critical,
235}
236
237/// A directed dependency edge in the work graph.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
239pub struct WorkEdge {
240    /// Stable identity and revision of the durable edge subject.
241    pub identity: WorkEdgeIdentity,
242    /// Predecessor node that must be handled first.
243    pub parent_id: Uuid,
244    /// Dependent node.
245    pub child_id: Uuid,
246}
247
248/// Stable identity and revision of a dependency edge.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
250pub struct WorkEdgeIdentity {
251    /// Stable UUID identity.
252    pub id: Uuid,
253    /// Monotonically increasing persisted revision.
254    pub revision: u64,
255}
256
257/// Error returned when a proposed graph edge is invalid.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
259pub enum WorkGraphError {
260    /// An edge points to itself.
261    #[error("work node cannot depend on itself: {0}")]
262    SelfDependency(Uuid),
263    /// An edge would introduce a cycle.
264    #[error("work dependency would create a cycle: {parent_id} -> {child_id}")]
265    Cycle { parent_id: Uuid, child_id: Uuid },
266    /// A graph contains an edge whose endpoint is absent.
267    #[error("work edge references an unknown node")]
268    UnknownNode,
269    /// A node identity occurs more than once.
270    #[error("work graph contains a duplicate node identity: {0}")]
271    DuplicateNode(Uuid),
272    /// An edge identity or endpoint pair occurs more than once.
273    #[error("work graph contains a duplicate edge")]
274    DuplicateEdge,
275    /// A containment relationship does not match Mission/Goal/WorkUnit roles.
276    #[error("invalid work containment")]
277    InvalidContainment,
278}
279
280/// An immutable, validated canonical work graph snapshot.
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
282pub struct WorkGraph {
283    /// Nodes in deterministic insertion order.
284    pub nodes: Vec<WorkNode>,
285    /// Validated dependency edges.
286    pub edges: Vec<WorkEdge>,
287}
288
289impl WorkGraph {
290    /// Construct a graph after validating identities, containment and acyclicity.
291    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            // Legacy Todo WorkUnits are permitted to remain session-rooted during the declared
310            // compatibility window; new contained WorkUnits must have a Goal parent.
311            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    /// Return a graph with one new node, rejecting duplicate identities and invalid containment.
343    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    /// Return a graph with one validated dependency edge.
350    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    /// Return a graph without one edge identity.
357    #[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    /// Find one node by its stable UUID.
371    #[must_use]
372    pub fn node(&self, id: Uuid) -> Option<&WorkNode> {
373        self.nodes.iter().find(|node| node.identity.id == id)
374    }
375}
376
377/// Validate adding one edge to an existing acyclic edge set.
378pub 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        // CompletionClaim::new plus an accepted report models the completed WorkUnit's
637        // independent evaluation without introducing a persistence dependency in P4.
638        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}