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}