Skip to main content

dpm_engine/
change.rs

1use crate::EngineError;
2use dpm_model::Plan;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::{BTreeMap, BTreeSet};
6
7mod applicability;
8mod external;
9pub use applicability::ApplicabilityChange;
10mod independence;
11mod patch;
12pub(crate) use independence::refuse_own_relaxation;
13pub use independence::{EdgeRelaxation, RelaxedConstraint};
14pub(crate) use patch::authorize;
15pub(crate) use patch::canonical;
16pub use patch::{apply_plan_change, json_equivalent, patch, plan_change};
17mod policy;
18mod protection;
19pub use protection::protected_work;
20mod replacement;
21pub use replacement::AffectedWork;
22mod revert;
23pub use revert::propose_revert;
24
25/// One entity-level semantic difference, including explicit additions and removals.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct EntityChange {
29    /// Domain collection such as `external_references`, `dependencies` per edge, or the workspace-wide
30    /// `workspace` / `links` / `calendars`.
31    pub collection: String,
32    /// Stable entity identity, absent for workspace-wide values.
33    pub id: Option<String>,
34    /// Changed field names; empty for an added or removed entity.
35    pub fields: Vec<String>,
36    /// Full previous entity; null means addition.
37    pub before: Value,
38    /// Full proposed entity; null means deletion.
39    pub after: Value,
40}
41
42/// Validated, read-only review of a proposed plan against one revision.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct ChangePreview {
45    /// Revision that must still be current when applying the proposal.
46    pub base_revision: u64,
47    /// Deterministically ordered semantic differences.
48    pub changes: Vec<EntityChange>,
49    /// Work to reassess because a decision in its context is replaced; never a gate.
50    pub affected_work: Vec<AffectedWork>,
51    /// Work entering or leaving the active graph, including in-flight work a choice change affects.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub applicability_changes: Vec<ApplicabilityChange>,
54}
55
56/// Validate a proposed plan without changing state, lifecycle, evidence or decision outcomes.
57///
58/// New tasks must be Proposed. Existing execution and its prerequisite/context basis are protected;
59/// review is a prerequisite to applying a change, not permission for agents to approve scope.
60pub fn propose_change(current: &Plan, proposed: &Plan) -> Result<ChangePreview, EngineError> {
61    current.validate()?;
62    proposed.validate()?;
63    protection::validate(current, proposed)?;
64    let changes = differences(current, proposed)?;
65    Ok(ChangePreview {
66        base_revision: current.revision,
67        changes,
68        affected_work: replacement::affected_work(current, proposed),
69        applicability_changes: applicability::changes(current, proposed),
70    })
71}
72
73/// Collections serialized as maps keyed by stable identity, compared entity by entity.
74const KEYED_COLLECTIONS: [&str; 7] = [
75    "projects",
76    "assets",
77    "work_items",
78    "requirements",
79    "decisions",
80    "risks",
81    "external_references",
82];
83
84/// The entity-level differences between two plans in canonical order, without validation.
85///
86/// Artifacts, the revision and the format version are not compared: protection refuses a proposal
87/// that changes them, so they never appear in a reviewed change.
88/// A member of a serialized entity, or null where it is absent, as a new or removed entity reads.
89fn field<'a>(value: &'a Value, key: &str) -> &'a Value {
90    static ABSENT: Value = Value::Null;
91    value.get(key).unwrap_or(&ABSENT)
92}
93
94pub(crate) fn differences(
95    current: &Plan,
96    proposed: &Plan,
97) -> Result<Vec<EntityChange>, EngineError> {
98    let before = serde_json::to_value(current)?;
99    let after = serde_json::to_value(proposed)?;
100    let mut changes = Vec::new();
101    append(
102        &mut changes,
103        "workspace",
104        None,
105        field(&before, "workspace"),
106        field(&after, "workspace"),
107    );
108    for collection in KEYED_COLLECTIONS {
109        let (old, new) = (field(&before, collection), field(&after, collection));
110        let ids: BTreeSet<_> = current_keys(old).chain(current_keys(new)).collect();
111        for id in ids {
112            append(
113                &mut changes,
114                collection,
115                Some(id.clone()),
116                field(old, &id),
117                field(new, &id),
118            );
119        }
120    }
121    let before_edges = edges_by_id(current)?;
122    let after_edges = edges_by_id(proposed)?;
123    for id in before_edges
124        .keys()
125        .chain(after_edges.keys())
126        .collect::<BTreeSet<_>>()
127    {
128        append(
129            &mut changes,
130            "dependencies",
131            Some(id.clone()),
132            before_edges.get(id).unwrap_or(&Value::Null),
133            after_edges.get(id).unwrap_or(&Value::Null),
134        );
135    }
136    append(
137        &mut changes,
138        "links",
139        None,
140        &sorted_links(current)?,
141        &sorted_links(proposed)?,
142    );
143    append(
144        &mut changes,
145        "calendars",
146        None,
147        field(&before, "calendars"),
148        field(&after, "calendars"),
149    );
150    Ok(changes)
151}
152
153fn current_keys(value: &Value) -> impl Iterator<Item = String> + '_ {
154    value
155        .as_object()
156        .into_iter()
157        .flat_map(|map| map.keys().cloned())
158}
159
160fn append(
161    changes: &mut Vec<EntityChange>,
162    collection: &str,
163    id: Option<String>,
164    before: &Value,
165    after: &Value,
166) {
167    if before == after {
168        return;
169    }
170    let fields = if before.is_object() && after.is_object() {
171        current_keys(before)
172            .chain(current_keys(after))
173            .collect::<BTreeSet<_>>()
174            .into_iter()
175            .filter(|field| before[field] != after[field])
176            .collect()
177    } else {
178        Vec::new()
179    };
180    changes.push(EntityChange {
181        collection: collection.into(),
182        id,
183        fields,
184        before: before.clone(),
185        after: after.clone(),
186    });
187}
188
189fn edges_by_id(plan: &Plan) -> Result<BTreeMap<String, Value>, EngineError> {
190    plan.dependencies
191        .iter()
192        .map(|edge| Ok((edge.id.to_string(), serde_json::to_value(edge)?)))
193        .collect()
194}
195
196fn sorted_links(plan: &Plan) -> Result<Value, EngineError> {
197    let mut links = plan.links.clone();
198    links.sort_by_key(|l| (l.kind, l.source, l.target));
199    Ok(serde_json::to_value(links)?)
200}
201
202pub(crate) fn invalid(entity: impl ToString, reason: &str) -> EngineError {
203    EngineError::InvalidCommand {
204        entity: entity.to_string(),
205        reason: reason.into(),
206    }
207}
208
209#[cfg(test)]
210mod tests;