Skip to main content

praxis_policy_apl_runtime/
dispatch_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Praxis Contributors
3
4// `RouteDispatchPlan` + `DispatchCache` — pre-resolved per-route plugin
5// lineup that lets APL bypass praxis-policy-core's hook-name + condition routing
6// while still going through the executor's full 5-phase pipeline.
7//
8// # Why pre-resolve?
9//
10// praxis-policy-core's `invoke_named(hook_name, ...)` resolves the lineup on
11// every call: hook lookup → route/condition filter → group by mode →
12// dispatch. APL routes are already authoritative lineups (the YAML's
13// `routes.<r>.policy: [plugin(x), plugin(y)]` IS the plan). Re-resolving
14// per call wastes work and lets praxis-policy-core's parallel routing model
15// (entity-based conditions) override APL's intent.
16//
17// Building once per `(route_key, snapshot_generation)` and caching turns
18// dispatch into: cache lookup → pick handler by invocation context →
19// call `engine.invoke_entries::<CmfHook>(&[entry], ...)`.
20//
21// # Override materialization
22//
23// When APL declares a route-level `plugins.<name>:` block that narrows
24// `capabilities` or changes `on_error`, the plan creates a derived
25// `PluginRef` wrapping the same plugin `Arc<dyn Plugin>` with a merged
26// `TrustedConfig`. Each derived
27// PluginRef gets a fresh `AtomicBool` circuit breaker — failures in the
28// override-context plugin don't disable the base, and vice versa.
29//
30// # Hook-context classification (v0)
31//
32// A plugin may register handlers for multiple hooks (e.g. both
33// `cmf.tool_pre_invoke` for policy steps and `cmf.field_redact` for
34// args/result pipelines). The plan picks one handler per invocation
35// context (Step vs Field) by a naming heuristic — hook names containing
36// `field`, `redact`, `scan`, or `validate` are treated as field
37// handlers. When the heuristic stops being sufficient, the plugin
38// declaration will gain an explicit `{step: ..., field: ...}` mapping
39// form alongside the flat hook list.
40
41use std::collections::{HashMap, HashSet};
42use std::sync::{Arc, RwLock};
43
44use praxis_policy_core::delegation::HOOK_TOKEN_DELEGATE;
45use praxis_policy_core::elicitation::HOOK_ELICIT;
46use praxis_policy_core::engine::PolicyEngine;
47use praxis_policy_core::hooks::{HookPhase, lookup_hook_metadata};
48use praxis_policy_core::plugin::OnError;
49use praxis_policy_core::registry::HookEntry;
50
51use praxis_policy_apl_core::pipeline::Stage;
52use praxis_policy_apl_core::plugin_decl::{EffectivePlugin, PluginRegistry};
53use praxis_policy_apl_core::rules::{CompiledRoute, Effect};
54
55/// Per-plugin pre-resolved entries for one route. Stores ALL hook
56/// entries the plugin registered (keyed by hook name) so the
57/// dispatcher can pick the right one for the current context via the
58/// praxis-policy-core hook routing table (`hooks::metadata::lookup`).
59///
60/// Replaces the prior `step_entry` / `field_entry` slot model, which
61/// used a brittle naming heuristic to classify hooks and silently
62/// collapsed plugins with multiple step-context hooks (e.g. both
63/// `tool_pre_invoke` and `tool_post_invoke`) to a single entry.
64#[derive(Clone)]
65pub struct RoutePluginEntry {
66    /// The plugin this entry dispatches to.
67    pub plugin_name: String,
68    /// All hook entries the plugin registered, keyed by hook name.
69    /// Per-call overrides (route-level config / caps / `on_error`) are
70    /// already applied via `build_override_entries` before being
71    /// stored here.
72    pub entries_by_hook: HashMap<String, HookEntry>,
73}
74
75impl RoutePluginEntry {
76    /// Pick the entry whose registered hook matches the current
77    /// dispatch context. Walks `entries_by_hook`, consults the
78    /// praxis-policy-core hook metadata table for each, returns the first
79    /// matching entry.
80    ///
81    /// `requested_entity_type` comes from the request's
82    /// `MetaExtension.entity_type` (or `None` if the dispatcher
83    /// doesn't have one — in which case any hook's `entity_type`
84    /// matches). `requested_phase` comes from the APL invocation
85    /// context — `Pre` for `args:` / `pre_invocation:`, `Post` for
86    /// `result:` / `post_invocation:`, `Unphased` for unphased
87    /// dispatchers (rare in APL).
88    ///
89    /// Returns `None` when the plugin has no hook matching the
90    /// context — caller surfaces this as `PluginError::Dispatch`
91    /// with the requested context in the message.
92    pub fn pick_entry(
93        &self,
94        requested_entity_type: Option<&str>,
95        requested_phase: HookPhase,
96    ) -> Option<&HookEntry> {
97        self.entries_by_hook
98            .iter()
99            .find(|(hook_name, _)| {
100                lookup_hook_metadata(hook_name).matches(requested_entity_type, requested_phase)
101            })
102            .map(|(_, entry)| entry)
103    }
104}
105
106/// A route's resolved plugin lineup. One per `(route_key, generation)`
107/// in the cache.
108///
109/// `plugins` holds entries for CMF-family dispatch (policy steps,
110/// pipe-chain stages). `delegation_entries` holds entries for the
111/// `token.delegate` hook used by `Step::Delegate` — kept separate
112/// because the hook family is different and the dispatch is
113/// per-call rather than per-route-chain.
114#[derive(Clone, Default)]
115pub struct RouteDispatchPlan {
116    /// Per-plugin dispatch entries, keyed by plugin name.
117    pub plugins: HashMap<String, RoutePluginEntry>,
118    /// Plugin name → resolved `token.delegate` hook entry for routes
119    /// that declared `delegate(...)` steps. Empty when the route has
120    /// no delegation. Built at plan time to avoid per-request
121    /// `find_plugin_entries` lookups in the hot path.
122    pub delegation_entries: HashMap<String, HookEntry>,
123    /// Plugin name → resolved `elicit` hook entry for routes that
124    /// declared `require_approval(...)` / `confirm(...)` / … steps. Same
125    /// `name → entry` shape as `delegation_entries` — elicitation routes
126    /// by plugin name, not by `(kind, channel)`. Empty when the route has
127    /// no elicitation.
128    pub elicitation_entries: HashMap<String, HookEntry>,
129}
130
131impl RouteDispatchPlan {
132    /// Build a plan for the given route. Walks all steps + pipeline
133    /// stages, collects the unique set of plugin names, resolves each
134    /// against praxis-policy-core, and applies any APL route-level overrides.
135    ///
136    /// Plugins referenced by APL but absent from praxis-policy-core's registry
137    /// (or absent from the APL `plugins:` block) are logged at `warn`
138    /// and excluded — dispatch then fails with `PluginError::NotFound`
139    /// when those plugins are invoked, which is the right behavior for
140    /// surfacing config drift.
141    pub async fn build(
142        route: &CompiledRoute,
143        registry: &PluginRegistry,
144        engine: &PolicyEngine,
145    ) -> Self {
146        let mut plan = Self::default();
147        for name in collect_plugin_names(route) {
148            let eff = if let Some(e) =
149                EffectivePlugin::resolve(&name, registry, &route.plugin_overrides)
150            {
151                e
152            } else {
153                tracing::warn!(
154                    plugin = %name,
155                    route = %route.route_key,
156                    "APL route references plugin not in `plugins:` block — skipping",
157                );
158                continue;
159            };
160
161            // Pull the three overrideable values off the effective view.
162            // `EffectivePlugin` borrows from the registry / route overrides,
163            // so the captures here are slice / Option<&Value> refs.
164            let override_block = route.plugin_overrides.get(&name);
165            let config_override = override_block.and_then(|o| o.config.as_ref());
166            let caps_override: Option<std::collections::HashSet<String>> = matches!(
167                eff.capabilities,
168                praxis_policy_apl_core::plugin_decl::CapsView::Override(_)
169            )
170            .then(|| eff.capabilities.as_slice().iter().cloned().collect());
171            let on_error_override = override_block
172                .and_then(|o| o.on_error.as_deref())
173                .and_then(parse_on_error);
174
175            // Hand the override decision to praxis-policy-core. When no overrides
176            // are declared, this returns the base entries unchanged
177            // (no allocation, no factory call). When only caps/on_error
178            // differ, it wraps the shared base plugin in a fresh
179            // `PluginRef` with merged trusted config. When config
180            // differs, it invokes the factory + initializes a brand-new
181            // instance with its own circuit breaker.
182            let entries = engine
183                .build_override_entries(
184                    &name,
185                    config_override,
186                    caps_override.as_ref(),
187                    on_error_override,
188                )
189                .await;
190            if entries.is_empty() {
191                tracing::warn!(
192                    plugin = %name,
193                    route = %route.route_key,
194                    "APL plugin not resolvable (not registered, factory missing, \
195                     or override construction failed) — skipping",
196                );
197                continue;
198            }
199
200            // Store every (hook_name, HookEntry) pair the plugin
201            // registered. Dispatch-time entry selection (pick_entry)
202            // consults praxis-policy-core's hook routing table per hook name.
203            // Replaces the prior naming heuristic.
204            let mut entries_by_hook: HashMap<String, HookEntry> = HashMap::new();
205            for (hook_name, entry) in entries {
206                entries_by_hook.insert(hook_name, entry);
207            }
208
209            plan.plugins.insert(
210                name.clone(),
211                RoutePluginEntry {
212                    plugin_name: name,
213                    entries_by_hook,
214                },
215            );
216        }
217
218        // Resolve token.delegate entries for any plugins the route
219        // calls via `Step::Delegate`. These don't go through the
220        // step/field classification — they're a separate hook family.
221        // We still apply per-call config overrides via the existing
222        // `build_override_entries` pathway, threading the step's
223        // `config_override` as the only override surface (per-step
224        // caps and on_error overrides aren't exposed on delegation
225        // entries — the on_error lives in the IR step itself and is
226        // honored by the evaluator).
227        for name in collect_delegate_plugin_names(route) {
228            let entries = engine.build_override_entries(&name, None, None, None).await;
229            // Pick the first token.delegate entry. Per delegation-hooks
230            // spec, plugins typically register one handler under the
231            // single `token.delegate` hook name; multiple handlers
232            // would be unusual.
233            let delegate_entry = entries
234                .into_iter()
235                .find(|(hook_name, _)| hook_name == HOOK_TOKEN_DELEGATE);
236            if let Some((_, entry)) = delegate_entry {
237                plan.delegation_entries.insert(name, entry);
238            } else {
239                tracing::warn!(
240                    plugin = %name,
241                    route = %route.route_key,
242                    "APL route references delegate plugin not registered under \
243                     token.delegate hook — `delegate(...)` step will fail at dispatch",
244                );
245            }
246        }
247
248        // Resolve `elicit` entries for any plugins the route calls via an
249        // elicitation verb (`require_approval(...)`, `confirm(...)`, …).
250        // Same `name → entry` resolution as delegation — elicitation
251        // routes by plugin name, not `(kind, channel)`.
252        for name in collect_elicit_plugin_names(route) {
253            let entries = engine.build_override_entries(&name, None, None, None).await;
254            let elicit_entry = entries
255                .into_iter()
256                .find(|(hook_name, _)| hook_name == HOOK_ELICIT);
257            if let Some((_, entry)) = elicit_entry {
258                plan.elicitation_entries.insert(name, entry);
259            } else {
260                tracing::warn!(
261                    plugin = %name,
262                    route = %route.route_key,
263                    "APL route references elicitation plugin not registered under \
264                     elicit hook — `require_approval(...)`/`confirm(...)` step will \
265                     fail at dispatch",
266                );
267            }
268        }
269
270        plan
271    }
272
273    /// Look up the resolved entries for a plugin by name. None when the
274    /// plugin wasn't referenced by the route (or was skipped during
275    /// build due to config drift).
276    pub fn get(&self, plugin_name: &str) -> Option<&RoutePluginEntry> {
277        self.plugins.get(plugin_name)
278    }
279
280    /// Resolve a single plugin's entries straight off praxis-policy-core, with
281    /// no APL route-level overrides. Convenience for tests and for hosts
282    /// that wire the invoker without a `CompiledRoute` in scope (e.g.
283    /// adapters that invoke a single plugin imperatively). Returns
284    /// `None` if praxis-policy-core has no entries for the plugin.
285    pub fn resolve_plugin(engine: &PolicyEngine, plugin_name: &str) -> Option<RoutePluginEntry> {
286        let base_entries = engine.find_plugin_entries(plugin_name);
287        if base_entries.is_empty() {
288            return None;
289        }
290        let mut entries_by_hook: HashMap<String, HookEntry> = HashMap::new();
291        for (hook_name, entry) in base_entries {
292            entries_by_hook.insert(hook_name, entry);
293        }
294        Some(RoutePluginEntry {
295            plugin_name: plugin_name.to_owned(),
296            entries_by_hook,
297        })
298    }
299}
300
301fn parse_on_error(s: &str) -> Option<OnError> {
302    match s.to_ascii_lowercase().as_str() {
303        "fail" => Some(OnError::Fail),
304        "ignore" => Some(OnError::Ignore),
305        "disable" => Some(OnError::Disable),
306        _ => None,
307    }
308}
309
310/// Recursively walk every effect node in an `Effect` tree, invoking
311/// `visit` on each. Used by `collect_*_names` below to find Plugin /
312/// Delegate references that may be nested inside `Effect::When`,
313/// `Effect::Sequential`, `Effect::Parallel`, or `Effect::Pdp` reaction
314/// lists. Previously these were flat — `Step::Plugin` lived directly under
315/// policy: — so a simple `iter()` was enough; now the IR is tree-
316/// shaped and the same scan needs recursion.
317fn walk_effects<F: FnMut(&Effect)>(effects: &[Effect], visit: &mut F) {
318    for e in effects {
319        visit(e);
320        match e {
321            Effect::When { body, .. } => walk_effects(body, visit),
322            Effect::Sequential(inner) | Effect::Parallel(inner) => walk_effects(inner, visit),
323            Effect::Pdp {
324                on_allow, on_deny, ..
325            } => {
326                walk_effects(on_allow, visit);
327                walk_effects(on_deny, visit);
328            },
329            _ => {},
330        }
331    }
332}
333
334/// Walk a `CompiledRoute` and return the unique delegate-plugin names
335/// referenced by any `Effect::Delegate` anywhere in `policy` /
336/// `post_policy` (including effects nested inside When / Sequential /
337/// Parallel / Pdp reactions). Insertion-ordered for build determinism.
338/// Separate from [`collect_plugin_names`] because delegate plugins
339/// resolve under a different hook family (`token.delegate`) and the
340/// dispatch plan keeps them in a separate map.
341pub(crate) fn collect_delegate_plugin_names(route: &CompiledRoute) -> Vec<String> {
342    let mut out: Vec<String> = Vec::new();
343    let mut seen: HashSet<String> = HashSet::new();
344    let mut visit = |e: &Effect| {
345        if let Effect::Delegate(ds) = e
346            && seen.insert(ds.plugin_name.clone())
347        {
348            out.push(ds.plugin_name.clone());
349        }
350    };
351    walk_effects(&route.policy, &mut visit);
352    walk_effects(&route.post_policy, &mut visit);
353    out
354}
355
356/// Walk a `CompiledRoute` and return the unique elicitation-plugin names
357/// referenced by any `Effect::Elicit` anywhere in `policy` /
358/// `post_policy` (including nested). Insertion-ordered for build
359/// determinism. Separate from [`collect_plugin_names`] because
360/// elicitation plugins resolve under the `elicit` hook family and the
361/// plan keeps them in their own map.
362pub(crate) fn collect_elicit_plugin_names(route: &CompiledRoute) -> Vec<String> {
363    let mut out: Vec<String> = Vec::new();
364    let mut seen: HashSet<String> = HashSet::new();
365    let mut visit = |e: &Effect| {
366        if let Effect::Elicit(es) = e
367            && seen.insert(es.plugin_name.clone())
368        {
369            out.push(es.plugin_name.clone());
370        }
371    };
372    walk_effects(&route.policy, &mut visit);
373    walk_effects(&route.post_policy, &mut visit);
374    out
375}
376
377/// Walk a `CompiledRoute` and return the unique plugin names referenced
378/// by any `Effect::Plugin` anywhere in `policy` / `post_policy` (including
379/// nested) or `Stage::Plugin` (in `args` / `result` pipelines).
380/// Insertion-ordered for build determinism.
381pub(crate) fn collect_plugin_names(route: &CompiledRoute) -> Vec<String> {
382    let mut out: Vec<String> = Vec::new();
383    let mut seen: HashSet<String> = HashSet::new();
384    let mut visit = |e: &Effect| {
385        if let Effect::Plugin { name } = e
386            && seen.insert(name.clone())
387        {
388            out.push(name.clone());
389        }
390    };
391    walk_effects(&route.policy, &mut visit);
392    walk_effects(&route.post_policy, &mut visit);
393    for fr in route.args.iter().chain(route.result.iter()) {
394        for stage in &fr.pipeline.stages {
395            if let Stage::Plugin { name } = stage
396                && seen.insert(name.clone())
397            {
398                out.push(name.clone());
399            }
400        }
401    }
402    out
403}
404
405/// Compute the union of capabilities declared by every plugin a
406/// `CompiledRoute` can dispatch to (with per-route overrides applied).
407///
408/// This is what the synthetic `AplRouteHandler`'s `PluginConfig.capabilities`
409/// must be set to: praxis-policy-core's executor filters the `Extensions` view
410/// before invoking every plugin (including the synthetic one), so if
411/// the handler has fewer capabilities than its inner plugins need,
412/// downstream views get doubly-filtered and label/delegation mutations
413/// fail monotonicity checks on the way back out.
414///
415/// Plugins missing from the registry are silently skipped — the
416/// dispatch plan will log a `warn!` and surface a `NotFound` at
417/// invocation time, so config drift surfaces in the right place
418/// rather than as a confusing capability gap.
419pub(crate) fn route_capability_union(
420    route: &CompiledRoute,
421    registry: &PluginRegistry,
422) -> std::collections::HashSet<String> {
423    let mut caps: std::collections::HashSet<String> = std::collections::HashSet::new();
424    // Plugin steps (`plugin(name)` in policy / `plugin: name` in
425    // args / result pipelines).
426    for name in collect_plugin_names(route) {
427        if let Some(eff) = EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) {
428            for cap in eff.capabilities.as_slice() {
429                caps.insert(cap.clone());
430            }
431        }
432    }
433    // Delegate steps (`delegate(name, ...)`). Without this, a
434    // delegator plugin that declares `capabilities:
435    // [read_inbound_credentials, write_delegated_tokens]` in YAML
436    // gets those stripped at the AplRouteHandler boundary — the
437    // synthetic handler doesn't union its caps in, so the executor
438    // filters out the inbound bearer before DelegationPluginInvoker
439    // dispatches, and the delegator handler sees an empty token.
440    // Hosts WANT to express per-plugin caps in YAML rather than
441    // widening the AplRouteHandler's baseline (which would leak
442    // those creds to every other step in the route).
443    for name in collect_delegate_plugin_names(route) {
444        if let Some(eff) = EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) {
445            for cap in eff.capabilities.as_slice() {
446                caps.insert(cap.clone());
447            }
448        }
449    }
450    // Elicitation steps (`require_approval(name, ...)`, …) — same reason
451    // as delegation: an elicitation handler that declares e.g.
452    // `read_subject` (to read the approver identity) must not have it
453    // stripped at the AplRouteHandler boundary.
454    let elicit_plugins = collect_elicit_plugin_names(route);
455    for name in &elicit_plugins {
456        if let Some(eff) = EffectivePlugin::resolve(name, registry, &route.plugin_overrides) {
457            for cap in eff.capabilities.as_slice() {
458                caps.insert(cap.clone());
459            }
460        }
461    }
462    // A route with an elicitation step needs `read_headers` on the synthetic
463    // handler so the `X-Policy-Elicitation-Id` retry header survives the
464    // capability filter and reaches the bag for retry seeding. Without
465    // it, the `http` extension is stripped before the handler reads it and
466    // every retry re-dispatches a fresh elicitation instead of checking.
467    if !elicit_plugins.is_empty() {
468        caps.insert("read_headers".to_owned());
469    }
470    caps
471}
472
473/// Host-owned dispatch cache. Construct once, share via `Arc<DispatchCache>`
474/// across all `CmfPluginInvoker::for_request` calls so plans built for
475/// one request can be reused by the next.
476///
477/// Cache key is the APL `route_key`. Entries pair with the praxis-policy-core
478/// snapshot generation observed at build time; a mismatch on lookup
479/// triggers eviction and rebuild. v0 keys on `route_key` only —
480/// entity-aware caching (`entity_type/entity_name` from `MetaExtension`)
481/// is a follow-up when per-tenant lineup variation lands.
482#[derive(Default)]
483pub struct DispatchCache {
484    inner: RwLock<HashMap<String, (u64, Arc<RouteDispatchPlan>)>>,
485}
486
487impl DispatchCache {
488    /// An empty plan.
489    pub fn new() -> Self {
490        Self::default()
491    }
492
493    /// Get-or-build a plan for the route. Read-locked fast path returns
494    /// the cached plan when the generation matches; otherwise drop the
495    /// read lock, rebuild, and write-lock-insert. The brief window
496    /// between read-miss and write-insert may let two concurrent
497    /// builders race — both produce identical plans and the second
498    /// insert just overwrites the first. Cheap relative to the cost of
499    /// the build itself, and avoids holding a write lock across the
500    /// build call.
501    ///
502    /// Async because `RouteDispatchPlan::build` may invoke
503    /// `PolicyEngine::build_override_entries`, which calls plugin
504    /// factories and `initialize()` for routes that declare `config:`
505    /// overrides. Routes with no overrides take a synchronous path
506    /// inside the engine (no `.await` does any real work), so the
507    /// async cost is zero for the common case.
508    pub async fn get_or_build(
509        &self,
510        route: &CompiledRoute,
511        registry: &PluginRegistry,
512        engine: &PolicyEngine,
513    ) -> Arc<RouteDispatchPlan> {
514        let current_gen = engine.config_generation();
515        {
516            let r = self
517                .inner
518                .read()
519                .unwrap_or_else(std::sync::PoisonError::into_inner);
520            if let Some((stored_gen, plan)) = r.get(&route.route_key)
521                && *stored_gen == current_gen
522            {
523                return Arc::clone(plan);
524            }
525        }
526        let plan = Arc::new(RouteDispatchPlan::build(route, registry, engine).await);
527        let mut w = self
528            .inner
529            .write()
530            .unwrap_or_else(std::sync::PoisonError::into_inner);
531        w.insert(route.route_key.clone(), (current_gen, Arc::clone(&plan)));
532        plan
533    }
534}