Skip to main content

dpm_engine/
execution.rs

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
6/// Apply one validated semantic command atomically and return its audit operation.
7///
8/// Errors leave every field, including the revision, unchanged. Revisions wrap at `u64::MAX`.
9/// The caller supplies the operation identity so replaying a recorded operation reproduces it
10/// exactly; the engine itself holds no source of randomness.
11pub 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    // A reviewed change comes back in whatever encoding its client used; the log records the canonical form.
21    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    // Only the canonical difference is recorded, so the log reads the same as `plan diff` and a
134    // reordered or padded change list cannot replay to the same state under another description.
135    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    // Applying the review is what makes a replacement's choice, so, as with decide, the command
142    // records its own time; the proposal cannot carry one (see propose_change).
143    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
181/// Refuse a reviewer that wrote evidence attached to the task: its review would judge its own
182/// contribution. Only the task's own artifacts count; evidence on a containing package is context.
183fn 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    // Evidence authors cannot review the task, so only the executor attaches evidence to it.
221    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    // Planning sources come from reviewed plans; labelling evidence as one would hide its author.
228    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
249/// Plan-authored context (design documents, requirements sources) rather than execution evidence.
250fn 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;