dpm_engine/change/
protection.rs1use super::invalid;
2use crate::EngineError;
3use dpm_model::{Plan, WorkItem, WorkItemId, WorkKind, WorkStatus};
4use std::collections::BTreeSet;
5
6pub(super) fn validate(current: &Plan, proposed: &Plan) -> Result<(), EngineError> {
7 if current.revision != proposed.revision {
8 return Err(EngineError::RevisionConflict {
9 expected: proposed.revision,
10 actual: current.revision,
11 });
12 }
13 if current.workspace.id != proposed.workspace.id
14 || current.format_version != proposed.format_version
15 {
16 return Err(invalid(
17 current.workspace.id,
18 "plan changes must preserve workspace identity and format",
19 ));
20 }
21 if current.artifacts != proposed.artifacts {
22 return Err(invalid(
23 "artifacts",
24 "use the evidence command; plan changes cannot alter evidence",
25 ));
26 }
27 super::policy::protect_waivers(current, proposed)?;
28 let locked = protected_work(current);
29 for work in current.work_items.values() {
30 let next = proposed.work_items.get(&work.id);
31 if locked.contains(&work.id) {
32 if next != Some(work) {
33 return Err(invalid(
34 &work.key,
35 "execution and its prerequisite contracts are protected; create follow-up work",
36 ));
37 }
38 protect_context(current, proposed, work)?;
39 let old_edges: Vec<_> = current
40 .dependencies
41 .iter()
42 .filter(|d| d.successor == work.id)
43 .collect();
44 let new_edges: Vec<_> = proposed
45 .dependencies
46 .iter()
47 .filter(|d| d.successor == work.id)
48 .collect();
49 if old_edges.len() != new_edges.len()
50 || old_edges.iter().any(|d| !new_edges.contains(d))
51 {
52 return Err(invalid(
53 &work.key,
54 "cannot change the prerequisite basis of execution",
55 ));
56 }
57 } else if next.is_none() && !work.execution.artifact_ids.is_empty() {
58 return Err(invalid(
59 &work.key,
60 "cannot delete work with attached evidence",
61 ));
62 } else if let Some(next) = next
63 && (work.key != next.key || work.kind != next.kind || !same_execution(work, next))
64 {
65 return Err(invalid(
66 &work.key,
67 "preserve keys, kinds and execution fields; use lifecycle commands",
68 ));
69 }
70 }
71 validate_new_work(current, proposed)?;
72 protect_observations(current, proposed)?;
73 super::replacement::validate(current, proposed, &locked)
74}
75
76fn protect_observations(current: &Plan, proposed: &Plan) -> Result<(), EngineError> {
78 for (id, decision) in &proposed.decisions {
79 let recorded = current.decisions.get(id).and_then(|d| d.resolved_at);
80 if decision.resolved_at != recorded {
81 return Err(invalid(
82 &decision.key,
83 "resolution times are recorded by decide; plan changes cannot add or rewrite them",
84 ));
85 }
86 }
87 super::external::protect_references(current, proposed)
88}
89
90fn validate_new_work(current: &Plan, proposed: &Plan) -> Result<(), EngineError> {
91 for work in proposed
92 .work_items
93 .values()
94 .filter(|w| !current.work_items.contains_key(&w.id))
95 {
96 let expected = if work.kind == WorkKind::Task {
97 WorkStatus::Proposed
98 } else {
99 WorkStatus::Planned
100 };
101 let pristine = dpm_model::ExecutionRecord {
102 status: expected,
103 ..Default::default()
104 };
105 if work.execution != pristine {
106 return Err(invalid(
107 &work.key,
108 "new tasks must be Proposed; new work cannot carry execution or evidence",
109 ));
110 }
111 }
112 Ok(())
113}
114
115fn same_execution(a: &WorkItem, b: &WorkItem) -> bool {
116 a.execution == b.execution
117}
118
119#[must_use]
122pub fn protected_work(plan: &Plan) -> BTreeSet<WorkItemId> {
123 let mut locked: BTreeSet<_> = plan
124 .work_items
125 .values()
126 .filter(|w| {
127 !matches!(
128 w.execution.status,
129 WorkStatus::Proposed | WorkStatus::Planned
130 ) || w.execution.owner.is_some()
131 || w.execution.last_rejection.is_some()
132 })
133 .map(|w| w.id)
134 .collect();
135 loop {
136 let mut next = locked.clone();
137 for id in &locked {
138 next.extend(plan.work_items.get(id).and_then(|work| work.parent));
139 next.extend(
140 plan.dependencies
141 .iter()
142 .filter(|d| d.successor == *id)
143 .map(|d| d.predecessor),
144 );
145 }
146 if next == locked {
147 return locked;
148 }
149 locked = next;
150 }
151}
152
153fn protect_context(current: &Plan, proposed: &Plan, work: &WorkItem) -> Result<(), EngineError> {
154 let mut project = Some(work.project);
155 while let Some(id) = project {
156 if current.projects.get(&id) != proposed.projects.get(&id) {
157 return Err(invalid(
158 &work.key,
159 "cannot change the project basis of execution",
160 ));
161 }
162 project = current.projects.get(&id).and_then(|p| p.parent);
163 }
164 if work
165 .contract
166 .requirement_ids
167 .iter()
168 .any(|id| current.requirements.get(id) != proposed.requirements.get(id))
169 || work
170 .contract
171 .assets
172 .iter()
173 .any(|r| current.assets.get(&r.asset) != proposed.assets.get(&r.asset))
174 {
175 return Err(invalid(
176 &work.key,
177 "cannot change the requirement or asset basis of execution",
178 ));
179 }
180 Ok(())
181}