Skip to main content

apl_cpex/
dispatch_plan.rs

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