Skip to main content

praxis_policy_apl_runtime/
parallel_safety.rs

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