Skip to main content

apl_cpex/
parallel_safety.rs

1// Location: ./crates/apl-cpex/src/parallel_safety.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Route-compile-time plugin-mode validation for APL `parallel:` blocks.
7//
8// `apl-core::Effect::validate_parallel_purity` already rejects FieldOp /
9// Delegate at the IR level — those are statically detectable without
10// any plugin knowledge. Plugin calls (`Effect::Plugin { name }`) need
11// a second pass because their concurrency-safety depends on each
12// plugin's registered `PluginMode` — information that lives in the
13// PluginManager, not the IR.
14//
15// Lives in apl-cpex because:
16//   * apl-core can't see plugin modes (plugin-agnostic by design)
17//   * The PluginManager is constructed in the host integration, not in
18//     apl-core's compiler
19//   * The visitor that turns YAML routes into `CompiledRoute`s is the
20//     natural place to run all post-IR-level validations together
21//
22// # Mode rules
23//
24// Allowed inside `parallel:`:
25//   - `Audit` — read-only by declaration
26//   - `Concurrent` — explicitly designed for parallel execution
27//   - `FireAndForget` — side-effects only, no return value to merge
28//   - `Disabled` — skipped at runtime anyway
29//
30// Rejected inside `parallel:`:
31//   - `Sequential` — `can_modify() == true`, would silently lose its mutation
32//   - `Transform` — same as Sequential for our purposes
33//
34// The asymmetry exists because parallel branches each get a *cloned*
35// bag and payload; any mutation a branch makes lives only inside its
36// clone. Plugins authored under Sequential / Transform semantics
37// reasonably assume their writes persist. Detecting the misuse at
38// route-compile means the operator sees a clear error instead of a
39// confusing "but my plugin ran and the bag didn't change" runtime
40// surprise.
41
42use apl_core::rules::{CompiledRoute, Effect};
43use cpex_core::manager::PluginManager;
44use cpex_core::plugin::PluginMode;
45
46/// Read-only "what mode is plugin X registered with" lookup, used by
47/// the validator. A trait (rather than a `&PluginManager`) so:
48///
49///   * Tests can pass a small HashMap-backed mock without constructing
50///     a real `PluginManager` (which requires plugin registration and
51///     a bunch of cpex-core internal types).
52///   * Future consumers that store plugin modes in a different shape
53///     (e.g. a separate config catalogue) plug in without forcing them
54///     to back the lookup with a full PluginManager.
55pub trait PluginModeLookup {
56    /// Returns the mode for `name`, or `None` if no plugin by that
57    /// name is registered.
58    fn mode_for(&self, name: &str) -> Option<PluginMode>;
59}
60
61impl PluginModeLookup for PluginManager {
62    fn mode_for(&self, name: &str) -> Option<PluginMode> {
63        self.get_plugin(name).map(|p| p.mode())
64    }
65}
66
67/// Walk a compiled route looking for `Effect::Plugin` calls nested
68/// inside any `Effect::Parallel` block, and check that each named
69/// plugin's registered mode is safe for parallel execution.
70///
71/// Returns `Ok(())` if all plugins inside parallel blocks have safe
72/// modes (or the route has no parallel blocks). On failure, returns a
73/// `;`-separated list of every violation found — running a single pass
74/// over the route surfaces all problems at once instead of stopping
75/// at the first.
76pub fn validate_parallel_plugin_modes<L: PluginModeLookup + ?Sized>(
77    route: &CompiledRoute,
78    registry: &L,
79) -> Result<(), String> {
80    let mut errors: Vec<String> = Vec::new();
81    for (phase_name, effects) in [
82        ("pre_invocation", route.policy.as_slice()),
83        ("post_invocation", route.post_policy.as_slice()),
84    ] {
85        for (idx, effect) in effects.iter().enumerate() {
86            walk_effect(
87                effect,
88                &format!("routes.{}.{}[{}]", route.route_key, phase_name, idx),
89                false,
90                registry,
91                &mut errors,
92            );
93        }
94    }
95    if errors.is_empty() {
96        Ok(())
97    } else {
98        Err(errors.join("; "))
99    }
100}
101
102/// Recursive traversal. `under_parallel` is true once we've descended
103/// into a `Parallel` node; from then on every `Plugin` we hit gets
104/// checked against the mode allowlist. Nested `Parallel`/`Sequential`
105/// both keep the flag true (a sequential block inside a parallel one
106/// is still ultimately running in the parallel branch's cloned state).
107fn walk_effect<L: PluginModeLookup + ?Sized>(
108    effect: &Effect,
109    location: &str,
110    under_parallel: bool,
111    registry: &L,
112    errors: &mut Vec<String>,
113) {
114    match effect {
115        Effect::Plugin { name } if under_parallel => {
116            check_plugin_mode(name, location, registry, errors);
117        },
118        Effect::Parallel(inner) => {
119            for e in inner {
120                walk_effect(e, location, true, registry, errors);
121            }
122        },
123        Effect::Sequential(inner) => {
124            for e in inner {
125                walk_effect(e, location, under_parallel, registry, errors);
126            }
127        },
128        Effect::When { body, .. } => {
129            // A `when:` body inherits the parallel context of its
130            // enclosing scope. Plugin calls inside `when:` under a
131            // `parallel:` are still subject to the mode check.
132            for e in body {
133                walk_effect(e, location, under_parallel, registry, errors);
134            }
135        },
136        Effect::Pdp {
137            on_allow, on_deny, ..
138        } => {
139            for e in on_allow.iter().chain(on_deny.iter()) {
140                walk_effect(e, location, under_parallel, registry, errors);
141            }
142        },
143        // Other variants (Allow/Deny/Plugin-not-in-parallel/Delegate/
144        // Taint/FieldOp) don't carry nested effects today. Note that
145        // `Delegate` / `FieldOp` inside Parallel was already rejected
146        // by `apl-core::Effect::validate_parallel_purity` at parse
147        // time — no need to re-check here.
148        _ => {},
149    }
150}
151
152fn check_plugin_mode<L: PluginModeLookup + ?Sized>(
153    name: &str,
154    location: &str,
155    registry: &L,
156    errors: &mut Vec<String>,
157) {
158    let mode = match registry.mode_for(name) {
159        Some(m) => m,
160        None => {
161            errors.push(format!(
162                "{}: `parallel:` references unknown plugin `{}`",
163                location, name
164            ));
165            return;
166        },
167    };
168    if !is_safe_in_parallel(mode) {
169        errors.push(format!(
170            "{}: plugin `{}` has mode `{}` which can modify state; parallel \
171             branches discard mutations, so this would silently lose its effect. \
172             Use `sequential:` for ordered mutations or change the plugin's mode.",
173            location, name, mode,
174        ));
175    }
176}
177
178/// Allowlist check. Centralised so the rule is documented in one
179/// place and easy to find if `PluginMode` gains a new variant.
180fn is_safe_in_parallel(mode: PluginMode) -> bool {
181    matches!(
182        mode,
183        PluginMode::Audit
184            | PluginMode::Concurrent
185            | PluginMode::FireAndForget
186            | PluginMode::Disabled
187    )
188}
189
190// =====================================================================
191// Tests
192// =====================================================================
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use apl_core::rules::Expression;
198    use std::collections::HashMap;
199
200    /// Test mock — a plain `HashMap<name, mode>`. Implements the
201    /// lookup trait without needing the real cpex-core registry's
202    /// plugin / hook registration machinery.
203    struct MockLookup(HashMap<String, PluginMode>);
204
205    impl MockLookup {
206        fn new() -> Self {
207            Self(HashMap::new())
208        }
209        fn with(mut self, name: &str, mode: PluginMode) -> Self {
210            self.0.insert(name.to_string(), mode);
211            self
212        }
213    }
214
215    impl PluginModeLookup for MockLookup {
216        fn mode_for(&self, name: &str) -> Option<PluginMode> {
217            self.0.get(name).copied()
218        }
219    }
220
221    fn route_with_policy(effects: Vec<Effect>) -> CompiledRoute {
222        let mut r = CompiledRoute::new("test_route");
223        r.policy = effects;
224        r
225    }
226
227    fn rule(effects: Vec<Effect>) -> Effect {
228        Effect::When {
229            condition: Expression::Always,
230            body: effects,
231            source: "test".into(),
232        }
233    }
234
235    fn parallel_plugin(name: &str) -> Effect {
236        Effect::Parallel(vec![Effect::Plugin { name: name.into() }])
237    }
238
239    // --- Allowed modes ---
240
241    #[test]
242    fn audit_plugin_in_parallel_is_accepted() {
243        let reg = MockLookup::new().with("audit_logger", PluginMode::Audit);
244        let route = route_with_policy(vec![rule(vec![parallel_plugin("audit_logger")])]);
245        assert!(validate_parallel_plugin_modes(&route, &reg).is_ok());
246    }
247
248    #[test]
249    fn concurrent_plugin_in_parallel_is_accepted() {
250        let reg = MockLookup::new().with("pii_scanner", PluginMode::Concurrent);
251        let route = route_with_policy(vec![rule(vec![parallel_plugin("pii_scanner")])]);
252        assert!(validate_parallel_plugin_modes(&route, &reg).is_ok());
253    }
254
255    #[test]
256    fn fire_and_forget_in_parallel_is_accepted() {
257        let reg = MockLookup::new().with("metrics", PluginMode::FireAndForget);
258        let route = route_with_policy(vec![rule(vec![parallel_plugin("metrics")])]);
259        assert!(validate_parallel_plugin_modes(&route, &reg).is_ok());
260    }
261
262    // --- Rejected modes ---
263
264    #[test]
265    fn sequential_plugin_in_parallel_is_rejected() {
266        let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
267        let route = route_with_policy(vec![rule(vec![parallel_plugin("mutator")])]);
268        let err = validate_parallel_plugin_modes(&route, &reg).unwrap_err();
269        assert!(err.contains("mutator"), "names plugin: {}", err);
270        assert!(err.contains("sequential"), "names mode: {}", err);
271        assert!(err.contains("`sequential:`"), "suggests fix: {}", err);
272    }
273
274    #[test]
275    fn transform_plugin_in_parallel_is_rejected() {
276        let reg = MockLookup::new().with("redactor", PluginMode::Transform);
277        let route = route_with_policy(vec![rule(vec![parallel_plugin("redactor")])]);
278        let err = validate_parallel_plugin_modes(&route, &reg).unwrap_err();
279        assert!(err.contains("transform"));
280    }
281
282    #[test]
283    fn unknown_plugin_in_parallel_is_rejected() {
284        let reg = MockLookup::new();
285        let route = route_with_policy(vec![rule(vec![parallel_plugin("ghost")])]);
286        let err = validate_parallel_plugin_modes(&route, &reg).unwrap_err();
287        assert!(err.contains("unknown plugin"));
288        assert!(err.contains("ghost"));
289    }
290
291    // --- Scoping: only mismatches INSIDE a parallel block are caught ---
292
293    #[test]
294    fn sequential_plugin_outside_parallel_is_allowed() {
295        // The same Sequential-mode plugin is fine at the top level —
296        // only its appearance INSIDE a parallel block is the problem.
297        let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
298        let route = route_with_policy(vec![rule(vec![Effect::Plugin {
299            name: "mutator".into(),
300        }])]);
301        assert!(validate_parallel_plugin_modes(&route, &reg).is_ok());
302    }
303
304    #[test]
305    fn nested_sequential_inside_parallel_still_validates_plugins() {
306        // `parallel: [sequential: [plugin(seq_mode)]]` — the sequential
307        // is just a grouping construct; the plugin still runs inside
308        // the parallel branch's cloned state.
309        let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
310        let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![
311            Effect::Sequential(vec![Effect::Plugin {
312                name: "mutator".into(),
313            }]),
314        ])])]);
315        let err = validate_parallel_plugin_modes(&route, &reg).unwrap_err();
316        assert!(err.contains("mutator"));
317    }
318
319    // --- Diagnostics: every violation, both phases ---
320
321    #[test]
322    fn multiple_violations_all_reported() {
323        // Surface every violation in one pass so the operator can fix
324        // them all at once instead of one error per build cycle.
325        let reg = MockLookup::new()
326            .with("a", PluginMode::Sequential)
327            .with("b", PluginMode::Transform);
328        let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![
329            Effect::Plugin { name: "a".into() },
330            Effect::Plugin { name: "b".into() },
331        ])])]);
332        let err = validate_parallel_plugin_modes(&route, &reg).unwrap_err();
333        assert!(err.contains("`a`"), "names a: {}", err);
334        assert!(err.contains("`b`"), "names b: {}", err);
335    }
336
337    #[test]
338    fn post_policy_phase_is_validated_too() {
339        let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
340        let mut route = CompiledRoute::new("test_route");
341        route.post_policy = vec![rule(vec![parallel_plugin("mutator")])];
342        let err = validate_parallel_plugin_modes(&route, &reg).unwrap_err();
343        assert!(err.contains("post_invocation"));
344    }
345}