Skip to content

Commit 0ed345f

Browse files
committed
feat(mission): implement child-entity gates for mission transitions
- Add evaluate_mission_transition to domain gating logic - Enforce child-entity requirements in activate, achieve, and verify mission lifecycle methods - Consolidate business rules into the domain layer - Update mission lifecycle tests to verify the new gates
1 parent 663a097 commit 0ed345f

4 files changed

Lines changed: 141 additions & 28 deletions

File tree

.keel/missions/VDZsowb2k/LOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@
66
## 2026-03-11T11:26:07
77

88
Restored board coherence by formally decomposing and completing the diagnostics epics. Resolved legacy bearing warnings and ensured all entities are in a healthy, terminal state.
9+
10+
## 2026-03-11T12:46:50
11+
12+
Implemented formal domain gates for mission transitions. Missions now require at least one child entity (epic, bearing, or ADR) before they can be activated, achieved, or verified. Consolidated gating logic into the domain layer and updated MissionLifecycleService to enforce these rules. Fixed all board health issues and verified with 774 tests passing.

src/application/mission_lifecycle.rs

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
use anyhow::{Result, anyhow};
44
use std::path::Path;
55

6+
use crate::domain::state_machine::{
7+
evaluate_mission_transition, format_gate_error, MissionTransition,
8+
};
69
use crate::domain::transitions::mission::{execute, mission_transitions};
710
use crate::infrastructure::loader::load_board;
811
use crate::infrastructure::markdown_sections::{extract_section, replace_section};
@@ -44,6 +47,11 @@ impl MissionLifecycleService {
4447
let board = load_board(board_dir)?;
4548
let mission = board.require_mission(id)?;
4649

50+
let problems = evaluate_mission_transition(&board, mission, MissionTransition::Achieve);
51+
if !problems.is_empty() {
52+
return Err(anyhow!(format_gate_error("mission", "achieve", &problems)));
53+
}
54+
4755
// Verify goals before achievement
4856
let charter_path = mission.path.parent().unwrap().join("CHARTER.md");
4957
let charter_content = std::fs::read_to_string(&charter_path).unwrap_or_default();
@@ -68,14 +76,6 @@ impl MissionLifecycleService {
6876
return Err(anyhow!("Mission has unmet board goals"));
6977
}
7078

71-
// Verify child entities
72-
if board.mission_child_count(id) == 0 {
73-
return Err(anyhow!(
74-
"Cannot achieve mission {}. At least one child entity (epic, bearing, or ADR) is required.",
75-
id
76-
));
77-
}
78-
7979
// Verify log entries
8080
let log_path = mission.path.parent().unwrap().join("LOG.md");
8181
let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
@@ -94,18 +94,14 @@ impl MissionLifecycleService {
9494

9595
pub fn verify(board_dir: &Path, id: &str) -> Result<()> {
9696
let board = load_board(board_dir)?;
97-
let _mission = board.require_mission(id)?;
97+
let mission = board.require_mission(id)?;
9898

99-
// Verify child entities
100-
if board.mission_child_count(id) == 0 {
101-
return Err(anyhow!(
102-
"Cannot verify mission {}. At least one child entity (epic, bearing, or ADR) is required.",
103-
id
104-
));
99+
let problems = evaluate_mission_transition(&board, mission, MissionTransition::Verify);
100+
if !problems.is_empty() {
101+
return Err(anyhow!(format_gate_error("mission", "verify", &problems)));
105102
}
106103

107104
// Verify log entries
108-
let mission = board.require_mission(id)?;
109105
let log_path = mission.path.parent().unwrap().join("LOG.md");
110106
let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
111107
let (_, entries) = parse_log_entries(&log_content);
@@ -131,6 +127,11 @@ impl MissionLifecycleService {
131127
let board = load_board(board_dir)?;
132128
let mission = board.require_mission(id)?;
133129

130+
let problems = evaluate_mission_transition(&board, mission, MissionTransition::Activate);
131+
if !problems.is_empty() {
132+
return Err(anyhow!(format_gate_error("mission", "activate", &problems)));
133+
}
134+
134135
let charter_path = mission.path.parent().unwrap().join("CHARTER.md");
135136
let charter_content = std::fs::read_to_string(&charter_path).unwrap_or_default();
136137
let goals = charter::parse_mission_goals(&charter_content);
@@ -142,14 +143,6 @@ impl MissionLifecycleService {
142143
));
143144
}
144145

145-
// Verify child entities - missions need at least one child to be actionable
146-
if board.mission_child_count(id) == 0 {
147-
return Err(anyhow!(
148-
"Cannot activate mission {}. At least one child entity (epic, bearing, or ADR) is required before activation.",
149-
id
150-
));
151-
}
152-
153146
execute(board_dir, id, &mission_transitions::ACTIVATE)?;
154147
println!("Activated mission: {}", id);
155148
Ok(())
@@ -401,7 +394,7 @@ mod tests {
401394

402395
let res = MissionLifecycleService::activate(temp.path(), "M1");
403396
assert!(res.is_err());
404-
assert!(res.unwrap_err().to_string().contains("child entity"));
397+
assert!(res.unwrap_err().to_string().contains("no child entities"));
405398
}
406399

407400
#[test]
@@ -412,6 +405,7 @@ mod tests {
412405
.title("Mission One")
413406
.status("defining"),
414407
)
408+
.epic(TestEpic::new("E1").mission("M1"))
415409
.build();
416410

417411
// Empty CHARTER.md (no goals)
@@ -457,7 +451,7 @@ mod tests {
457451
// Should fail because no children
458452
let res = MissionLifecycleService::achieve(temp.path(), "M1");
459453
assert!(res.is_err());
460-
assert!(res.unwrap_err().to_string().contains("one child entity"));
454+
assert!(res.unwrap_err().to_string().contains("no child entities"));
461455

462456
// Add a child (epic)
463457
let temp = TestBoardBuilder::new()

src/domain/state_machine/gating.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::fs;
99

1010
use crate::domain::model::{Board, Story, StoryState, Voyage};
1111
use crate::domain::state_machine::invariants;
12+
use crate::domain::state_machine::mission::MissionTransition;
1213
use crate::domain::state_machine::story::StoryTransition;
1314
use crate::domain::state_machine::voyage::VoyageTransition;
1415
use crate::infrastructure::validation::{CheckId, Problem, Severity};
@@ -61,6 +62,119 @@ pub fn problem_blocks_runtime(problem: &Problem, strict: bool) -> bool {
6162
strict || matches!(problem.severity, Severity::Error)
6263
}
6364

65+
/// Return gate problems for mission lifecycle transition checks.
66+
pub fn evaluate_mission_transition(
67+
board: &Board,
68+
mission: &crate::domain::model::Mission,
69+
transition: MissionTransition,
70+
) -> Vec<Problem> {
71+
match transition {
72+
MissionTransition::Activate => evaluate_mission_activation(board, mission),
73+
MissionTransition::Achieve => evaluate_mission_achieve(board, mission),
74+
MissionTransition::Verify => evaluate_mission_verification(board, mission),
75+
_ => Vec::new(),
76+
}
77+
}
78+
79+
fn evaluate_mission_activation(
80+
board: &Board,
81+
mission: &crate::domain::model::Mission,
82+
) -> Vec<Problem> {
83+
let mut problems = Vec::new();
84+
85+
let child_count = board
86+
.epics
87+
.values()
88+
.filter(|e| e.frontmatter.mission.as_deref() == Some(mission.id()))
89+
.count()
90+
+ board
91+
.bearings
92+
.values()
93+
.filter(|b| b.frontmatter.mission.as_deref() == Some(mission.id()))
94+
.count()
95+
+ board
96+
.adrs
97+
.values()
98+
.filter(|a| a.frontmatter.mission.as_deref() == Some(mission.id()))
99+
.count();
100+
101+
if child_count == 0 {
102+
problems.push(Problem::error(
103+
mission.path.clone(),
104+
format!("Mission {} cannot be activated: no child entities found. Link at least one epic, bearing, or ADR first.", mission.id())
105+
));
106+
}
107+
108+
problems
109+
}
110+
111+
fn evaluate_mission_achieve(
112+
board: &Board,
113+
mission: &crate::domain::model::Mission,
114+
) -> Vec<Problem> {
115+
let mut problems = Vec::new();
116+
117+
let child_count = board
118+
.epics
119+
.values()
120+
.filter(|e| e.frontmatter.mission.as_deref() == Some(mission.id()))
121+
.count()
122+
+ board
123+
.bearings
124+
.values()
125+
.filter(|b| b.frontmatter.mission.as_deref() == Some(mission.id()))
126+
.count()
127+
+ board
128+
.adrs
129+
.values()
130+
.filter(|a| a.frontmatter.mission.as_deref() == Some(mission.id()))
131+
.count();
132+
133+
if child_count == 0 {
134+
problems.push(Problem::error(
135+
mission.path.clone(),
136+
format!(
137+
"Mission {} cannot be achieved: no child entities found.",
138+
mission.id()
139+
),
140+
));
141+
}
142+
143+
problems
144+
}
145+
146+
fn evaluate_mission_verification(
147+
board: &Board,
148+
mission: &crate::domain::model::Mission,
149+
) -> Vec<Problem> {
150+
let mut problems = Vec::new();
151+
152+
let child_count = board
153+
.epics
154+
.values()
155+
.filter(|e| e.frontmatter.mission.as_deref() == Some(mission.id()))
156+
.count()
157+
+ board
158+
.bearings
159+
.values()
160+
.filter(|b| b.frontmatter.mission.as_deref() == Some(mission.id()))
161+
.count()
162+
+ board
163+
.adrs
164+
.values()
165+
.filter(|a| a.frontmatter.mission.as_deref() == Some(mission.id()))
166+
.count();
167+
168+
if child_count == 0 {
169+
problems.push(Problem::error(
170+
mission.path.clone(),
171+
format!("Mission {} cannot be verified: no child entities found.", mission.id())
172+
));
173+
}
174+
175+
problems
176+
}
177+
64178
/// Return runtime-blocking gate problems for a story transition.
65179
pub fn evaluate_story_transition(
66180
board: &Board,

src/domain/state_machine/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ pub use flow::{FlowState, FlowStateMachine, QueueDepths};
3434
pub use formatting::format_transition_error;
3535
#[allow(unused_imports)]
3636
pub use gating::{
37-
VoyageCompletionPolicy, evaluate_epic_done, evaluate_story_transition,
38-
evaluate_voyage_completion, evaluate_voyage_transition, format_gate_error,
37+
VoyageCompletionPolicy, evaluate_epic_done, evaluate_mission_transition,
38+
evaluate_story_transition, evaluate_voyage_completion, evaluate_voyage_transition,
39+
format_gate_error,
3940
};
4041
#[allow(unused_imports)]
4142
pub use mission::{MissionStateMachine, MissionStatus, MissionTransition};

0 commit comments

Comments
 (0)