Skip to main content

apl_cpex/
visitor.rs

1// Location: ./crates/apl-cpex/src/visitor.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// `AplConfigVisitor` — the cpex-core `ConfigVisitor` implementation that
7// stacks the unified-config hierarchy (global → defaults → tag bundles
8// → routes) into a single `CompiledRoute` per route and installs an
9// [`AplRouteHandler`] for each phase via `PluginManager::annotate_route`.
10//
11// # Hierarchy stacking
12//
13// Each `visit_*` call carries a single block of raw YAML. The visitor
14// finds the `apl:` sub-block (if any), compiles it to a `CompiledRoute`,
15// and stashes it in interior state:
16//
17//   visit_global       → state.global_layer
18//   visit_default      → state.default_layers[entity_type]
19//   visit_policy_bundle → state.tag_layers[tag]
20//   visit_route        → build effective route by layering and annotate.
21//
22// At `visit_route` we layer least-to-most-specific:
23//
24//   effective = global
25//   effective.apply_layer(default_layer_for(entity_type))
26//   for tag in route.meta.tags { effective.apply_layer(tag_layer(tag)) }
27//   effective.apply_layer(route_apl_block)
28//
29// then construct one `AplRouteHandler` per phase (Pre, Post) and call
30// `annotate_route` for each `(entity_type, entity_name, scope, hook)`.
31//
32// # Hook names per entity type
33//
34// Each entity type binds to its own CMF hook pair:
35//
36//   * `tool:`     → `cmf.tool_pre_invoke`     / `cmf.tool_post_invoke`
37//   * `llm:`      → `cmf.llm_input`           / `cmf.llm_output`
38//   * `prompt:`   → `cmf.prompt_pre_invoke`   / `cmf.prompt_post_invoke`
39//   * `resource:` → `cmf.resource_pre_fetch`  / `cmf.resource_post_fetch`
40//
41// The mapping lives in [`hook_pair_for_entity`]. Hosts fire
42// `mgr.invoke_named::<CmfHook>("cmf.llm_input", ...)` for LLM
43// invocations; the visitor's annotation on `cmf.llm_input` for the
44// matching route's entity_name is what AplRouteHandler intercepts.
45//
46// `tool_pre_invoke` / `tool_post_invoke` are exposed as legacy
47// re-exports for callers that wired against the v0 constants — the
48// per-entity dispatch is the load-bearing path now.
49
50use std::collections::HashMap;
51use std::sync::{Arc, RwLock, Weak};
52
53use cpex_core::cmf::constants::{
54    ENTITY_LLM, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_LLM_INPUT,
55    HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE, HOOK_CMF_PROMPT_PRE_INVOKE,
56    HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_TOOL_POST_INVOKE,
57    HOOK_CMF_TOOL_PRE_INVOKE,
58};
59use cpex_core::config::RouteEntry;
60use cpex_core::manager::PluginManager;
61use cpex_core::plugin::PluginConfig;
62use cpex_core::visitor::{ConfigVisitor, VisitorError};
63
64use apl_core::parser::compile_policy_block_value;
65use apl_core::plugin_decl::{PluginDeclaration, PluginRegistry};
66use apl_core::rules::CompiledRoute;
67use apl_core::step::{PdpFactory, PdpResolver};
68
69use crate::dispatch_plan::DispatchCache;
70use crate::pdp_router::PdpRouter;
71use crate::route_handler::{AplRouteHandler, Phase};
72use crate::session_store::{SessionStore, SessionStoreFactory};
73
74/// Legacy alias for the tool-family pre hook. Kept exported for
75/// callers that wired against the v0 visitor constants — the
76/// per-entity-type dispatch via `hook_pair_for_entity` is the
77/// load-bearing path now.
78pub const HOOK_PRE: &str = HOOK_CMF_TOOL_PRE_INVOKE;
79/// Legacy alias for the tool-family post hook. See `HOOK_PRE`.
80pub const HOOK_POST: &str = HOOK_CMF_TOOL_POST_INVOKE;
81
82/// Resolve the (pre, post) CMF hook pair for an entity_type. Drives
83/// per-entity `annotate_route` calls so an `llm:` route annotates on
84/// `cmf.llm_input` / `cmf.llm_output` rather than the tool-family
85/// hooks. Returns `None` for unknown entity types — the visitor logs
86/// + skips those routes.
87fn hook_pair_for_entity(entity_type: &str) -> Option<(&'static str, &'static str)> {
88    match entity_type {
89        ENTITY_TOOL => Some((HOOK_CMF_TOOL_PRE_INVOKE, HOOK_CMF_TOOL_POST_INVOKE)),
90        ENTITY_LLM => Some((HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT)),
91        ENTITY_PROMPT => Some((HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_PROMPT_POST_INVOKE)),
92        ENTITY_RESOURCE => Some((HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_RESOURCE_POST_FETCH)),
93        _ => None,
94    }
95}
96
97/// Interior state accumulated as the manager walks the visitor.
98/// `plugin_registry` is populated by `visit_plugins` (called once per
99/// load); the layer fields are populated as the visitor walks
100/// `global` / `defaults` / `policies` / `routes`; `pdp_router` is
101/// populated by both code-supplied resolvers (`register_pdp`) and
102/// unified-config-driven entries under `global.apl.pdp[]` (built
103/// during `visit_global`).
104#[derive(Default)]
105struct VisitorState {
106    plugin_registry: PluginRegistry,
107    global_layer: Option<CompiledRoute>,
108    default_layers: HashMap<String, CompiledRoute>,
109    tag_layers: HashMap<String, CompiledRoute>,
110    pdp_router: PdpRouter,
111}
112
113/// APL implementation of [`cpex_core::visitor::ConfigVisitor`]. Construct
114/// once per host with the shared infrastructure (dispatch cache, session
115/// store, manager handle) and register with `PluginManager::register_visitor`
116/// before calling `load_config_yaml`.
117///
118/// PDPs come from two sources, both feeding the same internal
119/// [`PdpRouter`]:
120///
121/// 1. **Code-supplied** via `register_pdp` (or `AplOptions.pdps`) —
122///    the host built the resolver in code and hands it in.
123/// 2. **Config-supplied** via `global.apl.pdp[]` blocks in the unified
124///    config — the visitor sees the block, looks up a factory by
125///    `kind`, and constructs the resolver during `visit_global`.
126///
127/// Factories are registered up front by `kind` name (`"cedar-direct"`,
128/// `"opa"`, …). The visitor knows nothing about specific PDP
129/// backends; everything dispatches through `PdpFactory`.
130pub struct AplConfigVisitor {
131    state: RwLock<VisitorState>,
132    dispatch_cache: Arc<DispatchCache>,
133    /// Active session store. Behind a `RwLock` because a
134    /// `global.apl.session_store` block can swap it during the
135    /// config walk (`visit_global`), which runs before route handlers
136    /// capture the store in `visit_route`. Only touched during the
137    /// single-threaded config walk — never on the request hot path,
138    /// where each handler holds its own cloned `Arc`.
139    session_store: RwLock<Arc<dyn SessionStore>>,
140    manager: Weak<PluginManager>,
141    /// Baseline capabilities granted to every synthetic `AplRouteHandler`
142    /// the visitor installs. Unioned with the per-route plugin
143    /// capability set so APL predicates that touch extensions
144    /// (`require(authenticated)` needs `read_subject`, etc.) work even
145    /// when no plugins are referenced. Hosts that want strict gating
146    /// can set this to an empty set.
147    base_capabilities: std::collections::HashSet<String>,
148    /// Factories the visitor consults when it encounters a
149    /// `global.apl.pdp[]` entry. Keyed by the factory's `kind()` —
150    /// matches the `kind:` field in the YAML block.
151    pdp_factories: HashMap<String, Arc<dyn PdpFactory>>,
152    /// Factories the visitor consults for a `global.apl.session_store`
153    /// block. Keyed by the factory's `kind()`. Empty by default, in
154    /// which case the constructor-supplied store (typically
155    /// `MemorySessionStore`) stays active.
156    session_store_factories: HashMap<String, Arc<dyn SessionStoreFactory>>,
157}
158
159impl AplConfigVisitor {
160    pub fn new(
161        dispatch_cache: Arc<DispatchCache>,
162        session_store: Arc<dyn SessionStore>,
163        manager: Weak<PluginManager>,
164    ) -> Self {
165        Self {
166            state: RwLock::new(VisitorState::default()),
167            dispatch_cache,
168            session_store: RwLock::new(session_store),
169            manager,
170            base_capabilities: default_base_capabilities(),
171            pdp_factories: HashMap::new(),
172            session_store_factories: HashMap::new(),
173        }
174    }
175
176    /// Register a code-supplied PDP resolver. Equivalent to declaring a
177    /// PDP in the unified config but for hosts that prefer wiring
178    /// resolvers in Rust. Resolvers are pushed into the internal
179    /// `PdpRouter`; the first registration per dialect wins (matches
180    /// `PdpRouter::register` semantics).
181    pub fn register_pdp(&self, resolver: Arc<dyn PdpResolver>) {
182        let mut state = self.state.write().unwrap_or_else(|p| p.into_inner());
183        state.pdp_router.register(resolver);
184    }
185
186    /// Register a PDP factory by its `kind()`. Called during
187    /// `register_apl` setup; the visitor uses these to instantiate
188    /// resolvers from `global.apl.pdp[]` config blocks.
189    pub fn register_pdp_factory(&mut self, factory: Arc<dyn PdpFactory>) {
190        self.pdp_factories
191            .insert(factory.kind().to_string(), factory);
192    }
193
194    /// Register a `SessionStoreFactory` by its `kind()`. Called during
195    /// `register_apl` setup; the visitor uses these to swap in the
196    /// config-selected session store when it sees a
197    /// `global.apl.session_store` block.
198    pub fn register_session_store_factory(&mut self, factory: Arc<dyn SessionStoreFactory>) {
199        self.session_store_factories
200            .insert(factory.kind().to_string(), factory);
201    }
202
203    /// Parse the optional `global.apl.session_store` block and swap the
204    /// active store. Looks up the factory by `kind`, builds the store,
205    /// and replaces the constructor-supplied default. Runs during
206    /// `visit_global` — before `visit_route` clones the store into each
207    /// handler — so the selected store is the one handlers capture.
208    /// Absent block → no-op (the default store stays active).
209    fn build_session_store_from_config(
210        &self,
211        block: &serde_yaml::Value,
212    ) -> Result<(), VisitorError> {
213        let map = block.as_mapping().ok_or_else(|| {
214            "global.apl.session_store must be a mapping with a `kind:` field".to_string()
215        })?;
216        let kind = map
217            .get(serde_yaml::Value::String("kind".to_string()))
218            .and_then(|v| v.as_str())
219            .ok_or_else(|| "global.apl.session_store missing required `kind:` field".to_string())?;
220        let factory = self.session_store_factories.get(kind).ok_or_else(|| {
221            format!(
222                "global.apl.session_store declared kind='{}' but no factory is registered for that \
223                 kind — host must call register_session_store_factory(...) before load_config_yaml",
224                kind
225            )
226        })?;
227        let store = factory.build(block).map_err(|e| {
228            format!(
229                "global.apl.session_store (kind='{}') failed to build: {}",
230                kind, e
231            )
232        })?;
233        *self
234            .session_store
235            .write()
236            .unwrap_or_else(|p| p.into_inner()) = store;
237        Ok(())
238    }
239
240    /// Replace the baseline capability set granted to every installed
241    /// `AplRouteHandler`. Default covers read-only attributes APL
242    /// predicates commonly touch (subject, role, labels, delegation,
243    /// agent). Tighten this when the deployment's policy plugins
244    /// don't need broad reads — every cap removed is one fewer
245    /// extension slot a buggy predicate can leak through.
246    pub fn with_base_capabilities(mut self, caps: std::collections::HashSet<String>) -> Self {
247        self.base_capabilities = caps;
248        self
249    }
250
251    /// Parse one entry from `global.apl.pdp[]`. Reads `kind`, dispatches
252    /// to the matching factory, installs the resulting resolver into
253    /// the internal `PdpRouter`. Called per entry during `visit_global`.
254    ///
255    /// `index` is used only for diagnostics — operators see "the third
256    /// pdp entry failed" rather than a generic "a pdp entry failed."
257    fn build_pdp_from_config(
258        &self,
259        entry: &serde_yaml::Value,
260        index: usize,
261    ) -> Result<(), VisitorError> {
262        let map = entry.as_mapping().ok_or_else(|| {
263            format!(
264                "global.apl.pdp[{}] must be a mapping with a `kind:` field",
265                index
266            )
267        })?;
268        let kind = map
269            .get(serde_yaml::Value::String("kind".to_string()))
270            .and_then(|v| v.as_str())
271            .ok_or_else(|| format!("global.apl.pdp[{}] missing required `kind:` field", index))?;
272        let factory = self.pdp_factories.get(kind).ok_or_else(|| {
273            format!(
274                "global.apl.pdp[{}] declared kind='{}' but no factory is registered for that kind — \
275                 host must call register_pdp_factory(...) before load_config_yaml",
276                index, kind
277            )
278        })?;
279        let resolver = factory.build(entry).map_err(|e| {
280            format!(
281                "global.apl.pdp[{}] (kind='{}') failed to build: {}",
282                index, kind, e
283            )
284        })?;
285        let mut state = self.state.write().unwrap_or_else(|p| p.into_inner());
286        state.pdp_router.register(resolver);
287        Ok(())
288    }
289}
290
291/// Read-only baseline for APL predicates: enough to make
292/// `authenticated`, `role.*`, `perm.*`, `subject.*`, `claim.*`,
293/// `subject.teams`, `security.labels`, `delegated`, `delegation.*`,
294/// and `agent.*` evaluate correctly. Excludes all *write* capabilities
295/// — those are granted on demand by the per-route plugin union when a
296/// plugin declares `append_labels` / `append_delegation` /
297/// `write_headers`.
298///
299/// `read_subject` alone unlocks only `subject.id` / `subject.type`;
300/// roles, permissions, teams, and claims are each gated by their own
301/// capability (`read_roles` / `read_permissions` / `read_teams` /
302/// `read_claims`). PDP-driven policies routinely read principal.roles /
303/// principal.claims, so the baseline grants all four — tightening
304/// further would surprise APL authors whose `cedar:` policies suddenly
305/// see empty role sets in deployments with no plugin-declared caps.
306/// Hosts that want strict subject access override this via
307/// `AplOptions.base_capabilities`.
308fn default_base_capabilities() -> std::collections::HashSet<String> {
309    [
310        "read_subject",
311        "read_roles",
312        "read_permissions",
313        "read_teams",
314        "read_claims",
315        "read_labels",
316        "read_delegation",
317        "read_agent",
318        "read_meta",
319    ]
320    .iter()
321    .map(|s| s.to_string())
322    .collect()
323}
324
325impl ConfigVisitor for AplConfigVisitor {
326    fn name(&self) -> &str {
327        "apl"
328    }
329
330    fn visit_plugins(
331        &self,
332        _mgr: &Arc<PluginManager>,
333        plugins: &[PluginConfig],
334    ) -> Result<(), VisitorError> {
335        // Translate cpex-core's typed PluginConfig into apl-core's
336        // PluginDeclaration. Field-for-field except `capabilities` is a
337        // `HashSet` on the cpex side and a `Vec` on the apl side, and
338        // `config` is wrapped in `serde_yaml::Value::Mapping` to match
339        // apl-core's opaque shape. cpex-core has already validated
340        // uniqueness by this point so we don't re-check.
341        let mut state = self.state.write().unwrap_or_else(|p| p.into_inner());
342        state.plugin_registry.clear();
343        for cfg in plugins {
344            let decl = PluginDeclaration {
345                name: cfg.name.clone(),
346                kind: cfg.kind.clone(),
347                hooks: cfg.hooks.clone(),
348                capabilities: cfg.capabilities.iter().cloned().collect(),
349                config: plugin_config_to_yaml(&cfg.config),
350                on_error: Some(on_error_to_string(&cfg.on_error)),
351                extra: HashMap::new(),
352            };
353            state.plugin_registry.insert(cfg.name.clone(), decl);
354        }
355        Ok(())
356    }
357
358    fn visit_global(
359        &self,
360        _mgr: &Arc<PluginManager>,
361        yaml: &serde_yaml::Value,
362    ) -> Result<(), VisitorError> {
363        let Some(apl_block) = apl_subblock(yaml) else {
364            return Ok(());
365        };
366
367        // Process `apl.pdp[]` before stacking the policy/post_policy
368        // layer — route handlers that reference PDPs need them
369        // resolvable by the time `visit_route` runs.
370        if let Some(pdp_entries) = apl_block.get("pdp").and_then(|v| v.as_sequence()) {
371            for (i, entry) in pdp_entries.iter().enumerate() {
372                self.build_pdp_from_config(entry, i)?;
373            }
374        }
375
376        // Process an optional `global.apl.session_store` block: swap the
377        // active store before `visit_route` clones it into handlers.
378        if let Some(block) = apl_block.get("session_store") {
379            self.build_session_store_from_config(block)?;
380        }
381
382        // The `pdp:` / `session_store:` sub-keys aren't APL DSL fields;
383        // strip them before handing the block to
384        // `compile_policy_block_value` so the compiler doesn't see unknown
385        // keys. `compile_policy_block_value` accepts maps with `policy:` /
386        // `post_policy:` / `args:` / `result:` / `plugins:` (and inert
387        // fields it ignores), so a shallow strip on a clone is enough.
388        let policy_only = strip_non_dsl_keys(&apl_block);
389        let compiled = compile_policy_block_value("global.apl", &policy_only)
390            .map_err(|e| Box::new(e) as VisitorError)?;
391        self.state
392            .write()
393            .unwrap_or_else(|p| p.into_inner())
394            .global_layer = Some(compiled);
395        Ok(())
396    }
397
398    fn visit_default(
399        &self,
400        _mgr: &Arc<PluginManager>,
401        entity_type: &str,
402        yaml: &serde_yaml::Value,
403    ) -> Result<(), VisitorError> {
404        let Some(apl_block) = apl_subblock(yaml) else {
405            return Ok(());
406        };
407        let source = format!("global.defaults.{}.apl", entity_type);
408        warn_if_global_only_key_at_nonglobal_scope(&source, &apl_block);
409        let compiled = compile_policy_block_value(&source, &apl_block)
410            .map_err(|e| Box::new(e) as VisitorError)?;
411        self.state
412            .write()
413            .unwrap_or_else(|p| p.into_inner())
414            .default_layers
415            .insert(entity_type.to_string(), compiled);
416        Ok(())
417    }
418
419    fn visit_policy_bundle(
420        &self,
421        _mgr: &Arc<PluginManager>,
422        tag: &str,
423        yaml: &serde_yaml::Value,
424    ) -> Result<(), VisitorError> {
425        let Some(apl_block) = apl_subblock(yaml) else {
426            return Ok(());
427        };
428        let source = format!("global.policies.{}.apl", tag);
429        warn_if_global_only_key_at_nonglobal_scope(&source, &apl_block);
430        let compiled = compile_policy_block_value(&source, &apl_block)
431            .map_err(|e| Box::new(e) as VisitorError)?;
432        self.state
433            .write()
434            .unwrap_or_else(|p| p.into_inner())
435            .tag_layers
436            .insert(tag.to_string(), compiled);
437        Ok(())
438    }
439
440    fn visit_route(
441        &self,
442        mgr: &Arc<PluginManager>,
443        yaml: &serde_yaml::Value,
444        parsed: &RouteEntry,
445    ) -> Result<(), VisitorError> {
446        // Extract the route's APL block (if any) and the entity identity
447        // we need for annotate_route. A route without an APL block AND
448        // without inherited layers contributes nothing — skip.
449        let route_apl = apl_subblock(yaml);
450        let (entity_type, entity_names) = match entity_identity(parsed) {
451            Some(e) => e,
452            None => {
453                tracing::warn!(
454                    "APL visitor: route has no tool/resource/prompt/llm match — skipping",
455                );
456                return Ok(());
457            },
458        };
459        if let Some(block) = &route_apl {
460            warn_if_global_only_key_at_nonglobal_scope(&format!("routes.{entity_type}"), block);
461        }
462        let scope = parsed.meta.as_ref().and_then(|m| m.scope.clone());
463        let tags: Vec<String> = parsed
464            .meta
465            .as_ref()
466            .map(|m| m.tags.clone())
467            .unwrap_or_default();
468
469        // Snapshot the plugin registry + PDP router once outside the
470        // per-entity loop. `visit_plugins` populated the registry
471        // before any `visit_route` call; the router has been populated
472        // by code-supplied `register_pdp` calls + `visit_global`
473        // factory dispatch. Routes share both, so cloning each into an
474        // `Arc` once and handing clones to each handler is cheaper than
475        // re-reading the RwLock per entity. Cloning `PdpRouter` is
476        // refcount bumps on each inner resolver — cheap.
477        let (plugin_registry, pdp_router_arc) = {
478            let state = self.state.read().unwrap_or_else(|p| p.into_inner());
479            (
480                Arc::new(state.plugin_registry.clone()),
481                Arc::new(state.pdp_router.clone()) as Arc<dyn PdpResolver>,
482            )
483        };
484
485        for (idx, entity_name) in entity_names.iter().enumerate() {
486            // route_key is what `DispatchCache` keys on, so it must
487            // disambiguate scoped vs unscoped routes for the same
488            // entity — otherwise two same-named annotations share one
489            // cached plan and the second's overrides leak into the first.
490            let route_key = match &scope {
491                Some(s) => format!("{}:{}@{}", entity_type, entity_name, s),
492                None => format!("{}:{}", entity_type, entity_name),
493            };
494            let state = self.state.read().unwrap_or_else(|p| p.into_inner());
495
496            // Stack least-to-most-specific. Each apply_layer call appends
497            // policy/post_policy steps and merges args/result/plugin_overrides
498            // by field; the resulting CompiledRoute represents the route's
499            // effective policy in evaluation order.
500            let mut effective = CompiledRoute::new(&route_key);
501            if let Some(layer) = state.global_layer.clone() {
502                effective.apply_layer(layer);
503            }
504            if let Some(layer) = state.default_layers.get(entity_type).cloned() {
505                effective.apply_layer(layer);
506            }
507            for tag in &tags {
508                if let Some(layer) = state.tag_layers.get(tag).cloned() {
509                    effective.apply_layer(layer);
510                }
511            }
512            drop(state);
513
514            if let Some(block) = &route_apl {
515                let source = format!("routes.{}.apl", route_key);
516                let route_layer = compile_policy_block_value(&source, block)
517                    .map_err(|e| Box::new(e) as VisitorError)?;
518                effective.apply_layer(route_layer);
519            }
520
521            // Load-time lint, once per route: flag any APL `plugins:`
522            // override declared for a plugin that no policy / delegate step
523            // references. Checked on the fully-stacked `effective` route so
524            // an override consumed by an inherited (global / default / tag)
525            // policy is not falsely flagged. The overrides and referenced
526            // names are entity-independent, so the first entity is
527            // representative — guarding on `idx == 0` keeps it to one pass.
528            if idx == 0 {
529                warn_unreferenced_plugin_overrides(&effective);
530            }
531
532            // No layers contributed anything? Don't install a handler — the
533            // route falls back to cpex-core's plugin-chain execution.
534            if effective.declared_phases().is_empty() {
535                continue;
536            }
537
538            // E3.1 — plugin-mode validation for `parallel:` blocks.
539            // `apl-core::Effect::validate_parallel_purity` already rejected
540            // FieldOp / Delegate at parse time; this pass checks that every
541            // `plugin(X)` inside a `parallel:` references a plugin whose
542            // mode is safe for concurrent execution (Audit / Concurrent /
543            // FireAndForget). Sequential / Transform plugins would silently
544            // lose their mutations inside cloned branches.
545            //
546            // Looks up modes through the cpex-core PluginManager (it has
547            // the authoritative registration state). The lookup trait
548            // is `parallel_safety::PluginModeLookup`, which
549            // `PluginManager` implements.
550            if let Err(msg) =
551                crate::parallel_safety::validate_parallel_plugin_modes(&effective, mgr.as_ref())
552            {
553                let err_msg = format!("route '{}': parallel-safety: {}", route_key, msg);
554                return Err(err_msg.into());
555            }
556
557            let route_arc = Arc::new(effective);
558
559            // Resolve the entity-specific CMF hook pair. The visitor's
560            // entity_identity() already filtered out unknown types, but
561            // hook_pair_for_entity returning None would just skip the
562            // annotation rather than crash — defense in depth.
563            let (hook_pre, hook_post) = match hook_pair_for_entity(entity_type) {
564                Some(pair) => pair,
565                None => {
566                    tracing::warn!(
567                        entity_type,
568                        entity_name,
569                        "APL visitor: no CMF hook pair for entity_type — skipping route",
570                    );
571                    continue;
572                },
573            };
574
575            // Snapshot the active session store (a `global.apl.session_store`
576            // block in `visit_global` may have swapped it). Each handler
577            // captures its own clone, so request-time dispatch never touches
578            // the visitor's lock.
579            let session_store = self
580                .session_store
581                .read()
582                .unwrap_or_else(|p| p.into_inner())
583                .clone();
584
585            // Install Pre + Post handlers. Each handler instance is bound to
586            // ONE phase so the executor can pick the right entry-point off
587            // the (entity_type, entity_name, scope, hook_name) key.
588            install_handler(
589                mgr,
590                entity_type,
591                entity_name,
592                scope.clone(),
593                hook_pre,
594                Phase::Pre,
595                Arc::clone(&route_arc),
596                &plugin_registry,
597                &self.dispatch_cache,
598                &session_store,
599                &self.manager,
600                Some(Arc::clone(&pdp_router_arc)),
601                &self.base_capabilities,
602            );
603            install_handler(
604                mgr,
605                entity_type,
606                entity_name,
607                scope.clone(),
608                hook_post,
609                Phase::Post,
610                route_arc,
611                &plugin_registry,
612                &self.dispatch_cache,
613                &session_store,
614                &self.manager,
615                Some(Arc::clone(&pdp_router_arc)),
616                &self.base_capabilities,
617            );
618        }
619
620        Ok(())
621    }
622}
623
624// =====================================================================
625// Helpers
626// =====================================================================
627
628#[allow(clippy::too_many_arguments)]
629fn install_handler(
630    mgr: &Arc<PluginManager>,
631    entity_type: &str,
632    entity_name: &str,
633    scope: Option<String>,
634    hook_name: &str,
635    phase: Phase,
636    route: Arc<CompiledRoute>,
637    plugin_registry: &Arc<PluginRegistry>,
638    dispatch_cache: &Arc<DispatchCache>,
639    session_store: &Arc<dyn SessionStore>,
640    manager: &Weak<PluginManager>,
641    pdp: Option<Arc<dyn PdpResolver>>,
642    base_capabilities: &std::collections::HashSet<String>,
643) {
644    // Capability gating at the synthetic-handler boundary. cpex-core's
645    // executor calls `filter_extensions(&ext, &caps)` before every
646    // handler invoke — including this one. If the synthetic handler
647    // has fewer capabilities than its downstream plugins need, the
648    // executor strips extensions on the way in (so APL predicates and
649    // downstream plugins see empty views) and rejects mutations on the
650    // way out (label / delegation appends fail monotonicity checks).
651    //
652    // Granted caps = union of every plugin's caps (with per-route
653    // overrides applied) ∪ host-supplied baseline. The baseline
654    // typically covers read-only attributes APL predicates touch
655    // (`subject.*`, `role.*`, `delegated`, …) even when no plugins are
656    // referenced.
657    let mut capabilities = base_capabilities.clone();
658    capabilities.extend(crate::dispatch_plan::route_capability_union(
659        &route,
660        plugin_registry,
661    ));
662
663    let plugin_config = PluginConfig {
664        name: format!(
665            "apl::{}::{}::{}",
666            entity_type,
667            entity_name,
668            if phase == Phase::Pre { "pre" } else { "post" }
669        ),
670        kind: "builtin".to_string(),
671        // The annotated handler covers exactly one CMF hook name.
672        hooks: vec![hook_name.to_string()],
673        capabilities,
674        ..Default::default()
675    };
676    let mut handler = AplRouteHandler::new(
677        plugin_config.clone(),
678        route,
679        phase,
680        Arc::clone(plugin_registry),
681        Arc::clone(dispatch_cache),
682        Arc::clone(session_store),
683        manager.clone(),
684    );
685    if let Some(pdp) = pdp {
686        handler = handler.with_pdp(pdp);
687    }
688    mgr.annotate_route(
689        entity_type.to_string(),
690        entity_name.to_string(),
691        scope,
692        hook_name.to_string(),
693        Arc::new(handler),
694        plugin_config,
695    );
696}
697
698/// Pick the route's entity identities from the first non-None match
699/// field. v0: tool > resource > prompt > llm precedence. A list-form
700/// match (`tool: [a, b]`) yields one annotation per element so each
701/// request gets routed by its specific name.
702fn entity_identity(route: &RouteEntry) -> Option<(&'static str, Vec<String>)> {
703    if let Some(t) = &route.tool {
704        return Some(("tool", names_of(t)));
705    }
706    if let Some(r) = &route.resource {
707        return Some(("resource", names_of(r)));
708    }
709    if let Some(p) = &route.prompt {
710        return Some(("prompt", names_of(p)));
711    }
712    if let Some(l) = &route.llm {
713        return Some(("llm", names_of(l)));
714    }
715    None
716}
717
718fn names_of(sol: &cpex_core::config::StringOrList) -> Vec<String> {
719    match sol {
720        cpex_core::config::StringOrList::Single(p) => vec![p.as_str().to_string()],
721        cpex_core::config::StringOrList::List(v) => v.clone(),
722    }
723}
724
725/// Warn when an APL block carries a global-only wiring key
726/// ([`GLOBAL_ONLY_NON_DSL_KEYS`]: `pdp`, `session_store`) at a scope that
727/// cannot act on it. Only [`AplConfigVisitor::visit_global`] builds PDPs
728/// and selects the session store (they are process-global CPEX wiring); a
729/// `pdp:` / `session_store:` written under a default / policy-bundle /
730/// route block is folded into the policy body and silently discarded by
731/// `compile_policy_block_value`. Surfacing it here turns that quiet no-op
732/// into an actionable signal. Applies to both the flat and `apl:`-wrapped
733/// forms — neither is processed off the global scope.
734fn warn_if_global_only_key_at_nonglobal_scope(scope: &str, apl_block: &serde_yaml::Value) {
735    for key in GLOBAL_ONLY_NON_DSL_KEYS {
736        if apl_block.get(key).is_some() {
737            tracing::warn!(
738                scope,
739                key,
740                "APL visitor: this key is only honored under the top-level `global:` block; \
741                 the declaration at this scope is ignored",
742            );
743        }
744    }
745}
746
747/// Load-time lint: warn when an APL `plugins:` override is declared for a
748/// plugin that no `plugin(...)` / `run(...)` policy step (or `delegate(...)`
749/// step) in the effective route references. The `plugins:` map only
750/// *configures* a plugin — policy steps do the *activating* — so an
751/// unreferenced override has no effect and is almost always a typo or a
752/// leftover. Inspects the fully-stacked route, so an override consumed by an
753/// inherited (global / default / tag) policy is not falsely flagged. Called
754/// once per route from `visit_route` at config-load time, never per request.
755fn warn_unreferenced_plugin_overrides(route: &CompiledRoute) {
756    if route.plugin_overrides.is_empty() {
757        return;
758    }
759    let mut referenced: std::collections::HashSet<String> =
760        crate::dispatch_plan::collect_plugin_names(route)
761            .into_iter()
762            .collect();
763    referenced.extend(crate::dispatch_plan::collect_delegate_plugin_names(route));
764    for name in route.plugin_overrides.keys() {
765        if !referenced.contains(name) {
766            tracing::warn!(
767                plugin = %name,
768                route = %route.route_key,
769                "APL `plugins:` override declared for a plugin no policy step references \
770                 — the override has no effect (the `plugins:` map configures; policy steps activate)",
771            );
772        }
773    }
774}
775
776/// APL sub-keys that are CPEX *wiring*, not policy DSL: they are honored
777/// only under the top-level `global:` block (where `visit_global` acts on
778/// them) and are stripped before the remainder is handed to
779/// `compile_policy_block_value`, which doesn't model them. Kept as a single
780/// source of truth shared by [`strip_non_dsl_keys`] and
781/// [`warn_if_global_only_key_at_nonglobal_scope`].
782const GLOBAL_ONLY_NON_DSL_KEYS: [&str; 2] = ["pdp", "session_store"];
783
784/// Strip the global-only wiring sub-keys ([`GLOBAL_ONLY_NON_DSL_KEYS`])
785/// from an `apl:` mapping so the remainder can be handed to
786/// `compile_policy_block_value` (which doesn't model PDP / session-store
787/// declarations — those are CPEX wiring concerns). Returns a clone of the
788/// mapping with those keys removed; the original is left intact.
789fn strip_non_dsl_keys(apl_block: &serde_yaml::Value) -> serde_yaml::Value {
790    let Some(map) = apl_block.as_mapping() else {
791        return apl_block.clone();
792    };
793    let mut cloned = map.clone();
794    for key in GLOBAL_ONLY_NON_DSL_KEYS {
795        cloned.remove(serde_yaml::Value::String(key.to_string()));
796    }
797    serde_yaml::Value::Mapping(cloned)
798}
799
800/// Bridge cpex-core's JSON-based `Option<serde_json::Value>` config slot
801/// into apl-core's `Option<serde_yaml::Value>` shape. JSON is a strict
802/// subset of YAML's value model so this is round-trip safe; failure
803/// here would only happen if `serde_yaml::to_value` rejects a value
804/// `serde_json::Value` already accepted (in practice: never).
805fn plugin_config_to_yaml(cfg: &Option<serde_json::Value>) -> Option<serde_yaml::Value> {
806    cfg.as_ref().and_then(|v| serde_yaml::to_value(v).ok())
807}
808
809/// Map cpex-core's `OnError` enum onto the string shape apl-core's
810/// `PluginDeclaration` carries (kept stringly-typed there because the
811/// APL spec also allows custom orchestrator-defined error modes).
812fn on_error_to_string(on_err: &cpex_core::plugin::OnError) -> String {
813    on_err.to_string()
814}
815
816/// APL keys recognized directly on a section (route / global / defaults /
817/// policy-bundle) when the `apl:` wrapper is omitted. Includes the policy
818/// DSL terms plus the global-only wiring keys ([`GLOBAL_ONLY_NON_DSL_KEYS`]):
819/// `pdp` and `session_store` are accepted flat for parse symmetry with their
820/// `apl:`-wrapped form, but only `visit_global` acts on them — at other
821/// scopes they are inert and flagged by
822/// [`warn_if_global_only_key_at_nonglobal_scope`].
823/// `plugins` is intentionally absent here — it is shape-ambiguous (a
824/// structural plugin-ref *list* vs an apl-override *map*) and handled
825/// separately in [`apl_subblock`].
826const FLAT_APL_KEYS: [&str; 6] = [
827    "policy",
828    "post_policy",
829    "args",
830    "result",
831    "pdp",
832    "session_store",
833];
834
835/// Pull a section's APL block out of its raw YAML.
836///
837/// The explicit `apl:` wrapper (`route -> apl -> policy`) takes
838/// precedence. When it is absent, APL terms written directly on the
839/// section (`route -> policy`) are accepted too: a synthetic block is
840/// assembled from the recognized [`FLAT_APL_KEYS`] present on the
841/// container, plus `plugins` when (and only when) it is a *mapping* —
842/// the apl-override shape. A structural `plugins:` *list*
843/// (`RouteEntry` / `PolicyGroup`) is left untouched. Returns `None`
844/// when neither a wrapper nor any flat APL key is present — callers
845/// treat that as "no contribution from this section" and move on.
846fn apl_subblock(yaml: &serde_yaml::Value) -> Option<serde_yaml::Value> {
847    // Explicit `apl:` wrapper wins.
848    if let Some(block) = yaml.get("apl") {
849        return if block.is_null() {
850            None
851        } else {
852            Some(block.clone())
853        };
854    }
855
856    // Fallback: APL terms written directly on the section, with no
857    // `apl:` nesting. Copy only the unambiguous APL keys so structural
858    // keys (tool / identity / defaults / ...) are never misread.
859    let mut block = serde_yaml::Mapping::new();
860    for key in FLAT_APL_KEYS {
861        if let Some(value) = yaml.get(key) {
862            block.insert(serde_yaml::Value::String(key.to_string()), value.clone());
863        }
864    }
865    // `plugins` only in its apl-override (map) shape; a list is the
866    // structural plugin-ref form and belongs to the section's own parse.
867    if let Some(value) = yaml.get("plugins") {
868        if value.is_mapping() {
869            block.insert(
870                serde_yaml::Value::String("plugins".to_string()),
871                value.clone(),
872            );
873        }
874    }
875
876    if block.is_empty() {
877        None
878    } else {
879        Some(serde_yaml::Value::Mapping(block))
880    }
881}
882
883#[cfg(test)]
884mod tests {
885    use super::apl_subblock;
886
887    fn yaml(s: &str) -> serde_yaml::Value {
888        serde_yaml::from_str(s).expect("valid yaml")
889    }
890
891    #[test]
892    fn apl_wrapper_is_returned_as_is() {
893        let v = yaml("apl:\n  policy:\n    - \"deny\"\n");
894        let block = apl_subblock(&v).expect("wrapper present");
895        assert!(
896            block.get("policy").is_some(),
897            "wrapper block exposes policy"
898        );
899    }
900
901    #[test]
902    fn null_apl_wrapper_is_none() {
903        let v = yaml("apl: null\n");
904        assert!(
905            apl_subblock(&v).is_none(),
906            "explicit null apl => no contribution"
907        );
908    }
909
910    #[test]
911    fn flat_policy_without_wrapper_is_collected() {
912        let v = yaml("tool: get_weather\npolicy:\n  - \"deny\"\n");
913        let block = apl_subblock(&v).expect("flat policy recognized");
914        assert!(
915            block.get("policy").is_some(),
916            "flat policy lifted into the block"
917        );
918        assert!(
919            block.get("tool").is_none(),
920            "structural keys must not leak into the apl block",
921        );
922    }
923
924    #[test]
925    fn flat_session_store_without_wrapper_is_collected() {
926        // A `session_store:` written directly on `global:` (no `apl:`
927        // wrapper) must be lifted into the block so `visit_global` can act
928        // on it — symmetric with the `apl:`-wrapped form and with `pdp:`.
929        let v = yaml("session_store:\n  kind: valkey\n  endpoint: localhost:6379\n");
930        let block = apl_subblock(&v).expect("flat session_store recognized");
931        let ss = block
932            .get("session_store")
933            .expect("session_store lifted into the block");
934        assert_eq!(
935            ss.get("kind").and_then(|k| k.as_str()),
936            Some("valkey"),
937            "the session_store mapping is preserved intact",
938        );
939    }
940
941    #[test]
942    fn flat_plugins_map_included_but_list_excluded() {
943        // Map shape is the apl-override form → kept.
944        let m = yaml("plugins:\n  audit:\n    on_error: ignore\n");
945        let block = apl_subblock(&m).expect("plugins map is an apl term");
946        assert!(block.get("plugins").is_some(), "plugins map is kept");
947
948        // List shape is structural plugin-refs → not an apl block; with no
949        // other APL keys present, the section contributes nothing.
950        let l = yaml("plugins:\n  - audit\n");
951        assert!(
952            apl_subblock(&l).is_none(),
953            "structural plugins list must not be treated as an apl block",
954        );
955    }
956
957    #[test]
958    fn section_without_apl_terms_is_none() {
959        let v = yaml("tool: get_weather\n");
960        assert!(
961            apl_subblock(&v).is_none(),
962            "no APL terms => no contribution"
963        );
964    }
965
966    #[test]
967    fn explicit_wrapper_wins_over_flat_keys() {
968        let v = yaml("apl:\n  policy:\n    - \"allow\"\npolicy:\n  - \"deny\"\n");
969        let block = apl_subblock(&v).expect("wrapper present");
970        let policy = block
971            .get("policy")
972            .and_then(|p| p.as_sequence())
973            .expect("policy sequence");
974        assert_eq!(policy.len(), 1);
975        assert_eq!(
976            policy[0].as_str(),
977            Some("allow"),
978            "the explicit apl wrapper takes precedence over flat top-level keys",
979        );
980    }
981
982    #[test]
983    fn warn_if_global_only_key_at_nonglobal_scope_is_a_safe_noop() {
984        use super::warn_if_global_only_key_at_nonglobal_scope;
985        // The helper only emits a tracing event; it must never panic for
986        // either global-only wiring key (`pdp` / `session_store`), or for
987        // none present. (The drop semantics are exercised end-to-end; here
988        // we just guard the helper's contract.)
989        let with_pdp = yaml("policy:\n  - \"deny\"\npdp:\n  - kind: cel\n");
990        let with_session_store = yaml("policy:\n  - \"deny\"\nsession_store:\n  kind: valkey\n");
991        let without = yaml("policy:\n  - \"deny\"\n");
992        warn_if_global_only_key_at_nonglobal_scope("route", &with_pdp);
993        warn_if_global_only_key_at_nonglobal_scope("routes.tool", &with_session_store);
994        warn_if_global_only_key_at_nonglobal_scope("global.defaults.tool.apl", &without);
995    }
996
997    #[test]
998    fn unreferenced_plugin_override_is_detectable_and_lint_is_safe() {
999        use super::{compile_policy_block_value, warn_unreferenced_plugin_overrides};
1000        // A route configures two plugins but its policy only activates one:
1001        // `used` is referenced by a `plugin(...)` step, `unused` is only
1002        // configured. The lint relies on `collect_plugin_names` seeing the
1003        // referenced set; verify that linkage, then that the helper runs.
1004        let block = yaml(
1005            "policy:\n  - \"plugin(used)\"\n\
1006             plugins:\n  used:\n    on_error: ignore\n  unused:\n    on_error: ignore\n",
1007        );
1008        let route = compile_policy_block_value("test", &block).expect("compiles");
1009
1010        let referenced = crate::dispatch_plan::collect_plugin_names(&route);
1011        assert!(
1012            referenced.contains(&"used".to_string()),
1013            "policy step is referenced"
1014        );
1015        assert!(
1016            !referenced.contains(&"unused".to_string()),
1017            "config-only override is not a reference",
1018        );
1019        assert!(
1020            route.plugin_overrides.contains_key("unused"),
1021            "override was compiled in"
1022        );
1023
1024        // Must not panic; it warns on `unused` and stays silent on `used`.
1025        warn_unreferenced_plugin_overrides(&route);
1026    }
1027}