Skip to main content

exo_catapult/
oda.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 Detachment Alpha — team structure adapted for business.
18//!
19//! Each newco is staffed by a 12-agent ODA following Army Special Operations
20//! doctrine. Two founding agents (HR and Deep Researcher) recruit the
21//! remaining ten through a governed assessment-and-selection pipeline.
22
23use serde::{Deserialize, Serialize};
24
25/// Military Occupational Specialty codes adapted for Catapult business operations.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
27pub enum MosCode {
28    /// 18A — Detachment Commander.
29    Alpha18A,
30    /// 180A — Assistant Detachment Commander.
31    Alpha180A,
32    /// 18Z — Operations Sergeant.
33    Zulu18Z,
34    /// 18F — Intelligence Sergeant.
35    Fox18F,
36    /// 18B — Weapons Sergeant (Growth).
37    Bravo18B,
38    /// 18E — Communications Sergeant.
39    Echo18E,
40    /// 18D — Medical Sergeant (HR/People).
41    Delta18D,
42    /// 18C — Engineering Sergeant.
43    Charlie18C,
44}
45
46/// Named slot in the ODA roster, mapping FM 3-05 positions to business roles.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
48pub enum OdaSlot {
49    /// 18A — Mission authority, strategic decisions.
50    VentureCommander,
51    /// 180A — Operational continuity, backup command.
52    OperationsDeputy,
53    /// 18Z — Workflow orchestration, training.
54    ProcessArchitect,
55    /// 18F — Market/competitive intelligence. **Founding agent.**
56    DeepResearcher,
57    /// 18B — Market attack, revenue generation (slot 1).
58    GrowthEngineer1,
59    /// 18B — Market attack, revenue generation (slot 2).
60    GrowthEngineer2,
61    /// 18E — Brand, stakeholder, PR (slot 1).
62    Communications1,
63    /// 18E — Brand, stakeholder, PR (slot 2).
64    Communications2,
65    /// 18D — Team health, talent, assessment. **Founding agent.**
66    HrPeopleOps1,
67    /// 18D — Team health, culture (slot 2).
68    HrPeopleOps2,
69    /// 18C — Product/service building (slot 1).
70    PlatformEngineer1,
71    /// 18C — Product/service building (slot 2).
72    PlatformEngineer2,
73}
74
75impl OdaSlot {
76    /// All 12 ODA slots in hierarchy order.
77    pub const ALL: [OdaSlot; 12] = [
78        Self::VentureCommander,
79        Self::OperationsDeputy,
80        Self::ProcessArchitect,
81        Self::DeepResearcher,
82        Self::GrowthEngineer1,
83        Self::GrowthEngineer2,
84        Self::Communications1,
85        Self::Communications2,
86        Self::HrPeopleOps1,
87        Self::HrPeopleOps2,
88        Self::PlatformEngineer1,
89        Self::PlatformEngineer2,
90    ];
91
92    /// The two founding agents that bootstrap every newco.
93    pub const FOUNDERS: [OdaSlot; 2] = [Self::HrPeopleOps1, Self::DeepResearcher];
94
95    /// Return the FM 3-05 MOS code for this slot.
96    #[must_use]
97    pub const fn mos_code(&self) -> MosCode {
98        match self {
99            Self::VentureCommander => MosCode::Alpha18A,
100            Self::OperationsDeputy => MosCode::Alpha180A,
101            Self::ProcessArchitect => MosCode::Zulu18Z,
102            Self::DeepResearcher => MosCode::Fox18F,
103            Self::GrowthEngineer1 | Self::GrowthEngineer2 => MosCode::Bravo18B,
104            Self::Communications1 | Self::Communications2 => MosCode::Echo18E,
105            Self::HrPeopleOps1 | Self::HrPeopleOps2 => MosCode::Delta18D,
106            Self::PlatformEngineer1 | Self::PlatformEngineer2 => MosCode::Charlie18C,
107        }
108    }
109
110    /// Whether this slot is one of the two founding agents.
111    #[must_use]
112    pub const fn is_founding(&self) -> bool {
113        matches!(self, Self::HrPeopleOps1 | Self::DeepResearcher)
114    }
115
116    /// Human-readable display name for the slot.
117    #[must_use]
118    pub const fn display_name(&self) -> &'static str {
119        match self {
120            Self::VentureCommander => "Venture Commander",
121            Self::OperationsDeputy => "Operations Deputy",
122            Self::ProcessArchitect => "Process Architect",
123            Self::DeepResearcher => "Deep Researcher",
124            Self::GrowthEngineer1 => "Growth Engineer 1",
125            Self::GrowthEngineer2 => "Growth Engineer 2",
126            Self::Communications1 => "Communications 1",
127            Self::Communications2 => "Communications 2",
128            Self::HrPeopleOps1 => "HR/People Ops 1",
129            Self::HrPeopleOps2 => "HR/People Ops 2",
130            Self::PlatformEngineer1 => "Platform Engineer 1",
131            Self::PlatformEngineer2 => "Platform Engineer 2",
132        }
133    }
134
135    /// Stable lowercase label for identifiers, APIs, and error messages.
136    #[must_use]
137    pub const fn slug(&self) -> &'static str {
138        match self {
139            Self::VentureCommander => "venturecommander",
140            Self::OperationsDeputy => "operationsdeputy",
141            Self::ProcessArchitect => "processarchitect",
142            Self::DeepResearcher => "deepresearcher",
143            Self::GrowthEngineer1 => "growthengineer1",
144            Self::GrowthEngineer2 => "growthengineer2",
145            Self::Communications1 => "communications1",
146            Self::Communications2 => "communications2",
147            Self::HrPeopleOps1 => "hrpeopleops1",
148            Self::HrPeopleOps2 => "hrpeopleops2",
149            Self::PlatformEngineer1 => "platformengineer1",
150            Self::PlatformEngineer2 => "platformengineer2",
151        }
152    }
153
154    /// Authority depth in the ODA hierarchy (0 = highest authority).
155    #[must_use]
156    pub const fn authority_depth(&self) -> u32 {
157        match self {
158            Self::VentureCommander => 0,
159            Self::OperationsDeputy => 1,
160            Self::ProcessArchitect => 2,
161            Self::DeepResearcher => 2,
162            Self::GrowthEngineer1
163            | Self::GrowthEngineer2
164            | Self::Communications1
165            | Self::Communications2
166            | Self::HrPeopleOps1
167            | Self::HrPeopleOps2
168            | Self::PlatformEngineer1
169            | Self::PlatformEngineer2 => 3,
170        }
171    }
172}
173
174impl std::fmt::Display for OdaSlot {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        f.write_str(self.slug())
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn all_slots_count() {
186        assert_eq!(OdaSlot::ALL.len(), 12);
187    }
188
189    #[test]
190    fn founders() {
191        assert_eq!(OdaSlot::FOUNDERS.len(), 2);
192        for f in &OdaSlot::FOUNDERS {
193            assert!(f.is_founding());
194        }
195        // Non-founders should not be founding
196        assert!(!OdaSlot::VentureCommander.is_founding());
197        assert!(!OdaSlot::PlatformEngineer1.is_founding());
198    }
199
200    #[test]
201    fn mos_codes() {
202        assert_eq!(OdaSlot::VentureCommander.mos_code(), MosCode::Alpha18A);
203        assert_eq!(OdaSlot::DeepResearcher.mos_code(), MosCode::Fox18F);
204        assert_eq!(OdaSlot::HrPeopleOps1.mos_code(), MosCode::Delta18D);
205        assert_eq!(OdaSlot::HrPeopleOps2.mos_code(), MosCode::Delta18D);
206        assert_eq!(OdaSlot::GrowthEngineer1.mos_code(), MosCode::Bravo18B);
207        assert_eq!(OdaSlot::GrowthEngineer2.mos_code(), MosCode::Bravo18B);
208    }
209
210    #[test]
211    fn authority_depth_hierarchy() {
212        assert_eq!(OdaSlot::VentureCommander.authority_depth(), 0);
213        assert_eq!(OdaSlot::OperationsDeputy.authority_depth(), 1);
214        assert_eq!(OdaSlot::ProcessArchitect.authority_depth(), 2);
215        assert_eq!(OdaSlot::PlatformEngineer1.authority_depth(), 3);
216    }
217
218    #[test]
219    fn display_names() {
220        for slot in &OdaSlot::ALL {
221            assert!(!slot.display_name().is_empty());
222        }
223    }
224
225    #[test]
226    fn stable_slot_slugs() {
227        assert_eq!(OdaSlot::VentureCommander.slug(), "venturecommander");
228        assert_eq!(OdaSlot::HrPeopleOps1.slug(), "hrpeopleops1");
229        assert_eq!(OdaSlot::PlatformEngineer2.to_string(), "platformengineer2");
230    }
231
232    #[test]
233    fn slot_serde_roundtrip() {
234        for slot in &OdaSlot::ALL {
235            let j = serde_json::to_string(slot).unwrap();
236            let rt: OdaSlot = serde_json::from_str(&j).unwrap();
237            assert_eq!(&rt, slot);
238        }
239    }
240
241    #[test]
242    fn mos_serde_roundtrip() {
243        let codes = [
244            MosCode::Alpha18A,
245            MosCode::Alpha180A,
246            MosCode::Zulu18Z,
247            MosCode::Fox18F,
248            MosCode::Bravo18B,
249            MosCode::Echo18E,
250            MosCode::Delta18D,
251            MosCode::Charlie18C,
252        ];
253        for code in &codes {
254            let j = serde_json::to_string(code).unwrap();
255            let rt: MosCode = serde_json::from_str(&j).unwrap();
256            assert_eq!(&rt, code);
257        }
258    }
259}