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>.pre_invocation: [run(x), run(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::{HookMetadata, HookPhase, lookup_hook_metadata};
48use praxis_policy_core::plugin::OnError;
49use praxis_policy_core::registry::HookEntry;
50
51use praxis_policy_apl_core::pipeline::{FieldRule, 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/// `cmf.tool_pre_invoke` and `cmf.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    ///
93    /// A hook with no metadata entry falls back to
94    /// [`HookMetadata::permissive`], which matches any context, so a
95    /// plugin registered under a name nobody described still dispatches.
96    pub fn pick_entry(
97        &self,
98        requested_entity_type: Option<&str>,
99        requested_phase: HookPhase,
100    ) -> Option<&HookEntry> {
101        self.entries_by_hook
102            .iter()
103            .find(|(hook_name, _)| {
104                lookup_hook_metadata(hook_name)
105                    .unwrap_or_else(HookMetadata::permissive)
106                    .matches(requested_entity_type, requested_phase)
107            })
108            .map(|(_, entry)| entry)
109    }
110}
111
112/// A route's resolved plugin lineup. One per `(route_key, generation)`
113/// in the cache.
114///
115/// `plugins` holds entries for CMF-family dispatch (policy steps,
116/// pipe-chain stages). `delegation_entries` holds entries for the
117/// `token.delegate` hook used by `Step::Delegate` — kept separate
118/// because the hook family is different and the dispatch is
119/// per-call rather than per-route-chain.
120#[derive(Clone, Default)]
121pub struct RouteDispatchPlan {
122    /// Per-plugin dispatch entries, keyed by plugin name.
123    pub plugins: HashMap<String, RoutePluginEntry>,
124    /// Plugin name → resolved `token.delegate` hook entry for routes
125    /// that declared `delegate(...)` steps. Empty when the route has
126    /// no delegation. Built at plan time to avoid per-request
127    /// `find_plugin_entries` lookups in the hot path.
128    pub delegation_entries: HashMap<String, HookEntry>,
129    /// Plugin name → resolved `elicit` hook entry for routes that
130    /// declared `require_approval(...)` / `confirm(...)` / … steps. Same
131    /// `name → entry` shape as `delegation_entries` — elicitation routes
132    /// by plugin name, not by `(kind, channel)`. Empty when the route has
133    /// no elicitation.
134    pub elicitation_entries: HashMap<String, HookEntry>,
135}
136
137impl RouteDispatchPlan {
138    /// Build a plan for the given route. Walks all steps + pipeline
139    /// stages, collects the unique set of plugin names, resolves each
140    /// against praxis-policy-core, and applies any APL route-level overrides.
141    ///
142    /// Plugins referenced by APL but absent from praxis-policy-core's registry
143    /// (or absent from the APL `plugins:` block) are logged at `warn`
144    /// and excluded — dispatch then fails with `PluginError::NotFound`
145    /// when those plugins are invoked, which is the right behavior for
146    /// surfacing config drift.
147    pub async fn build(
148        route: &CompiledRoute,
149        registry: &PluginRegistry,
150        engine: &PolicyEngine,
151    ) -> Self {
152        let mut plan = Self::default();
153        for name in collect_plugin_names(route) {
154            let eff = if let Some(e) =
155                EffectivePlugin::resolve(&name, registry, &route.plugin_overrides)
156            {
157                e
158            } else {
159                tracing::warn!(
160                    plugin = %name,
161                    route = %route.route_key,
162                    "APL route references plugin not in `plugins:` block — skipping",
163                );
164                continue;
165            };
166
167            // Pull the three overrideable values off the effective view.
168            // `EffectivePlugin` borrows from the registry / route overrides,
169            // so the captures here are slice / Option<&Value> refs.
170            let override_block = route.plugin_overrides.get(&name);
171            let config_override = override_block.and_then(|o| o.config.as_ref());
172            let caps_override: Option<std::collections::HashSet<String>> = matches!(
173                eff.capabilities,
174                praxis_policy_apl_core::plugin_decl::CapsView::Override(_)
175            )
176            .then(|| eff.capabilities.as_slice().iter().cloned().collect());
177            let on_error_override = override_block
178                .and_then(|o| o.on_error.as_deref())
179                .and_then(parse_on_error);
180
181            // Hand the override decision to praxis-policy-core. When no overrides
182            // are declared, this returns the base entries unchanged
183            // (no allocation, no factory call). When only caps/on_error
184            // differ, it wraps the shared base plugin in a fresh
185            // `PluginRef` with merged trusted config. When config
186            // differs, it invokes the factory + initializes a brand-new
187            // instance with its own circuit breaker.
188            let entries = engine
189                .build_override_entries(
190                    &name,
191                    config_override,
192                    caps_override.as_ref(),
193                    on_error_override,
194                )
195                .await;
196            if entries.is_empty() {
197                tracing::warn!(
198                    plugin = %name,
199                    route = %route.route_key,
200                    "APL plugin not resolvable (not registered, factory missing, \
201                     or override construction failed) — skipping",
202                );
203                continue;
204            }
205
206            // Store every (hook_name, HookEntry) pair the plugin
207            // registered. Dispatch-time entry selection (pick_entry)
208            // consults praxis-policy-core's hook routing table per hook name.
209            // Replaces the prior naming heuristic.
210            let mut entries_by_hook: HashMap<String, HookEntry> = HashMap::new();
211            for (hook_name, entry) in entries {
212                entries_by_hook.insert(hook_name, entry);
213            }
214
215            plan.plugins.insert(
216                name.clone(),
217                RoutePluginEntry {
218                    plugin_name: name,
219                    entries_by_hook,
220                },
221            );
222        }
223
224        // Resolve token.delegate entries for any plugins the route
225        // calls via `Step::Delegate`. These don't go through the
226        // step/field classification — they're a separate hook family.
227        // We still apply per-call config overrides via the existing
228        // `build_override_entries` pathway, threading the step's
229        // `config_override` as the only override surface (per-step
230        // caps and on_error overrides aren't exposed on delegation
231        // entries — the on_error lives in the IR step itself and is
232        // honored by the evaluator).
233        for name in collect_delegate_plugin_names(route) {
234            let entries = engine.build_override_entries(&name, None, None, None).await;
235            // Pick the first token.delegate entry. Per delegation-hooks
236            // spec, plugins typically register one handler under the
237            // single `token.delegate` hook name; multiple handlers
238            // would be unusual.
239            let delegate_entry = entries
240                .into_iter()
241                .find(|(hook_name, _)| hook_name == HOOK_TOKEN_DELEGATE);
242            if let Some((_, entry)) = delegate_entry {
243                plan.delegation_entries.insert(name, entry);
244            } else {
245                tracing::warn!(
246                    plugin = %name,
247                    route = %route.route_key,
248                    "APL route references delegate plugin not registered under \
249                     token.delegate hook — `delegate(...)` step will fail at dispatch",
250                );
251            }
252        }
253
254        // Resolve `elicit` entries for any plugins the route calls via an
255        // elicitation verb (`require_approval(...)`, `confirm(...)`, …).
256        // Same `name → entry` resolution as delegation — elicitation
257        // routes by plugin name, not `(kind, channel)`.
258        for name in collect_elicit_plugin_names(route) {
259            let entries = engine.build_override_entries(&name, None, None, None).await;
260            let elicit_entry = entries
261                .into_iter()
262                .find(|(hook_name, _)| hook_name == HOOK_ELICIT);
263            if let Some((_, entry)) = elicit_entry {
264                plan.elicitation_entries.insert(name, entry);
265            } else {
266                tracing::warn!(
267                    plugin = %name,
268                    route = %route.route_key,
269                    "APL route references elicitation plugin not registered under \
270                     elicit hook — `require_approval(...)`/`confirm(...)` step will \
271                     fail at dispatch",
272                );
273            }
274        }
275
276        plan
277    }
278
279    /// Look up the resolved entries for a plugin by name. None when the
280    /// plugin wasn't referenced by the route (or was skipped during
281    /// build due to config drift).
282    pub fn get(&self, plugin_name: &str) -> Option<&RoutePluginEntry> {
283        self.plugins.get(plugin_name)
284    }
285
286    /// Resolve a single plugin's entries straight off praxis-policy-core, with
287    /// no APL route-level overrides. Convenience for tests and for hosts
288    /// that wire the invoker without a `CompiledRoute` in scope (e.g.
289    /// adapters that invoke a single plugin imperatively). Returns
290    /// `None` if praxis-policy-core has no entries for the plugin.
291    pub fn resolve_plugin(engine: &PolicyEngine, plugin_name: &str) -> Option<RoutePluginEntry> {
292        let base_entries = engine.find_plugin_entries(plugin_name);
293        if base_entries.is_empty() {
294            return None;
295        }
296        let mut entries_by_hook: HashMap<String, HookEntry> = HashMap::new();
297        for (hook_name, entry) in base_entries {
298            entries_by_hook.insert(hook_name, entry);
299        }
300        Some(RoutePluginEntry {
301            plugin_name: plugin_name.to_owned(),
302            entries_by_hook,
303        })
304    }
305}
306
307fn parse_on_error(s: &str) -> Option<OnError> {
308    match s.to_ascii_lowercase().as_str() {
309        "fail" => Some(OnError::Fail),
310        "ignore" => Some(OnError::Ignore),
311        "disable" => Some(OnError::Disable),
312        _ => None,
313    }
314}
315
316/// Recursively walk every effect node in an `Effect` tree, invoking
317/// `visit` on each. Used by `collect_*_names` below to find Plugin /
318/// Delegate references that may be nested inside `Effect::When`,
319/// `Effect::Sequential`, `Effect::Parallel`, or `Effect::Pdp` reaction
320/// lists. Previously these were flat — `Step::Plugin` lived directly under
321/// policy: — so a simple `iter()` was enough; now the IR is tree-
322/// shaped and the same scan needs recursion.
323pub(crate) fn walk_effects<F: FnMut(&Effect)>(effects: &[Effect], visit: &mut F) {
324    for e in effects {
325        visit(e);
326        match e {
327            Effect::When { body, .. } => walk_effects(body, visit),
328            Effect::Sequential(inner) | Effect::Parallel(inner) => walk_effects(inner, visit),
329            Effect::Pdp {
330                on_allow, on_deny, ..
331            } => {
332                walk_effects(on_allow, visit);
333                walk_effects(on_deny, visit);
334            },
335            _ => {},
336        }
337    }
338}
339
340/// Walk a `CompiledRoute` and return the unique delegate-plugin names
341/// referenced by any `Effect::Delegate` anywhere in `pre_invocation` /
342/// `post_invocation` (including effects nested inside When / Sequential /
343/// Parallel / Pdp reactions). Insertion-ordered for build determinism.
344/// Separate from [`collect_plugin_names`] because delegate plugins
345/// resolve under a different hook family (`token.delegate`) and the
346/// dispatch plan keeps them in a separate map.
347#[cfg(test)]
348#[allow(
349    clippy::expect_used,
350    clippy::unwrap_used,
351    reason = "tests assert on known-good values"
352)]
353mod identity_check_tests {
354    use super::*;
355
356    fn delegate_step(plugin: &str, subject: Option<&str>) -> Effect {
357        let config_override = subject.map(|s| {
358            let mut m = serde_yaml::Mapping::new();
359            m.insert(
360                serde_yaml::Value::String("subject".into()),
361                serde_yaml::Value::String(s.into()),
362            );
363            serde_yaml::Value::Mapping(m)
364        });
365        Effect::Delegate(praxis_policy_apl_core::step::DelegateStep {
366            plugin_name: plugin.to_owned(),
367            config_override,
368            on_error: None,
369            source: "test".to_owned(),
370        })
371    }
372
373    fn route_with(steps: Vec<Effect>) -> CompiledRoute {
374        let mut route = CompiledRoute::new("tool:get_compensation");
375        route.pre_invocation = steps;
376        route
377    }
378
379    #[test]
380    fn a_step_with_no_subject_defaults_to_user() {
381        // The invoker defaults an unspecified subject to `user`, so an
382        // unannotated step does exchange the caller's credential.
383        let route = route_with(vec![delegate_step("oauth", None)]);
384        assert_eq!(
385            delegate_steps_exchanging_caller_credentials(&route),
386            vec!["oauth".to_owned()]
387        );
388    }
389
390    #[test]
391    fn caller_credential_subjects_are_reported() {
392        for subject in ["user", "client", "caller_workload"] {
393            let route = route_with(vec![delegate_step("oauth", Some(subject))]);
394            assert_eq!(
395                delegate_steps_exchanging_caller_credentials(&route),
396                vec!["oauth".to_owned()],
397                "subject {subject} exchanges a credential the caller presented"
398            );
399        }
400    }
401
402    #[test]
403    fn this_workload_is_not_reported() {
404        // No inbound credential exists for this subject, so identity
405        // resolution has nothing to say about it and warning would be
406        // noise on a correct config.
407        for subject in ["this_workload", "gateway"] {
408            let route = route_with(vec![delegate_step("oauth", Some(subject))]);
409            assert!(
410                delegate_steps_exchanging_caller_credentials(&route).is_empty(),
411                "subject {subject} carries no caller credential"
412            );
413        }
414    }
415
416    #[test]
417    fn a_mixed_route_reports_only_the_steps_that_need_identity() {
418        let route = route_with(vec![
419            delegate_step("gateway-oauth", Some("this_workload")),
420            delegate_step("user-oauth", Some("user")),
421        ]);
422        assert_eq!(
423            delegate_steps_exchanging_caller_credentials(&route),
424            vec!["user-oauth".to_owned()]
425        );
426    }
427
428    #[test]
429    fn a_route_with_no_delegation_reports_nothing() {
430        assert!(delegate_steps_exchanging_caller_credentials(&route_with(Vec::new())).is_empty());
431    }
432}
433
434/// Warn when a route exchanges a credential the caller presented but
435/// resolves no identity for it.
436///
437/// A route that delegates the caller's own credential depends on
438/// something having validated it first. Identity resolution is a
439/// separate hook the host invokes at request entry, and it is per-route
440/// configuration: `route.authentication` is optional, and a route with no
441/// identity block resolves no identity plugins at all. Nothing then
442/// rejects a token the `IdP` never signed before the delegator exchanges
443/// it.
444///
445/// A warning rather than a hard error: the host, not this crate, decides
446/// how identity is established, and a deployment may legitimately
447/// validate upstream of PPE. But it is silent otherwise, and the failure
448/// it precedes (a delegation minted against an unvalidated credential)
449/// does not look like a configuration problem when it surfaces.
450///
451/// Called from the visitor's config walk rather than from
452/// [`RouteDispatchPlan::build`], which runs on a route's first request:
453/// a misconfigured route nobody has called yet is exactly the one an
454/// operator wants told about, and at plan-build time the warning would
455/// wait for the traffic it is meant to precede.
456pub(crate) fn warn_if_delegating_without_identity(
457    route: &CompiledRoute,
458    entity_type: &str,
459    entity_name: &str,
460    scope: Option<&str>,
461    engine: &PolicyEngine,
462) {
463    let unvalidated = delegate_steps_exchanging_caller_credentials(route);
464    if unvalidated.is_empty()
465        || engine.route_has_identity_resolution(entity_type, entity_name, scope)
466    {
467        return;
468    }
469    tracing::warn!(
470        alarm = "delegation_without_identity_resolution",
471        route = %route.route_key,
472        plugins = ?unvalidated,
473        "route delegates the caller's own credential but resolves no identity for it; \
474         the token handed to the delegator has not been validated by this process, so \
475         the IdP's rejection of a forged one is the only thing standing between an \
476         unauthenticated caller and a token exchange. Add an `identity:` block to the \
477         route (or globally), or use `subject: this_workload` if the delegation should \
478         not carry the caller's credential at all.",
479    );
480}
481
482/// Delegate steps on this route that exchange a credential the *caller*
483/// presented, and therefore depend on something having validated it.
484///
485/// `subject: this_workload` is excluded: that delegation has no inbound
486/// credential by design — the instance authenticates as itself with its
487/// own client credentials — so identity resolution is irrelevant to it.
488/// `user` is the default when a step names no subject, matching the
489/// invoker.
490fn delegate_steps_exchanging_caller_credentials(route: &CompiledRoute) -> Vec<String> {
491    let mut out: Vec<String> = Vec::new();
492    let mut seen: HashSet<String> = HashSet::new();
493    let mut visit = |e: &Effect| {
494        let Effect::Delegate(ds) = e else {
495            return;
496        };
497        let subject = ds
498            .config_override
499            .as_ref()
500            .and_then(serde_yaml::Value::as_mapping)
501            .and_then(|m| m.get(serde_yaml::Value::String("subject".into())))
502            .and_then(serde_yaml::Value::as_str)
503            .unwrap_or("user");
504        // `gateway` is the deprecated spelling of `this_workload`.
505        if matches!(subject, "this_workload" | "gateway") {
506            return;
507        }
508        if seen.insert(ds.plugin_name.clone()) {
509            out.push(ds.plugin_name.clone());
510        }
511    };
512    walk_effects(&route.pre_invocation, &mut visit);
513    walk_effects(&route.post_invocation, &mut visit);
514    out
515}
516
517pub(crate) fn collect_delegate_plugin_names(route: &CompiledRoute) -> Vec<String> {
518    let mut out: Vec<String> = Vec::new();
519    let mut seen: HashSet<String> = HashSet::new();
520    let mut visit = |e: &Effect| {
521        if let Effect::Delegate(ds) = e
522            && seen.insert(ds.plugin_name.clone())
523        {
524            out.push(ds.plugin_name.clone());
525        }
526    };
527    walk_effects(&route.pre_invocation, &mut visit);
528    walk_effects(&route.post_invocation, &mut visit);
529    out
530}
531
532/// Walk a `CompiledRoute` and return the unique elicitation-plugin names
533/// referenced by any `Effect::Elicit` anywhere in `pre_invocation` /
534/// `post_invocation` (including nested). Insertion-ordered for build
535/// determinism. Separate from [`collect_plugin_names`] because
536/// elicitation plugins resolve under the `elicit` hook family and the
537/// plan keeps them in their own map.
538pub(crate) fn collect_elicit_plugin_names(route: &CompiledRoute) -> Vec<String> {
539    let mut out: Vec<String> = Vec::new();
540    let mut seen: HashSet<String> = HashSet::new();
541    let mut visit = |e: &Effect| {
542        if let Effect::Elicit(es) = e
543            && seen.insert(es.plugin_name.clone())
544        {
545            out.push(es.plugin_name.clone());
546        }
547    };
548    walk_effects(&route.pre_invocation, &mut visit);
549    walk_effects(&route.post_invocation, &mut visit);
550    out
551}
552
553/// Walk a `CompiledRoute` and return the unique plugin names referenced
554/// by any `Effect::Plugin` anywhere in `pre_invocation` / `post_invocation` (including
555/// nested) or `Stage::Plugin` (in `args` / `result` pipelines).
556/// Insertion-ordered for build determinism.
557pub(crate) fn collect_plugin_names(route: &CompiledRoute) -> Vec<String> {
558    let mut out: Vec<String> = Vec::new();
559    let mut seen: HashSet<String> = HashSet::new();
560    let mut visit = |e: &Effect| {
561        if let Effect::Plugin { name } = e
562            && seen.insert(name.clone())
563        {
564            out.push(name.clone());
565        }
566    };
567    walk_effects(&route.pre_invocation, &mut visit);
568    walk_effects(&route.post_invocation, &mut visit);
569    for fr in route.args.iter().chain(route.result.iter()) {
570        for stage in &fr.pipeline.stages {
571            if let Stage::Plugin { name } = stage
572                && seen.insert(name.clone())
573            {
574                out.push(name.clone());
575            }
576        }
577    }
578    out
579}
580
581/// The plugin names a `CompiledRoute` reaches, split by which half reaches
582/// them: `(pre, post)`.
583///
584/// [`collect_plugin_names`] unions the two halves, which is what a dispatch
585/// plan wants. The reachability report wants them apart, because a route's two
586/// halves install under different hooks and a plugin reached on one is not
587/// covered on the other. `args` pipelines run on the pre half and `result`
588/// pipelines on the post half, so each stage joins the side it runs on.
589///
590/// Delegation and elicitation names join both sides: their effects carry a
591/// plugin the route dispatches to the same way a step does.
592pub(crate) fn collect_plugin_names_by_half(route: &CompiledRoute) -> (Vec<String>, Vec<String>) {
593    fn names_in(effects: &[Effect]) -> Vec<String> {
594        let mut out: Vec<String> = Vec::new();
595        let mut seen: HashSet<String> = HashSet::new();
596        let mut visit = |e: &Effect| {
597            let name = match e {
598                Effect::Plugin { name } => name,
599                Effect::Delegate(ds) => &ds.plugin_name,
600                Effect::Elicit(es) => &es.plugin_name,
601                _ => return,
602            };
603            if seen.insert(name.clone()) {
604                out.push(name.clone());
605            }
606        };
607        walk_effects(effects, &mut visit);
608        out
609    }
610
611    fn stage_names(fields: &[FieldRule]) -> Vec<String> {
612        let mut out: Vec<String> = Vec::new();
613        for fr in fields {
614            for stage in &fr.pipeline.stages {
615                if let Stage::Plugin { name } = stage
616                    && !out.contains(name)
617                {
618                    out.push(name.clone());
619                }
620            }
621        }
622        out
623    }
624
625    fn extend_unique(into: &mut Vec<String>, names: Vec<String>) {
626        for name in names {
627            if !into.contains(&name) {
628                into.push(name);
629            }
630        }
631    }
632
633    let mut pre = names_in(&route.pre_invocation);
634    extend_unique(&mut pre, stage_names(&route.args));
635    let mut post = names_in(&route.post_invocation);
636    extend_unique(&mut post, stage_names(&route.result));
637    (pre, post)
638}
639
640/// Return `(plugin, hook)` pairs for effects whose family fixes the hook.
641///
642/// Delegation uses `token.delegate`; elicitation uses `elicit`, independent of
643/// the route's entity.
644pub(crate) fn collect_family_fixed_plugin_hooks(
645    route: &CompiledRoute,
646) -> Vec<(String, &'static str)> {
647    let mut out: Vec<(String, &'static str)> = Vec::new();
648    let mut visit = |e: &Effect| {
649        let pair = match e {
650            Effect::Delegate(ds) => (ds.plugin_name.clone(), HOOK_TOKEN_DELEGATE),
651            Effect::Elicit(es) => (es.plugin_name.clone(), HOOK_ELICIT),
652            _ => return,
653        };
654        if !out.contains(&pair) {
655            out.push(pair);
656        }
657    };
658    walk_effects(&route.pre_invocation, &mut visit);
659    walk_effects(&route.post_invocation, &mut visit);
660    out
661}
662
663/// Compute the union of capabilities declared by every plugin a
664/// `CompiledRoute` can dispatch to (with per-route overrides applied).
665///
666/// This is what the synthetic `AplRouteHandler`'s `PluginConfig.capabilities`
667/// must be set to: praxis-policy-core's executor filters the `Extensions` view
668/// before invoking every plugin (including the synthetic one), so if
669/// the handler has fewer capabilities than its inner plugins need,
670/// downstream views get doubly-filtered and label/delegation mutations
671/// fail monotonicity checks on the way back out.
672///
673/// Plugins missing from the registry are silently skipped — the
674/// dispatch plan will log a `warn!` and surface a `NotFound` at
675/// invocation time, so config drift surfaces in the right place
676/// rather than as a confusing capability gap.
677pub(crate) fn route_capability_union(
678    route: &CompiledRoute,
679    registry: &PluginRegistry,
680) -> std::collections::HashSet<String> {
681    let mut caps: std::collections::HashSet<String> = std::collections::HashSet::new();
682    // Plugin steps (`run(name)` in policy / `plugin: name` in
683    // args / result pipelines).
684    for name in collect_plugin_names(route) {
685        if let Some(eff) = EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) {
686            for cap in eff.capabilities.as_slice() {
687                caps.insert(cap.clone());
688            }
689        }
690    }
691    // Delegate steps (`delegate(name, ...)`). Without this, a
692    // delegator plugin that declares `capabilities:
693    // [read_inbound_credentials, write_delegated_tokens]` in YAML
694    // gets those stripped at the AplRouteHandler boundary — the
695    // synthetic handler doesn't union its caps in, so the executor
696    // filters out the inbound bearer before DelegationPluginInvoker
697    // dispatches, and the delegator handler sees an empty token.
698    // Hosts WANT to express per-plugin caps in YAML rather than
699    // widening the AplRouteHandler's baseline (which would leak
700    // those creds to every other step in the route).
701    for name in collect_delegate_plugin_names(route) {
702        if let Some(eff) = EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) {
703            for cap in eff.capabilities.as_slice() {
704                caps.insert(cap.clone());
705            }
706        }
707    }
708    // Elicitation steps (`require_approval(name, ...)`, …) — same reason
709    // as delegation: an elicitation handler that declares e.g.
710    // `read_subject` (to read the approver identity) must not have it
711    // stripped at the AplRouteHandler boundary.
712    let elicit_plugins = collect_elicit_plugin_names(route);
713    for name in &elicit_plugins {
714        if let Some(eff) = EffectivePlugin::resolve(name, registry, &route.plugin_overrides) {
715            for cap in eff.capabilities.as_slice() {
716                caps.insert(cap.clone());
717            }
718        }
719    }
720    // A route with an elicitation step needs `read_headers` on the synthetic
721    // handler so the `X-Policy-Elicitation-Id` retry header survives the
722    // capability filter and reaches the bag for retry seeding. Without
723    // it, the `http` extension is stripped before the handler reads it and
724    // every retry re-dispatches a fresh elicitation instead of checking.
725    if !elicit_plugins.is_empty() {
726        caps.insert("read_headers".to_owned());
727    }
728    caps
729}
730
731/// Host-owned dispatch cache. Construct once, share via `Arc<DispatchCache>`
732/// across all `CmfPluginInvoker::for_request` calls so plans built for
733/// one request can be reused by the next.
734///
735/// Cache key is the APL `route_key`. Entries pair with the praxis-policy-core
736/// snapshot generation observed at build time; a mismatch on lookup
737/// triggers eviction and rebuild. v0 keys on `route_key` only —
738/// entity-aware caching (`entity_type/entity_name` from `MetaExtension`)
739/// is a follow-up when per-tenant lineup variation lands.
740#[derive(Default)]
741pub struct DispatchCache {
742    inner: RwLock<HashMap<String, (u64, Arc<RouteDispatchPlan>)>>,
743}
744
745impl DispatchCache {
746    /// An empty plan.
747    pub fn new() -> Self {
748        Self::default()
749    }
750
751    /// Get-or-build a plan for the route. Read-locked fast path returns
752    /// the cached plan when the generation matches; otherwise drop the
753    /// read lock, rebuild, and write-lock-insert. The brief window
754    /// between read-miss and write-insert may let two concurrent
755    /// builders race — both produce identical plans and the second
756    /// insert just overwrites the first. Cheap relative to the cost of
757    /// the build itself, and avoids holding a write lock across the
758    /// build call.
759    ///
760    /// Async because `RouteDispatchPlan::build` may invoke
761    /// `PolicyEngine::build_override_entries`, which calls plugin
762    /// factories and `initialize()` for routes that declare `config:`
763    /// overrides. Routes with no overrides take a synchronous path
764    /// inside the engine (no `.await` does any real work), so the
765    /// async cost is zero for the common case.
766    pub async fn get_or_build(
767        &self,
768        route: &CompiledRoute,
769        registry: &PluginRegistry,
770        engine: &PolicyEngine,
771    ) -> Arc<RouteDispatchPlan> {
772        let current_gen = engine.config_generation();
773        {
774            let r = self
775                .inner
776                .read()
777                .unwrap_or_else(std::sync::PoisonError::into_inner);
778            if let Some((stored_gen, plan)) = r.get(&route.route_key)
779                && *stored_gen == current_gen
780            {
781                return Arc::clone(plan);
782            }
783        }
784        let plan = Arc::new(RouteDispatchPlan::build(route, registry, engine).await);
785        let mut w = self
786            .inner
787            .write()
788            .unwrap_or_else(std::sync::PoisonError::into_inner);
789        w.insert(route.route_key.clone(), (current_gen, Arc::clone(&plan)));
790        plan
791    }
792}