Skip to main content

exo_catapult/
phase.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! FM 3-05 operational phases adapted for newco lifecycle.
18//!
19//! The six phases mirror Army Special Operations doctrine:
20//! Assessment → Selection → Preparation → Execution → Sustainment → Transition.
21
22use serde::{Deserialize, Serialize};
23
24use crate::{
25    error::{CatapultError, Result},
26    oda::OdaSlot,
27};
28
29/// Operational phase of a newco, aligned with FM 3-05 doctrine.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
31pub enum OperationalPhase {
32    /// Phase 1: Market opportunity validation, resource survey.
33    Assessment,
34    /// Phase 2: Agent team composition, capability matching, vetting.
35    Selection,
36    /// Phase 3: Agent specialization, workflow calibration, business plan.
37    Preparation,
38    /// Phase 4: Newco launch, tenant provisioning, active operations.
39    Execution,
40    /// Phase 5: Heartbeat monitoring, budget enforcement, performance.
41    Sustainment,
42    /// Phase 6: Scale, pivot, franchise replication, or orderly close.
43    Transition,
44}
45
46impl OperationalPhase {
47    /// Valid forward and backward transitions from this phase.
48    #[must_use]
49    pub fn valid_transitions(self) -> &'static [OperationalPhase] {
50        use OperationalPhase::*;
51        match self {
52            Assessment => &[Selection],
53            Selection => &[Preparation, Assessment],
54            Preparation => &[Execution, Selection],
55            Execution => &[Sustainment],
56            Sustainment => &[Transition, Execution],
57            Transition => &[Assessment],
58        }
59    }
60
61    /// Check whether a transition to `target` is permitted.
62    #[must_use]
63    pub fn can_transition_to(self, target: OperationalPhase) -> bool {
64        self.valid_transitions().contains(&target)
65    }
66
67    /// Attempt a phase transition, returning an error if invalid.
68    pub fn transition(self, target: OperationalPhase) -> Result<OperationalPhase> {
69        if self.can_transition_to(target) {
70            Ok(target)
71        } else {
72            Err(CatapultError::InvalidPhaseTransition {
73                from: self,
74                to: target,
75            })
76        }
77    }
78
79    /// Minimum ODA slots required to enter this phase.
80    #[must_use]
81    pub fn min_roster(self) -> &'static [OdaSlot] {
82        use OperationalPhase::*;
83        match self {
84            Assessment => &[],
85            Selection => &OdaSlot::FOUNDERS,
86            Preparation => &[
87                OdaSlot::HrPeopleOps1,
88                OdaSlot::DeepResearcher,
89                OdaSlot::VentureCommander,
90                OdaSlot::ProcessArchitect,
91            ],
92            Execution | Sustainment => &OdaSlot::ALL,
93            Transition => &[OdaSlot::VentureCommander, OdaSlot::OperationsDeputy],
94        }
95    }
96
97    /// All six phases in lifecycle order.
98    pub const ALL: [OperationalPhase; 6] = [
99        Self::Assessment,
100        Self::Selection,
101        Self::Preparation,
102        Self::Execution,
103        Self::Sustainment,
104        Self::Transition,
105    ];
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn happy_path_forward() {
114        use OperationalPhase::*;
115        let path = [
116            Assessment,
117            Selection,
118            Preparation,
119            Execution,
120            Sustainment,
121            Transition,
122        ];
123        for w in path.windows(2) {
124            assert!(
125                w[0].can_transition_to(w[1]),
126                "{:?} should transition to {:?}",
127                w[0],
128                w[1]
129            );
130        }
131    }
132
133    #[test]
134    fn backward_transitions() {
135        use OperationalPhase::*;
136        // Selection can loop back to Assessment
137        assert!(Selection.can_transition_to(Assessment));
138        // Preparation can loop back to Selection
139        assert!(Preparation.can_transition_to(Selection));
140        // Sustainment can re-enter Execution
141        assert!(Sustainment.can_transition_to(Execution));
142        // Transition can restart the cycle
143        assert!(Transition.can_transition_to(Assessment));
144    }
145
146    #[test]
147    fn invalid_transitions() {
148        use OperationalPhase::*;
149        assert!(!Assessment.can_transition_to(Execution));
150        assert!(!Assessment.can_transition_to(Transition));
151        assert!(!Execution.can_transition_to(Assessment));
152        assert!(!Sustainment.can_transition_to(Selection));
153    }
154
155    #[test]
156    fn transition_result() {
157        use OperationalPhase::*;
158        assert_eq!(Assessment.transition(Selection).unwrap(), Selection);
159        assert!(Assessment.transition(Execution).is_err());
160    }
161
162    #[test]
163    fn min_roster_assessment_empty() {
164        assert!(OperationalPhase::Assessment.min_roster().is_empty());
165    }
166
167    #[test]
168    fn min_roster_selection_founders() {
169        let roster = OperationalPhase::Selection.min_roster();
170        assert_eq!(roster.len(), 2);
171        assert!(roster.contains(&OdaSlot::HrPeopleOps1));
172        assert!(roster.contains(&OdaSlot::DeepResearcher));
173    }
174
175    #[test]
176    fn min_roster_execution_full() {
177        assert_eq!(OperationalPhase::Execution.min_roster().len(), 12);
178    }
179
180    #[test]
181    fn min_roster_transition_minimal() {
182        let roster = OperationalPhase::Transition.min_roster();
183        assert_eq!(roster.len(), 2);
184        assert!(roster.contains(&OdaSlot::VentureCommander));
185        assert!(roster.contains(&OdaSlot::OperationsDeputy));
186    }
187
188    #[test]
189    fn all_phases_count() {
190        assert_eq!(OperationalPhase::ALL.len(), 6);
191    }
192
193    #[test]
194    fn serde_roundtrip() {
195        for phase in &OperationalPhase::ALL {
196            let j = serde_json::to_string(phase).unwrap();
197            let rt: OperationalPhase = serde_json::from_str(&j).unwrap();
198            assert_eq!(&rt, phase);
199        }
200    }
201}