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::hooks::{lookup_hook_metadata, HookPhase};
48use cpex_core::manager::PluginManager;
49use cpex_core::plugin::OnError;
50use cpex_core::registry::HookEntry;
51
52use apl_core::pipeline::Stage;
53use apl_core::plugin_decl::{EffectivePlugin, PluginRegistry};
54use apl_core::rules::{CompiledRoute, Effect};
55
56/// Per-plugin pre-resolved entries for one route. Stores ALL hook
57/// entries the plugin registered (keyed by hook name) so the
58/// dispatcher can pick the right one for the current context via the
59/// cpex-core hook routing table (`hooks::metadata::lookup`).
60///
61/// Replaces the prior `step_entry` / `field_entry` slot model, which
62/// used a brittle naming heuristic to classify hooks and silently
63/// collapsed plugins with multiple step-context hooks (e.g. both
64/// `tool_pre_invoke` and `tool_post_invoke`) to a single entry.
65#[derive(Clone)]
66pub struct RoutePluginEntry {
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 /// cpex-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:` / `policy:`, `Post` for
86 /// `result:` / `post_policy:`, `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 pub plugins: HashMap<String, RoutePluginEntry>,
117 /// Plugin name → resolved `token.delegate` hook entry for routes
118 /// that declared `delegate(...)` steps. Empty when the route has
119 /// no delegation. Built at plan time to avoid per-request
120 /// `find_plugin_entries` lookups in the hot path.
121 pub delegation_entries: HashMap<String, HookEntry>,
122}
123
124impl RouteDispatchPlan {
125 /// Build a plan for the given route. Walks all steps + pipeline
126 /// stages, collects the unique set of plugin names, resolves each
127 /// against cpex-core, and applies any APL route-level overrides.
128 ///
129 /// Plugins referenced by APL but absent from cpex-core's registry
130 /// (or absent from the APL `plugins:` block) are logged at `warn`
131 /// and excluded — dispatch then fails with `PluginError::NotFound`
132 /// when those plugins are invoked, which is the right behavior for
133 /// surfacing config drift.
134 pub async fn build(
135 route: &CompiledRoute,
136 registry: &PluginRegistry,
137 manager: &PluginManager,
138 ) -> Self {
139 let mut plan = Self::default();
140 for name in collect_plugin_names(route) {
141 let eff = match EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) {
142 Some(e) => e,
143 None => {
144 tracing::warn!(
145 plugin = %name,
146 route = %route.route_key,
147 "APL route references plugin not in `plugins:` block — skipping",
148 );
149 continue;
150 },
151 };
152
153 // Pull the three overrideable values off the effective view.
154 // `EffectivePlugin` borrows from the registry / route overrides,
155 // so the captures here are slice / Option<&Value> refs.
156 let override_block = route.plugin_overrides.get(&name);
157 let config_override = override_block.and_then(|o| o.config.as_ref());
158 let caps_override: Option<std::collections::HashSet<String>> = if matches!(
159 eff.capabilities,
160 apl_core::plugin_decl::CapsView::Override(_)
161 ) {
162 Some(eff.capabilities.as_slice().iter().cloned().collect())
163 } else {
164 None
165 };
166 let on_error_override = override_block
167 .and_then(|o| o.on_error.as_deref())
168 .and_then(parse_on_error);
169
170 // Hand the override decision to cpex-core. When no overrides
171 // are declared, this returns the base entries unchanged
172 // (no allocation, no factory call). When only caps/on_error
173 // differ, it wraps the shared base plugin in a fresh
174 // `PluginRef` with merged trusted config. When config
175 // differs, it invokes the factory + initializes a brand-new
176 // instance with its own circuit breaker.
177 let entries = manager
178 .build_override_entries(
179 &name,
180 config_override,
181 caps_override.as_ref(),
182 on_error_override,
183 )
184 .await;
185 if entries.is_empty() {
186 tracing::warn!(
187 plugin = %name,
188 route = %route.route_key,
189 "APL plugin not resolvable (not registered, factory missing, \
190 or override construction failed) — skipping",
191 );
192 continue;
193 }
194
195 // Store every (hook_name, HookEntry) pair the plugin
196 // registered. Dispatch-time entry selection (pick_entry)
197 // consults cpex-core's hook routing table per hook name.
198 // Replaces the prior naming heuristic.
199 let mut entries_by_hook: HashMap<String, HookEntry> = HashMap::new();
200 for (hook_name, entry) in entries {
201 entries_by_hook.insert(hook_name, entry);
202 }
203
204 plan.plugins.insert(
205 name.clone(),
206 RoutePluginEntry {
207 plugin_name: name,
208 entries_by_hook,
209 },
210 );
211 }
212
213 // Resolve token.delegate entries for any plugins the route
214 // calls via `Step::Delegate`. These don't go through the
215 // step/field classification — they're a separate hook family.
216 // We still apply per-call config overrides via the existing
217 // `build_override_entries` pathway, threading the step's
218 // `config_override` as the only override surface (Slice B
219 // doesn't expose per-step caps or on_error overrides on
220 // delegation entries — the on_error lives in the IR step
221 // itself and is honored by the evaluator).
222 for name in collect_delegate_plugin_names(route) {
223 let entries = manager
224 .build_override_entries(&name, None, None, None)
225 .await;
226 // Pick the first token.delegate entry. Per delegation-hooks
227 // spec, plugins typically register one handler under the
228 // single `token.delegate` hook name; multiple handlers
229 // would be unusual.
230 let delegate_entry = entries
231 .into_iter()
232 .find(|(hook_name, _)| hook_name == HOOK_TOKEN_DELEGATE);
233 if let Some((_, entry)) = delegate_entry {
234 plan.delegation_entries.insert(name, entry);
235 } else {
236 tracing::warn!(
237 plugin = %name,
238 route = %route.route_key,
239 "APL route references delegate plugin not registered under \
240 token.delegate hook — `delegate(...)` step will fail at dispatch",
241 );
242 }
243 }
244
245 plan
246 }
247
248 /// Look up the resolved entries for a plugin by name. None when the
249 /// plugin wasn't referenced by the route (or was skipped during
250 /// build due to config drift).
251 pub fn get(&self, plugin_name: &str) -> Option<&RoutePluginEntry> {
252 self.plugins.get(plugin_name)
253 }
254
255 /// Resolve a single plugin's entries straight off cpex-core, with
256 /// no APL route-level overrides. Convenience for tests and for hosts
257 /// that wire the invoker without a `CompiledRoute` in scope (e.g.
258 /// adapters that invoke a single plugin imperatively). Returns
259 /// `None` if cpex-core has no entries for the plugin.
260 pub fn resolve_plugin(manager: &PluginManager, plugin_name: &str) -> Option<RoutePluginEntry> {
261 let base_entries = manager.find_plugin_entries(plugin_name);
262 if base_entries.is_empty() {
263 return None;
264 }
265 let mut entries_by_hook: HashMap<String, HookEntry> = HashMap::new();
266 for (hook_name, entry) in base_entries {
267 entries_by_hook.insert(hook_name, entry);
268 }
269 Some(RoutePluginEntry {
270 plugin_name: plugin_name.to_string(),
271 entries_by_hook,
272 })
273 }
274}
275
276fn parse_on_error(s: &str) -> Option<OnError> {
277 match s.to_ascii_lowercase().as_str() {
278 "fail" => Some(OnError::Fail),
279 "ignore" => Some(OnError::Ignore),
280 "disable" => Some(OnError::Disable),
281 _ => None,
282 }
283}
284
285/// Recursively walk every effect node in an `Effect` tree, invoking
286/// `visit` on each. Used by `collect_*_names` below to find Plugin /
287/// Delegate references that may be nested inside `Effect::When`,
288/// `Effect::Sequential`, `Effect::Parallel`, or `Effect::Pdp` reaction
289/// lists. Pre-E4 these were flat — Step::Plugin lived directly under
290/// policy: — so a simple iter() was enough; after E4 the IR is tree-
291/// shaped and the same scan needs recursion.
292fn walk_effects<F: FnMut(&Effect)>(effects: &[Effect], visit: &mut F) {
293 for e in effects {
294 visit(e);
295 match e {
296 Effect::When { body, .. } => walk_effects(body, visit),
297 Effect::Sequential(inner) | Effect::Parallel(inner) => walk_effects(inner, visit),
298 Effect::Pdp {
299 on_allow, on_deny, ..
300 } => {
301 walk_effects(on_allow, visit);
302 walk_effects(on_deny, visit);
303 },
304 _ => {},
305 }
306 }
307}
308
309/// Walk a `CompiledRoute` and return the unique delegate-plugin names
310/// referenced by any `Effect::Delegate` anywhere in `policy` /
311/// `post_policy` (including effects nested inside When / Sequential /
312/// Parallel / Pdp reactions). Insertion-ordered for build determinism.
313/// Separate from [`collect_plugin_names`] because delegate plugins
314/// resolve under a different hook family (`token.delegate`) and the
315/// dispatch plan keeps them in a separate map.
316pub(crate) fn collect_delegate_plugin_names(route: &CompiledRoute) -> Vec<String> {
317 let mut out: Vec<String> = Vec::new();
318 let mut seen: HashSet<String> = HashSet::new();
319 let mut visit = |e: &Effect| {
320 if let Effect::Delegate(ds) = e {
321 if seen.insert(ds.plugin_name.clone()) {
322 out.push(ds.plugin_name.clone());
323 }
324 }
325 };
326 walk_effects(&route.policy, &mut visit);
327 walk_effects(&route.post_policy, &mut visit);
328 out
329}
330
331/// Walk a `CompiledRoute` and return the unique plugin names referenced
332/// by any `Effect::Plugin` anywhere in `policy` / `post_policy` (including
333/// nested) or `Stage::Plugin` (in `args` / `result` pipelines).
334/// Insertion-ordered for build determinism.
335pub(crate) fn collect_plugin_names(route: &CompiledRoute) -> Vec<String> {
336 let mut out: Vec<String> = Vec::new();
337 let mut seen: HashSet<String> = HashSet::new();
338 let mut visit = |e: &Effect| {
339 if let Effect::Plugin { name } = e {
340 if seen.insert(name.clone()) {
341 out.push(name.clone());
342 }
343 }
344 };
345 walk_effects(&route.policy, &mut visit);
346 walk_effects(&route.post_policy, &mut visit);
347 for fr in route.args.iter().chain(route.result.iter()) {
348 for stage in &fr.pipeline.stages {
349 if let Stage::Plugin { name } = stage {
350 if seen.insert(name.clone()) {
351 out.push(name.clone());
352 }
353 }
354 }
355 }
356 out
357}
358
359/// Compute the union of capabilities declared by every plugin a
360/// `CompiledRoute` can dispatch to (with per-route overrides applied).
361///
362/// This is what the synthetic `AplRouteHandler`'s `PluginConfig.capabilities`
363/// must be set to: cpex-core's executor filters the `Extensions` view
364/// before invoking every plugin (including the synthetic one), so if
365/// the handler has fewer capabilities than its inner plugins need,
366/// downstream views get doubly-filtered and label/delegation mutations
367/// fail monotonicity checks on the way back out.
368///
369/// Plugins missing from the registry are silently skipped — the
370/// dispatch plan will log a `warn!` and surface a `NotFound` at
371/// invocation time, so config drift surfaces in the right place
372/// rather than as a confusing capability gap.
373pub(crate) fn route_capability_union(
374 route: &CompiledRoute,
375 registry: &PluginRegistry,
376) -> std::collections::HashSet<String> {
377 let mut caps: std::collections::HashSet<String> = std::collections::HashSet::new();
378 // Plugin steps (`plugin(name)` in policy / `plugin: name` in
379 // args / result pipelines).
380 for name in collect_plugin_names(route) {
381 if let Some(eff) = EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) {
382 for cap in eff.capabilities.as_slice() {
383 caps.insert(cap.clone());
384 }
385 }
386 }
387 // Delegate steps (`delegate(name, ...)`). Without this, a
388 // delegator plugin that declares `capabilities:
389 // [read_inbound_credentials, write_delegated_tokens]` in YAML
390 // gets those stripped at the AplRouteHandler boundary — the
391 // synthetic handler doesn't union its caps in, so the executor
392 // filters out the inbound bearer before DelegationPluginInvoker
393 // dispatches, and the delegator handler sees an empty token.
394 // Hosts WANT to express per-plugin caps in YAML rather than
395 // widening the AplRouteHandler's baseline (which would leak
396 // those creds to every other step in the route).
397 for name in collect_delegate_plugin_names(route) {
398 if let Some(eff) = EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) {
399 for cap in eff.capabilities.as_slice() {
400 caps.insert(cap.clone());
401 }
402 }
403 }
404 caps
405}
406
407/// Host-owned dispatch cache. Construct once, share via `Arc<DispatchCache>`
408/// across all `CmfPluginInvoker::for_request` calls so plans built for
409/// one request can be reused by the next.
410///
411/// Cache key is the APL `route_key`. Entries pair with the cpex-core
412/// snapshot generation observed at build time; a mismatch on lookup
413/// triggers eviction and rebuild. v0 keys on `route_key` only —
414/// entity-aware caching (entity_type/entity_name from `MetaExtension`)
415/// is a follow-up when per-tenant lineup variation lands.
416#[derive(Default)]
417pub struct DispatchCache {
418 inner: RwLock<HashMap<String, (u64, Arc<RouteDispatchPlan>)>>,
419}
420
421impl DispatchCache {
422 pub fn new() -> Self {
423 Self::default()
424 }
425
426 /// Get-or-build a plan for the route. Read-locked fast path returns
427 /// the cached plan when the generation matches; otherwise drop the
428 /// read lock, rebuild, and write-lock-insert. The brief window
429 /// between read-miss and write-insert may let two concurrent
430 /// builders race — both produce identical plans and the second
431 /// insert just overwrites the first. Cheap relative to the cost of
432 /// the build itself, and avoids holding a write lock across the
433 /// build call.
434 ///
435 /// Async because `RouteDispatchPlan::build` may invoke
436 /// `PluginManager::build_override_entries`, which calls plugin
437 /// factories and `initialize()` for routes that declare `config:`
438 /// overrides. Routes with no overrides take a synchronous path
439 /// inside the manager (no `.await` does any real work), so the
440 /// async cost is zero for the common case.
441 pub async fn get_or_build(
442 &self,
443 route: &CompiledRoute,
444 registry: &PluginRegistry,
445 manager: &PluginManager,
446 ) -> Arc<RouteDispatchPlan> {
447 let current_gen = manager.config_generation();
448 {
449 let r = self.inner.read().unwrap_or_else(|p| p.into_inner());
450 if let Some((stored_gen, plan)) = r.get(&route.route_key) {
451 if *stored_gen == current_gen {
452 return Arc::clone(plan);
453 }
454 }
455 }
456 let plan = Arc::new(RouteDispatchPlan::build(route, registry, manager).await);
457 let mut w = self.inner.write().unwrap_or_else(|p| p.into_inner());
458 w.insert(route.route_key.clone(), (current_gen, Arc::clone(&plan)));
459 plan
460 }
461}