Skip to main content

dpm_engine/change/
protection.rs

1use 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
76/// Observations and decision times are attributed records, so review cannot author them.
77fn 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/// Work whose authored contract and position are fixed by execution, including its ancestors
120/// and transitive prerequisites. Adapters may use these anchors without duplicating review policy.
121#[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}