Skip to main content

dpm_engine/change/
replacement.rs

1use super::invalid;
2use crate::EngineError;
3use crate::context::{applicable_decisions, lineage};
4use dpm_model::{Decision, DecisionStatus, Key, Plan, WorkItemId, WorkKind, WorkStatus};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeSet;
7
8/// Work whose execution context contains a replaced decision or its replacement.
9///
10/// Listing it is a prompt for review, not a gate or a lifecycle change.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct AffectedWork {
13    /// Decision being superseded.
14    pub decision: Key,
15    /// Decision recording the new choice.
16    pub replacement: Key,
17    /// Stable identity of the affected work.
18    pub work: WorkItemId,
19    /// Human-readable key of the affected work.
20    pub key: Key,
21    /// Kind of the affected work.
22    pub kind: WorkKind,
23    /// Current lifecycle, so reviewers see started work that may need reassessment.
24    pub status: WorkStatus,
25}
26
27/// Existing decisions change only by superseding a Decided choice with a linked replacement, or by
28/// refining an Open question without loosening it.
29///
30/// An Open gate is resolved only by `decide`; superseding it here would bypass that gate.
31pub(super) fn validate(
32    current: &Plan,
33    proposed: &Plan,
34    locked: &BTreeSet<WorkItemId>,
35) -> Result<(), EngineError> {
36    for decision in current.decisions.values() {
37        match proposed.decisions.get(&decision.id) {
38            Some(next) if next == decision => {}
39            Some(next) if refines_open(decision, next) => {
40                let added: BTreeSet<_> = next.blocks.difference(&decision.blocks).collect();
41                if added.iter().any(|id| locked.contains(id)) {
42                    return Err(invalid(
43                        &decision.key,
44                        "cannot add a gate to the basis of existing execution",
45                    ));
46                }
47            }
48            Some(next) if supersedes_only(decision, next) => {
49                if !proposed
50                    .decisions
51                    .values()
52                    .any(|d| d.supersedes == Some(decision.id))
53                {
54                    return Err(invalid(
55                        &decision.key,
56                        "a superseded decision needs a linked replacement",
57                    ));
58                }
59            }
60            _ => {
61                return Err(invalid(
62                    &decision.key,
63                    "existing decisions change only when a linked replacement supersedes a Decided choice; use decide for an open outcome",
64                ));
65            }
66        }
67    }
68    for decision in proposed
69        .decisions
70        .values()
71        .filter(|d| !current.decisions.contains_key(&d.id))
72    {
73        match decision.supersedes {
74            None => new_question(decision, locked)?,
75            Some(target) => replacement(current, decision, target)?,
76        }
77    }
78    Ok(())
79}
80
81/// A sharper question, more context or more gates on a still-Open decision; outcome, options and
82/// existing gates stay as they are, so nothing a reviewer or executor relies on is loosened.
83fn refines_open(before: &Decision, after: &Decision) -> bool {
84    before.status == DecisionStatus::Open
85        && after.blocks.is_superset(&before.blocks)
86        && Decision {
87            question: before.question.clone(),
88            related_work: before.related_work.clone(),
89            blocks: before.blocks.clone(),
90            ..after.clone()
91        } == *before
92}
93
94fn supersedes_only(before: &Decision, after: &Decision) -> bool {
95    before.status == DecisionStatus::Decided
96        && after.status == DecisionStatus::Superseded
97        && Decision {
98            status: before.status,
99            ..after.clone()
100        } == *before
101}
102
103fn new_question(decision: &Decision, locked: &BTreeSet<WorkItemId>) -> Result<(), EngineError> {
104    if decision.status != DecisionStatus::Open || decision.outcome.is_some() {
105        return Err(invalid(
106            &decision.key,
107            "new decisions must be open unless they replace a Decided choice",
108        ));
109    }
110    if !decision.blocks.is_disjoint(locked) {
111        return Err(invalid(
112            &decision.key,
113            "cannot add a gate to the basis of existing execution",
114        ));
115    }
116    Ok(())
117}
118
119fn replacement(
120    current: &Plan,
121    decision: &Decision,
122    target: dpm_model::DecisionId,
123) -> Result<(), EngineError> {
124    if current
125        .decisions
126        .get(&target)
127        .is_none_or(|d| d.status != DecisionStatus::Decided)
128    {
129        return Err(invalid(
130            &decision.key,
131            "a replacement must supersede an existing Decided choice",
132        ));
133    }
134    if decision.status != DecisionStatus::Decided
135        || decision
136            .rationale
137            .as_ref()
138            .is_none_or(|r| r.trim().is_empty())
139    {
140        return Err(invalid(
141            &decision.key,
142            "a replacement records a Decided outcome with its rationale",
143        ));
144    }
145    if !decision.blocks.is_empty() {
146        return Err(invalid(
147            &decision.key,
148            "a replacement is context; it cannot add execution gates",
149        ));
150    }
151    Ok(())
152}
153
154/// Work whose explain context includes either side of each replacement in the proposal.
155pub(super) fn affected_work(current: &Plan, proposed: &Plan) -> Vec<AffectedWork> {
156    let mut affected = Vec::new();
157    for new in proposed
158        .decisions
159        .values()
160        .filter(|d| !current.decisions.contains_key(&d.id))
161    {
162        let Some(old) = new.supersedes.and_then(|id| proposed.decisions.get(&id)) else {
163            continue;
164        };
165        for work in proposed.work_items.values() {
166            let ids: BTreeSet<_> = lineage(proposed, work).iter().map(|w| w.id).collect();
167            // Uses explain's own selection, so every item whose context changes is listed.
168            let context = applicable_decisions(proposed, &ids);
169            if context.contains(&old.id) || context.contains(&new.id) {
170                affected.push(AffectedWork {
171                    decision: old.key.clone(),
172                    replacement: new.key.clone(),
173                    work: work.id,
174                    key: work.key.clone(),
175                    kind: work.kind,
176                    status: work.execution.status,
177                });
178            }
179        }
180    }
181    affected.sort_by(|a, b| (&a.decision, &a.key).cmp(&(&b.decision, &b.key)));
182    affected
183}
184
185#[cfg(test)]
186mod tests;