Skip to main content

planarian_exec/
derive.rs

1//! Deriving policy facts (I14): from the evaluated generations and the unit
2//! definitions, never from a proposal.
3
4use crate::host::changed_units;
5use planarian_policy::Tables;
6use planarian_schema::spec::dependents;
7use planarian_schema::*;
8use planarian_state::Canonical;
9use std::collections::{BTreeMap, BTreeSet};
10
11pub const CORE_UNITS: [&str; 2] = ["planariad", "planarian-recovery"];
12
13pub struct DeriveInput<'a> {
14    pub transition: &'a Transition,
15    pub defs: &'a UnitDefs,
16    pub base: &'a EvaluatedGeneration,
17    pub candidate: Option<&'a EvaluatedGeneration>,
18    pub activation: Option<&'a ActivationPlan>,
19    pub base_overrides: &'a OverrideSet,
20    pub candidate_overrides: Option<&'a OverrideSet>,
21    pub caller: Actor,
22    pub tables: &'a Tables,
23    pub canonical: &'a Canonical,
24    /// The observation this derivation is made against: the one passed to
25    /// it, current at proposal, approval and before Activating (§9 step 6).
26    pub state: &'a SystemState,
27}
28
29pub fn manifest_of(e: &EvaluatedGeneration) -> Manifest {
30    Manifest { units: e.units.clone(), user_units: e.user_units.clone() }
31}
32
33/// §11 as amended in #22 (owner decision, Nov 2): "A unit that has already
34/// failed (observed `Failed`, or not running while it is desired running,
35/// when the facts are derived) contributes no disruption of its own". Read
36/// from `s`, the observation this derivation is made against, never from an
37/// incident or from evidence past its `stale_after` (`status` ignores that).
38/// `Unknown` counts as running (§10: "`Unknown`, never `Failed`"), so a probe
39/// that timed out, errored or was abandoned buys no exemption; so does
40/// Degraded, Transitioning, an override, or a unit not desired Running.
41fn already_failed(s: &SystemState, defs: &UnitDefs, u: &UnitId) -> bool {
42    let Some(d) = defs.get(u) else { return false };
43    d.desired.status == DesiredStatus::Running
44        && s.expected(u, defs)
45        && matches!(s.status(u, defs), UnitStatus::Failed(_) | UnitStatus::Stopped | UnitStatus::Absent)
46}
47
48/// §11, §12d: whether `u`'s traffic is held now, in the observation `s`
49/// (lane-daemon's ledger `holds`, reconciled with `/run/planarian/hold/`).
50/// §11 waives the RestartUnit row's escalations because "its traffic is
51/// held", so the waiver reads the hold itself, not the footprint that leads
52/// to it: §12d fails closed toward applying a hold, and with this a missing
53/// or stale footprint buys a waiver only once traffic is in fact blocked.
54/// `SystemState.holds`: the journal's holds and the host's, their union.
55fn hold_in_place(s: &SystemState, u: &UnitId) -> bool {
56    s.holds.contains(u)
57}
58
59fn unit_facts<'a>(
60    units: impl IntoIterator<Item = &'a UnitId>,
61    defs: &UnitDefs,
62    services: &BTreeMap<String, Sensitivities>,
63    already_failed: impl Fn(&UnitId) -> bool,
64) -> (Sensitivities, Disruptions) {
65    let mut sens = Sensitivities::default();
66    // C10 (§11): every kind any unit carries, not the `max` of them. The
67    // floors are not ordered, so a fold would drop one.
68    let mut disruption = Disruptions::default();
69    for u in units {
70        // Fail closed (§11 sensitivity completeness, B-5): a unit with no
71        // sensitivity entry, or none in the manifest at all, is not taken
72        // as Routine. External: nothing is known about what it touches.
73        let unknown = Sensitivities::of(&[Sensitivity::External]);
74        let empty = |s: &Sensitivities| !(s.routine || s.security || s.user_data || s.external || s.core);
75        match defs.get(u) {
76            Some(d) => {
77                sens = sens.union(if empty(&d.sensitivity) { &unknown } else { &d.sensitivity });
78                // What an already-failed unit provides is already lost:
79                // restarting it restores rather than costs. Its sensitivity
80                // still counts.
81                if !already_failed(u) {
82                    disruption.insert(d.restart_disruption);
83                }
84            }
85            // A user-scope unit runs with that user's rights only; an
86            // undeclared one stays Routine (deviations.md).
87            None if matches!(u.scope, Scope::User { .. }) => {}
88            // A system service NixOS defines but the manifest does not
89            // declare has the shipped tables' entry (the export's `services`).
90            None => match services.get(&u.name) {
91                Some(t) if !empty(t) => sens = sens.union(t),
92                _ => sens = sens.union(&unknown),
93            },
94        }
95        if CORE_UNITS.contains(&u.name.as_str()) {
96            sens.insert(Sensitivity::Core);
97        }
98    }
99    (sens, disruption)
100}
101
102pub fn diff(base: &OverrideSet, cand: &OverrideSet) -> ConfigDiff {
103    let mut d = ConfigDiff::default();
104    for (k, v) in &cand.assignments {
105        if base.assignments.get(k) != Some(v) {
106            d.options.insert(k.clone(), (base.assignments.get(k).cloned(), Some(v.clone())));
107        }
108    }
109    for (k, v) in &base.assignments {
110        if !cand.assignments.contains_key(k) {
111            d.options.insert(k.clone(), (Some(v.clone()), None));
112        }
113    }
114    d
115}
116
117/// The options two evaluated generations set differently (§11, §16).
118pub fn option_diff(base: &BTreeMap<String, Value>, cand: &BTreeMap<String, Value>) -> ConfigDiff {
119    let mut d = ConfigDiff::default();
120    for (k, v) in cand {
121        if base.get(k) != Some(v) {
122            d.options.insert(k.clone(), (base.get(k).cloned(), Some(v.clone())));
123        }
124    }
125    for (k, v) in base {
126        if !cand.contains_key(k) {
127            d.options.insert(k.clone(), (Some(v.clone()), None));
128        }
129    }
130    d
131}
132
133/// A generation tried to define a canonical probe differently from the core
134/// package, or dropped one from a unit that is still present (I10).
135fn touches_canonical(base: &Manifest, cand: &Manifest, canonical: &Canonical) -> bool {
136    for (id, d) in cand.units.iter().chain(cand.user_units.iter().map(|u| (&u.id, u))) {
137        let Some(core) = canonical.get(&id.name) else { continue };
138        for p in &d.probes {
139            if let Some(c) = core.iter().find(|c| c.id == p.id)
140                && c != p
141            {
142                return true;
143            }
144        }
145        // Present in the base with a canonical probe declared, and the candidate
146        // drops that declaration while keeping the unit.
147        if let Some(b) = base.units.get(id) {
148            let declared = |x: &UnitDef| x.probes.iter().filter(|p| core.iter().any(|c| c.id == p.id)).count();
149            if declared(b) > declared(d) {
150                return true;
151            }
152        }
153    }
154    false
155}
156
157/// C19 (§9, §11 ApplyChange row): whether the candidate's manifest differs
158/// from the base's in any part of any entry (`desired`, `edges`,
159/// `sensitivity`, `restart_disruption`, the user probes, `synonyms`, and the
160/// exported `systemd_restart`, `startup_grace`, `runtime_repair`,
161/// `stops_are_faults`), in user-scope entries as in system ones, or adds or
162/// removes an entry. Canonical probes are left out: they come from the core
163/// package, and redefining one is `touches_canonical`. So are the fields the
164/// module derives (`holds`; [`crate::host::declared`]).
165fn touches_manifest(base: &Manifest, cand: &Manifest, diff: &ConfigDiff) -> bool {
166    if diff.options.keys().any(|k| k.starts_with("services.planarian.")) {
167        return true;
168    }
169    // An entry as the manifest declares it: every declared field, the user
170    // probes only.
171    let entry = |d: &UnitDef| {
172        let mut d = crate::host::declared(d);
173        d.probes.retain(|p| p.origin == ProbeOrigin::User);
174        d
175    };
176    let entries = |m: &Manifest| -> (BTreeMap<UnitId, UnitDef>, BTreeMap<UnitId, UnitDef>) {
177        (
178            m.units.iter().map(|(id, d)| (id.clone(), entry(d))).collect(),
179            m.user_units.iter().map(|d| (d.id.clone(), entry(d))).collect(),
180        )
181    };
182    entries(base) != entries(cand)
183}
184
185fn changes_desired(base: &Manifest, cand: &Manifest) -> bool {
186    base.units.keys().ne(cand.units.keys())
187        || base.units.iter().any(|(id, b)| cand.units.get(id).is_some_and(|c| c.desired != b.desired))
188}
189
190/// What a generation transition takes away (§11 Rollback row, §16): units
191/// present now and absent from the target, and units its activation stops,
192/// sorted, with their sensitivity. A removed user template's definition is in
193/// the base manifest, not in `defs` (which holds the per-session instances), so
194/// it is looked up there: an unknown unit must not count as Routine (§11
195/// sensitivity completeness, fail closed).
196pub(crate) fn removed_units(
197    base_m: &Manifest,
198    cand_m: &Manifest,
199    activation: Option<&ActivationPlan>,
200    defs: &UnitDefs,
201    services: &BTreeMap<String, Sensitivities>,
202) -> (Vec<UnitId>, Vec<Sensitivities>, Sensitivities) {
203    let mut removed: Vec<UnitId> = base_m.units.keys().filter(|u| !cand_m.units.contains_key(*u)).cloned().collect();
204    removed.extend(
205        base_m.user_units.iter().filter(|b| !cand_m.user_units.iter().any(|c| c.id == b.id)).map(|b| b.id.clone()),
206    );
207    if let Some(a) = activation {
208        removed.extend(a.stop.iter().cloned());
209    }
210    let mut known = defs.clone();
211    for b in &base_m.user_units {
212        known.entry(b.id.clone()).or_insert_with(|| b.clone());
213    }
214    let sens = unit_facts(&removed, &known, services, |_| false).0;
215    // Each one's own, from the same lookup as the decision's (§16: what the
216    // approval shows is what was decided on).
217    let removed = unit_set(removed);
218    let each = removed.iter().map(|u| unit_facts(std::iter::once(u), &known, services, |_| false).0).collect();
219    (removed, each, sens)
220}
221
222pub fn derive(i: &DeriveInput<'_>) -> DerivedFacts {
223    let base_m = manifest_of(i.base);
224    let one = |u: &UnitId| {
225        let mut s = dependents(u, i.defs);
226        s.insert(u.clone());
227        s
228    };
229    let mut f = DerivedFacts {
230        affected_units: Vec::new(),
231        sensitivity: Sensitivities::of(&[Sensitivity::Routine]),
232        disruption: Disruptions::default(),
233        recovery: Recovery::Idempotent,
234        touches_manifest: false,
235        touches_canonical_probe: false,
236        changes_desired: false,
237        touches_core: false,
238        below_security_floor: false,
239        restores_unattributed_stop: false,
240        restarts_failed_unit: false,
241        removes_sensitivity: Default::default(),
242        removes: Default::default(),
243        removes_each: Default::default(),
244        restarts_held_unit: false,
245        other_sessions_affected: false,
246        affected_sessions: Vec::new(),
247        caller: i.caller.clone(),
248        config_diff: None,
249    };
250    let unit_op = |f: &mut DerivedFacts, u: &UnitId, recovery: Recovery| {
251        f.affected_units = unit_set(one(u));
252        let (s, d) = unit_facts(&f.affected_units, i.defs, &i.base.services, |u| already_failed(i.state, i.defs, u));
253        f.sensitivity = s;
254        f.disruption = d;
255        f.recovery = recovery;
256        f.touches_core = f.sensitivity.contains(Sensitivity::Core);
257    };
258    match i.transition {
259        Transition::RestartUnit(u) => {
260            unit_op(&mut f, u, Recovery::Idempotent);
261            // §11 (owner decision, Nov 2): restarting an already-failed unit
262            // waives the RestartUnit row's External escalation (policy reads
263            // this; Security still escalates). The same reading as the
264            // disruption rule: current observation, Unknown is not failed.
265            f.restarts_failed_unit = already_failed(i.state, i.defs, u);
266            // §11, §12d (owner decision, Nov 2): a failed unit whose traffic
267            // is held: its restart only ends an outage, and waives both
268            // escalations of the RestartUnit row.
269            f.restarts_held_unit = f.restarts_failed_unit && hold_in_place(i.state, u);
270        }
271        Transition::StopUnit(u) | Transition::StopAndSuppress { unit: u, .. } => {
272            unit_op(&mut f, u, Recovery::Revertible)
273        }
274        // Equivalent effects, equivalent facts (§11, rule 20, R03): an
275        // override that stops, removes or detaches a unit does what a stop
276        // does to it and its dependents, so it carries the same facts as
277        // StopAndSuppress. Only LeaveAlone changes nothing on the machine.
278        Transition::SetOverride(s) => match s.desired {
279            OverrideDesired::LeaveAlone => {
280                unit_op(&mut f, &s.unit, Recovery::Idempotent);
281                f.affected_units = vec![s.unit.clone()];
282                f.disruption = Disruptions::default();
283            }
284            OverrideDesired::Stopped | OverrideDesired::Absent | OverrideDesired::Detected => {
285                unit_op(&mut f, &s.unit, Recovery::Revertible)
286            }
287        },
288        Transition::LiftOverride(u) => {
289            unit_op(&mut f, u, Recovery::Idempotent);
290            f.affected_units = vec![u.clone()];
291            f.disruption = Disruptions::default();
292        }
293        Transition::BindDriver { device, .. } => unit_op(&mut f, device, Recovery::Revertible),
294        Transition::SetRuntimeOption { path, .. } => {
295            f.sensitivity = i.tables.option_sensitivity(&format!("runtime.{path}"));
296            // §11, §12a (owner decision, Nov 2): Wi-Fi selection's
297            // ConnectivityLoss is its providing unit's (NetworkManager's),
298            // and none while that unit has already failed. The providing
299            // units are those whose manifest entry names this option as its
300            // `runtime_repair`; with none declared, the loss stays (fail
301            // closed), and every one of them must have failed.
302            let providers: Vec<&UnitId> =
303                i.defs.values().filter(|d| d.runtime_repair.as_deref() == Some(path.as_str())).map(|d| &d.id).collect();
304            let provider_failed = !providers.is_empty() && providers.iter().all(|u| already_failed(i.state, i.defs, u));
305            f.disruption = if path == "wifi.network" && !provider_failed {
306                Disruptions::of(Disruption::ConnectivityLoss)
307            } else {
308                Disruptions::default()
309            };
310            f.recovery = Recovery::Revertible;
311        }
312        Transition::CollectGarbage { .. } | Transition::Resolve { .. } => {}
313        Transition::RestoreSnapshot(_) => {
314            f.recovery = Recovery::NonReversible;
315            f.sensitivity = Sensitivities::of(&[Sensitivity::UserData]);
316        }
317        Transition::ApplyChange { .. }
318        | Transition::SwitchGeneration(_)
319        | Transition::Revert { .. }
320        | Transition::Rollback
321        | Transition::LoadModel(_) => {
322            f.recovery = Recovery::Revertible;
323            let Some(cand) = i.candidate else { return f };
324            let cand_m = manifest_of(cand);
325            let mut affected = changed_units(&base_m, &cand_m);
326            if let Some(a) = i.activation {
327                affected.extend(a.start.iter().chain(&a.stop).chain(&a.restart).chain(&a.reload).cloned());
328            }
329            let mut config = i.candidate_overrides.map(|c| diff(i.base_overrides, c)).unwrap_or_default();
330            // A generation built outside Planarian (known_good often is) has no
331            // recorded override, so for a switch, revert or rollback the
332            // evaluated option sets are compared too: an option it would
333            // restore is in the diff even without an override (§11 Rollback
334            // row, I11, §16; owner decision, Nov 2).
335            if matches!(
336                i.transition,
337                Transition::SwitchGeneration(_) | Transition::Revert { .. } | Transition::Rollback
338            ) {
339                for (k, v) in option_diff(&i.base.options, &cand.options).options {
340                    if !config.options.contains_key(&k) {
341                        config.options.insert(k, v);
342                    }
343                }
344            }
345            let mut all_defs = i.defs.clone();
346            for (k, v) in &cand.units {
347                all_defs.entry(k.clone()).or_insert_with(|| v.clone());
348            }
349            let mut services = i.base.services.clone();
350            services.extend(cand.services.iter().map(|(k, v)| (k.clone(), *v)));
351            // Kinds come from `all_defs` (a unit only the candidate adds has its
352            // definition there); the already-failed rule reads `i.defs`, the
353            // definitions `i.state` was observed against. A unit only the
354            // candidate adds is not in `i.defs` and was never observed, so it
355            // is never exempt.
356            let (s, d) = unit_facts(&affected, &all_defs, &services, |u| already_failed(i.state, i.defs, u));
357            f.sensitivity = s.union(&i.tables.diff_sensitivity(&config));
358            f.disruption = d;
359            f.touches_manifest = touches_manifest(&base_m, &cand_m, &config);
360            f.touches_canonical_probe = touches_canonical(&base_m, &cand_m, i.canonical);
361            f.changes_desired = changes_desired(&base_m, &cand_m);
362            f.touches_core = cand.core_units != i.base.core_units || f.sensitivity.contains(Sensitivity::Core);
363            // A catalog change may bring only what its override explains. The
364            // staged tree is the owner's as it is now, so it can carry edits the
365            // running generation does not have; those were never classified.
366            if let Transition::ApplyChange { change, args } = i.transition
367                && unexplained(i.base, cand, change, args)
368            {
369                f.sensitivity = f.sensitivity.union(&Sensitivities::of(&[Sensitivity::Security]));
370                f.touches_manifest = true;
371            }
372            let (removes, each, removes_sensitivity) =
373                removed_units(&base_m, &cand_m, i.activation, &all_defs, &services);
374            f.removes_sensitivity = removes_sensitivity;
375            f.removes = removes;
376            f.removes_each = each;
377            f.affected_units = unit_set(affected);
378            f.config_diff = (!config.options.is_empty()).then_some(config);
379        }
380    }
381    f
382}
383
384/// Whether the candidate differs from the running generation in ways a
385/// catalog change's override cannot explain: services declared or removed
386/// (other than the one a `service-enable` names), flake inputs, or a package
387/// with known vulnerabilities that the running generation does not have.
388pub fn unexplained(
389    base: &EvaluatedGeneration,
390    cand: &EvaluatedGeneration,
391    change: &ChangeId,
392    args: &BTreeMap<String, Value>,
393) -> bool {
394    let named = (change.0 == "service-enable").then(|| match args.get("service") {
395        Some(Value::Str(s)) => s.clone(),
396        _ => String::new(),
397    });
398    let services = |e: &EvaluatedGeneration| -> BTreeSet<String> {
399        e.declarations.keys().filter(|k| Some(*k) != named.as_ref()).cloned().collect()
400    };
401    // A generation that predates the export has no declarations to compare.
402    let declared = !base.declarations.is_empty() && services(base) != services(cand);
403    // Packages change with most changes (a service brings its own); a newly
404    // insecure one does not come from a catalog change.
405    declared || base.inputs != cand.inputs || !cand.insecure.is_subset(&base.insecure)
406}
407
408/// §11 session facts, from logind's sessions: which sessions the disruption
409/// lands on (sorted), and whether a system-scope session or connectivity loss
410/// reaches a session of another uid than the caller's. A user unit lands on
411/// its own session, or every session of its uid; a disruptive system unit on
412/// every session.
413pub fn session_facts(f: &mut DerivedFacts, sessions: &[SessionInfo]) {
414    let live: Vec<&SessionInfo> = sessions.iter().filter(|s| s.establishes()).collect();
415    let disruptive = f.disruption.session_loss || f.disruption.connectivity_loss || f.disruption.data_interruption;
416    let mut hit: BTreeSet<String> = BTreeSet::new();
417    let mut system = false;
418    for u in &f.affected_units {
419        match &u.scope {
420            Scope::User { uid, session: Some(s) } if live.iter().any(|x| x.uid == *uid && &x.id == s) => {
421                hit.insert(s.clone());
422            }
423            Scope::User { uid, .. } => hit.extend(live.iter().filter(|x| x.uid == *uid).map(|x| x.id.clone())),
424            Scope::System => system = true,
425        }
426    }
427    if system && disruptive {
428        hit.extend(live.iter().map(|x| x.id.clone()));
429    }
430    let caller = f.caller.uid();
431    f.other_sessions_affected = system
432        && (f.disruption.session_loss || f.disruption.connectivity_loss)
433        && live.iter().any(|x| Some(x.uid) != caller);
434    f.affected_sessions = hit.into_iter().collect();
435}
436
437/// §11: whether a RestartUnit restores a unit stopped outside Planarian and
438/// not attributed: stopped by `systemctl stop` or a termination signal (not
439/// a crash), under no override, and desired Running now. From systemd's state, never
440/// from the proposal (I14).
441pub fn stop_fact(f: &mut DerivedFacts, t: &Transition, s: &SystemState, defs: &UnitDefs, ended: Option<Ending>) {
442    let Transition::RestartUnit(u) = t else { return };
443    // Inactive after a `systemctl stop` (an orderly exit or a termination
444    // signal), or failed with Result=signal from a termination signal
445    // (kill -9). A crash, a non-zero exit or the OOM killer is a fault.
446    // (Inactive, not failed, is a stop on request whatever the exit status:
447    // sshd, for one, exits 255 on SIGTERM.)
448    let stopped = match (s.units.get(u).map(|(o, _)| o), ended) {
449        (Some(Observed::Bound), _) => true,
450        (Some(Observed::Failed(_)), Some(e)) => e.is_termination(),
451        _ => false,
452    };
453    f.restores_unattributed_stop = stopped
454        && s.override_of(u).is_none()
455        && s.expected(u, defs)
456        && defs.get(u).is_some_and(|d| d.desired.status == DesiredStatus::Running);
457}
458
459/// §12e: whether the generation a transition goes to is below the security
460/// floor. Planarian's generation ids grow with time, so "below" is older.
461pub fn floor_fact(f: &mut DerivedFacts, t: &Transition, s: &SystemState) {
462    let Some(floor) = s.security_floor else { return };
463    let target = match t {
464        Transition::SwitchGeneration(g) | Transition::Revert { to: g } => Some(*g),
465        Transition::Rollback => Some(s.known_good),
466        _ => None,
467    };
468    f.below_security_floor = target.is_some_and(|g| g.0 < floor.0);
469}
470
471#[cfg(test)]
472mod sensitivity_completeness {
473    use super::*;
474
475    /// §11 (B-5): a system unit with no sensitivity entry, or none in the
476    /// manifest, is not Routine; a user's own undeclared user unit is.
477    #[test]
478    fn unknown_units_fail_closed() {
479        let mut defs = UnitDefs::new();
480        let mut empty = planarian_fake::fixtures::gym_units()[&UnitId::service("gym-web")].clone();
481        empty.id = UnitId::service("gym-untagged");
482        empty.sensitivity = Sensitivities::default();
483        defs.insert(empty.id.clone(), empty.clone());
484        let none = BTreeMap::new();
485        let (s, _) = unit_facts([&empty.id], &defs, &none, |_| false);
486        assert!(s.external && !s.routine, "an empty sensitivity is External: {s:?}");
487        let ghost = UnitId::service("not-in-the-manifest");
488        let (s, _) = unit_facts([&ghost], &defs, &none, |_| false);
489        assert!(s.external && !s.routine, "an undeclared system unit is External: {s:?}");
490        // One NixOS defines has the shipped tables' entry instead.
491        let tables = BTreeMap::from([
492            ("dbus-broker".to_string(), Sensitivities::of(&[Sensitivity::Routine])),
493            ("blank".to_string(), Sensitivities::default()),
494        ]);
495        let (s, _) = unit_facts([&UnitId::service("dbus-broker")], &defs, &tables, |_| false);
496        assert!(s.routine && !s.external, "a defined service takes its table entry: {s:?}");
497        let (s, _) = unit_facts([&UnitId::service("blank")], &defs, &tables, |_| false);
498        assert!(s.external, "an empty table entry still fails closed: {s:?}");
499        let own =
500            UnitId { kind: UnitKind::Service, name: "notes".into(), scope: Scope::User { uid: 1000, session: None } };
501        let (s, _) = unit_facts([&own], &defs, &none, |_| false);
502        assert!(!s.external, "an undeclared user unit stays as it was: {s:?}");
503    }
504
505    /// §11 Rollback row (owner decision, Nov 2): a removed user template
506    /// carries its own sensitivity, read from the base manifest (the per-session
507    /// instances alone would miss it), and a removed or stopped system unit
508    /// carries its own.
509    #[test]
510    fn removed_units_carry_their_sensitivity() {
511        let mut vpn = planarian_fake::fixtures::gym_user_units()[0].clone();
512        vpn.id.name = "user-vpn".into();
513        vpn.sensitivity = Sensitivities::of(&[Sensitivity::Security]);
514        let base = Manifest { units: planarian_fake::fixtures::gym_units(), user_units: vec![vpn.clone()] };
515        let mut cand = base.clone();
516        cand.user_units.clear();
517        let none = BTreeMap::new();
518        let (removed, each, s) = removed_units(&base, &cand, None, &UnitDefs::new(), &none);
519        assert_eq!(removed, vec![vpn.id.clone()]);
520        assert_eq!(each.len(), removed.len(), "parallel");
521        assert!(each[0].security, "each carries its own: {each:?}");
522        assert!(s.security, "the removed user template is Security: {s:?}");
523        // Nothing removed: nothing carried.
524        let (removed, each, s) = removed_units(&base, &base, None, &base.units, &none);
525        assert!(removed.is_empty() && each.is_empty() && s == Sensitivities::default(), "{removed:?} {s:?}");
526        // A unit the activation stops counts too.
527        let web = UnitId::service("gym-web");
528        let plan = ActivationPlan { stop: [web.clone()].into(), ..Default::default() };
529        let (removed, each, _) = removed_units(&base, &base, Some(&plan), &base.units, &none);
530        assert_eq!(removed, vec![web]);
531        assert_eq!(each.len(), 1);
532    }
533}
534
535// C19 (§9 manifest, §11 ApplyChange row): the reviewer's acceptance test.
536#[cfg(test)]
537#[path = "derive_c19_tests.rs"]
538mod c19;
539
540// Catch-up property tests (docs/regression-ledger.md: C1).
541#[cfg(test)]
542#[path = "derive_props.rs"]
543mod props;