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_HTTP, ENTITY_LLM, ENTITY_NAME_GLOBAL, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL,
55    HOOK_CMF_HTTP_REQUEST, HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE,
56    HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH,
57    HOOK_CMF_TOOL_POST_INVOKE, 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, DenyResponse};
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    /// Snapshot the request-time dispatch state — plugin registry, PDP
291    /// router, and active session store — each `Arc`-wrapped for a handler
292    /// to capture. Reads the visitor's `RwLock`s once through a single
293    /// poison-recovery path shared by both handler-install sites
294    /// (`visit_global`'s entity-less HTTP handler and `visit_route`'s
295    /// per-entity handlers) so the policy can't diverge between them.
296    fn snapshot_dispatch_state(
297        &self,
298    ) -> (
299        Arc<PluginRegistry>,
300        Arc<dyn PdpResolver>,
301        Arc<dyn SessionStore>,
302    ) {
303        let (plugin_registry, pdp_router_arc) = {
304            let state = self.state.read().unwrap_or_else(|p| p.into_inner());
305            (
306                Arc::new(state.plugin_registry.clone()),
307                Arc::new(state.pdp_router.clone()) as Arc<dyn PdpResolver>,
308            )
309        };
310        let session_store = self
311            .session_store
312            .read()
313            .unwrap_or_else(|p| p.into_inner())
314            .clone();
315        (plugin_registry, pdp_router_arc, session_store)
316    }
317}
318
319/// Read-only baseline for APL predicates: enough to make
320/// `authenticated`, `role.*`, `perm.*`, `subject.*`, `claim.*`,
321/// `subject.teams`, `security.labels`, `delegated`, `delegation.*`,
322/// and `agent.*` evaluate correctly. Excludes all *write* capabilities
323/// — those are granted on demand by the per-route plugin union when a
324/// plugin declares `append_labels` / `append_delegation` /
325/// `write_headers`.
326///
327/// `read_subject` alone unlocks only `subject.id` / `subject.type`;
328/// roles, permissions, teams, and claims are each gated by their own
329/// capability (`read_roles` / `read_permissions` / `read_teams` /
330/// `read_claims`). PDP-driven policies routinely read principal.roles /
331/// principal.claims, so the baseline grants all four — tightening
332/// further would surprise APL authors whose `cedar:` policies suddenly
333/// see empty role sets in deployments with no plugin-declared caps.
334/// Hosts that want strict subject access override this via
335/// `AplOptions.base_capabilities`.
336fn default_base_capabilities() -> std::collections::HashSet<String> {
337    [
338        "read_subject",
339        "read_roles",
340        "read_permissions",
341        "read_teams",
342        "read_claims",
343        "read_labels",
344        "read_delegation",
345        "read_agent",
346        "read_meta",
347    ]
348    .iter()
349    .map(|s| s.to_string())
350    .collect()
351}
352
353impl ConfigVisitor for AplConfigVisitor {
354    fn name(&self) -> &str {
355        "apl"
356    }
357
358    fn visit_plugins(
359        &self,
360        _mgr: &Arc<PluginManager>,
361        plugins: &[PluginConfig],
362    ) -> Result<(), VisitorError> {
363        // Translate cpex-core's typed PluginConfig into apl-core's
364        // PluginDeclaration. Field-for-field except `capabilities` is a
365        // `HashSet` on the cpex side and a `Vec` on the apl side, and
366        // `config` is wrapped in `serde_yaml::Value::Mapping` to match
367        // apl-core's opaque shape. cpex-core has already validated
368        // uniqueness by this point so we don't re-check.
369        let mut state = self.state.write().unwrap_or_else(|p| p.into_inner());
370        state.plugin_registry.clear();
371        for cfg in plugins {
372            let decl = PluginDeclaration {
373                name: cfg.name.clone(),
374                kind: cfg.kind.clone(),
375                hooks: cfg.hooks.clone(),
376                capabilities: cfg.capabilities.iter().cloned().collect(),
377                config: plugin_config_to_yaml(&cfg.config),
378                on_error: Some(on_error_to_string(&cfg.on_error)),
379                extra: HashMap::new(),
380            };
381            state.plugin_registry.insert(cfg.name.clone(), decl);
382        }
383        Ok(())
384    }
385
386    fn visit_global(
387        &self,
388        mgr: &Arc<PluginManager>,
389        yaml: &serde_yaml::Value,
390    ) -> Result<(), VisitorError> {
391        reject_legacy_apl_keys("global", yaml)?;
392        let Some(apl_block) = apl_subblock(yaml) else {
393            // No `apl:` wrapper and no flat DSL keys — there is nothing to
394            // compile or install. But a bare `global: { response: {...} }`
395            // (a denyWith with no accompanying policy) would otherwise be
396            // dropped here silently, before the `response_subblock` read
397            // below ever runs. Warn so this fail-open-by-omission case gets
398            // the same signal as the args/policy-empty case handled further
399            // down, rather than vanishing without a trace.
400            if response_yaml_block(yaml).is_some_and(|v| !v.is_null()) {
401                tracing::warn!(
402                    "APL visitor: global.response is set but global.apl has no policy/args block \
403                     — the entity-less HTTP catch-all handler will not install, so this response can never fire",
404                );
405            }
406            return Ok(());
407        };
408
409        // Process `apl.pdp[]` before stacking the pre/post-invocation
410        // layer — route handlers that reference PDPs need them
411        // resolvable by the time `visit_route` runs.
412        if let Some(pdp_entries) = apl_block.get("pdp").and_then(|v| v.as_sequence()) {
413            for (i, entry) in pdp_entries.iter().enumerate() {
414                self.build_pdp_from_config(entry, i)?;
415            }
416        }
417
418        // Process an optional `global.apl.session_store` block: swap the
419        // active store before `visit_route` clones it into handlers.
420        if let Some(block) = apl_block.get("session_store") {
421            self.build_session_store_from_config(block)?;
422        }
423
424        // The `pdp:` / `session_store:` sub-keys aren't APL DSL fields;
425        // strip them before handing the block to
426        // `compile_policy_block_value` so the compiler doesn't see unknown
427        // keys. `compile_policy_block_value` accepts maps with
428        // `authorization:` / `pre_invocation:` / `post_invocation:` /
429        // `args:` / `result:` / `plugins:` (and inert fields it ignores),
430        // so a shallow strip on a clone is enough.
431        let policy_only = strip_non_dsl_keys(&apl_block);
432        let mut compiled = compile_policy_block_value("global.apl", &policy_only)
433            .map_err(|e| Box::new(e) as VisitorError)?;
434        // A `response:` block at the global scope is the catch-all denyWith.
435        compiled.response = response_subblock(yaml, "global");
436
437        // Install a catch-all handler so the global policy also evaluates for
438        // generic (non-MCP/A2A) HTTP requests, which carry no entity.
439        // Entity routes still stack `global` via apply_layer in visit_route;
440        // this is the *entity-less* evaluation path. Pre-phase only —
441        // authorization is an admission check, so there is no post handler.
442        let installs_pre_handler = http_catchall_should_install(&compiled);
443        if !installs_pre_handler && compiled.response.is_some() {
444            tracing::warn!(
445                "APL visitor: global.response is set but global.apl has no `args:`/`policy:` steps \
446                 — the entity-less HTTP catch-all handler will not install, so this response can never fire",
447            );
448        }
449        if installs_pre_handler {
450            let (plugin_registry, pdp_router_arc, session_store) = self.snapshot_dispatch_state();
451            // `read_headers` (for `http.*`) is granted to every synthetic policy
452            // handler in `install_handler`, so the baseline is passed as-is here.
453            install_handler(
454                mgr,
455                ENTITY_HTTP,
456                ENTITY_NAME_GLOBAL,
457                None,
458                HOOK_CMF_HTTP_REQUEST,
459                Phase::Pre,
460                Arc::new(compiled.clone()),
461                &plugin_registry,
462                &self.dispatch_cache,
463                &session_store,
464                &self.manager,
465                Some(pdp_router_arc),
466                &self.base_capabilities,
467            );
468        }
469
470        self.state
471            .write()
472            .unwrap_or_else(|p| p.into_inner())
473            .global_layer = Some(compiled);
474        Ok(())
475    }
476
477    fn visit_default(
478        &self,
479        _mgr: &Arc<PluginManager>,
480        entity_type: &str,
481        yaml: &serde_yaml::Value,
482    ) -> Result<(), VisitorError> {
483        let source = format!("global.defaults.{}.apl", entity_type);
484        reject_legacy_apl_keys(&source, yaml)?;
485        warn_if_response_at_unsupported_scope(yaml, &format!("global.defaults.{entity_type}"));
486        let Some(apl_block) = apl_subblock(yaml) else {
487            return Ok(());
488        };
489        warn_if_global_only_key_at_nonglobal_scope(&source, &apl_block);
490        let compiled = compile_policy_block_value(&source, &apl_block)
491            .map_err(|e| Box::new(e) as VisitorError)?;
492        self.state
493            .write()
494            .unwrap_or_else(|p| p.into_inner())
495            .default_layers
496            .insert(entity_type.to_string(), compiled);
497        Ok(())
498    }
499
500    fn visit_policy_bundle(
501        &self,
502        _mgr: &Arc<PluginManager>,
503        tag: &str,
504        yaml: &serde_yaml::Value,
505    ) -> Result<(), VisitorError> {
506        let source = format!("global.policies.{}.apl", tag);
507        reject_legacy_apl_keys(&source, yaml)?;
508        warn_if_response_at_unsupported_scope(yaml, &format!("global.policies.{tag}"));
509        let Some(apl_block) = apl_subblock(yaml) else {
510            return Ok(());
511        };
512        warn_if_global_only_key_at_nonglobal_scope(&source, &apl_block);
513        let compiled = compile_policy_block_value(&source, &apl_block)
514            .map_err(|e| Box::new(e) as VisitorError)?;
515        self.state
516            .write()
517            .unwrap_or_else(|p| p.into_inner())
518            .tag_layers
519            .insert(tag.to_string(), compiled);
520        Ok(())
521    }
522
523    fn visit_route(
524        &self,
525        mgr: &Arc<PluginManager>,
526        yaml: &serde_yaml::Value,
527        parsed: &RouteEntry,
528    ) -> Result<(), VisitorError> {
529        // Extract the route's APL block (if any) and the entity identity
530        // we need for annotate_route. A route without an APL block AND
531        // without inherited layers contributes nothing — skip.
532        reject_legacy_apl_keys("route", yaml)?;
533        let route_apl = apl_subblock(yaml);
534        let (entity_type, entity_names) = match entity_identity(parsed) {
535            Some(e) => e,
536            None => {
537                tracing::warn!(
538                    "APL visitor: route has no tool/resource/prompt/llm match — skipping",
539                );
540                return Ok(());
541            },
542        };
543        if let Some(block) = &route_apl {
544            warn_if_global_only_key_at_nonglobal_scope(&format!("routes.{entity_type}"), block);
545        }
546        let scope = parsed.meta.as_ref().and_then(|m| m.scope.clone());
547        let tags: Vec<String> = parsed
548            .meta
549            .as_ref()
550            .map(|m| m.tags.clone())
551            .unwrap_or_default();
552
553        // Snapshot the dispatch state once outside the per-entity loop.
554        // `visit_plugins` populated the registry before any `visit_route`
555        // call; the router + session store were finalized in `visit_global`.
556        // Routes share all three, so cloning each into an `Arc` once and
557        // handing clones to each handler is cheaper than re-reading the
558        // RwLocks per entity. Cloning `PdpRouter` is refcount bumps on each
559        // inner resolver — cheap.
560        let (plugin_registry, pdp_router_arc, session_store) = self.snapshot_dispatch_state();
561
562        // Route-level denial response (transpiled `denyWith`) — parsed once;
563        // its input (`yaml`) is loop-invariant across the entity names this
564        // route matches, so hoisting avoids re-deserializing (and
565        // re-warning) once per entity. `response` is scope-local: an entity
566        // route carries only its own block, never an inherited `global` one.
567        let route_response = response_subblock(yaml, &format!("routes.{entity_type}"));
568
569        for (idx, entity_name) in entity_names.iter().enumerate() {
570            // route_key is what `DispatchCache` keys on, so it must
571            // disambiguate scoped vs unscoped routes for the same
572            // entity — otherwise two same-named annotations share one
573            // cached plan and the second's overrides leak into the first.
574            let route_key = match &scope {
575                Some(s) => format!("{}:{}@{}", entity_type, entity_name, s),
576                None => format!("{}:{}", entity_type, entity_name),
577            };
578            let state = self.state.read().unwrap_or_else(|p| p.into_inner());
579
580            // Stack least-to-most-specific. Each apply_layer call appends
581            // policy/post_policy steps and merges args/result/plugin_overrides
582            // by field; the resulting CompiledRoute represents the route's
583            // effective policy in evaluation order.
584            let mut effective = CompiledRoute::new(&route_key);
585            if let Some(layer) = state.global_layer.clone() {
586                effective.apply_layer(layer);
587            }
588            if let Some(layer) = state.default_layers.get(entity_type).cloned() {
589                effective.apply_layer(layer);
590            }
591            for tag in &tags {
592                if let Some(layer) = state.tag_layers.get(tag).cloned() {
593                    effective.apply_layer(layer);
594                }
595            }
596            drop(state);
597
598            if let Some(block) = &route_apl {
599                let source = format!("routes.{}.apl", route_key);
600                let route_layer = compile_policy_block_value(&source, block)
601                    .map_err(|e| Box::new(e) as VisitorError)?;
602                effective.apply_layer(route_layer);
603            }
604
605            // Route-level denial response (transpiled `denyWith`), parsed
606            // above the loop. Route scope is most-specific and inheritance
607            // was removed, so this is the only source of `response` for an
608            // entity route — a malformed or absent block leaves it `None`
609            // (host default denial), never a leaked `global` response.
610            effective.response = route_response.clone();
611
612            // Load-time lint, once per route: flag any APL `plugins:`
613            // override declared for a plugin that no policy / delegate step
614            // references. Checked on the fully-stacked `effective` route so
615            // an override consumed by an inherited (global / default / tag)
616            // policy is not falsely flagged. The overrides and referenced
617            // names are entity-independent, so the first entity is
618            // representative — guarding on `idx == 0` keeps it to one pass.
619            if idx == 0 {
620                warn_unreferenced_plugin_overrides(&effective);
621            }
622
623            // No layers contributed anything? Don't install a handler — the
624            // route falls back to cpex-core's plugin-chain execution.
625            if effective.declared_phases().is_empty() {
626                continue;
627            }
628
629            // E3.1 — plugin-mode validation for `parallel:` blocks.
630            // `apl-core::Effect::validate_parallel_purity` already rejected
631            // FieldOp / Delegate at parse time; this pass checks that every
632            // `plugin(X)` inside a `parallel:` references a plugin whose
633            // mode is safe for concurrent execution (Audit / Concurrent /
634            // FireAndForget). Sequential / Transform plugins would silently
635            // lose their mutations inside cloned branches.
636            //
637            // Looks up modes through the cpex-core PluginManager (it has
638            // the authoritative registration state). The lookup trait
639            // is `parallel_safety::PluginModeLookup`, which
640            // `PluginManager` implements.
641            if let Err(msg) =
642                crate::parallel_safety::validate_parallel_plugin_modes(&effective, mgr.as_ref())
643            {
644                let err_msg = format!("route '{}': parallel-safety: {}", route_key, msg);
645                return Err(err_msg.into());
646            }
647
648            let route_arc = Arc::new(effective);
649
650            // Resolve the entity-specific CMF hook pair. The visitor's
651            // entity_identity() already filtered out unknown types, but
652            // hook_pair_for_entity returning None would just skip the
653            // annotation rather than crash — defense in depth.
654            let (hook_pre, hook_post) = match hook_pair_for_entity(entity_type) {
655                Some(pair) => pair,
656                None => {
657                    tracing::warn!(
658                        entity_type,
659                        entity_name,
660                        "APL visitor: no CMF hook pair for entity_type — skipping route",
661                    );
662                    continue;
663                },
664            };
665
666            // Install Pre + Post handlers. Each handler instance is bound to
667            // ONE phase so the executor can pick the right entry-point off
668            // the (entity_type, entity_name, scope, hook_name) key.
669            install_handler(
670                mgr,
671                entity_type,
672                entity_name,
673                scope.clone(),
674                hook_pre,
675                Phase::Pre,
676                Arc::clone(&route_arc),
677                &plugin_registry,
678                &self.dispatch_cache,
679                &session_store,
680                &self.manager,
681                Some(Arc::clone(&pdp_router_arc)),
682                &self.base_capabilities,
683            );
684            install_handler(
685                mgr,
686                entity_type,
687                entity_name,
688                scope.clone(),
689                hook_post,
690                Phase::Post,
691                route_arc,
692                &plugin_registry,
693                &self.dispatch_cache,
694                &session_store,
695                &self.manager,
696                Some(Arc::clone(&pdp_router_arc)),
697                &self.base_capabilities,
698            );
699        }
700
701        Ok(())
702    }
703}
704
705// =====================================================================
706// Helpers
707// =====================================================================
708
709#[allow(clippy::too_many_arguments)]
710fn install_handler(
711    mgr: &Arc<PluginManager>,
712    entity_type: &str,
713    entity_name: &str,
714    scope: Option<String>,
715    hook_name: &str,
716    phase: Phase,
717    route: Arc<CompiledRoute>,
718    plugin_registry: &Arc<PluginRegistry>,
719    dispatch_cache: &Arc<DispatchCache>,
720    session_store: &Arc<dyn SessionStore>,
721    manager: &Weak<PluginManager>,
722    pdp: Option<Arc<dyn PdpResolver>>,
723    base_capabilities: &std::collections::HashSet<String>,
724) {
725    // Capability gating at the synthetic-handler boundary. cpex-core's
726    // executor calls `filter_extensions(&ext, &caps)` before every
727    // handler invoke — including this one. If the synthetic handler
728    // has fewer capabilities than its downstream plugins need, the
729    // executor strips extensions on the way in (so APL predicates and
730    // downstream plugins see empty views) and rejects mutations on the
731    // way out (label / delegation appends fail monotonicity checks).
732    //
733    // Granted caps = union of every plugin's caps (with per-route
734    // overrides applied) ∪ host-supplied baseline. The baseline
735    // typically covers read-only attributes APL predicates touch
736    // (`subject.*`, `role.*`, `delegated`, …) even when no plugins are
737    // referenced.
738    let mut capabilities = base_capabilities.clone();
739    capabilities.extend(crate::dispatch_plan::route_capability_union(
740        &route,
741        plugin_registry,
742    ));
743    // Every synthetic policy handler (the entity-less HTTP catch-all, per-entity
744    // routes, and defaults) is granted `read_headers` so `http.*` request
745    // attributes are available to policy evaluation wherever the host attaches
746    // an `HttpExtension`. This lets an entity-route rule combine `http.*` with
747    // entity/`args.*` predicates in one evaluation. It is a no-op for hosts that
748    // never populate the HTTP extension (nothing to read).
749    capabilities.insert("read_headers".to_string());
750
751    let plugin_config = PluginConfig {
752        name: format!(
753            "apl::{}::{}::{}",
754            entity_type,
755            entity_name,
756            if phase == Phase::Pre { "pre" } else { "post" }
757        ),
758        kind: "builtin".to_string(),
759        // The annotated handler covers exactly one CMF hook name.
760        hooks: vec![hook_name.to_string()],
761        capabilities,
762        ..Default::default()
763    };
764    let mut handler = AplRouteHandler::new(
765        plugin_config.clone(),
766        route,
767        phase,
768        Arc::clone(plugin_registry),
769        Arc::clone(dispatch_cache),
770        Arc::clone(session_store),
771        manager.clone(),
772    );
773    if let Some(pdp) = pdp {
774        handler = handler.with_pdp(pdp);
775    }
776    mgr.annotate_route(
777        entity_type.to_string(),
778        entity_name.to_string(),
779        scope,
780        hook_name.to_string(),
781        Arc::new(handler),
782        plugin_config,
783    );
784}
785
786/// Pick the route's entity identities from the first non-None match
787/// field. v0: tool > resource > prompt > llm precedence. A list-form
788/// match (`tool: [a, b]`) yields one annotation per element so each
789/// request gets routed by its specific name.
790fn entity_identity(route: &RouteEntry) -> Option<(&'static str, Vec<String>)> {
791    if let Some(t) = &route.tool {
792        return Some(("tool", names_of(t)));
793    }
794    if let Some(r) = &route.resource {
795        return Some(("resource", names_of(r)));
796    }
797    if let Some(p) = &route.prompt {
798        return Some(("prompt", names_of(p)));
799    }
800    if let Some(l) = &route.llm {
801        return Some(("llm", names_of(l)));
802    }
803    None
804}
805
806fn names_of(sol: &cpex_core::config::StringOrList) -> Vec<String> {
807    match sol {
808        cpex_core::config::StringOrList::Single(p) => vec![p.as_str().to_string()],
809        cpex_core::config::StringOrList::List(v) => v.clone(),
810    }
811}
812
813/// Warn when an APL block carries a global-only wiring key
814/// ([`GLOBAL_ONLY_NON_DSL_KEYS`]: `pdp`, `session_store`) at a scope that
815/// cannot act on it. Only [`AplConfigVisitor::visit_global`] builds PDPs
816/// and selects the session store (they are process-global CPEX wiring); a
817/// `pdp:` / `session_store:` written under a default / policy-bundle /
818/// route block is folded into the policy body and silently discarded by
819/// `compile_policy_block_value`. Surfacing it here turns that quiet no-op
820/// into an actionable signal. Applies to both the flat and `apl:`-wrapped
821/// forms — neither is processed off the global scope.
822fn warn_if_global_only_key_at_nonglobal_scope(scope: &str, apl_block: &serde_yaml::Value) {
823    for key in GLOBAL_ONLY_NON_DSL_KEYS {
824        if apl_block.get(key).is_some() {
825            tracing::warn!(
826                scope,
827                key,
828                "APL visitor: this key is only honored under the top-level `global:` block; \
829                 the declaration at this scope is ignored",
830            );
831        }
832    }
833}
834
835/// Load-time lint: warn when an APL `plugins:` override is declared for a
836/// plugin that no `plugin(...)` / `run(...)` policy step (or `delegate(...)`
837/// step) in the effective route references. The `plugins:` map only
838/// *configures* a plugin — policy steps do the *activating* — so an
839/// unreferenced override has no effect and is almost always a typo or a
840/// leftover. Inspects the fully-stacked route, so an override consumed by an
841/// inherited (global / default / tag) policy is not falsely flagged. Called
842/// once per route from `visit_route` at config-load time, never per request.
843fn warn_unreferenced_plugin_overrides(route: &CompiledRoute) {
844    if route.plugin_overrides.is_empty() {
845        return;
846    }
847    let mut referenced: std::collections::HashSet<String> =
848        crate::dispatch_plan::collect_plugin_names(route)
849            .into_iter()
850            .collect();
851    referenced.extend(crate::dispatch_plan::collect_delegate_plugin_names(route));
852    for name in route.plugin_overrides.keys() {
853        if !referenced.contains(name) {
854            tracing::warn!(
855                plugin = %name,
856                route = %route.route_key,
857                "APL `plugins:` override declared for a plugin no policy step references \
858                 — the override has no effect (the `plugins:` map configures; policy steps activate)",
859            );
860        }
861    }
862}
863
864/// APL sub-keys that are CPEX *wiring*, not policy DSL: they are honored
865/// only under the top-level `global:` block (where `visit_global` acts on
866/// them) and are stripped before the remainder is handed to
867/// `compile_policy_block_value`, which doesn't model them. Kept as a single
868/// source of truth shared by [`strip_non_dsl_keys`] and
869/// [`warn_if_global_only_key_at_nonglobal_scope`].
870const GLOBAL_ONLY_NON_DSL_KEYS: [&str; 2] = ["pdp", "session_store"];
871
872/// Legacy APL config keys, mapped to their replacements. The flat-key path
873/// in [`apl_subblock`] only copies recognized keys into the synthetic block,
874/// so a config still using an old name would otherwise be *silently dropped*
875/// here — a fail-open for `policy` / `post_policy`. We reject them loudly.
876/// (The `apl:`-wrapped form is caught downstream by apl-core instead.)
877const RENAMED_APL_KEYS: [(&str, &str); 2] = [
878    (
879        "policy",
880        "authorization.pre_invocation (or flat pre_invocation)",
881    ),
882    (
883        "post_policy",
884        "authorization.post_invocation (or flat post_invocation)",
885    ),
886];
887
888/// Fail loudly when a section carries a renamed legacy APL key directly
889/// (flat form). Guards the fail-open where the flat-key filter in
890/// [`apl_subblock`] would otherwise drop an unrecognized `policy:` block.
891fn reject_legacy_apl_keys(scope: &str, yaml: &serde_yaml::Value) -> Result<(), VisitorError> {
892    let Some(map) = yaml.as_mapping() else {
893        return Ok(());
894    };
895    for (old, new) in RENAMED_APL_KEYS {
896        if map.contains_key(serde_yaml::Value::String(old.to_string())) {
897            return Err(format!(
898                "in `{scope}`: config field `{old}` was renamed to `{new}` — update your config",
899            )
900            .into());
901        }
902    }
903    Ok(())
904}
905
906/// Strip the global-only wiring sub-keys ([`GLOBAL_ONLY_NON_DSL_KEYS`])
907/// from an `apl:` mapping so the remainder can be handed to
908/// `compile_policy_block_value` (which doesn't model PDP / session-store
909/// declarations — those are CPEX wiring concerns). Returns a clone of the
910/// mapping with those keys removed; the original is left intact.
911fn strip_non_dsl_keys(apl_block: &serde_yaml::Value) -> serde_yaml::Value {
912    let Some(map) = apl_block.as_mapping() else {
913        return apl_block.clone();
914    };
915    let mut cloned = map.clone();
916    for key in GLOBAL_ONLY_NON_DSL_KEYS {
917        cloned.remove(serde_yaml::Value::String(key.to_string()));
918    }
919    serde_yaml::Value::Mapping(cloned)
920}
921
922/// Bridge cpex-core's JSON-based `Option<serde_json::Value>` config slot
923/// into apl-core's `Option<serde_yaml::Value>` shape. JSON is a strict
924/// subset of YAML's value model so this is round-trip safe; failure
925/// here would only happen if `serde_yaml::to_value` rejects a value
926/// `serde_json::Value` already accepted (in practice: never).
927fn plugin_config_to_yaml(cfg: &Option<serde_json::Value>) -> Option<serde_yaml::Value> {
928    cfg.as_ref().and_then(|v| serde_yaml::to_value(v).ok())
929}
930
931/// Map cpex-core's `OnError` enum onto the string shape apl-core's
932/// `PluginDeclaration` carries (kept stringly-typed there because the
933/// APL spec also allows custom orchestrator-defined error modes).
934fn on_error_to_string(on_err: &cpex_core::plugin::OnError) -> String {
935    on_err.to_string()
936}
937
938/// APL keys recognized directly on a section (route / global / defaults /
939/// policy-bundle) when the `apl:` wrapper is omitted. Includes the policy
940/// DSL terms plus the global-only wiring keys ([`GLOBAL_ONLY_NON_DSL_KEYS`]):
941/// `pdp` and `session_store` are accepted flat for parse symmetry with their
942/// `apl:`-wrapped form, but only `visit_global` acts on them — at other
943/// scopes they are inert and flagged by
944/// [`warn_if_global_only_key_at_nonglobal_scope`].
945/// `plugins` is intentionally absent here — it is shape-ambiguous (a
946/// structural plugin-ref *list* vs an apl-override *map*) and handled
947/// separately in [`apl_subblock`].
948///
949/// `authorization` is the nested `{ pre_invocation, post_invocation }`
950/// block; it is copied through verbatim and un-nested by apl-core's
951/// `compile_policy_block_value`, so nesting lives in exactly one place.
952const FLAT_APL_KEYS: [&str; 7] = [
953    "pre_invocation",
954    "post_invocation",
955    "authorization",
956    "args",
957    "result",
958    "pdp",
959    "session_store",
960];
961
962/// Pull a section's APL block out of its raw YAML.
963///
964/// The explicit `apl:` wrapper (`route -> apl -> authorization`) takes
965/// precedence. When it is absent, APL terms written directly on the
966/// section (`route -> authorization`) are accepted too: a synthetic block is
967/// assembled from the recognized [`FLAT_APL_KEYS`] present on the
968/// container, plus `plugins` when (and only when) it is a *mapping* —
969/// the apl-override shape. A structural `plugins:` *list*
970/// (`RouteEntry` / `PolicyGroup`) is left untouched. Returns `None`
971/// when neither a wrapper nor any flat APL key is present — callers
972/// treat that as "no contribution from this section" and move on.
973fn apl_subblock(yaml: &serde_yaml::Value) -> Option<serde_yaml::Value> {
974    // Explicit `apl:` wrapper wins.
975    if let Some(block) = yaml.get("apl") {
976        return if block.is_null() {
977            None
978        } else {
979            Some(block.clone())
980        };
981    }
982
983    // Fallback: APL terms written directly on the section, with no
984    // `apl:` nesting. Copy only the unambiguous APL keys so structural
985    // keys (tool / identity / defaults / ...) are never misread.
986    let mut block = serde_yaml::Mapping::new();
987    for key in FLAT_APL_KEYS {
988        if let Some(value) = yaml.get(key) {
989            block.insert(serde_yaml::Value::String(key.to_string()), value.clone());
990        }
991    }
992    // `plugins` only in its apl-override (map) shape; a list is the
993    // structural plugin-ref form and belongs to the section's own parse.
994    if let Some(value) = yaml.get("plugins") {
995        if value.is_mapping() {
996            block.insert(
997                serde_yaml::Value::String("plugins".to_string()),
998                value.clone(),
999            );
1000        }
1001    }
1002
1003    if block.is_empty() {
1004        None
1005    } else {
1006        Some(serde_yaml::Value::Mapping(block))
1007    }
1008}
1009
1010/// Whether the entity-less HTTP catch-all handler (Pre-phase only) should
1011/// install for a compiled `global` layer. Gate on both Pre-phase steps
1012/// (`args` + `policy`, via [`CompiledRoute::declared_phases`]), not
1013/// `policy` alone — an operator whose `global.apl` has only an `args:`
1014/// admission block (no `policy:`) must still get the catch-all installed,
1015/// or entity-less HTTP traffic silently bypasses it entirely (fail-open by
1016/// omission).
1017fn http_catchall_should_install(compiled: &CompiledRoute) -> bool {
1018    let declared = compiled.declared_phases();
1019    declared.contains(apl_core::rules::Phase::Args)
1020        || declared.contains(apl_core::rules::Phase::Policy)
1021}
1022
1023/// `response:` is not an APL DSL term (it never enters [`apl_subblock`]'s
1024/// [`FLAT_APL_KEYS`]) — it is documented and tested as a sibling of `apl:`
1025/// (`global: { apl: {...}, response: {...} }`). But an operator who mirrors
1026/// the `pdp:` / `session_store:` convention (which *do* work identically
1027/// whether flat or nested under `apl:`) may reasonably nest `response:`
1028/// inside `apl:` too. Accept both spellings so that mistake degrades to
1029/// "the other spelling wins," not "silently dropped."
1030///
1031/// PRECEDENCE — deliberately the INVERSE of [`apl_subblock`]. `apl_subblock`
1032/// makes an explicit `apl:` wrapper win *entirely* over flat top-level keys
1033/// (for `policy:`/`pdp:`/`session_store:`); here the top-level sibling
1034/// `response:` wins over an `apl:`-nested one. This is intentional, not an
1035/// oversight: the top-level sibling is the documented, already-shipped,
1036/// tested form, so preferring it preserves backward compatibility, and the
1037/// choice can only affect the *rendered denial shape* (status/body/headers)
1038/// — never an Allow/Deny outcome. Do NOT "align" this with `apl_subblock`'s
1039/// wrapper-wins rule without a deliberate compatibility decision.
1040fn response_yaml_block(yaml: &serde_yaml::Value) -> Option<&serde_yaml::Value> {
1041    yaml.get("response")
1042        .or_else(|| yaml.get("apl").and_then(|apl| apl.get("response")))
1043}
1044
1045/// Warn when a `response:` block appears at a scope that never renders it.
1046/// A custom denial response is honored only at `global` (the entity-less
1047/// HTTP path) or on a route; at `default` / policy-bundle scope it is inert
1048/// — there is no propagation path to a handler. Mirrors the existing
1049/// global-only-key lint so a misplaced `response:` fails loud, not silent.
1050fn warn_if_response_at_unsupported_scope(yaml: &serde_yaml::Value, scope: &str) {
1051    if response_yaml_block(yaml).is_some_and(|v| !v.is_null()) {
1052        tracing::warn!(
1053            scope,
1054            "APL visitor: `response:` is honored only at `global` or route scope; ignoring here",
1055        );
1056    }
1057}
1058
1059/// Extract a route-level `response:` block — the transpiled `denyWith`.
1060/// cpex-core tolerates this out-of-band key on the route; here we
1061/// deserialize it into a [`DenyResponse`]. A malformed block is logged
1062/// and skipped (best-effort) rather than failing the whole config.
1063fn response_subblock(yaml: &serde_yaml::Value, route_key: &str) -> Option<DenyResponse> {
1064    let block = response_yaml_block(yaml)?;
1065    if block.is_null() {
1066        return None;
1067    }
1068    match serde_yaml::from_value::<DenyResponse>(block.clone()) {
1069        Ok(resp) => Some(resp),
1070        Err(e) => {
1071            tracing::warn!(route = route_key, error = %e, "APL visitor: ignoring malformed route `response:` block");
1072            None
1073        },
1074    }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use super::{apl_subblock, http_catchall_should_install, response_subblock};
1080    use apl_core::pipeline::{FieldRule, Pipeline, Stage, TypeCheck};
1081    use apl_core::rules::{CompiledRoute, Effect};
1082
1083    fn yaml(s: &str) -> serde_yaml::Value {
1084        serde_yaml::from_str(s).expect("valid yaml")
1085    }
1086
1087    fn deny_effect() -> Effect {
1088        Effect::Deny {
1089            reason: None,
1090            code: None,
1091        }
1092    }
1093
1094    fn field_rule(field: &str) -> FieldRule {
1095        FieldRule {
1096            field: field.to_string(),
1097            pipeline: Pipeline {
1098                stages: vec![Stage::Type(TypeCheck::Str)],
1099            },
1100            source: "test".to_string(),
1101        }
1102    }
1103
1104    #[test]
1105    fn http_catchall_installs_for_args_only_global_block() {
1106        // Regression for the fail-open-by-omission gap: a `global.apl` with
1107        // only `args:` (no `policy:`) must still get the entity-less HTTP
1108        // catch-all installed. Before the fix this gated on
1109        // `!compiled.policy.is_empty()` alone, so an args-only admission
1110        // block silently disabled authorization for all entity-less HTTP
1111        // traffic.
1112        let mut route = CompiledRoute::new("global");
1113        route.args.push(field_rule("http.method"));
1114        assert!(
1115            http_catchall_should_install(&route),
1116            "an args-only global block must still install the catch-all handler"
1117        );
1118    }
1119
1120    #[test]
1121    fn http_catchall_installs_for_policy_only_global_block() {
1122        let mut route = CompiledRoute::new("global");
1123        route.policy.push(deny_effect());
1124        assert!(http_catchall_should_install(&route));
1125    }
1126
1127    #[test]
1128    fn http_catchall_does_not_install_for_empty_or_post_only_global_block() {
1129        let empty = CompiledRoute::new("global");
1130        assert!(
1131            !http_catchall_should_install(&empty),
1132            "an empty global block has nothing to evaluate; installing would be a no-op handler"
1133        );
1134
1135        let mut post_only = CompiledRoute::new("global");
1136        post_only.post_policy.push(deny_effect());
1137        assert!(
1138            !http_catchall_should_install(&post_only),
1139            "post_policy never runs on the Pre-phase-only catch-all, so it must not gate installation"
1140        );
1141    }
1142
1143    #[test]
1144    fn response_subblock_parses_denywith() {
1145        let v = yaml(
1146            "tool: \"*\"\nresponse:\n  status: 403\n  body: \"{\\\"error\\\":\\\"forbidden\\\"}\"\n  headers:\n    WWW-Authenticate: \"Bearer\"\n",
1147        );
1148        let resp = response_subblock(&v, "tool:*").expect("response present");
1149        assert_eq!(resp.status, Some(403));
1150        assert_eq!(resp.body.as_deref(), Some("{\"error\":\"forbidden\"}"));
1151        assert_eq!(
1152            resp.headers.get("WWW-Authenticate").map(String::as_str),
1153            Some("Bearer")
1154        );
1155    }
1156
1157    #[test]
1158    fn response_subblock_absent_is_none() {
1159        let v = yaml("tool: \"*\"\npolicy:\n  - \"deny\"\n");
1160        assert!(response_subblock(&v, "tool:*").is_none());
1161    }
1162
1163    #[test]
1164    fn response_subblock_nested_under_apl_wrapper_is_read() {
1165        // An operator mirroring the pdp:/session_store: convention (which
1166        // work identically flat or nested under `apl:`) may nest `response:`
1167        // under `apl:` too. It must not be silently absorbed.
1168        let v =
1169            yaml("tool: \"*\"\napl:\n  policy:\n    - \"deny\"\n  response:\n    status: 401\n");
1170        let resp = response_subblock(&v, "tool:*").expect("nested response present");
1171        assert_eq!(resp.status, Some(401));
1172    }
1173
1174    #[test]
1175    fn response_subblock_top_level_wins_over_nested_apl_form() {
1176        let v = yaml(
1177            "tool: \"*\"\napl:\n  policy:\n    - \"deny\"\n  response:\n    status: 401\nresponse:\n  status: 403\n",
1178        );
1179        let resp = response_subblock(&v, "tool:*").expect("response present");
1180        assert_eq!(
1181            resp.status,
1182            Some(403),
1183            "top-level sibling response takes precedence over the nested apl: form"
1184        );
1185    }
1186
1187    #[test]
1188    fn response_subblock_malformed_is_none_not_propagated() {
1189        // `status` must deserialize as a u16; a string value fails to parse.
1190        // A malformed block must be dropped (warn-only), never bubble up an
1191        // error that fails the whole config load.
1192        let v = yaml("tool: \"*\"\nresponse:\n  status: \"not-a-number\"\n");
1193        assert!(
1194            response_subblock(&v, "tool:*").is_none(),
1195            "malformed response: block must be ignored, not panic or propagate an error"
1196        );
1197    }
1198
1199    #[test]
1200    fn warn_if_response_at_unsupported_scope_is_a_safe_noop() {
1201        use super::warn_if_response_at_unsupported_scope;
1202        // The helper only emits a tracing event; it must never panic whether
1203        // `response:` is present or absent at a scope that can't render it.
1204        let with_response = yaml("policy:\n  - \"deny\"\nresponse:\n  status: 403\n");
1205        let without = yaml("policy:\n  - \"deny\"\n");
1206        warn_if_response_at_unsupported_scope(&with_response, "global.defaults.tool");
1207        warn_if_response_at_unsupported_scope(&with_response, "global.policies.some-tag");
1208        warn_if_response_at_unsupported_scope(&without, "global.defaults.tool");
1209    }
1210
1211    #[test]
1212    fn apl_wrapper_is_returned_as_is() {
1213        let v = yaml("apl:\n  pre_invocation:\n    - \"deny\"\n");
1214        let block = apl_subblock(&v).expect("wrapper present");
1215        assert!(
1216            block.get("pre_invocation").is_some(),
1217            "wrapper block exposes pre_invocation"
1218        );
1219    }
1220
1221    #[test]
1222    fn null_apl_wrapper_is_none() {
1223        let v = yaml("apl: null\n");
1224        assert!(
1225            apl_subblock(&v).is_none(),
1226            "explicit null apl => no contribution"
1227        );
1228    }
1229
1230    #[test]
1231    fn flat_pre_invocation_without_wrapper_is_collected() {
1232        let v = yaml("tool: get_weather\npre_invocation:\n  - \"deny\"\n");
1233        let block = apl_subblock(&v).expect("flat pre_invocation recognized");
1234        assert!(
1235            block.get("pre_invocation").is_some(),
1236            "flat pre_invocation lifted into the block"
1237        );
1238        assert!(
1239            block.get("tool").is_none(),
1240            "structural keys must not leak into the apl block",
1241        );
1242    }
1243
1244    #[test]
1245    fn flat_session_store_without_wrapper_is_collected() {
1246        // A `session_store:` written directly on `global:` (no `apl:`
1247        // wrapper) must be lifted into the block so `visit_global` can act
1248        // on it — symmetric with the `apl:`-wrapped form and with `pdp:`.
1249        let v = yaml("session_store:\n  kind: valkey\n  endpoint: localhost:6379\n");
1250        let block = apl_subblock(&v).expect("flat session_store recognized");
1251        let ss = block
1252            .get("session_store")
1253            .expect("session_store lifted into the block");
1254        assert_eq!(
1255            ss.get("kind").and_then(|k| k.as_str()),
1256            Some("valkey"),
1257            "the session_store mapping is preserved intact",
1258        );
1259    }
1260
1261    #[test]
1262    fn flat_plugins_map_included_but_list_excluded() {
1263        // Map shape is the apl-override form → kept.
1264        let m = yaml("plugins:\n  audit:\n    on_error: ignore\n");
1265        let block = apl_subblock(&m).expect("plugins map is an apl term");
1266        assert!(block.get("plugins").is_some(), "plugins map is kept");
1267
1268        // List shape is structural plugin-refs → not an apl block; with no
1269        // other APL keys present, the section contributes nothing.
1270        let l = yaml("plugins:\n  - audit\n");
1271        assert!(
1272            apl_subblock(&l).is_none(),
1273            "structural plugins list must not be treated as an apl block",
1274        );
1275    }
1276
1277    #[test]
1278    fn section_without_apl_terms_is_none() {
1279        let v = yaml("tool: get_weather\n");
1280        assert!(
1281            apl_subblock(&v).is_none(),
1282            "no APL terms => no contribution"
1283        );
1284    }
1285
1286    #[test]
1287    fn explicit_wrapper_wins_over_flat_keys() {
1288        let v = yaml("apl:\n  pre_invocation:\n    - \"allow\"\npre_invocation:\n  - \"deny\"\n");
1289        let block = apl_subblock(&v).expect("wrapper present");
1290        let pre_invocation = block
1291            .get("pre_invocation")
1292            .and_then(|p| p.as_sequence())
1293            .expect("pre_invocation sequence");
1294        assert_eq!(pre_invocation.len(), 1);
1295        assert_eq!(
1296            pre_invocation[0].as_str(),
1297            Some("allow"),
1298            "the explicit apl wrapper takes precedence over flat top-level keys",
1299        );
1300    }
1301
1302    #[test]
1303    fn warn_if_global_only_key_at_nonglobal_scope_is_a_safe_noop() {
1304        use super::warn_if_global_only_key_at_nonglobal_scope;
1305        // The helper only emits a tracing event; it must never panic for
1306        // either global-only wiring key (`pdp` / `session_store`), or for
1307        // none present. (The drop semantics are exercised end-to-end; here
1308        // we just guard the helper's contract.)
1309        let with_pdp = yaml("pre_invocation:\n  - \"deny\"\npdp:\n  - kind: cel\n");
1310        let with_session_store =
1311            yaml("pre_invocation:\n  - \"deny\"\nsession_store:\n  kind: valkey\n");
1312        let without = yaml("pre_invocation:\n  - \"deny\"\n");
1313        warn_if_global_only_key_at_nonglobal_scope("route", &with_pdp);
1314        warn_if_global_only_key_at_nonglobal_scope("routes.tool", &with_session_store);
1315        warn_if_global_only_key_at_nonglobal_scope("global.defaults.tool.apl", &without);
1316    }
1317
1318    #[test]
1319    fn unreferenced_plugin_override_is_detectable_and_lint_is_safe() {
1320        use super::{compile_policy_block_value, warn_unreferenced_plugin_overrides};
1321        // A route configures two plugins but its pre_invocation only activates one:
1322        // `used` is referenced by a `plugin(...)` step, `unused` is only
1323        // configured. The lint relies on `collect_plugin_names` seeing the
1324        // referenced set; verify that linkage, then that the helper runs.
1325        let block = yaml(
1326            "pre_invocation:\n  - \"plugin(used)\"\n\
1327             plugins:\n  used:\n    on_error: ignore\n  unused:\n    on_error: ignore\n",
1328        );
1329        let route = compile_policy_block_value("test", &block).expect("compiles");
1330
1331        let referenced = crate::dispatch_plan::collect_plugin_names(&route);
1332        assert!(
1333            referenced.contains(&"used".to_string()),
1334            "pre_invocation step is referenced"
1335        );
1336        assert!(
1337            !referenced.contains(&"unused".to_string()),
1338            "config-only override is not a reference",
1339        );
1340        assert!(
1341            route.plugin_overrides.contains_key("unused"),
1342            "override was compiled in"
1343        );
1344
1345        // Must not panic; it warns on `unused` and stays silent on `used`.
1346        warn_unreferenced_plugin_overrides(&route);
1347    }
1348}