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}