1use crate::{Command, EngineError, EntityChange, Operation};
2use chrono::{DateTime, Utc};
3use dpm_model::{ActorId, Artifact, DecisionStatus, OperationId, Plan, WorkItem, WorkItemId};
4use lifecycle::{block, claim, report_progress, start, submit, unblock, verify};
5
6pub fn apply_command(
12 plan: &mut Plan,
13 actor: ActorId,
14 command: Command,
15 timestamp: DateTime<Utc>,
16 id: OperationId,
17) -> Result<Operation, EngineError> {
18 plan.validate()?;
19 nonempty(&actor.to_string(), "actor name", &actor.name)?;
20 let command = match command {
22 Command::ApplyChange { changes, reason } => Command::ApplyChange {
23 changes: crate::change::canonical(plan, &changes).unwrap_or(changes),
24 reason,
25 },
26 Command::RevertChange {
27 operation,
28 changes,
29 reason,
30 } => Command::RevertChange {
31 operation,
32 changes: crate::change::canonical(plan, &changes).unwrap_or(changes),
33 reason,
34 },
35 other => other,
36 };
37 let mut candidate = plan.clone();
38 execute(&mut candidate, &actor, &command, timestamp)?;
39 candidate.revision = plan.revision.wrapping_add(1);
40 candidate.validate()?;
41 let operation = Operation {
42 id,
43 base_revision: plan.revision,
44 resulting_revision: candidate.revision,
45 actor,
46 timestamp,
47 command,
48 };
49 *plan = candidate;
50 Ok(operation)
51}
52
53fn execute(
54 plan: &mut Plan,
55 actor: &ActorId,
56 command: &Command,
57 at: DateTime<Utc>,
58) -> Result<(), EngineError> {
59 match command {
60 Command::ApplyChange { changes, reason }
61 | Command::RevertChange {
62 changes, reason, ..
63 } => apply_change(plan, actor, changes, reason, at),
64 Command::RatifyContract { work } => review::ratify(plan, actor, *work),
65 Command::Reject { work, reason } => review::reject(plan, actor, *work, reason, at),
66 Command::Claim { work } => claim(plan, actor, *work, at),
67 Command::Release { work, reason } => ownership::release(plan, actor, *work, reason, at),
68 Command::Handoff {
69 work,
70 from,
71 to,
72 reason,
73 } => ownership::handoff(
74 plan,
75 actor,
76 ownership::HandoffRequest {
77 work: *work,
78 from,
79 to,
80 reason,
81 },
82 at,
83 ),
84 Command::Start { work, occurred_at } => start(plan, actor, *work, *occurred_at, at),
85 Command::Block { work, reason } => block(plan, actor, *work, reason),
86 Command::Unblock { work } => unblock(plan, actor, *work),
87 Command::ReportProgress { work, percent, .. } => {
88 report_progress(plan, actor, *work, *percent)
89 }
90 Command::Submit {
91 work, occurred_at, ..
92 } => submit(plan, actor, *work, *occurred_at, at),
93 Command::Verify {
94 work, occurred_at, ..
95 } => verify(plan, actor, *work, *occurred_at, at),
96 Command::AttachArtifact { work, artifact } => attach(plan, actor, *work, artifact),
97 Command::LinkExternal(request) => tracking::link(plan, actor, request, at),
98 Command::UnlinkExternal { work, reference } => tracking::unlink(plan, *work, *reference),
99 Command::WaiveDependency { dependency, reason } => {
100 waiver::waive(plan, actor, *dependency, reason, at)
101 }
102 Command::RestoreDependency { dependency, reason } => {
103 waiver::restore(plan, actor, *dependency, reason)
104 }
105 Command::RevalidateBasis {
106 work,
107 dependency,
108 attempt,
109 reason,
110 } => revalidation::revalidate(plan, actor, *work, *dependency, *attempt, reason, at),
111 Command::Decide { decision, outcome } => {
112 choice::decide(plan, actor, *decision, outcome, at)
113 }
114 }
115}
116
117fn apply_change(
118 plan: &mut Plan,
119 actor: &ActorId,
120 changes: &[EntityChange],
121 reason: &str,
122 at: DateTime<Utc>,
123) -> Result<(), EngineError> {
124 crate::change::authorize(actor, reason)?;
125 let mut candidate = crate::patch(plan, changes)?;
126 let preview = crate::propose_change(plan, &candidate)?;
127 if preview.changes.is_empty() {
128 return Err(EngineError::InvalidCommand {
129 entity: "plan".into(),
130 reason: "proposal has no semantic changes".into(),
131 });
132 }
133 if preview.changes != changes {
136 return Err(EngineError::InvalidCommand {
137 entity: "plan".into(),
138 reason: "changes must be the canonical difference plan_change computes".into(),
139 });
140 }
141 for decision in candidate
144 .decisions
145 .values_mut()
146 .filter(|d| !plan.decisions.contains_key(&d.id) && d.status != DecisionStatus::Open)
147 {
148 decision.resolved_at = Some(at);
149 }
150 crate::change::refuse_own_relaxation(plan, &candidate, actor, at)?;
151 *plan = candidate;
152 Ok(())
153}
154
155fn task_mut(plan: &mut Plan, work: WorkItemId) -> Result<&mut WorkItem, EngineError> {
156 let item = plan
157 .work_items
158 .get_mut(&work)
159 .ok_or(EngineError::MissingWorkItem(work))?;
160 if !item.is_executable() {
161 return Err(EngineError::NotATask {
162 work,
163 kind: item.kind,
164 });
165 }
166 Ok(item)
167}
168
169fn owns(item: &WorkItem, actor: &ActorId) -> Result<(), EngineError> {
170 if let Some(owner) = &item.execution.owner
171 && owner != actor
172 {
173 return Err(EngineError::OwnedByAnother {
174 work: item.id,
175 owner: owner.clone(),
176 });
177 }
178 Ok(())
179}
180
181fn refuse_evidence_author(
184 plan: &Plan,
185 actor: &ActorId,
186 work: WorkItemId,
187 action: &'static str,
188) -> Result<(), EngineError> {
189 let item = plan
190 .work_items
191 .get(&work)
192 .ok_or(EngineError::MissingWorkItem(work))?;
193 let authored = item
194 .execution
195 .artifact_ids
196 .iter()
197 .filter_map(|id| plan.artifacts.get(id))
198 .filter(|artifact| !is_planning_source(artifact))
199 .any(|artifact| artifact.created_by == *actor);
200 if authored {
201 return Err(EngineError::ActorNotAllowed {
202 actor: actor.clone(),
203 action,
204 });
205 }
206 Ok(())
207}
208
209fn attach(
210 plan: &mut Plan,
211 actor: &ActorId,
212 work: WorkItemId,
213 artifact: &Artifact,
214) -> Result<(), EngineError> {
215 let item = plan
216 .work_items
217 .get(&work)
218 .ok_or(EngineError::MissingWorkItem(work))?;
219 owns(item, actor)?;
220 if item.is_executable() && item.execution.owner.is_none() {
222 return Err(EngineError::ActorNotAllowed {
223 actor: actor.clone(),
224 action: "attach evidence to an unclaimed task; claim it first",
225 });
226 }
227 if is_planning_source(artifact) {
229 return Err(EngineError::InvalidCommand {
230 entity: artifact.id.to_string(),
231 reason: "planning sources arrive through reviewed plan changes, not as evidence".into(),
232 });
233 }
234 if artifact.created_by != *actor || plan.artifacts.contains_key(&artifact.id) {
235 return Err(EngineError::InvalidCommand {
236 entity: artifact.id.to_string(),
237 reason: "artifact creator must match actor and id must be new".into(),
238 });
239 }
240 let item = plan
241 .work_items
242 .get_mut(&work)
243 .ok_or(EngineError::MissingWorkItem(work))?;
244 item.execution.artifact_ids.insert(artifact.id);
245 plan.artifacts.insert(artifact.id, artifact.clone());
246 Ok(())
247}
248
249fn is_planning_source(artifact: &Artifact) -> bool {
251 artifact
252 .metadata
253 .get("role")
254 .is_some_and(|role| role == PLANNING_SOURCE_ROLE)
255}
256
257const PLANNING_SOURCE_ROLE: &str = "planning_source";
258
259fn nonempty(entity: &str, field: &str, value: &str) -> Result<(), EngineError> {
260 if value.trim().is_empty() {
261 return Err(EngineError::InvalidCommand {
262 entity: entity.into(),
263 reason: format!("{field} must not be empty"),
264 });
265 }
266 Ok(())
267}
268
269mod choice;
270mod lifecycle;
271mod occurrence;
272mod ownership;
273mod revalidation;
274mod review;
275mod tracking;
276mod waiver;
277
278#[cfg(test)]
279mod tests;