Skip to main content

dpm_engine/change/
patch.rs

1//! Reviewed plan changes recorded as entity deltas rather than whole proposed plans.
2//!
3//! The operation log stores only what a review changed: each [`EntityChange`] names one entity
4//! with its full `before` and `after` values. Replaying the change checks every `before` against
5//! the state it is applied to, so a delta never silently lands on a state it was not computed for.
6//!
7//! Artifacts are not part of the delta. Evidence is attributed to its author and changes only
8//! through the evidence command, so a proposal that alters artifacts is refused before a delta is
9//! built, and the delta leaves the recorded artifacts exactly as they were.
10//!
11//! The difference identifies edges by ID and compares links as a set, so it cannot carry their
12//! order. A patch keeps retained edges and links in their current order, edits edges in place and
13//! appends additions in the order of the changes. Order leaves every schedule number unchanged but
14//! is the display order of lists built from these collections, such as critical activities, the
15//! dependencies `explain` lists and the exported JSON.
16
17use super::{EntityChange, invalid};
18use crate::{Command, EngineError, Operation};
19use chrono::{DateTime, Utc};
20use dpm_model::{ActorId, ActorKind, OperationId, Plan};
21use serde_json::{Map, Value};
22use std::collections::BTreeSet;
23
24/// Refuse what no reviewed plan change may do, before any difference is computed.
25pub(crate) fn authorize(actor: &ActorId, reason: &str) -> Result<(), EngineError> {
26    if reason.trim().is_empty() {
27        return Err(invalid("plan change", "reason must not be empty"));
28    }
29    if actor.kind == ActorKind::Agent {
30        return Err(EngineError::ActorNotAllowed {
31            actor: actor.clone(),
32            action: "approve plan scope",
33        });
34    }
35    Ok(())
36}
37
38/// Turn a reviewed full proposal into the delta command the operation log records.
39///
40/// The proposal is validated exactly as [`crate::propose_change`] validates it, which also refuses
41/// what a delta cannot carry: a stale revision, another format, or changed artifacts.
42pub fn plan_change(
43    current: &Plan,
44    proposed: &Plan,
45    reason: impl Into<String>,
46) -> Result<Command, EngineError> {
47    let preview = crate::propose_change(current, proposed)?;
48    Ok(Command::ApplyChange {
49        changes: preview.changes,
50        reason: reason.into(),
51    })
52}
53
54/// Apply a reviewed full proposal as one delta operation.
55///
56/// The actor and reason are checked before the proposal is compared, as the command itself checks
57/// them first, so every refusal is the one applying the full proposal would produce.
58pub fn apply_plan_change(
59    plan: &mut Plan,
60    actor: ActorId,
61    proposed: &Plan,
62    reason: impl Into<String>,
63    timestamp: DateTime<Utc>,
64    id: OperationId,
65) -> Result<Operation, EngineError> {
66    let reason = reason.into();
67    authorize(&actor, &reason)?;
68    let command = plan_change(plan, proposed, reason)?;
69    crate::apply_command(plan, actor, command, timestamp, id)
70}
71
72/// The canonical difference `changes` amount to against `current`, when they carry exactly its data: the form
73/// a review produced, which the log records whatever number encoding a client sent back.
74pub(crate) fn canonical(current: &Plan, changes: &[EntityChange]) -> Option<Vec<EntityChange>> {
75    let candidate = patch(current, changes).ok()?;
76    let canonical = super::differences(current, &candidate).ok()?;
77    let matches = canonical.len() == changes.len()
78        && canonical.iter().zip(changes).all(|(a, b)| {
79            a.collection == b.collection
80                && a.id == b.id
81                && a.fields == b.fields
82                && same(&a.before, &b.before)
83                && same(&a.after, &b.after)
84        });
85    matches.then_some(canonical)
86}
87
88/// Apply entity changes to `current` without any policy check.
89///
90/// Every change's `before` must equal the entity in `current` (null for an addition), otherwise
91/// the delta was computed against another state and [`EngineError::StaleChange`] is returned.
92/// Each entity may appear once. Validation and protection are the caller's responsibility.
93pub fn patch(current: &Plan, changes: &[EntityChange]) -> Result<Plan, EngineError> {
94    let mut value = serde_json::to_value(current)?;
95    let mut seen = BTreeSet::new();
96    for change in changes {
97        if !seen.insert((change.collection.as_str(), change.id.as_deref())) {
98            return Err(invalid(
99                describe(change),
100                "a plan change names each entity at most once",
101            ));
102        }
103        apply_one(&mut value, current, change)?;
104    }
105    Ok(serde_json::from_value(value)?)
106}
107
108fn apply_one(value: &mut Value, current: &Plan, change: &EntityChange) -> Result<(), EngineError> {
109    let root = value
110        .as_object_mut()
111        .ok_or_else(|| invalid("plan", "a plan serializes as an object"))?;
112    match (change.collection.as_str(), change.id.as_deref()) {
113        ("workspace", None) => {
114            let slot = root.entry("workspace").or_insert(Value::Null);
115            replace(slot, change)
116        }
117        ("links", None) => patch_links(root, current, change),
118        ("calendars", None) => {
119            let found = root.get("calendars").cloned().unwrap_or(Value::Null);
120            fresh(&found, change)?;
121            if change.after.is_null() {
122                root.remove("calendars");
123            } else {
124                root.insert("calendars".into(), change.after.clone());
125            }
126            Ok(())
127        }
128        ("dependencies", Some(id)) => patch_edges(root, id, change),
129        (collection, Some(id)) if super::KEYED_COLLECTIONS.contains(&collection) => {
130            let map = root
131                .entry(collection)
132                .or_insert_with(|| Value::Object(Map::new()))
133                .as_object_mut()
134                .ok_or_else(|| invalid(collection, "a keyed collection serializes as an object"))?;
135            let found = map.get(id).cloned().unwrap_or(Value::Null);
136            fresh(&found, change)?;
137            if change.after.is_null() {
138                map.remove(id);
139            } else {
140                map.insert(id.to_owned(), change.after.clone());
141            }
142            Ok(())
143        }
144        _ => Err(invalid(describe(change), "unknown plan change target")),
145    }
146}
147
148fn replace(slot: &mut Value, change: &EntityChange) -> Result<(), EngineError> {
149    fresh(slot, change)?;
150    if change.after.is_null() {
151        return Err(invalid(describe(change), "a workspace cannot be removed"));
152    }
153    *slot = change.after.clone();
154    Ok(())
155}
156
157fn patch_edges(
158    root: &mut Map<String, Value>,
159    id: &str,
160    change: &EntityChange,
161) -> Result<(), EngineError> {
162    let edges = root
163        .entry("dependencies")
164        .or_insert_with(|| Value::Array(Vec::new()))
165        .as_array_mut()
166        .ok_or_else(|| invalid("dependencies", "dependencies serialize as an array"))?;
167    let position = edges
168        .iter()
169        .position(|edge| edge.get("id").and_then(Value::as_str) == Some(id));
170    let found = position
171        .and_then(|index| edges.get(index))
172        .cloned()
173        .unwrap_or(Value::Null);
174    fresh(&found, change)?;
175    match (position, change.after.is_null()) {
176        (Some(index), true) => {
177            edges.remove(index);
178        }
179        (Some(index), false) => {
180            if let Some(slot) = edges.get_mut(index) {
181                *slot = change.after.clone();
182            }
183        }
184        (None, false) => edges.push(change.after.clone()),
185        (None, true) => {}
186    }
187    Ok(())
188}
189
190/// Links are compared as the sorted set the difference records; retained links keep their order.
191fn patch_links(
192    root: &mut Map<String, Value>,
193    current: &Plan,
194    change: &EntityChange,
195) -> Result<(), EngineError> {
196    fresh(&super::sorted_links(current)?, change)?;
197    let wanted = change.after.as_array().cloned().unwrap_or_default();
198    let existing = root
199        .get("links")
200        .and_then(Value::as_array)
201        .cloned()
202        .unwrap_or_default();
203    let mut links: Vec<Value> = existing
204        .into_iter()
205        .filter(|link| wanted.contains(link))
206        .collect();
207    let added: Vec<Value> = wanted
208        .into_iter()
209        .filter(|link| !links.contains(link))
210        .collect();
211    links.extend(added);
212    root.insert("links".into(), Value::Array(links));
213    Ok(())
214}
215
216fn fresh(found: &Value, change: &EntityChange) -> Result<(), EngineError> {
217    if same(found, &change.before) {
218        return Ok(());
219    }
220    Err(EngineError::StaleChange {
221        collection: change.collection.clone(),
222        id: change.id.clone(),
223    })
224}
225
226/// Whether two JSON values carry the same data. Numbers compare by value: a client that decodes and re-encodes
227/// a reviewed change may write `2.0` as `2`, and that is the state it was reviewed against, not a stale one.
228pub fn json_equivalent(left: &Value, right: &Value) -> bool {
229    same(left, right)
230}
231
232fn same(left: &Value, right: &Value) -> bool {
233    match (left, right) {
234        (Value::Number(a), Value::Number(b)) => match (a.as_f64(), b.as_f64()) {
235            (Some(x), Some(y)) if a.is_f64() || b.is_f64() => x == y,
236            _ => a == b,
237        },
238        (Value::Array(a), Value::Array(b)) => {
239            a.len() == b.len() && a.iter().zip(b).all(|(x, y)| same(x, y))
240        }
241        (Value::Object(a), Value::Object(b)) => {
242            a.len() == b.len()
243                && a.iter()
244                    .all(|(key, x)| b.get(key).is_some_and(|y| same(x, y)))
245        }
246        _ => left == right,
247    }
248}
249
250fn describe(change: &EntityChange) -> String {
251    match &change.id {
252        Some(id) => format!("{} {id}", change.collection),
253        None => change.collection.clone(),
254    }
255}
256
257#[cfg(test)]
258mod tests;