Skip to main content

cpex_core/
manager.rs

1// Location: ./crates/cpex-core/src/manager.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor, Fred Araujo
5//
6// Plugin manager.
7//
8// Owns the plugin lifecycle (initialize, dispatch, shutdown) and
9// the PluginRegistry. Provides two invoke paths:
10//
11// - `invoke::<H>()` — typed dispatch for Rust callers. Zero-cost.
12//   The hook type is known at compile time; no registry lookup or
13//   downcast needed for the payload.
14//
15// - `invoke_by_name()` — dynamic dispatch for Python/Go/WASM callers.
16//   Hook name resolved from the registry; payload passed as
17//   Box<dyn PluginPayload>.
18//
19// The manager reads plugin configs from the config loader and wraps
20// each plugin in a PluginRef with the authoritative config. Plugins
21// never provide their own config to the manager. Trust flows:
22//   config loader → manager → PluginRef → executor
23//
24// Mirrors the Python framework's PluginManager in
25// cpex/framework/manager.py.
26
27use std::hash::{Hash, Hasher};
28use std::path::Path;
29use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
30use std::sync::{Arc, RwLock};
31
32use hashbrown::HashMap;
33use tracing::{error, info, warn};
34
35use crate::config::{self, CpexConfig};
36use crate::context::PluginContextTable;
37use crate::error::PluginError;
38use crate::executor::{BackgroundTasks, Executor, ExecutorConfig, PipelineResult};
39use crate::factory::PluginFactoryRegistry;
40use crate::hooks::adapter::TypedHandlerAdapter;
41use crate::hooks::payload::{Extensions, PluginPayload};
42use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult};
43use crate::hooks::HookType;
44use crate::plugin::{Plugin, PluginConfig};
45use crate::registry::{AnyHookHandler, PluginRef, PluginRegistry};
46
47// ---------------------------------------------------------------------------
48// Manager Configuration
49// ---------------------------------------------------------------------------
50
51/// Default upper bound on the routing cache. Caps memory growth from
52/// attacker-controlled entity names without forcing operators to tune.
53pub const DEFAULT_ROUTE_CACHE_MAX_ENTRIES: usize = 10_000;
54
55/// Configuration for the PluginManager.
56#[derive(Debug, Clone)]
57pub struct ManagerConfig {
58    /// Executor configuration (timeout, short-circuit behavior).
59    pub executor: ExecutorConfig,
60
61    /// Maximum number of entries in the routing cache. When the cache
62    /// reaches this size, further inserts are rejected (with a one-shot
63    /// warn log) and resolutions fall back to the slow path. See
64    /// `PluginSettings::route_cache_max_entries` for the YAML surface.
65    pub route_cache_max_entries: usize,
66}
67
68impl Default for ManagerConfig {
69    fn default() -> Self {
70        Self {
71            executor: ExecutorConfig::default(),
72            route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES,
73        }
74    }
75}
76
77// ---------------------------------------------------------------------------
78// Plugin Manager
79// ---------------------------------------------------------------------------
80
81/// Central plugin lifecycle and dispatch manager.
82///
83/// Owns the plugin registry and executor. Provides the public API
84/// that host systems (ContextForge, Kagenti, etc.) call to register
85/// plugins and invoke hooks.
86///
87/// # Lifecycle
88///
89/// ```text
90/// new() → register plugins → initialize() → invoke hooks → shutdown()
91/// ```
92///
93/// # Two Invoke Paths
94///
95/// - **`invoke::<H>()`** — typed dispatch. The hook type `H` is known
96///   at compile time. Payload type-checked at compile time. Used by
97///   Rust callers.
98///
99/// - **`invoke_by_name()`** — dynamic dispatch. The hook name is a
100///   string. Payload is `Box<dyn PluginPayload>`. Used by Python/Go/WASM
101///   callers via the FFI or PyO3 bindings.
102///
103/// Both paths use the same registry, executor, and 5-phase pipeline.
104///
105/// # Trust Model
106///
107/// The manager wraps each plugin in a `PluginRef` with an authoritative
108/// config from the config loader. The executor reads all scheduling
109/// decisions from `PluginRef.trusted_config` — never from the plugin.
110/// Cache key for resolved routing entries.
111///
112/// Includes entity type, name, hook name, and scope so that
113/// the same tool on different scopes or at different hook points
114/// caches separately.
115///
116/// Custom Hash/Eq implementations hash on `&str` slices so that
117/// `raw_entry` lookups with borrowed strings produce the same hash
118/// as the owned key — enabling zero-allocation cache hits.
119#[derive(Debug, Clone)]
120struct RouteCacheKey {
121    entity_type: String,
122    entity_name: String,
123    hook_name: String,
124    scope: Option<String>,
125}
126
127impl Hash for RouteCacheKey {
128    fn hash<H: Hasher>(&self, state: &mut H) {
129        self.entity_type.as_str().hash(state);
130        self.entity_name.as_str().hash(state);
131        self.hook_name.as_str().hash(state);
132        self.scope.as_deref().hash(state);
133    }
134}
135
136impl PartialEq for RouteCacheKey {
137    fn eq(&self, other: &Self) -> bool {
138        self.entity_type == other.entity_type
139            && self.entity_name == other.entity_name
140            && self.hook_name == other.hook_name
141            && self.scope == other.scope
142    }
143}
144
145impl Eq for RouteCacheKey {}
146
147/// Mutable runtime state held atomically swappable behind `ArcSwap`.
148///
149/// Every read on the hot path (`invoke_*`) does a single atomic load to
150/// get an `Arc<RuntimeSnapshot>` — no locks. Mutating operations
151/// (`register_*`, `load_config`, `unregister`) clone the current snapshot,
152/// mutate the clone, and atomically swap the new `Arc` in. Old readers
153/// finish on the old snapshot; new readers see the new one. This is the
154/// classic Read-Copy-Update / RCU pattern: lock-free reads, copy-on-write
155/// writes, no reader-writer contention.
156///
157/// Cloning `PluginRegistry` is cheap because every value inside (`PluginRef`,
158/// `AnyHookHandler`) is `Arc`-counted — only the `HashMap` shells duplicate.
159#[derive(Clone)]
160struct RuntimeSnapshot {
161    /// Plugin registry — stores PluginRefs and hook-to-handler mappings.
162    registry: PluginRegistry,
163
164    /// Executor — stateless 5-phase pipeline engine.
165    executor: Executor,
166
167    /// Parsed CPEX config (when loaded from file). Used for route resolution.
168    cpex_config: Option<CpexConfig>,
169
170    /// Maximum number of entries the route cache will hold. Once reached,
171    /// new resolutions are computed normally but not memoized (reject-on-full).
172    route_cache_max_entries: usize,
173
174    /// Per-route, per-hook handler overrides keyed by
175    /// `(entity_type, entity_name, scope, hook_name)`. When a request matches
176    /// an annotation, route resolution short-circuits to a single-entry list
177    /// containing the annotated handler instead of resolving the route's
178    /// imperative `plugins:` chain.
179    ///
180    /// Per-hook keying lets an orchestrator install distinct handlers for
181    /// `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` on the same route —
182    /// useful when the pre/post phases need different handler state (e.g.
183    /// apl-cpex's `AplRouteHandler` binds each instance to either
184    /// `evaluate_pre` or `evaluate_post`).
185    ///
186    /// `scope` (None vs `Some("virtual-server-A")`) lets two virtual
187    /// servers / gateways with the same tool name carry distinct
188    /// orchestrators. Matching mirrors cpex-core's existing
189    /// `find_matching_route` semantics: a scoped request first tries the
190    /// exact `(et, en, Some(req_scope), hook)` annotation; on miss it falls
191    /// back to the unscoped `(et, en, None, hook)` default. An unscoped
192    /// request only matches `(et, en, None, hook)`. Net effect: None-scope
193    /// annotations act as a global default, scoped annotations override
194    /// per-scope.
195    ///
196    /// The plugins listed under the matching route are *still* registered
197    /// in the registry — they remain discoverable via `find_plugin_entries`
198    /// so the annotated handler can dispatch into them by-name (this is
199    /// what apl-cpex's `AplRouteHandler` does via `CmfPluginInvoker` for
200    /// `plugin(name)` references inside APL rules).
201    route_annotations: HashMap<AnnotationKey, crate::registry::HookEntry>,
202}
203
204/// Composite key for route annotations. Includes the hook name so a single
205/// route can carry distinct handlers per phase (e.g. pre-invoke vs
206/// post-invoke).
207#[derive(Debug, Clone, Hash, PartialEq, Eq)]
208struct AnnotationKey {
209    entity_type: String,
210    entity_name: String,
211    scope: Option<String>,
212    hook_name: String,
213}
214
215pub struct PluginManager {
216    /// Hot-path runtime state. Swapped atomically on registration / config
217    /// reload — readers see a consistent view via a single `load_full()`.
218    runtime: arc_swap::ArcSwap<RuntimeSnapshot>,
219
220    /// Factory registry — owned by the manager. Used for initial
221    /// instantiation and for creating override instances when routes
222    /// override a plugin's base config.
223    ///
224    /// Held in a `RwLock` rather than the `ArcSwap` snapshot because
225    /// `Box<dyn PluginFactory>` is not `Clone`. Read on the slow path
226    /// (route cache miss + override config); write on `register_factory`.
227    /// The hot path never touches it.
228    factories: RwLock<PluginFactoryRegistry>,
229
230    /// Cache of resolved hook entries per (entity, hook, scope).
231    /// Populated on first access, invalidated on config reload.
232    /// Uses Arc so cache reads are refcount bumps (~1ns), not data copies.
233    route_cache: RwLock<HashMap<RouteCacheKey, Arc<Vec<crate::registry::HookEntry>>>>,
234
235    /// Hasher builder for zero-allocation cache lookups via raw_entry.
236    cache_hasher: hashbrown::DefaultHashBuilder,
237
238    /// Set to true after the first time the cache rejects an insert in a
239    /// given fill cycle, so the warn log fires once per cycle rather than
240    /// on every miss under DoS. Reset by `clear_routing_cache()`.
241    route_cache_full_warned: AtomicBool,
242
243    /// Whether initialize() has been called. Atomic so lifecycle methods
244    /// can be `&self` and the manager itself can sit behind `Arc`.
245    initialized: AtomicBool,
246
247    /// Monotonic config-generation counter. Bumped every time the runtime
248    /// snapshot is swapped (factory mutation, config (re)load, plugin
249    /// register/unregister). External orchestrators (apl-cpex's dispatch
250    /// plan cache) pair their cached values with the generation seen at
251    /// build time; a generation mismatch on lookup signals "evict + rebuild."
252    /// Starts at 0; first snapshot publish (empty registry) leaves it at 0,
253    /// so callers can use 0 as a "never observed" sentinel.
254    generation: AtomicU64,
255
256    /// Tracks in-flight fire-and-forget background tasks across all
257    /// invocations so `shutdown()` can wait for them to drain before
258    /// returning. Without this, audit/telemetry tasks spawned by recent
259    /// invokes get cancelled when the runtime tears down. Tasks are
260    /// `tracker.spawn`'d in `spawn_fire_and_forget`; `shutdown()` calls
261    /// `close().wait().await`.
262    ///
263    /// `TaskTracker` is internally `Arc`'d, so cloning is a refcount bump.
264    task_tracker: tokio_util::task::TaskTracker,
265
266    /// External orchestrators registered via `register_visitor`. Walked
267    /// in registration order during `load_config_yaml` (after plugin
268    /// instantiation) so each visitor can inspect raw YAML sections and
269    /// install handlers via `annotate_route`. Empty by default — the
270    /// `load_config(CpexConfig)` path skips visitors entirely.
271    visitors: RwLock<Vec<Arc<dyn crate::visitor::ConfigVisitor>>>,
272}
273
274/// Emit warnings for YAML settings that the runtime doesn't currently
275/// honor. Called once per `load_config` / `from_config` so operators
276/// who set these knobs aren't silently ignored.
277///
278/// `user_patterns` / `content_types` on `PluginCondition` are not warned
279/// — they were wired up alongside this fix and now actually filter.
280fn warn_on_inactive_settings(cfg: &CpexConfig) {
281    if !cfg.plugin_dirs.is_empty() {
282        warn!(
283            "config sets `plugin_dirs` (count={}) but the runtime does not \
284             scan directories for plugins — plugins must be registered via \
285             `register_factory()` and listed under `plugins:`. Setting ignored.",
286            cfg.plugin_dirs.len(),
287        );
288    }
289    if cfg.plugin_settings.parallel_execution_within_band {
290        warn!(
291            "config sets `plugin_settings.parallel_execution_within_band: true` \
292             but the runtime does not honor it — use `mode: concurrent` on \
293             individual plugins for parallel execution. Setting ignored.",
294        );
295    }
296    if cfg.plugin_settings.fail_on_plugin_error {
297        warn!(
298            "config sets `plugin_settings.fail_on_plugin_error: true` but the \
299             runtime does not honor it — use per-plugin `on_error: fail` for \
300             that behavior. Setting ignored.",
301        );
302    }
303}
304
305/// Instantiate every plugin in `plugin_configs` via the matching factory
306/// and register the resulting handlers into `target_registry`. Shared by
307/// `PluginManager::from_config` (fresh registry) and `load_config` (clone
308/// of the existing registry) so the instantiation loop lives in one place.
309///
310/// Returns on the first failure (factory missing, factory.create error, or
311/// duplicate-name registration). On error, `target_registry` is in a
312/// partial state — both callers discard it on failure (load_config builds
313/// the new registry on a clone and only swaps on Ok; from_config bails
314/// before publishing the snapshot).
315fn instantiate_plugins_into(
316    target_registry: &mut PluginRegistry,
317    plugin_configs: &[crate::plugin::PluginConfig],
318    factories: &PluginFactoryRegistry,
319) -> Result<(), Box<PluginError>> {
320    for plugin_config in plugin_configs {
321        let factory = factories
322            .get(&plugin_config.kind)
323            .ok_or_else(|| PluginError::Config {
324                message: format!(
325                    "no factory registered for plugin kind '{}' (plugin '{}')",
326                    plugin_config.kind, plugin_config.name
327                ),
328            })?;
329
330        let instance = factory.create(plugin_config)?;
331
332        target_registry
333            .register_multi_handler(instance.plugin, plugin_config.clone(), instance.handlers)
334            .map_err(|msg| Box::new(PluginError::Config { message: msg }))?;
335
336        info!(
337            "Registered plugin '{}' (kind: '{}') for hooks: {:?}",
338            plugin_config.name, plugin_config.kind, plugin_config.hooks
339        );
340    }
341    Ok(())
342}
343
344/// Build a `RuntimeSnapshot` from a populated registry plus the YAML
345/// settings on `cpex_config`. Pulls executor timeout / short-circuit and
346/// the route-cache cap from `plugin_settings` so both registration paths
347/// agree on field-by-field translation.
348fn snapshot_from_config(registry: PluginRegistry, cpex_config: CpexConfig) -> RuntimeSnapshot {
349    let executor = Executor::new(ExecutorConfig {
350        timeout_seconds: cpex_config.plugin_settings.plugin_timeout,
351        short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny,
352    });
353    let route_cache_max_entries = cpex_config.plugin_settings.route_cache_max_entries;
354    RuntimeSnapshot {
355        registry,
356        executor,
357        cpex_config: Some(cpex_config),
358        route_cache_max_entries,
359        route_annotations: HashMap::new(),
360    }
361}
362
363impl PluginManager {
364    /// Create a new PluginManager with the given configuration.
365    pub fn new(config: ManagerConfig) -> Self {
366        let cache_hasher = hashbrown::DefaultHashBuilder::default();
367        let snapshot = RuntimeSnapshot {
368            registry: PluginRegistry::new(),
369            executor: Executor::new(config.executor),
370            cpex_config: None,
371            route_cache_max_entries: config.route_cache_max_entries,
372            route_annotations: HashMap::new(),
373        };
374        Self {
375            runtime: arc_swap::ArcSwap::from_pointee(snapshot),
376            factories: RwLock::new(PluginFactoryRegistry::new()),
377            route_cache: RwLock::new(HashMap::with_hasher(cache_hasher.clone())),
378            cache_hasher,
379            route_cache_full_warned: AtomicBool::new(false),
380            initialized: AtomicBool::new(false),
381            generation: AtomicU64::new(0),
382            task_tracker: tokio_util::task::TaskTracker::new(),
383            visitors: RwLock::new(Vec::new()),
384        }
385    }
386
387    /// Load the current runtime snapshot (lock-free, single atomic op).
388    fn load_runtime(&self) -> Arc<RuntimeSnapshot> {
389        self.runtime.load_full()
390    }
391
392    /// Apply a mutation to the runtime snapshot via copy-on-write.
393    /// Clones the current snapshot, runs the closure on the clone, and
394    /// atomically swaps it in. Concurrent readers continue using the old
395    /// snapshot; subsequent readers see the new one.
396    fn mutate_runtime<F, R>(&self, f: F) -> R
397    where
398        F: FnOnce(&mut RuntimeSnapshot) -> R,
399    {
400        let current = self.runtime.load_full();
401        let mut next = (*current).clone();
402        let result = f(&mut next);
403        self.runtime.store(Arc::new(next));
404        // Release ordering pairs with the Acquire load in
405        // config_generation() — external cache consumers that observe a
406        // higher generation are guaranteed to see the new snapshot.
407        self.generation.fetch_add(1, Ordering::Release);
408        result
409    }
410
411    /// Like `mutate_runtime` but the mutation can fail — the new snapshot
412    /// is only published on `Ok`. On `Err`, the original snapshot is
413    /// untouched, so a partially-mutated clone is silently discarded.
414    fn try_mutate_runtime<F, T, E>(&self, f: F) -> Result<T, E>
415    where
416        F: FnOnce(&mut RuntimeSnapshot) -> Result<T, E>,
417    {
418        let current = self.runtime.load_full();
419        let mut next = (*current).clone();
420        let result = f(&mut next)?;
421        self.runtime.store(Arc::new(next));
422        // Same Release-ordered bump as mutate_runtime — only on Ok, since
423        // Err leaves the snapshot untouched.
424        self.generation.fetch_add(1, Ordering::Release);
425        Ok(result)
426    }
427
428    /// Monotonic counter that increments on every runtime snapshot swap
429    /// (registry mutation, config (re)load). External orchestrators
430    /// (e.g. apl-cpex's dispatch-plan cache) pair their cached values
431    /// with the generation seen at build time; a mismatch on lookup
432    /// signals "evict + rebuild." `Acquire` pairs with the `Release`
433    /// fetch_add in `mutate_runtime` / `try_mutate_runtime` so observing
434    /// a higher generation guarantees visibility of the new snapshot.
435    pub fn config_generation(&self) -> u64 {
436        self.generation.load(Ordering::Acquire)
437    }
438
439    // -----------------------------------------------------------------------
440    // Factory Registration
441    // -----------------------------------------------------------------------
442
443    /// Register a plugin factory for a given `kind` name.
444    ///
445    /// The host calls this to tell the manager how to create plugins
446    /// of a specific kind. Must be called before `load_config()`.
447    ///
448    /// # Examples
449    ///
450    /// ```rust,ignore
451    /// let mut manager = PluginManager::default();
452    /// manager.register_factory("builtin", Box::new(BuiltinFactory));
453    /// manager.register_factory("security/rate_limit", Box::new(RateLimiterFactory));
454    /// manager.load_config(Path::new("plugins.yaml"))?;
455    /// ```
456    pub fn register_factory(
457        &self,
458        kind: impl Into<String>,
459        factory: Box<dyn crate::factory::PluginFactory>,
460    ) {
461        self.factories
462            .write()
463            .unwrap_or_else(|p| p.into_inner())
464            .register(kind, factory);
465    }
466
467    // -----------------------------------------------------------------------
468    // Config Loading
469    // -----------------------------------------------------------------------
470
471    /// Load plugins from a YAML config file.
472    ///
473    /// Parses the config, looks up each plugin's `kind` in the
474    /// factory registry, instantiates the plugins, and registers
475    /// them. Factories must be registered via `register_factory()`
476    /// before calling this method.
477    ///
478    /// # Examples
479    ///
480    /// ```rust,ignore
481    /// let mut manager = PluginManager::default();
482    /// manager.register_factory("builtin", Box::new(BuiltinFactory));
483    /// manager.load_config_file(Path::new("plugins/config.yaml"))?;
484    /// manager.initialize().await?;
485    /// ```
486    pub fn load_config_file(&self, path: &Path) -> Result<(), Box<PluginError>> {
487        let cpex_config = config::load_config(path)?;
488        self.load_config(cpex_config)
489    }
490
491    /// Load plugins from a parsed config.
492    ///
493    /// Looks up each plugin's `kind` in the factory registry,
494    /// instantiates the plugins, and registers them with their
495    /// hook names from the config.
496    pub fn load_config(&self, cpex_config: CpexConfig) -> Result<(), Box<PluginError>> {
497        warn_on_inactive_settings(&cpex_config);
498
499        // Build the new snapshot from the current one — copy-on-write so
500        // concurrent invokes keep using the existing config until we swap.
501        // We can't use mutate_runtime here because we need to atomically
502        // ALSO build a new executor + new cache cap from the same config —
503        // the snapshot fields are coupled.
504        let factories = self.factories.read().unwrap_or_else(|p| p.into_inner());
505        let current = self.runtime.load_full();
506        let mut new_registry = current.registry.clone();
507
508        instantiate_plugins_into(&mut new_registry, &cpex_config.plugins, &factories)?;
509
510        // Drop the factories read lock before taking other locks
511        // (route_cache write below) to avoid lock-ordering hazards.
512        drop(factories);
513
514        self.runtime
515            .store(Arc::new(snapshot_from_config(new_registry, cpex_config)));
516        // Same generation bump as mutate_runtime — load_config doesn't
517        // go through that helper because it has to swap registry + executor
518        // + cache-cap atomically as one snapshot.
519        self.generation.fetch_add(1, Ordering::Release);
520
521        // Clear routing cache — config changed.
522        self.clear_routing_cache();
523
524        Ok(())
525    }
526
527    /// Register an external config visitor. Visitors run during
528    /// `load_config_yaml` (after plugin instantiation) and can install
529    /// per-route handler overrides via `annotate_route`. Visitor order
530    /// matches registration order. Multiple visitors are allowed —
531    /// they typically don't share state, so order rarely matters.
532    pub fn register_visitor(&self, visitor: Arc<dyn crate::visitor::ConfigVisitor>) {
533        let mut v = self.visitors.write().unwrap_or_else(|p| p.into_inner());
534        v.push(visitor);
535    }
536
537    /// Load a unified-config YAML string. Parses the YAML twice — once
538    /// into a typed `CpexConfig` for plugin instantiation, once into a
539    /// raw `serde_yaml::Value` so visitors can inspect orchestrator-
540    /// specific blocks (e.g. `apl:`) that cpex-core itself doesn't
541    /// model. Calls existing `load_config(cpex_config)` first, then
542    /// walks each registered visitor over the raw YAML's sections in
543    /// the documented hierarchy order:
544    ///
545    /// 1. `visit_global(global_yaml)`
546    /// 2. `visit_default(entity_type, default_yaml)` per `global.defaults` entry
547    /// 3. `visit_policy_bundle(tag, bundle_yaml)` per `global.policies` entry
548    /// 4. `visit_route(route_yaml, parsed_route)` per `routes[]` entry
549    ///
550    /// All sections for one visitor run before the next visitor starts,
551    /// giving each visitor a consistent view of its own accumulated
552    /// state. A visitor returning Err aborts the load — the plugin
553    /// snapshot stays at the post-`load_config` state (partial load is
554    /// not rolled back; operators should treat any error from this
555    /// method as a hard stop).
556    pub fn load_config_yaml(self: &Arc<Self>, yaml: &str) -> Result<(), Box<PluginError>> {
557        // Parse once into a Value so the raw shape is available to
558        // visitors. Then deserialize from that Value into CpexConfig —
559        // saves a second tokenize/lex pass vs parsing the string twice.
560        let raw: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|e| {
561            Box::new(PluginError::Config {
562                message: format!("YAML parse error: {}", e),
563            })
564        })?;
565        let cpex_config: CpexConfig = serde_yaml::from_value(raw.clone()).map_err(|e| {
566            Box::new(PluginError::Config {
567                message: format!("CpexConfig deserialize error: {}", e),
568            })
569        })?;
570
571        // Snapshot the parsed routes + plugin declarations before
572        // load_config moves the config — visitors get the typed
573        // structures side-by-side with the raw YAML so they don't have
574        // to re-deserialize anything cpex-core has already validated.
575        let parsed_routes: Vec<crate::config::RouteEntry> = cpex_config.routes.clone();
576        let parsed_plugins: Vec<crate::plugin::PluginConfig> = cpex_config.plugins.clone();
577
578        // Existing plugin-instantiation path.
579        self.load_config(cpex_config)?;
580
581        // Visitor walk. No-op when no visitors registered — the common
582        // case for hosts that don't use the orchestrator extension point.
583        let visitors = {
584            let v = self.visitors.read().unwrap_or_else(|p| p.into_inner());
585            if v.is_empty() {
586                return Ok(());
587            }
588            v.clone()
589        };
590
591        let mgr: Arc<PluginManager> = Arc::clone(self);
592        let global_yaml = raw
593            .get("global")
594            .cloned()
595            .unwrap_or(serde_yaml::Value::Null);
596        let defaults_yaml = global_yaml
597            .get("defaults")
598            .and_then(serde_yaml::Value::as_mapping)
599            .cloned();
600        let policies_yaml = global_yaml
601            .get("policies")
602            .and_then(serde_yaml::Value::as_mapping)
603            .cloned();
604        let routes_yaml: Vec<serde_yaml::Value> = raw
605            .get("routes")
606            .and_then(serde_yaml::Value::as_sequence)
607            .cloned()
608            .unwrap_or_default();
609
610        for visitor in &visitors {
611            visitor.visit_plugins(&mgr, &parsed_plugins).map_err(|e| {
612                Box::new(PluginError::Config {
613                    message: format!("visitor '{}' visit_plugins: {}", visitor.name(), e),
614                })
615            })?;
616
617            visitor.visit_global(&mgr, &global_yaml).map_err(|e| {
618                Box::new(PluginError::Config {
619                    message: format!("visitor '{}' visit_global: {}", visitor.name(), e),
620                })
621            })?;
622
623            if let Some(defaults) = &defaults_yaml {
624                for (k, v) in defaults {
625                    let Some(entity_type) = k.as_str() else {
626                        continue;
627                    };
628                    visitor.visit_default(&mgr, entity_type, v).map_err(|e| {
629                        Box::new(PluginError::Config {
630                            message: format!(
631                                "visitor '{}' visit_default('{}'): {}",
632                                visitor.name(),
633                                entity_type,
634                                e
635                            ),
636                        })
637                    })?;
638                }
639            }
640
641            if let Some(policies) = &policies_yaml {
642                for (k, v) in policies {
643                    let Some(tag) = k.as_str() else { continue };
644                    visitor.visit_policy_bundle(&mgr, tag, v).map_err(|e| {
645                        Box::new(PluginError::Config {
646                            message: format!(
647                                "visitor '{}' visit_policy_bundle('{}'): {}",
648                                visitor.name(),
649                                tag,
650                                e
651                            ),
652                        })
653                    })?;
654                }
655            }
656
657            for (i, parsed) in parsed_routes.iter().enumerate() {
658                let route_yaml = routes_yaml
659                    .get(i)
660                    .cloned()
661                    .unwrap_or(serde_yaml::Value::Null);
662                visitor
663                    .visit_route(&mgr, &route_yaml, parsed)
664                    .map_err(|e| {
665                        Box::new(PluginError::Config {
666                            message: format!(
667                                "visitor '{}' visit_route[{}]: {}",
668                                visitor.name(),
669                                i,
670                                e
671                            ),
672                        })
673                    })?;
674            }
675        }
676
677        Ok(())
678    }
679
680    /// Create a PluginManager from a parsed config (convenience).
681    ///
682    /// Uses the passed factory registry for initial instantiation.
683    /// Note: for route-level config overrides to create new instances
684    /// at runtime, use `register_factory()` + `load_config()` instead
685    /// so the manager owns the factories.
686    pub fn from_config(
687        cpex_config: CpexConfig,
688        factories: &PluginFactoryRegistry,
689    ) -> Result<Self, Box<PluginError>> {
690        warn_on_inactive_settings(&cpex_config);
691
692        let manager = Self::new(ManagerConfig {
693            executor: ExecutorConfig::default(),
694            route_cache_max_entries: cpex_config.plugin_settings.route_cache_max_entries,
695        });
696
697        // Instantiate into a fresh registry, then publish atomically.
698        let mut new_registry = PluginRegistry::new();
699        instantiate_plugins_into(&mut new_registry, &cpex_config.plugins, factories)?;
700
701        manager
702            .runtime
703            .store(Arc::new(snapshot_from_config(new_registry, cpex_config)));
704
705        Ok(manager)
706    }
707
708    // -----------------------------------------------------------------------
709    // Registration
710    // -----------------------------------------------------------------------
711
712    /// Register a plugin handler for its primary hook name.
713    ///
714    /// This is the preferred registration method. The framework creates
715    /// the type-erased adapter internally — no `AnyHookHandler` needed.
716    ///
717    /// # Type Parameters
718    ///
719    /// - `H` — the hook type (implements `HookTypeDef`).
720    /// - `P` — the plugin type (implements `Plugin + HookHandler<H>`).
721    ///
722    /// # Arguments
723    ///
724    /// - `plugin` — the plugin implementation.
725    /// - `config` — authoritative config from the config loader.
726    ///
727    /// # Examples
728    ///
729    /// ```rust,ignore
730    /// manager.register_handler::<CmfHook, _>(plugin, config)?;
731    /// ```
732    pub fn register_handler<H, P>(
733        &self,
734        plugin: Arc<P>,
735        config: PluginConfig,
736    ) -> Result<(), Box<PluginError>>
737    where
738        H: HookTypeDef,
739        H::Result: Into<PluginResult<H::Payload>>,
740        P: Plugin + HookHandler<H> + 'static,
741    {
742        let handler: Arc<dyn AnyHookHandler> =
743            Arc::new(TypedHandlerAdapter::<H, P>::new(Arc::clone(&plugin)));
744        self.try_mutate_runtime(|snap| {
745            snap.registry
746                .register::<H>(plugin, config, handler)
747                .map_err(|msg| Box::new(PluginError::Config { message: msg }))
748        })?;
749        self.clear_routing_cache();
750        Ok(())
751    }
752
753    /// Register a plugin handler for multiple hook names.
754    ///
755    /// This is the CMF pattern — one handler covers multiple hook
756    /// names (`cmf.tool_pre_invoke`, `cmf.llm_input`, etc.).
757    ///
758    /// # Examples
759    ///
760    /// ```rust,ignore
761    /// manager.register_handler_for_names::<CmfHook, _>(
762    ///     plugin, config,
763    ///     &["cmf.tool_pre_invoke", "cmf.llm_input", "cmf.llm_output"],
764    /// )?;
765    /// ```
766    pub fn register_handler_for_names<H, P>(
767        &self,
768        plugin: Arc<P>,
769        config: PluginConfig,
770        names: &[&str],
771    ) -> Result<(), Box<PluginError>>
772    where
773        H: HookTypeDef,
774        H::Result: Into<PluginResult<H::Payload>>,
775        P: Plugin + HookHandler<H> + 'static,
776    {
777        let handler: Arc<dyn AnyHookHandler> =
778            Arc::new(TypedHandlerAdapter::<H, P>::new(Arc::clone(&plugin)));
779        self.try_mutate_runtime(|snap| {
780            snap.registry
781                .register_for_names::<H>(plugin, config, handler, names)
782                .map_err(|msg| Box::new(PluginError::Config { message: msg }))
783        })?;
784        self.clear_routing_cache();
785        Ok(())
786    }
787
788    /// Register with an explicit AnyHookHandler (advanced use).
789    ///
790    /// For cases where the automatic adapter doesn't fit — e.g.,
791    /// Python/WASM bridge hosts that implement AnyHookHandler directly.
792    /// Most callers should use `register_handler` instead.
793    pub fn register_raw<H: HookTypeDef>(
794        &self,
795        plugin: Arc<dyn Plugin>,
796        config: PluginConfig,
797        handler: Arc<dyn AnyHookHandler>,
798    ) -> Result<(), Box<PluginError>> {
799        self.try_mutate_runtime(|snap| {
800            snap.registry
801                .register::<H>(plugin, config, handler)
802                .map_err(|msg| Box::new(PluginError::Config { message: msg }))
803        })?;
804        self.clear_routing_cache();
805        Ok(())
806    }
807
808    // -----------------------------------------------------------------------
809    // Lifecycle
810    // -----------------------------------------------------------------------
811
812    /// Initialize all registered plugins.
813    ///
814    /// Calls `plugin.initialize()` on each registered plugin. Must be
815    /// called before invoking any hooks. Idempotent — calling twice
816    /// has no effect.
817    pub async fn initialize(&self) -> Result<(), Box<PluginError>> {
818        if self.initialized.load(Ordering::Acquire) {
819            return Ok(());
820        }
821
822        // Snapshot once at start — subsequent registrations don't affect
823        // this initialize() call. They'd need their own initialize.
824        let snapshot = self.load_runtime();
825
826        info!(
827            "Initializing PluginManager with {} plugins",
828            snapshot.registry.plugin_count()
829        );
830
831        let mut initialized_plugins: Vec<String> = Vec::new();
832
833        for name in snapshot.registry.plugin_names() {
834            if let Some(plugin_ref) = snapshot.registry.get(&name) {
835                let plugin = plugin_ref.plugin().clone();
836                let plugin_name = name;
837
838                if let Err(e) = plugin.initialize().await {
839                    error!("Failed to initialize plugin '{}': {}", plugin_name, e);
840
841                    // Clean up already-initialized plugins
842                    for init_name in initialized_plugins.iter().rev() {
843                        if let Some(pr) = snapshot.registry.get(init_name) {
844                            if let Err(shutdown_err) = pr.plugin().shutdown().await {
845                                error!(
846                                    "Error shutting down plugin '{}' during rollback: {}",
847                                    init_name, shutdown_err
848                                );
849                            }
850                        }
851                    }
852
853                    return Err(Box::new(PluginError::Execution {
854                        plugin_name,
855                        message: format!("initialization failed: {}", e),
856                        source: Some(Box::new(e)),
857                        code: None,
858                        details: std::collections::HashMap::new(),
859                        proto_error_code: None,
860                    }));
861                }
862
863                initialized_plugins.push(plugin_name);
864            }
865        }
866
867        self.initialized.store(true, Ordering::Release);
868        info!("PluginManager initialized successfully");
869        Ok(())
870    }
871
872    /// Shutdown all registered plugins.
873    ///
874    /// Calls `plugin.shutdown()` on each registered plugin in reverse
875    /// registration order. Errors are logged but do not halt the
876    /// shutdown process — all plugins get a chance to clean up.
877    /// Shut the manager down. **Terminal:** after `shutdown()` returns,
878    /// no further `register_*` / `invoke_*` should be called. New
879    /// fire-and-forget tasks spawned after `close()` will not be tracked
880    /// (the `TaskTracker` is single-shot by design).
881    pub async fn shutdown(&self) {
882        if !self.initialized.load(Ordering::Acquire) {
883            return;
884        }
885
886        info!("Shutting down PluginManager");
887
888        // Drain in-flight fire-and-forget tasks BEFORE tearing down
889        // plugins — otherwise audit/telemetry tasks that depend on the
890        // plugin being alive (or the runtime being up) get cancelled
891        // mid-flight. `close()` prevents new tasks from being tracked
892        // (existing in-flight ones still complete); `wait()` returns
893        // when the in-flight count drops to zero.
894        self.task_tracker.close();
895        self.task_tracker.wait().await;
896
897        let snapshot = self.load_runtime();
898        for name in snapshot.registry.plugin_names() {
899            if let Some(plugin_ref) = snapshot.registry.get(&name) {
900                let plugin = plugin_ref.plugin().clone();
901
902                if let Err(e) = plugin.shutdown().await {
903                    error!("Error shutting down plugin '{}': {}", name, e);
904                    // Continue — don't let one plugin's failure block others
905                }
906            }
907        }
908
909        self.initialized.store(false, Ordering::Release);
910        info!("PluginManager shutdown complete");
911    }
912
913    // -----------------------------------------------------------------------
914    // Hook Invocation — Dynamic (invoke_by_name)
915    // -----------------------------------------------------------------------
916
917    /// Invoke a hook by name with a type-erased payload.
918    ///
919    /// This is the dynamic dispatch path used by Python/Go/WASM
920    /// callers via FFI or PyO3 bindings. The hook name is resolved
921    /// from the registry and dispatched through the 5-phase executor.
922    ///
923    /// # Arguments
924    ///
925    /// * `hook_name` — the hook name string (e.g., `"cmf.tool_pre_invoke"`).
926    /// * `payload` — the payload as `Box<dyn PluginPayload>`.
927    /// * `extensions` — the full extensions (filtered per plugin by the executor).
928    /// * `context_table` — optional context table from a previous hook
929    ///   invocation. Pass `None` on the first hook call; thread the
930    ///   returned table into subsequent calls to preserve per-plugin state.
931    ///
932    /// # Returns
933    ///
934    /// A tuple of `(PipelineResult, BackgroundTasks)`. The result
935    /// contains the final payload, extensions, violation, and context
936    /// table. Background tasks can be awaited or dropped.
937    pub async fn invoke_by_name(
938        &self,
939        hook_name: &str,
940        payload: Box<dyn PluginPayload>,
941        extensions: Extensions,
942        context_table: Option<PluginContextTable>,
943    ) -> (PipelineResult, BackgroundTasks) {
944        // Single atomic load — own the snapshot for the rest of the call so
945        // a concurrent register/load_config swapping in a new snapshot doesn't
946        // change our view mid-pipeline.
947        let snapshot = self.load_runtime();
948        let hook_type = HookType::new(hook_name);
949        let all_entries = snapshot.registry.entries_for_hook(&hook_type);
950
951        // Same caveat as `invoke_named`: route annotations can produce a
952        // dispatch entry without any plugin being registered on the
953        // hook directly, so we can only short-circuit when both the
954        // registry and the annotation map are empty.
955        if all_entries.is_empty() && snapshot.route_annotations.is_empty() {
956            return (
957                PipelineResult::allowed_with(
958                    payload,
959                    extensions,
960                    context_table.unwrap_or_default(),
961                ),
962                BackgroundTasks::empty(),
963            );
964        }
965
966        let entries = self
967            .filter_entries_by_route(&snapshot, all_entries, &extensions, hook_name)
968            .await;
969
970        if entries.is_empty() {
971            return (
972                PipelineResult::allowed_with(
973                    payload,
974                    extensions,
975                    context_table.unwrap_or_default(),
976                ),
977                BackgroundTasks::empty(),
978            );
979        }
980
981        snapshot
982            .executor
983            .execute(
984                &entries,
985                payload,
986                extensions,
987                context_table,
988                &self.task_tracker,
989            )
990            .await
991    }
992
993    // -----------------------------------------------------------------------
994    // Hook Invocation — Typed (invoke::<H>)
995
996    // -----------------------------------------------------------------------
997
998    /// Invoke a typed hook.
999    ///
1000    /// This is the compile-time dispatch path used by Rust callers.
1001    /// The hook type `H` determines the payload and result types.
1002    /// Dispatch goes through the same registry and 5-phase executor
1003    /// as `invoke_by_name()`.
1004    ///
1005    /// When routing is enabled, the entity is identified from
1006    /// `extensions.meta` (entity_type + entity_name). Only plugins
1007    /// matching the resolved route fire. When routing is disabled
1008    /// or meta is absent, all registered plugins fire.
1009    ///
1010    /// # Type Parameters
1011    ///
1012    /// - `H` — the hook type (implements `HookTypeDef`).
1013    ///
1014    /// # Arguments
1015    ///
1016    /// * `payload` — the typed payload.
1017    /// * `extensions` — the full extensions (includes meta for routing).
1018    /// * `context_table` — optional context table from a previous hook.
1019    ///
1020    /// # Returns
1021    ///
1022    /// A tuple of `(PipelineResult, BackgroundTasks)`.
1023    pub async fn invoke<H: HookTypeDef>(
1024        &self,
1025        payload: H::Payload,
1026        extensions: Extensions,
1027        context_table: Option<PluginContextTable>,
1028    ) -> (PipelineResult, BackgroundTasks) {
1029        let snapshot = self.load_runtime();
1030        let hook_type = HookType::new(H::NAME);
1031        let all_entries = snapshot.registry.entries_for_hook(&hook_type);
1032
1033        // See `invoke_named` for why we don't short-circuit on
1034        // `all_entries.is_empty()` alone — route annotations can fire
1035        // without a directly-registered plugin.
1036        if all_entries.is_empty() && snapshot.route_annotations.is_empty() {
1037            let boxed: Box<dyn PluginPayload> = Box::new(payload);
1038            return (
1039                PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1040                BackgroundTasks::empty(),
1041            );
1042        }
1043
1044        let entries = self
1045            .filter_entries_by_route(&snapshot, all_entries, &extensions, H::NAME)
1046            .await;
1047
1048        if entries.is_empty() {
1049            let boxed: Box<dyn PluginPayload> = Box::new(payload);
1050            return (
1051                PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1052                BackgroundTasks::empty(),
1053            );
1054        }
1055
1056        let boxed: Box<dyn PluginPayload> = Box::new(payload);
1057        snapshot
1058            .executor
1059            .execute(
1060                &entries,
1061                boxed,
1062                extensions,
1063                context_table,
1064                &self.task_tracker,
1065            )
1066            .await
1067    }
1068
1069    /// Invoke a typed hook by explicit name.
1070    ///
1071    /// Combines compile-time payload type checking (from `H`) with
1072    /// runtime hook name routing (from `hook_name`). Use this when
1073    /// a single hook type (e.g., `CmfHook`) covers multiple hook
1074    /// names (e.g., `cmf.tool_pre_invoke`, `cmf.tool_post_invoke`).
1075    ///
1076    /// # Type Parameters
1077    ///
1078    /// - `H` — the hook type (provides payload type checking).
1079    ///
1080    /// # Arguments
1081    ///
1082    /// * `hook_name` — the hook name for dispatch routing.
1083    /// * `payload` — the typed payload (compile-time checked against `H::Payload`).
1084    /// * `extensions` — the full extensions.
1085    /// * `context_table` — optional context table from a previous hook.
1086    ///
1087    /// # Examples
1088    ///
1089    /// ```rust,ignore
1090    /// // Compile-time: payload must be MessagePayload (from CmfHook)
1091    /// // Runtime: dispatches to plugins registered under "cmf.tool_pre_invoke"
1092    /// let (result, bg) = mgr.invoke_named::<CmfHook>(
1093    ///     "cmf.tool_pre_invoke", payload, ext, None,
1094    /// ).await;
1095    /// ```
1096    pub async fn invoke_named<H: HookTypeDef>(
1097        &self,
1098        hook_name: &str,
1099        payload: H::Payload,
1100        extensions: Extensions,
1101        context_table: Option<PluginContextTable>,
1102    ) -> (PipelineResult, BackgroundTasks) {
1103        let snapshot = self.load_runtime();
1104        let hook_type = HookType::new(hook_name);
1105        let all_entries = snapshot.registry.entries_for_hook(&hook_type);
1106
1107        // No registered entries AND no route annotations → nothing to
1108        // do. Allow-and-pass-through. We can't short-circuit on
1109        // `all_entries.is_empty()` alone, because route annotations
1110        // (external-orchestrator handlers from APL / future Rego /
1111        // Cedar-direct) can produce a single-entry dispatch even when
1112        // no plugin was registered on the hook directly.
1113        if all_entries.is_empty() && snapshot.route_annotations.is_empty() {
1114            let boxed: Box<dyn PluginPayload> = Box::new(payload);
1115            return (
1116                PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1117                BackgroundTasks::empty(),
1118            );
1119        }
1120
1121        let entries = self
1122            .filter_entries_by_route(&snapshot, all_entries, &extensions, hook_name)
1123            .await;
1124
1125        if entries.is_empty() {
1126            let boxed: Box<dyn PluginPayload> = Box::new(payload);
1127            return (
1128                PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1129                BackgroundTasks::empty(),
1130            );
1131        }
1132
1133        let boxed: Box<dyn PluginPayload> = Box::new(payload);
1134        snapshot
1135            .executor
1136            .execute(
1137                &entries,
1138                boxed,
1139                extensions,
1140                context_table,
1141                &self.task_tracker,
1142            )
1143            .await
1144    }
1145
1146    /// Find every (hook_name, HookEntry) pair belonging to the named
1147    /// plugin. Returns an empty `Vec` if the plugin isn't registered.
1148    ///
1149    /// Used by external orchestrators (notably apl-cpex) that decide
1150    /// the per-route plugin lineup themselves and need handler refs +
1151    /// trusted_config to build pre-resolved dispatch plans. Cheaper than
1152    /// going through `invoke_named` per request because the caller can
1153    /// cache the resulting entries — pair the result with
1154    /// [`config_generation`](Self::config_generation) to invalidate the
1155    /// cache on snapshot swaps.
1156    ///
1157    /// Bypasses route/entity filtering — caller has already decided this
1158    /// plugin should run. APL's `routes:` is itself the authoritative
1159    /// lineup; cpex-core's condition-based routing is a parallel model
1160    /// for non-APL hosts.
1161    pub fn find_plugin_entries(
1162        &self,
1163        plugin_name: &str,
1164    ) -> Vec<(String, crate::registry::HookEntry)> {
1165        let snapshot = self.load_runtime();
1166        snapshot.registry.entries_for_plugin(plugin_name)
1167    }
1168
1169    /// Dispatch a caller-supplied slice of HookEntries through the
1170    /// executor's full 5-phase pipeline (sequential, transform, audit,
1171    /// concurrent, fire-and-forget). All on_error / timeout / mode /
1172    /// write-token machinery applies.
1173    ///
1174    /// Bypasses hook-name lookup and route/entity filtering — caller has
1175    /// already resolved the lineup (typically via
1176    /// [`find_plugin_entries`](Self::find_plugin_entries) + a per-route
1177    /// dispatch plan). The `H: HookTypeDef` parameter enforces payload
1178    /// type at compile time; mismatched payloads fail to compile, same
1179    /// as [`invoke_named`](Self::invoke_named).
1180    ///
1181    /// Returns `(PipelineResult, BackgroundTasks)` identical in shape to
1182    /// `invoke_named` so callers can swap between the two paths without
1183    /// rewriting downstream result handling.
1184    pub async fn invoke_entries<H: HookTypeDef>(
1185        &self,
1186        entries: &[crate::registry::HookEntry],
1187        payload: H::Payload,
1188        extensions: Extensions,
1189        context_table: Option<PluginContextTable>,
1190    ) -> (PipelineResult, BackgroundTasks) {
1191        if entries.is_empty() {
1192            let boxed: Box<dyn PluginPayload> = Box::new(payload);
1193            return (
1194                PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1195                BackgroundTasks::empty(),
1196            );
1197        }
1198        let snapshot = self.load_runtime();
1199        let boxed: Box<dyn PluginPayload> = Box::new(payload);
1200        snapshot
1201            .executor
1202            .execute(
1203                entries,
1204                boxed,
1205                extensions,
1206                context_table,
1207                &self.task_tracker,
1208            )
1209            .await
1210    }
1211
1212    // -----------------------------------------------------------------------
1213    // Route Annotation
1214    // -----------------------------------------------------------------------
1215
1216    /// Override the resolved plugin list for one `(entity_type, entity_name)`
1217    /// pair on the listed hooks with a single synthetic handler. The handler
1218    /// takes responsibility for any further plugin dispatch within itself
1219    /// (typically by calling [`invoke_entries`](Self::invoke_entries) against
1220    /// the same registry's other entries — i.e. APL's `plugin(name)` →
1221    /// `CmfPluginInvoker` → `invoke_entries` flow).
1222    ///
1223    /// This is the integration point external orchestrators (APL, future
1224    /// Rego/Cedar-direct/Custom) use to drive plugins via their own
1225    /// semantics instead of cpex-core's imperative `routes.*.plugins:`
1226    /// chain. Bumps the config generation so cached dispatch plans in
1227    /// downstream caches invalidate.
1228    ///
1229    /// `config` provides the trusted_config for the synthetic plugin —
1230    /// the executor reads `mode`, `on_error`, `capabilities`, etc. from
1231    /// it the same way it does for any other registered plugin. Capabilities
1232    /// should be a *superset* of what the orchestrator needs to read from
1233    /// `Extensions` (cpex-core's per-plugin filter still applies to the
1234    /// synthetic handler).
1235    ///
1236    /// The underlying `plugins:` chain for this route is *not* removed —
1237    /// those plugins stay discoverable via [`find_plugin_entries`](Self::find_plugin_entries)
1238    /// so the orchestrator can dispatch into them by name.
1239    pub fn annotate_route<H>(
1240        &self,
1241        entity_type: impl Into<String>,
1242        entity_name: impl Into<String>,
1243        scope: Option<String>,
1244        hook_name: impl Into<String>,
1245        handler: Arc<H>,
1246        config: crate::plugin::PluginConfig,
1247    ) where
1248        H: crate::plugin::Plugin + crate::registry::AnyHookHandler + 'static,
1249    {
1250        let key = AnnotationKey {
1251            entity_type: entity_type.into(),
1252            entity_name: entity_name.into(),
1253            scope,
1254            hook_name: hook_name.into(),
1255        };
1256        let plugin_ref = Arc::new(crate::registry::PluginRef::new(
1257            handler.clone() as Arc<dyn crate::plugin::Plugin>,
1258            config,
1259        ));
1260        let entry = crate::registry::HookEntry {
1261            plugin_ref,
1262            handler: handler as Arc<dyn crate::registry::AnyHookHandler>,
1263        };
1264        self.mutate_runtime(|snap| {
1265            snap.route_annotations.insert(key, entry);
1266        });
1267    }
1268
1269    /// Remove a route annotation for a specific hook. No-op when no
1270    /// annotation exists for the key. Bumps the generation so downstream
1271    /// caches invalidate.
1272    pub fn remove_route_annotation(
1273        &self,
1274        entity_type: &str,
1275        entity_name: &str,
1276        scope: Option<&str>,
1277        hook_name: &str,
1278    ) {
1279        let key = AnnotationKey {
1280            entity_type: entity_type.to_string(),
1281            entity_name: entity_name.to_string(),
1282            scope: scope.map(str::to_string),
1283            hook_name: hook_name.to_string(),
1284        };
1285        self.mutate_runtime(|snap| {
1286            snap.route_annotations.remove(&key);
1287        });
1288    }
1289
1290    // -----------------------------------------------------------------------
1291    // Route Filtering
1292    // -----------------------------------------------------------------------
1293
1294    /// Filter hook entries based on route resolution, with caching.
1295    ///
1296    /// When routing is enabled and extensions.meta provides entity
1297    /// identification, resolves the route and returns only the entries
1298    /// for plugins that match. Results are cached by
1299    /// `(entity_type, entity_name, hook_name, scope)` — subsequent
1300    /// calls for the same key return an `Arc` to the cached entries
1301    /// (refcount bump, no data copy).
1302    ///
1303    /// When routing is disabled or meta is absent, returns all entries.
1304    async fn filter_entries_by_route(
1305        &self,
1306        snapshot: &RuntimeSnapshot,
1307        entries: &[crate::registry::HookEntry],
1308        extensions: &Extensions,
1309        hook_name: &str,
1310    ) -> Arc<Vec<crate::registry::HookEntry>> {
1311        // Route annotation short-circuit: if the request's
1312        // (entity_type, entity_name) has an annotation that handles this
1313        // hook, return a one-entry list containing the annotated handler.
1314        // External orchestrators (APL via apl-cpex; future Rego/Cedar)
1315        // register annotations to drive plugin dispatch under their own
1316        // semantics instead of cpex-core's imperative chain. Underlying
1317        // `plugins:` entries stay in the registry for the orchestrator
1318        // to dispatch into by-name via `invoke_entries`.
1319        if !snapshot.route_annotations.is_empty() {
1320            if let Some(meta) = &extensions.meta {
1321                if let (Some(et), Some(en)) = (&meta.entity_type, &meta.entity_name) {
1322                    // Scoped lookup first (specific wins); unscoped lookup
1323                    // falls back as a "global default" — matches the
1324                    // specificity tiebreaker `find_matching_route` uses.
1325                    // Lookup is keyed on the hook name as well, so a route
1326                    // can install distinct handlers per phase.
1327                    let scoped = meta.scope.as_ref().and_then(|s| {
1328                        snapshot.route_annotations.get(&AnnotationKey {
1329                            entity_type: et.clone(),
1330                            entity_name: en.clone(),
1331                            scope: Some(s.clone()),
1332                            hook_name: hook_name.to_string(),
1333                        })
1334                    });
1335                    let candidate = scoped.or_else(|| {
1336                        snapshot.route_annotations.get(&AnnotationKey {
1337                            entity_type: et.clone(),
1338                            entity_name: en.clone(),
1339                            scope: None,
1340                            hook_name: hook_name.to_string(),
1341                        })
1342                    });
1343                    if let Some(entry) = candidate {
1344                        return Arc::new(vec![entry.clone()]);
1345                    }
1346                }
1347            }
1348        }
1349
1350        // Routing disabled (or no config): fall back to per-plugin
1351        // condition filtering. Empty conditions Vec means "fire always",
1352        // so this is backward-compatible with configs that don't use
1353        // conditions. Mirrors the Python implementation.
1354        let cpex_config = match &snapshot.cpex_config {
1355            Some(c) if c.routing_enabled() => c,
1356            _ => {
1357                let filtered: Vec<_> = entries
1358                    .iter()
1359                    .filter(|e| e.plugin_ref.trusted_config().passes_conditions(extensions))
1360                    .cloned()
1361                    .collect();
1362                return Arc::new(filtered);
1363            },
1364        };
1365
1366        // Extract entity info from meta extension
1367        let meta = match &extensions.meta {
1368            Some(m) => m,
1369            None => return Arc::new(entries.to_vec()),
1370        };
1371
1372        let (entity_type, entity_name) = match (&meta.entity_type, &meta.entity_name) {
1373            (Some(t), Some(n)) => (t.as_str(), n.as_str()),
1374            _ => return Arc::new(entries.to_vec()),
1375        };
1376
1377        let request_scope = meta.scope.as_deref();
1378
1379        // Fast path: zero-allocation cache lookup with raw_entry
1380        let hash = {
1381            use std::hash::BuildHasher;
1382            let mut hasher = self.cache_hasher.build_hasher();
1383            entity_type.hash(&mut hasher);
1384            entity_name.hash(&mut hasher);
1385            hook_name.hash(&mut hasher);
1386            request_scope.hash(&mut hasher);
1387            hasher.finish()
1388        };
1389        {
1390            // Recover from poisoning: a panic in another thread while holding
1391            // this lock leaves the cache flagged poisoned. The cache's contents
1392            // are still valid (HashMap operations are panic-safe and stale
1393            // entries are healed by `clear_routing_cache()`), so we don't want
1394            // a one-time panic to permanently disable dispatch. Same idiom
1395            // applies to all four lock sites in this file.
1396            let cache = self
1397                .route_cache
1398                .read()
1399                .unwrap_or_else(|poisoned| poisoned.into_inner());
1400            if let Some((_, cached)) = cache.raw_entry().from_hash(hash, |key| {
1401                key.entity_type == entity_type
1402                    && key.entity_name == entity_name
1403                    && key.hook_name == hook_name
1404                    && key.scope.as_deref() == request_scope
1405            }) {
1406                return Arc::clone(cached);
1407            }
1408        }
1409
1410        // Slow path: resolve, filter, and cache (allocations only here).
1411        //
1412        // Hook-specific resolution for identity.resolve: the route's
1413        // `identity:` block is the authoritative dispatch list (NOT
1414        // the `plugins:` block, which in APL-driven routes means
1415        // "per-route overrides" rather than "binding"). For every
1416        // other hook, the generic plugins-block resolution applies.
1417        let resolved = if hook_name == crate::identity::HOOK_IDENTITY_RESOLVE {
1418            config::resolve_identity_plugins_for_route(
1419                cpex_config,
1420                entity_type,
1421                entity_name,
1422                request_scope,
1423            )
1424        } else {
1425            config::resolve_plugins_for_entity(
1426                cpex_config,
1427                entity_type,
1428                entity_name,
1429                request_scope,
1430                &meta.tags,
1431            )
1432        };
1433
1434        // Filter entries to resolved plugins, preserving resolution order.
1435        // If a plugin has config overrides and we have a factory for its kind,
1436        // create a new instance with the merged config.
1437        let mut filtered = Vec::new();
1438        for resolved_plugin in &resolved {
1439            if let Some(entry) = entries
1440                .iter()
1441                .find(|e| e.plugin_ref.name() == resolved_plugin.name)
1442            {
1443                if let Some(overrides) = &resolved_plugin.config_overrides {
1444                    // Try to create an override instance
1445                    if let Some(override_entry) =
1446                        self.create_override_instance(entry, overrides).await
1447                    {
1448                        filtered.push(override_entry);
1449                        continue;
1450                    }
1451                }
1452                filtered.push(entry.clone());
1453            }
1454        }
1455
1456        let cached = Arc::new(filtered);
1457
1458        // Store in cache — owned key allocated only on cache miss.
1459        // Reject-on-full: when the cache is at capacity we still return
1460        // the freshly resolved Vec but skip memoization, bounding memory
1461        // growth from attacker-controlled entity names.
1462        let cache_key = RouteCacheKey {
1463            entity_type: entity_type.to_string(),
1464            entity_name: entity_name.to_string(),
1465            hook_name: hook_name.to_string(),
1466            scope: meta.scope.clone(),
1467        };
1468        // Decide under the lock; log outside it so I/O doesn't block readers.
1469        // One warn per fill cycle — prevents log spam under DoS.
1470        let should_warn = {
1471            let mut cache = self
1472                .route_cache
1473                .write()
1474                .unwrap_or_else(|poisoned| poisoned.into_inner());
1475            if cache.len() >= snapshot.route_cache_max_entries {
1476                !self.route_cache_full_warned.swap(true, Ordering::AcqRel)
1477            } else {
1478                cache.insert(cache_key, Arc::clone(&cached));
1479                false
1480            }
1481        };
1482        if should_warn {
1483            warn!(
1484                max_entries = snapshot.route_cache_max_entries,
1485                "Routing cache at capacity — further routes will not be cached. \
1486                 Increase plugin_settings.route_cache_max_entries or \
1487                 investigate entity name growth.",
1488            );
1489        }
1490
1491        cached
1492    }
1493
1494    /// Build per-hook `HookEntry`s for a plugin with optional route-
1495    /// level overrides. Used by external orchestrators (notably
1496    /// apl-cpex's dispatch plan) that need to splice per-route plugin
1497    /// variants — different `config`, narrower `capabilities`, different
1498    /// `on_error` — into the dispatch lineup while keeping cpex-core
1499    /// the source of truth for instantiation and isolation.
1500    ///
1501    /// Behavior:
1502    /// - **All three overrides `None`:** returns the base entries
1503    ///   unchanged. Caller can use them as-is.
1504    /// - **Only `capabilities_override` / `on_error_override` set
1505    ///   (`config_override` is `None`):** builds new `PluginRef`s
1506    ///   sharing the *base plugin `Arc`* with a merged `TrustedConfig`
1507    ///   (override caps / on_error replace base values) and an
1508    ///   independent circuit breaker. Cheap — no factory call.
1509    /// - **`config_override` set:** invokes the registered factory for
1510    ///   the plugin's `kind` with a merged `PluginConfig` (override
1511    ///   `config` *replaces* base `config` wholesale per unified-config
1512    ///   spec — not deep merge), calls `initialize()` on the new
1513    ///   instance, and wraps every returned handler in a new
1514    ///   `PluginRef` with a fresh circuit breaker.
1515    ///
1516    /// Returns an empty `Vec` when:
1517    /// - the plugin name isn't registered in the manager,
1518    /// - the factory for the plugin's `kind` is missing,
1519    /// - the factory's `create` errors,
1520    /// - or `initialize()` fails on the new instance.
1521    ///
1522    /// Each of those is a configuration / wiring fault the caller
1523    /// should treat as `NotFound` at dispatch time. The method logs
1524    /// the underlying error before returning empty so debugging
1525    /// surfaces in operator logs rather than as a silent miss.
1526    pub async fn build_override_entries(
1527        &self,
1528        plugin_name: &str,
1529        config_override: Option<&serde_yaml::Value>,
1530        capabilities_override: Option<&std::collections::HashSet<String>>,
1531        on_error_override: Option<crate::plugin::OnError>,
1532    ) -> Vec<(String, crate::registry::HookEntry)> {
1533        let base_entries = self.find_plugin_entries(plugin_name);
1534        if base_entries.is_empty() {
1535            return Vec::new();
1536        }
1537
1538        // No overrides at all — caller can use base entries unchanged.
1539        if config_override.is_none()
1540            && capabilities_override.is_none()
1541            && on_error_override.is_none()
1542        {
1543            return base_entries;
1544        }
1545
1546        // Pull the base trusted_config off any of the base entries —
1547        // all of them share the same `Arc<PluginRef>` for a given
1548        // plugin name, so picking the first is fine.
1549        let base_ref = Arc::clone(&base_entries[0].1.plugin_ref);
1550        let mut merged_config = base_ref.trusted_config().clone();
1551
1552        // Capabilities: override replaces base when present.
1553        if let Some(caps) = capabilities_override {
1554            merged_config.capabilities = caps.clone();
1555        }
1556
1557        // on_error: override replaces base when present.
1558        if let Some(oe) = on_error_override {
1559            merged_config.on_error = oe;
1560        }
1561
1562        // Caps/on_error-only path — shared base plugin Arc, new
1563        // PluginRef with merged config + fresh circuit breaker.
1564        // No factory call, no async work.
1565        if config_override.is_none() {
1566            let new_ref = Arc::new(crate::registry::PluginRef::new(
1567                Arc::clone(base_ref.plugin()),
1568                merged_config,
1569            ));
1570            return base_entries
1571                .into_iter()
1572                .map(|(hook_name, base_entry)| {
1573                    (
1574                        hook_name,
1575                        crate::registry::HookEntry {
1576                            plugin_ref: Arc::clone(&new_ref),
1577                            handler: base_entry.handler,
1578                        },
1579                    )
1580                })
1581                .collect();
1582        }
1583
1584        // Config override present — factory path. Convert YAML
1585        // override value into the JSON shape `PluginConfig.config`
1586        // carries (YAML is a superset of JSON so serde re-serialization
1587        // is safe). Per spec, override `config` replaces the base
1588        // `config` wholesale.
1589        let cfg_yaml = config_override.expect("checked above");
1590        let cfg_json = match serde_json::to_value(cfg_yaml) {
1591            Ok(v) => v,
1592            Err(e) => {
1593                error!(
1594                    plugin = %plugin_name,
1595                    error = %e,
1596                    "build_override_entries: YAML→JSON config conversion failed",
1597                );
1598                return Vec::new();
1599            },
1600        };
1601        merged_config.config = Some(cfg_json);
1602
1603        let kind = merged_config.kind.clone();
1604        let instance = {
1605            let factories = self.factories.read().unwrap_or_else(|p| p.into_inner());
1606            let factory = match factories.get(&kind) {
1607                Some(f) => f,
1608                None => {
1609                    error!(
1610                        plugin = %plugin_name,
1611                        kind = %kind,
1612                        "build_override_entries: no factory registered for kind",
1613                    );
1614                    return Vec::new();
1615                },
1616            };
1617            match factory.create(&merged_config) {
1618                Ok(i) => i,
1619                Err(e) => {
1620                    error!(
1621                        plugin = %plugin_name,
1622                        error = %e,
1623                        "build_override_entries: factory.create failed",
1624                    );
1625                    return Vec::new();
1626                },
1627            }
1628        };
1629
1630        if let Err(e) = instance.plugin.initialize().await {
1631            error!(
1632                plugin = %plugin_name,
1633                error = %e,
1634                "build_override_entries: initialize() failed on new instance",
1635            );
1636            return Vec::new();
1637        }
1638
1639        // One PluginRef shared across the new instance's handlers —
1640        // all hooks served by one instance share a circuit breaker
1641        // (matches registration semantics).
1642        let new_ref = Arc::new(crate::registry::PluginRef::new(
1643            Arc::clone(&instance.plugin),
1644            merged_config,
1645        ));
1646        instance
1647            .handlers
1648            .into_iter()
1649            .map(|(hook_name, handler)| {
1650                (
1651                    hook_name.to_string(),
1652                    crate::registry::HookEntry {
1653                        plugin_ref: Arc::clone(&new_ref),
1654                        handler,
1655                    },
1656                )
1657            })
1658            .collect()
1659    }
1660
1661    /// Create an override plugin instance with merged config.
1662    ///
1663    /// When a route overrides a plugin's config, we create a new
1664    /// instance via the factory with the merged config and call
1665    /// `initialize()` on it so plugins that open DB connections / file
1666    /// handles / network clients run their setup.
1667    ///
1668    /// The override gets its OWN circuit breaker (`disabled` flag) and
1669    /// its own UUID, independent of the base. Config is part of the
1670    /// failure surface — an override with a bad connection string /
1671    /// wrong credentials / wrong limit value can fail for reasons that
1672    /// have nothing to do with the base's reliability. Coupling them
1673    /// would let a config-specific failure on one route silently
1674    /// disable the plugin on every other route, which is the opposite
1675    /// of the per-route blast-radius guarantee operators reach for
1676    /// overrides to get. The fresh UUID also keys the override's
1677    /// `local_state` in the context table, isolating per-instance
1678    /// state from the base for the same reason.
1679    ///
1680    /// Returns `None` (and the caller falls back to the base entry) if:
1681    /// - no factory is available for the plugin's kind,
1682    /// - the factory fails to create the instance,
1683    /// - the new instance has no handler for the target hook,
1684    /// - or `initialize()` fails on the new instance.
1685    async fn create_override_instance(
1686        &self,
1687        base_entry: &crate::registry::HookEntry,
1688        overrides: &serde_json::Value,
1689    ) -> Option<crate::registry::HookEntry> {
1690        let base_config = base_entry.plugin_ref.trusted_config();
1691        let kind = &base_config.kind;
1692
1693        // Merge: start with base config, overlay with overrides
1694        let mut merged_config = base_config.clone();
1695        if let Some(override_config) = overrides.get("config") {
1696            // Merge the plugin-specific config section
1697            if let Some(base_plugin_config) = &merged_config.config {
1698                let mut merged = base_plugin_config.clone();
1699                if let (Some(base_obj), Some(override_obj)) =
1700                    (merged.as_object_mut(), override_config.as_object())
1701                {
1702                    for (key, value) in override_obj {
1703                        base_obj.insert(key.clone(), value.clone());
1704                    }
1705                }
1706                merged_config.config = Some(merged);
1707            } else {
1708                merged_config.config = Some(override_config.clone());
1709            }
1710        }
1711
1712        // Create new instance with merged config — hold the factories
1713        // read lock just long enough to construct the instance, then drop
1714        // it before any `.await` so we never hold a sync lock across awaits.
1715        let target_hook = base_entry.handler.hook_type_name();
1716        let instance = {
1717            let factories = self.factories.read().unwrap_or_else(|p| p.into_inner());
1718            let factory = match factories.get(kind) {
1719                Some(f) => f,
1720                None => return None,
1721            };
1722            match factory.create(&merged_config) {
1723                Ok(i) => i,
1724                Err(e) => {
1725                    error!(
1726                        "Failed to create override instance for '{}': {}",
1727                        base_config.name, e
1728                    );
1729                    return None; // fall back to base instance
1730                },
1731            }
1732        };
1733
1734        // Find the handler matching the current hook before consuming
1735        // the instance so we don't pay for initialization on a doomed instance.
1736        let handler = instance
1737            .handlers
1738            .into_iter()
1739            .find(|(name, _)| *name == target_hook)
1740            .map(|(_, h)| h);
1741        let handler = match handler {
1742            Some(h) => h,
1743            None => {
1744                warn!(
1745                    "Override instance for '{}' has no handler for hook '{}'",
1746                    base_config.name, target_hook
1747                );
1748                return None;
1749            },
1750        };
1751
1752        // Initialize the new instance — without this, plugins that need to
1753        // set up DB connections / file handles / network clients run with
1754        // default state.
1755        if let Err(e) = instance.plugin.initialize().await {
1756            error!(
1757                "Failed to initialize override instance for '{}': {} — falling back to base",
1758                base_config.name, e
1759            );
1760            return None;
1761        }
1762
1763        // Independent circuit breaker + fresh UUID per (kind, name, config)
1764        // — see the doc comment above for why we don't share with the base.
1765        // Arc-wrapped for cheap cloning under group_by_mode.
1766        let plugin_ref = Arc::new(crate::registry::PluginRef::new(
1767            instance.plugin,
1768            merged_config,
1769        ));
1770        Some(crate::registry::HookEntry {
1771            plugin_ref,
1772            handler,
1773        })
1774    }
1775
1776    /// Clear the routing cache. Call when config is reloaded or
1777    /// plugins are registered/unregistered. Also resets the
1778    /// "cache full" warn-once latch so the next fill cycle can warn again.
1779    pub fn clear_routing_cache(&self) {
1780        let mut cache = self
1781            .route_cache
1782            .write()
1783            .unwrap_or_else(|poisoned| poisoned.into_inner());
1784        cache.clear();
1785        self.route_cache_full_warned.store(false, Ordering::Release);
1786    }
1787
1788    /// Number of entries in the routing cache.
1789    pub fn routing_cache_size(&self) -> usize {
1790        self.route_cache
1791            .read()
1792            .unwrap_or_else(|poisoned| poisoned.into_inner())
1793            .len()
1794    }
1795
1796    // -----------------------------------------------------------------------
1797    // Query Methods
1798    // -----------------------------------------------------------------------
1799
1800    /// Whether anything would run for the given hook name — either a
1801    /// registered plugin handler OR a route annotation targeting that hook.
1802    ///
1803    /// Route annotations (installed by APL from a route's `policy:` /
1804    /// `args:` / `result:` blocks) must be counted here: a route whose only
1805    /// handler for a phase is an annotation (e.g. a response-side
1806    /// `result: { ssn: redact(...) }` on `cmf.tool_post_invoke`, with no
1807    /// globally-registered post-invoke plugin) would otherwise report
1808    /// "no hooks" and be skipped by out-of-process hosts that use this as a
1809    /// fast-skip gate — silently dropping the route's policy for that phase.
1810    pub fn has_hooks_for(&self, hook_name: &str) -> bool {
1811        let snapshot = self.load_runtime();
1812        snapshot.registry.has_hooks_for(&HookType::new(hook_name))
1813            || snapshot
1814                .route_annotations
1815                .keys()
1816                .any(|k| k.hook_name.as_str() == hook_name)
1817    }
1818
1819    /// Look up a plugin by name. Returns an `Arc<PluginRef>` clone — works
1820    /// with the snapshot-based dispatch model where the registry sits
1821    /// behind a transient `Arc<RuntimeSnapshot>` guard. `Arc<PluginRef>`
1822    /// derefs to `PluginRef`, so callers can chain methods directly:
1823    /// `mgr.get_plugin("name").unwrap().is_disabled()` still compiles.
1824    pub fn get_plugin(&self, name: &str) -> Option<Arc<PluginRef>> {
1825        self.load_runtime().registry.get(name)
1826    }
1827
1828    /// Total number of registered plugins.
1829    pub fn plugin_count(&self) -> usize {
1830        self.load_runtime().registry.plugin_count()
1831    }
1832
1833    /// All registered plugin names (owned, not borrowed from the registry).
1834    pub fn plugin_names(&self) -> Vec<String> {
1835        self.load_runtime().registry.plugin_names()
1836    }
1837
1838    /// Whether the manager has been initialized.
1839    pub fn is_initialized(&self) -> bool {
1840        self.initialized.load(Ordering::Acquire)
1841    }
1842
1843    /// Unregister a plugin by name.
1844    pub fn unregister(&self, name: &str) -> Option<Arc<PluginRef>> {
1845        let removed = self.mutate_runtime(|snap| snap.registry.unregister(name));
1846        if removed.is_some() {
1847            self.clear_routing_cache();
1848        }
1849        removed
1850    }
1851}
1852
1853impl Default for PluginManager {
1854    fn default() -> Self {
1855        Self::new(ManagerConfig::default())
1856    }
1857}
1858
1859#[cfg(test)]
1860mod tests {
1861    use super::*;
1862    use crate::context::PluginContext;
1863    use crate::error::PluginViolation;
1864    use crate::hooks::payload::Extensions;
1865    use crate::hooks::{HookHandler, PluginResult};
1866    use crate::plugin::{OnError, PluginMode};
1867    use async_trait::async_trait;
1868
1869    // -- Test payload --
1870
1871    #[derive(Debug, Clone)]
1872    struct TestPayload {
1873        value: String,
1874    }
1875    crate::impl_plugin_payload!(TestPayload);
1876
1877    // -- Test hook type --
1878
1879    struct TestHook;
1880    impl HookTypeDef for TestHook {
1881        type Payload = TestPayload;
1882        type Result = PluginResult<TestPayload>;
1883        const NAME: &'static str = "test_hook";
1884    }
1885
1886    // -- Test plugins: implement Plugin + HookHandler<TestHook> --
1887    // No AnyHookHandler boilerplate — the framework handles it.
1888
1889    /// Plugin that allows everything.
1890    struct AllowPlugin {
1891        cfg: PluginConfig,
1892    }
1893
1894    #[async_trait]
1895    impl Plugin for AllowPlugin {
1896        fn config(&self) -> &PluginConfig {
1897            &self.cfg
1898        }
1899        async fn initialize(&self) -> Result<(), Box<PluginError>> {
1900            Ok(())
1901        }
1902        async fn shutdown(&self) -> Result<(), Box<PluginError>> {
1903            Ok(())
1904        }
1905    }
1906
1907    impl HookHandler<TestHook> for AllowPlugin {
1908        async fn handle(
1909            &self,
1910            _payload: &TestPayload,
1911            _extensions: &Extensions,
1912            _ctx: &mut PluginContext,
1913        ) -> PluginResult<TestPayload> {
1914            PluginResult::allow()
1915        }
1916    }
1917
1918    /// Plugin that denies everything.
1919    struct DenyPlugin {
1920        cfg: PluginConfig,
1921    }
1922
1923    #[async_trait]
1924    impl Plugin for DenyPlugin {
1925        fn config(&self) -> &PluginConfig {
1926            &self.cfg
1927        }
1928        async fn initialize(&self) -> Result<(), Box<PluginError>> {
1929            Ok(())
1930        }
1931        async fn shutdown(&self) -> Result<(), Box<PluginError>> {
1932            Ok(())
1933        }
1934    }
1935
1936    impl HookHandler<TestHook> for DenyPlugin {
1937        async fn handle(
1938            &self,
1939            _payload: &TestPayload,
1940            _extensions: &Extensions,
1941            _ctx: &mut PluginContext,
1942        ) -> PluginResult<TestPayload> {
1943            PluginResult::deny(PluginViolation::new("denied", "test denial"))
1944        }
1945    }
1946
1947    /// Handler that always returns an error (for testing on_error behavior).
1948    struct ErrorHandler;
1949
1950    #[async_trait]
1951    impl AnyHookHandler for ErrorHandler {
1952        async fn invoke(
1953            &self,
1954            _payload: &dyn PluginPayload,
1955            _extensions: &Extensions,
1956            _ctx: &mut PluginContext,
1957        ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
1958            Err(Box::new(PluginError::Execution {
1959                plugin_name: "error-plugin".into(),
1960                message: "simulated failure".into(),
1961                source: None,
1962                code: None,
1963                details: std::collections::HashMap::new(),
1964                proto_error_code: None,
1965            }))
1966        }
1967
1968        fn hook_type_name(&self) -> &'static str {
1969            "test_hook"
1970        }
1971    }
1972
1973    // -- Helpers --
1974
1975    fn make_config(name: &str, priority: i32, mode: PluginMode) -> PluginConfig {
1976        make_config_with_on_error(name, priority, mode, OnError::Fail)
1977    }
1978
1979    fn make_config_with_on_error(
1980        name: &str,
1981        priority: i32,
1982        mode: PluginMode,
1983        on_error: OnError,
1984    ) -> PluginConfig {
1985        PluginConfig {
1986            name: name.to_string(),
1987            kind: "test".to_string(),
1988            description: None,
1989            author: None,
1990            version: None,
1991            hooks: vec!["test_hook".to_string()],
1992            mode,
1993            priority,
1994            on_error,
1995            capabilities: Default::default(),
1996            tags: Vec::new(),
1997            conditions: Vec::new(),
1998            config: None,
1999        }
2000    }
2001
2002    fn make_config_with_conditions(
2003        name: &str,
2004        conditions: Vec<crate::plugin::PluginCondition>,
2005    ) -> PluginConfig {
2006        let mut cfg = make_config(name, 10, PluginMode::Sequential);
2007        cfg.conditions = conditions;
2008        cfg
2009    }
2010
2011    // -- Tests --
2012
2013    #[tokio::test]
2014    async fn test_manager_lifecycle() {
2015        let mgr = PluginManager::default();
2016        assert!(!mgr.is_initialized());
2017        assert_eq!(mgr.plugin_count(), 0);
2018
2019        mgr.initialize().await.unwrap();
2020        assert!(mgr.is_initialized());
2021
2022        // Idempotent
2023        mgr.initialize().await.unwrap();
2024
2025        mgr.shutdown().await;
2026        assert!(!mgr.is_initialized());
2027    }
2028
2029    #[tokio::test]
2030    async fn test_invoke_by_name_no_plugins() {
2031        let mgr = PluginManager::default();
2032        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2033            value: "test".into(),
2034        });
2035
2036        let (result, _) = mgr
2037            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2038            .await;
2039
2040        assert!(result.continue_processing);
2041        assert!(result.modified_payload.is_some());
2042    }
2043
2044    #[tokio::test]
2045    async fn test_invoke_by_name_allow() {
2046        let mgr = PluginManager::default();
2047        let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2048        let plugin = Arc::new(AllowPlugin {
2049            cfg: config.clone(),
2050        });
2051
2052        // Clean registration — no AnyHookHandler needed
2053        mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2054        mgr.initialize().await.unwrap();
2055
2056        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2057            value: "test".into(),
2058        });
2059
2060        let (result, _) = mgr
2061            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2062            .await;
2063
2064        assert!(result.continue_processing);
2065    }
2066
2067    #[tokio::test]
2068    async fn test_invoke_by_name_deny() {
2069        let mgr = PluginManager::default();
2070        let config = make_config("deny-plugin", 10, PluginMode::Sequential);
2071        let plugin = Arc::new(DenyPlugin {
2072            cfg: config.clone(),
2073        });
2074
2075        mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2076        mgr.initialize().await.unwrap();
2077
2078        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2079            value: "test".into(),
2080        });
2081
2082        let (result, _) = mgr
2083            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2084            .await;
2085
2086        assert!(!result.continue_processing);
2087        assert_eq!(result.violation.as_ref().unwrap().code, "denied");
2088    }
2089
2090    #[tokio::test]
2091    async fn test_invoke_typed() {
2092        let mgr = PluginManager::default();
2093        let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2094        let plugin = Arc::new(AllowPlugin {
2095            cfg: config.clone(),
2096        });
2097
2098        mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2099        mgr.initialize().await.unwrap();
2100
2101        let payload = TestPayload {
2102            value: "typed".into(),
2103        };
2104
2105        let (result, _) = mgr
2106            .invoke::<TestHook>(payload, Extensions::default(), None)
2107            .await;
2108
2109        assert!(result.continue_processing);
2110    }
2111
2112    #[tokio::test]
2113    async fn test_invoke_named() {
2114        // invoke_named::<H>(hook_name, ...) gives compile-time payload
2115        // type checking while routing to a specific hook name.
2116        let mgr = PluginManager::default();
2117        let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2118        let plugin = Arc::new(AllowPlugin {
2119            cfg: config.clone(),
2120        });
2121
2122        mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2123        mgr.initialize().await.unwrap();
2124
2125        let payload = TestPayload {
2126            value: "named".into(),
2127        };
2128
2129        // TestHook::NAME is "test_hook" — invoke_named routes by the
2130        // explicit hook_name parameter, not H::NAME
2131        let (result, _) = mgr
2132            .invoke_named::<TestHook>("test_hook", payload, Extensions::default(), None)
2133            .await;
2134
2135        assert!(result.continue_processing);
2136    }
2137
2138    #[tokio::test]
2139    async fn test_invoke_named_no_plugins_for_hook() {
2140        // invoke_named with a hook name that has no registered plugins
2141        let mgr = PluginManager::default();
2142        let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2143        let plugin = Arc::new(AllowPlugin {
2144            cfg: config.clone(),
2145        });
2146
2147        mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2148        mgr.initialize().await.unwrap();
2149
2150        let payload = TestPayload {
2151            value: "no-match".into(),
2152        };
2153
2154        // Plugin is registered under "test_hook", but we invoke "other_hook"
2155        let (result, _) = mgr
2156            .invoke_named::<TestHook>("other_hook", payload, Extensions::default(), None)
2157            .await;
2158
2159        // No plugins fire — allowed by default
2160        assert!(result.continue_processing);
2161    }
2162
2163    #[tokio::test]
2164    async fn test_invoke_named_deny() {
2165        let mgr = PluginManager::default();
2166        let config = make_config("deny-plugin", 10, PluginMode::Sequential);
2167        let plugin = Arc::new(DenyPlugin {
2168            cfg: config.clone(),
2169        });
2170
2171        mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2172        mgr.initialize().await.unwrap();
2173
2174        let payload = TestPayload {
2175            value: "denied".into(),
2176        };
2177
2178        let (result, _) = mgr
2179            .invoke_named::<TestHook>("test_hook", payload, Extensions::default(), None)
2180            .await;
2181
2182        assert!(!result.continue_processing);
2183        assert_eq!(result.violation.as_ref().unwrap().code, "denied");
2184    }
2185
2186    #[tokio::test]
2187    async fn test_has_hooks_for() {
2188        let mgr = PluginManager::default();
2189        assert!(!mgr.has_hooks_for("test_hook"));
2190
2191        let config = make_config("p1", 10, PluginMode::Sequential);
2192        let plugin = Arc::new(AllowPlugin {
2193            cfg: config.clone(),
2194        });
2195        mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2196
2197        assert!(mgr.has_hooks_for("test_hook"));
2198        assert!(!mgr.has_hooks_for("other_hook"));
2199    }
2200
2201    /// When `routing_enabled` is `false` (the legacy / default mode),
2202    /// each plugin's `conditions:` must be evaluated per request — a
2203    /// non-matching condition should keep the plugin from firing.
2204    /// Mirrors the Python implementation's per-plugin filtering.
2205    #[tokio::test]
2206    async fn test_conditions_filter_plugins_when_routing_disabled() {
2207        use std::sync::atomic::{AtomicUsize, Ordering};
2208        use std::sync::Arc as StdArc;
2209
2210        let counts: StdArc<[AtomicUsize; 2]> =
2211            StdArc::new([AtomicUsize::new(0), AtomicUsize::new(0)]);
2212
2213        struct CountingHandler {
2214            idx: usize,
2215            counts: StdArc<[AtomicUsize; 2]>,
2216        }
2217        #[async_trait]
2218        impl AnyHookHandler for CountingHandler {
2219            async fn invoke(
2220                &self,
2221                _payload: &dyn PluginPayload,
2222                _extensions: &Extensions,
2223                _ctx: &mut PluginContext,
2224            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2225                self.counts[self.idx].fetch_add(1, Ordering::SeqCst);
2226                let result: PluginResult<TestPayload> = PluginResult::allow();
2227                Ok(crate::executor::erase_result(result))
2228            }
2229            fn hook_type_name(&self) -> &'static str {
2230                "test_hook"
2231            }
2232        }
2233
2234        let mgr = PluginManager::default();
2235
2236        // Plugin A: condition requires tool == "wanted_tool" — fires for matching requests.
2237        let mut tools = std::collections::HashSet::new();
2238        tools.insert("wanted_tool".to_string());
2239        let cfg_a = make_config_with_conditions(
2240            "plugin_a",
2241            vec![crate::plugin::PluginCondition {
2242                tools: Some(tools),
2243                ..Default::default()
2244            }],
2245        );
2246        let plugin_a = Arc::new(AllowPlugin { cfg: cfg_a.clone() });
2247        let handler_a: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler {
2248            idx: 0,
2249            counts: StdArc::clone(&counts),
2250        });
2251        mgr.register_raw::<TestHook>(plugin_a, cfg_a, handler_a)
2252            .unwrap();
2253
2254        // Plugin B: empty conditions — fires unconditionally.
2255        let cfg_b = make_config("plugin_b", 20, PluginMode::Sequential);
2256        let plugin_b = Arc::new(AllowPlugin { cfg: cfg_b.clone() });
2257        let handler_b: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler {
2258            idx: 1,
2259            counts: StdArc::clone(&counts),
2260        });
2261        mgr.register_raw::<TestHook>(plugin_b, cfg_b, handler_b)
2262            .unwrap();
2263
2264        mgr.initialize().await.unwrap();
2265
2266        // Request 1: tool=wanted_tool → both A and B should fire.
2267        let ext_match = Extensions {
2268            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
2269                entity_type: Some("tool".into()),
2270                entity_name: Some("wanted_tool".into()),
2271                ..Default::default()
2272            })),
2273            ..Default::default()
2274        };
2275        let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "1".into() });
2276        let _ = mgr.invoke_by_name("test_hook", p, ext_match, None).await;
2277        assert_eq!(
2278            counts[0].load(Ordering::SeqCst),
2279            1,
2280            "plugin_a should fire on matching tool"
2281        );
2282        assert_eq!(
2283            counts[1].load(Ordering::SeqCst),
2284            1,
2285            "plugin_b should fire (no conditions)"
2286        );
2287
2288        // Request 2: tool=other_tool → only B fires (A's condition rejects).
2289        let ext_no_match = Extensions {
2290            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
2291                entity_type: Some("tool".into()),
2292                entity_name: Some("other_tool".into()),
2293                ..Default::default()
2294            })),
2295            ..Default::default()
2296        };
2297        let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "2".into() });
2298        let _ = mgr.invoke_by_name("test_hook", p, ext_no_match, None).await;
2299        assert_eq!(
2300            counts[0].load(Ordering::SeqCst),
2301            1,
2302            "plugin_a should NOT fire on non-matching tool"
2303        );
2304        assert_eq!(
2305            counts[1].load(Ordering::SeqCst),
2306            2,
2307            "plugin_b should fire on every request"
2308        );
2309    }
2310
2311    /// `user_patterns` glob matches against `extensions.security.subject.id`.
2312    /// Specifically: pattern `admin-*` matches `admin-alice` but not `user-bob`.
2313    #[tokio::test]
2314    async fn test_conditions_user_patterns_glob_filters() {
2315        use std::sync::atomic::{AtomicUsize, Ordering};
2316
2317        static FIRED: AtomicUsize = AtomicUsize::new(0);
2318        FIRED.store(0, Ordering::SeqCst);
2319
2320        struct CountHandler;
2321        #[async_trait]
2322        impl AnyHookHandler for CountHandler {
2323            async fn invoke(
2324                &self,
2325                _payload: &dyn PluginPayload,
2326                _extensions: &Extensions,
2327                _ctx: &mut PluginContext,
2328            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2329                FIRED.fetch_add(1, Ordering::SeqCst);
2330                let result: PluginResult<TestPayload> = PluginResult::allow();
2331                Ok(crate::executor::erase_result(result))
2332            }
2333            fn hook_type_name(&self) -> &'static str {
2334                "test_hook"
2335            }
2336        }
2337
2338        let mgr = PluginManager::default();
2339        let cfg = make_config_with_conditions(
2340            "admin_only",
2341            vec![crate::plugin::PluginCondition {
2342                user_patterns: Some(vec!["admin-*".to_string()]),
2343                ..Default::default()
2344            }],
2345        );
2346        let plugin = Arc::new(AllowPlugin { cfg: cfg.clone() });
2347        let handler: Arc<dyn AnyHookHandler> = Arc::new(CountHandler);
2348        mgr.register_raw::<TestHook>(plugin, cfg, handler).unwrap();
2349        mgr.initialize().await.unwrap();
2350
2351        let ext_with_user = |id: &str| Extensions {
2352            security: Some(std::sync::Arc::new(crate::extensions::SecurityExtension {
2353                subject: Some(crate::extensions::security::SubjectExtension {
2354                    id: Some(id.to_string()),
2355                    ..Default::default()
2356                }),
2357                ..Default::default()
2358            })),
2359            ..Default::default()
2360        };
2361
2362        let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "1".into() });
2363        let _ = mgr
2364            .invoke_by_name("test_hook", p, ext_with_user("admin-alice"), None)
2365            .await;
2366        assert_eq!(
2367            FIRED.load(Ordering::SeqCst),
2368            1,
2369            "admin-alice should match admin-*"
2370        );
2371
2372        let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "2".into() });
2373        let _ = mgr
2374            .invoke_by_name("test_hook", p, ext_with_user("user-bob"), None)
2375            .await;
2376        assert_eq!(
2377            FIRED.load(Ordering::SeqCst),
2378            1,
2379            "user-bob should NOT match admin-*"
2380        );
2381    }
2382
2383    #[tokio::test]
2384    async fn test_unregister() {
2385        let mgr = PluginManager::default();
2386        let config = make_config("removable", 10, PluginMode::Sequential);
2387        let plugin = Arc::new(AllowPlugin {
2388            cfg: config.clone(),
2389        });
2390        mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2391
2392        assert_eq!(mgr.plugin_count(), 1);
2393        mgr.unregister("removable");
2394        assert_eq!(mgr.plugin_count(), 0);
2395        assert!(!mgr.has_hooks_for("test_hook"));
2396    }
2397
2398    /// Wraps the manager in `Arc` and dispatches concurrently from many
2399    /// tasks. Also issues a `register_handler` call mid-flight to prove
2400    /// that runtime registration is safe alongside invocations — the whole
2401    /// point of the `ArcSwap`-based snapshot redesign. Before this fix,
2402    /// `register_*` was `&mut self`, so this pattern wouldn't even compile.
2403    #[tokio::test]
2404    async fn test_manager_arc_shareable_with_concurrent_dispatch_and_registration() {
2405        use std::sync::atomic::{AtomicUsize, Ordering};
2406
2407        static INVOKE_COUNT: AtomicUsize = AtomicUsize::new(0);
2408        INVOKE_COUNT.store(0, Ordering::SeqCst);
2409
2410        struct CountingHandler;
2411        #[async_trait]
2412        impl AnyHookHandler for CountingHandler {
2413            async fn invoke(
2414                &self,
2415                _payload: &dyn PluginPayload,
2416                _extensions: &Extensions,
2417                _ctx: &mut PluginContext,
2418            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2419                INVOKE_COUNT.fetch_add(1, Ordering::SeqCst);
2420                let result: PluginResult<TestPayload> = PluginResult::allow();
2421                Ok(crate::executor::erase_result(result))
2422            }
2423            fn hook_type_name(&self) -> &'static str {
2424                "test_hook"
2425            }
2426        }
2427
2428        let mgr = Arc::new(PluginManager::default());
2429
2430        // Register an initial plugin and initialize.
2431        let cfg = make_config("p0", 10, PluginMode::Sequential);
2432        let plugin: Arc<AllowPlugin> = Arc::new(AllowPlugin { cfg: cfg.clone() });
2433        let handler: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler);
2434        mgr.register_raw::<TestHook>(plugin, cfg, handler).unwrap();
2435        mgr.initialize().await.unwrap();
2436
2437        // Spawn N concurrent invokers; midway, register a second plugin
2438        // from a different task — the snapshot swaps under their feet.
2439        let n = 16;
2440        let mut handles = Vec::with_capacity(n + 1);
2441        for i in 0..n {
2442            let mgr = Arc::clone(&mgr);
2443            handles.push(tokio::spawn(async move {
2444                let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2445                    value: format!("call-{}", i),
2446                });
2447                let (result, _) = mgr
2448                    .invoke_by_name("test_hook", payload, Extensions::default(), None)
2449                    .await;
2450                assert!(result.continue_processing);
2451            }));
2452        }
2453
2454        // Concurrent registration — proves register_handler works through &Arc.
2455        {
2456            let mgr = Arc::clone(&mgr);
2457            handles.push(tokio::spawn(async move {
2458                let cfg = make_config("p1-late", 20, PluginMode::Sequential);
2459                let plugin: Arc<AllowPlugin> = Arc::new(AllowPlugin { cfg: cfg.clone() });
2460                let handler: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler);
2461                mgr.register_raw::<TestHook>(plugin, cfg, handler).unwrap();
2462            }));
2463        }
2464
2465        for h in handles {
2466            h.await.unwrap();
2467        }
2468
2469        // At least the initial plugin ran for every invoke (some invokes
2470        // may have raced past the registration and only seen the initial
2471        // plugin; others may have seen both). The exact count depends on
2472        // the race, but lower bound is `n` (one fire per invoke for p0).
2473        assert!(INVOKE_COUNT.load(Ordering::SeqCst) >= n);
2474        // Late registration is now visible.
2475        assert_eq!(mgr.plugin_count(), 2);
2476    }
2477
2478    #[tokio::test]
2479    async fn test_audit_plugin_cannot_block() {
2480        let mgr = PluginManager::default();
2481        let config = make_config("audit-denier", 10, PluginMode::Audit);
2482        let plugin = Arc::new(DenyPlugin {
2483            cfg: config.clone(),
2484        });
2485
2486        mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2487        mgr.initialize().await.unwrap();
2488
2489        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2490            value: "test".into(),
2491        });
2492
2493        let (result, _) = mgr
2494            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2495            .await;
2496
2497        // Audit mode — deny is suppressed, pipeline continues
2498        assert!(result.continue_processing);
2499    }
2500
2501    #[tokio::test]
2502    async fn test_on_error_disable_skips_plugin_on_subsequent_invocations() {
2503        let mgr = PluginManager::default();
2504
2505        // Register an error handler with on_error: Disable
2506        let config =
2507            make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Disable);
2508        let plugin = Arc::new(AllowPlugin {
2509            cfg: config.clone(),
2510        });
2511        let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2512        mgr.register_raw::<TestHook>(plugin, config, handler)
2513            .unwrap();
2514
2515        // Also register a normal allow plugin (lower priority = runs second)
2516        let config2 = make_config("allow-plugin", 20, PluginMode::Sequential);
2517        let plugin2 = Arc::new(AllowPlugin {
2518            cfg: config2.clone(),
2519        });
2520        mgr.register_handler::<TestHook, _>(plugin2, config2)
2521            .unwrap();
2522
2523        mgr.initialize().await.unwrap();
2524
2525        // First invocation — flaky plugin errors, gets disabled, pipeline continues
2526        // because on_error is Disable (not Fail). allow-plugin still runs.
2527        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2528            value: "first".into(),
2529        });
2530        let (result, _) = mgr
2531            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2532            .await;
2533        assert!(result.continue_processing);
2534
2535        // Verify the plugin is now disabled
2536        let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap();
2537        assert!(plugin_ref.is_disabled());
2538        assert_eq!(plugin_ref.mode(), PluginMode::Disabled);
2539
2540        // Second invocation — flaky plugin should be skipped entirely
2541        // (group_by_mode filters it out). Only allow-plugin runs.
2542        let payload2: Box<dyn PluginPayload> = Box::new(TestPayload {
2543            value: "second".into(),
2544        });
2545        let (result2, _) = mgr
2546            .invoke_by_name("test_hook", payload2, Extensions::default(), None)
2547            .await;
2548        assert!(result2.continue_processing);
2549    }
2550
2551    #[tokio::test]
2552    async fn test_on_error_ignore_continues_without_disabling() {
2553        let mgr = PluginManager::default();
2554
2555        // Register an error handler with on_error: Ignore
2556        let config =
2557            make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore);
2558        let plugin = Arc::new(AllowPlugin {
2559            cfg: config.clone(),
2560        });
2561        let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2562        mgr.register_raw::<TestHook>(plugin, config, handler)
2563            .unwrap();
2564
2565        mgr.initialize().await.unwrap();
2566
2567        // First invocation — plugin errors, ignored, pipeline continues
2568        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2569            value: "test".into(),
2570        });
2571        let (result, _) = mgr
2572            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2573            .await;
2574        assert!(result.continue_processing);
2575
2576        // Plugin should NOT be disabled — still in its original mode
2577        let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap();
2578        assert!(!plugin_ref.is_disabled());
2579        assert_eq!(plugin_ref.mode(), PluginMode::Sequential);
2580    }
2581
2582    /// Errors from `on_error: ignore` plugins must surface in
2583    /// `PipelineResult.errors` so callers can see swallowed failures
2584    /// programmatically — not just in log output.
2585    #[tokio::test]
2586    async fn test_on_error_ignore_records_in_pipeline_errors() {
2587        let mgr = PluginManager::default();
2588        let config =
2589            make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore);
2590        let plugin = Arc::new(AllowPlugin {
2591            cfg: config.clone(),
2592        });
2593        let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2594        mgr.register_raw::<TestHook>(plugin, config, handler)
2595            .unwrap();
2596
2597        mgr.initialize().await.unwrap();
2598
2599        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2600        let (result, _) = mgr
2601            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2602            .await;
2603
2604        // Pipeline continued (Ignore policy)…
2605        assert!(result.continue_processing);
2606        // …but the swallowed error is in result.errors with structured fields.
2607        assert_eq!(result.errors.len(), 1, "expected one error record");
2608        let rec = &result.errors[0];
2609        assert_eq!(rec.plugin_name, "error-plugin");
2610        assert!(
2611            rec.message.contains("simulated failure"),
2612            "message lost: {}",
2613            rec.message,
2614        );
2615    }
2616
2617    /// Errors from `on_error: disable` plugins must ALSO appear in
2618    /// `PipelineResult.errors` (not just trip the circuit breaker).
2619    #[tokio::test]
2620    async fn test_on_error_disable_records_in_pipeline_errors() {
2621        let mgr = PluginManager::default();
2622        let config =
2623            make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Disable);
2624        let plugin = Arc::new(AllowPlugin {
2625            cfg: config.clone(),
2626        });
2627        let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2628        mgr.register_raw::<TestHook>(plugin, config, handler)
2629            .unwrap();
2630
2631        mgr.initialize().await.unwrap();
2632
2633        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2634        let (result, _) = mgr
2635            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2636            .await;
2637
2638        assert!(result.continue_processing);
2639        assert_eq!(result.errors.len(), 1);
2640        // Plugin was also disabled (the Disable policy's other effect).
2641        assert!(mgr.get_plugin("flaky-plugin").unwrap().is_disabled());
2642    }
2643
2644    #[tokio::test]
2645    async fn test_on_error_fail_halts_pipeline() {
2646        let mgr = PluginManager::default();
2647
2648        // Register an error handler with on_error: Fail (default)
2649        let config =
2650            make_config_with_on_error("strict-plugin", 10, PluginMode::Sequential, OnError::Fail);
2651        let plugin = Arc::new(AllowPlugin {
2652            cfg: config.clone(),
2653        });
2654        let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2655        mgr.register_raw::<TestHook>(plugin, config, handler)
2656            .unwrap();
2657
2658        mgr.initialize().await.unwrap();
2659
2660        // Invocation — plugin errors, pipeline halts with a violation
2661        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2662            value: "test".into(),
2663        });
2664        let (result, _) = mgr
2665            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2666            .await;
2667        assert!(!result.continue_processing);
2668        assert_eq!(result.violation.as_ref().unwrap().code, "plugin_error");
2669        assert_eq!(
2670            result.violation.as_ref().unwrap().plugin_name.as_deref(),
2671            Some("strict-plugin"),
2672        );
2673    }
2674
2675    // -- Additional test plugins --
2676
2677    /// Plugin that modifies the payload (for Transform mode testing).
2678    struct TransformPlugin {
2679        cfg: PluginConfig,
2680    }
2681
2682    #[async_trait]
2683    impl Plugin for TransformPlugin {
2684        fn config(&self) -> &PluginConfig {
2685            &self.cfg
2686        }
2687        async fn initialize(&self) -> Result<(), Box<PluginError>> {
2688            Ok(())
2689        }
2690        async fn shutdown(&self) -> Result<(), Box<PluginError>> {
2691            Ok(())
2692        }
2693    }
2694
2695    impl HookHandler<TestHook> for TransformPlugin {
2696        async fn handle(
2697            &self,
2698            payload: &TestPayload,
2699            _extensions: &Extensions,
2700            _ctx: &mut PluginContext,
2701        ) -> PluginResult<TestPayload> {
2702            PluginResult::modify_payload(TestPayload {
2703                value: format!("{}_transformed", payload.value),
2704            })
2705        }
2706    }
2707
2708    /// Handler that sleeps (for timeout and fire-and-forget testing).
2709    struct SlowHandler {
2710        delay_ms: u64,
2711    }
2712
2713    #[async_trait]
2714    impl AnyHookHandler for SlowHandler {
2715        async fn invoke(
2716            &self,
2717            _payload: &dyn PluginPayload,
2718            _extensions: &Extensions,
2719            _ctx: &mut PluginContext,
2720        ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2721            tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await;
2722            let result: PluginResult<TestPayload> = PluginResult::allow();
2723            Ok(crate::executor::erase_result(result))
2724        }
2725
2726        fn hook_type_name(&self) -> &'static str {
2727            "test_hook"
2728        }
2729    }
2730
2731    // -- Bug-covering tests --
2732
2733    #[tokio::test]
2734    async fn test_transform_modifies_payload() {
2735        let mgr = PluginManager::default();
2736        let config = make_config("transformer", 10, PluginMode::Transform);
2737        let plugin = Arc::new(TransformPlugin {
2738            cfg: config.clone(),
2739        });
2740
2741        mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2742        mgr.initialize().await.unwrap();
2743
2744        let payload = TestPayload {
2745            value: "original".into(),
2746        };
2747
2748        let (result, _) = mgr
2749            .invoke::<TestHook>(payload, Extensions::default(), None)
2750            .await;
2751
2752        assert!(result.continue_processing);
2753        let final_payload = result.modified_payload.unwrap();
2754        let typed = final_payload
2755            .as_any()
2756            .downcast_ref::<TestPayload>()
2757            .unwrap();
2758        assert_eq!(typed.value, "original_transformed");
2759    }
2760
2761    /// Transform phase is documented `can_block: No` (plugin.rs PluginMode
2762    /// table). An `on_error: Fail` plugin error or timeout in Transform must
2763    /// NOT halt the pipeline — non-blocking is non-blocking, regardless of
2764    /// the plugin's stated on_error preference. Disable still works.
2765    #[tokio::test]
2766    async fn test_transform_on_error_fail_does_not_halt_pipeline() {
2767        let mgr = PluginManager::default();
2768        let config =
2769            make_config_with_on_error("flaky-transform", 10, PluginMode::Transform, OnError::Fail);
2770        let plugin = Arc::new(AllowPlugin {
2771            cfg: config.clone(),
2772        });
2773        let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2774        mgr.register_raw::<TestHook>(plugin, config, handler)
2775            .unwrap();
2776
2777        mgr.initialize().await.unwrap();
2778
2779        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2780        let (result, _) = mgr
2781            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2782            .await;
2783
2784        assert!(
2785            result.continue_processing,
2786            "Transform on_error:Fail must not halt the pipeline (phase is non-blocking)",
2787        );
2788        assert!(result.violation.is_none());
2789    }
2790
2791    /// Audit phase previously ignored `on_error` entirely, so an
2792    /// `on_error: Disable` plugin would error forever without the circuit
2793    /// breaker tripping. After the fix Audit honors Disable.
2794    #[tokio::test]
2795    async fn test_audit_on_error_disable_disables_plugin() {
2796        let mgr = PluginManager::default();
2797        let config =
2798            make_config_with_on_error("flaky-audit", 10, PluginMode::Audit, OnError::Disable);
2799        let plugin = Arc::new(AllowPlugin {
2800            cfg: config.clone(),
2801        });
2802        let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2803        mgr.register_raw::<TestHook>(plugin, config, handler)
2804            .unwrap();
2805
2806        mgr.initialize().await.unwrap();
2807
2808        assert!(!mgr.get_plugin("flaky-audit").unwrap().is_disabled());
2809
2810        // Invoke once — handler errors, on_error=Disable, plugin must be
2811        // disabled. Pipeline still returns success (Audit can't block).
2812        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2813        let (result, _) = mgr
2814            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2815            .await;
2816        assert!(result.continue_processing);
2817
2818        assert!(
2819            mgr.get_plugin("flaky-audit").unwrap().is_disabled(),
2820            "Audit phase must honor on_error:Disable",
2821        );
2822    }
2823
2824    #[tokio::test]
2825    async fn test_concurrent_multiple_plugins_all_run() {
2826        use std::sync::atomic::{AtomicUsize, Ordering};
2827
2828        // Shared counter to prove both plugins actually ran
2829        static CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
2830        CALL_COUNT.store(0, Ordering::SeqCst);
2831
2832        struct CountingHandler;
2833
2834        #[async_trait]
2835        impl AnyHookHandler for CountingHandler {
2836            async fn invoke(
2837                &self,
2838                _payload: &dyn PluginPayload,
2839                _extensions: &Extensions,
2840                _ctx: &mut PluginContext,
2841            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2842                // Small sleep to ensure both tasks are spawned before either finishes
2843                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2844                CALL_COUNT.fetch_add(1, Ordering::SeqCst);
2845                let result: PluginResult<TestPayload> = PluginResult::allow();
2846                Ok(crate::executor::erase_result(result))
2847            }
2848
2849            fn hook_type_name(&self) -> &'static str {
2850                "test_hook"
2851            }
2852        }
2853
2854        let mgr = PluginManager::default();
2855
2856        let c1 = make_config("concurrent-1", 10, PluginMode::Concurrent);
2857        let p1 = Arc::new(AllowPlugin { cfg: c1.clone() });
2858        let h1: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler);
2859        mgr.register_raw::<TestHook>(p1, c1, h1).unwrap();
2860
2861        let c2 = make_config("concurrent-2", 20, PluginMode::Concurrent);
2862        let p2 = Arc::new(AllowPlugin { cfg: c2.clone() });
2863        let h2: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler);
2864        mgr.register_raw::<TestHook>(p2, c2, h2).unwrap();
2865
2866        mgr.initialize().await.unwrap();
2867
2868        let start = std::time::Instant::now();
2869        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2870            value: "test".into(),
2871        });
2872        let (result, _) = mgr
2873            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2874            .await;
2875        let elapsed = start.elapsed();
2876
2877        assert!(result.continue_processing);
2878        assert_eq!(CALL_COUNT.load(Ordering::SeqCst), 2);
2879        // If they ran in parallel, total time should be ~50ms, not ~100ms
2880        assert!(
2881            elapsed.as_millis() < 90,
2882            "concurrent plugins ran serially: {}ms",
2883            elapsed.as_millis()
2884        );
2885    }
2886
2887    /// A deny on one concurrent plugin should short-circuit the pipeline
2888    /// AND cancel the slow plugin still running in another task. Previously
2889    /// `join_all` waited for every task before noticing the deny, so
2890    /// short_circuit_on_deny was a no-op in wall-clock terms and the slow
2891    /// plugin completed its side effects after the pipeline returned.
2892    #[tokio::test]
2893    async fn test_concurrent_short_circuit_aborts_slow_plugin() {
2894        use std::sync::atomic::{AtomicUsize, Ordering};
2895        use std::time::Duration;
2896
2897        static SLOW_COMPLETED: AtomicUsize = AtomicUsize::new(0);
2898        SLOW_COMPLETED.store(0, Ordering::SeqCst);
2899
2900        struct DenyImmediately;
2901        #[async_trait]
2902        impl AnyHookHandler for DenyImmediately {
2903            async fn invoke(
2904                &self,
2905                _payload: &dyn PluginPayload,
2906                _extensions: &Extensions,
2907                _ctx: &mut PluginContext,
2908            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2909                let result: PluginResult<TestPayload> =
2910                    PluginResult::deny(PluginViolation::new("denied", "fast deny"));
2911                Ok(crate::executor::erase_result(result))
2912            }
2913            fn hook_type_name(&self) -> &'static str {
2914                "test_hook"
2915            }
2916        }
2917
2918        struct SlowSideEffect;
2919        #[async_trait]
2920        impl AnyHookHandler for SlowSideEffect {
2921            async fn invoke(
2922                &self,
2923                _payload: &dyn PluginPayload,
2924                _extensions: &Extensions,
2925                _ctx: &mut PluginContext,
2926            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2927                tokio::time::sleep(Duration::from_secs(2)).await;
2928                // If the task isn't aborted at the sleep's await point,
2929                // this fetch_add fires after the pipeline already returned.
2930                SLOW_COMPLETED.fetch_add(1, Ordering::SeqCst);
2931                let result: PluginResult<TestPayload> = PluginResult::allow();
2932                Ok(crate::executor::erase_result(result))
2933            }
2934            fn hook_type_name(&self) -> &'static str {
2935                "test_hook"
2936            }
2937        }
2938
2939        let mgr = PluginManager::default();
2940
2941        let cfg_deny = make_config("denier", 10, PluginMode::Concurrent);
2942        let plugin_deny = Arc::new(AllowPlugin {
2943            cfg: cfg_deny.clone(),
2944        });
2945        mgr.register_raw::<TestHook>(
2946            plugin_deny,
2947            cfg_deny,
2948            Arc::new(DenyImmediately) as Arc<dyn AnyHookHandler>,
2949        )
2950        .unwrap();
2951
2952        let cfg_slow = make_config("slow", 20, PluginMode::Concurrent);
2953        let plugin_slow = Arc::new(AllowPlugin {
2954            cfg: cfg_slow.clone(),
2955        });
2956        mgr.register_raw::<TestHook>(
2957            plugin_slow,
2958            cfg_slow,
2959            Arc::new(SlowSideEffect) as Arc<dyn AnyHookHandler>,
2960        )
2961        .unwrap();
2962
2963        mgr.initialize().await.unwrap();
2964
2965        // Pipeline must return quickly — the deny short-circuits before
2966        // the 2s sleep completes.
2967        let start = std::time::Instant::now();
2968        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2969        let (result, _) = mgr
2970            .invoke_by_name("test_hook", payload, Extensions::default(), None)
2971            .await;
2972        let elapsed = start.elapsed();
2973
2974        assert!(!result.continue_processing);
2975        assert!(
2976            elapsed < Duration::from_millis(500),
2977            "pipeline should short-circuit on deny, but took {}ms (slow plugin not aborted)",
2978            elapsed.as_millis(),
2979        );
2980
2981        // Wait long enough that the slow plugin's sleep would have finished
2982        // if it hadn't been aborted, then verify its side effect didn't fire.
2983        tokio::time::sleep(Duration::from_millis(2_500)).await;
2984        assert_eq!(
2985            SLOW_COMPLETED.load(Ordering::SeqCst),
2986            0,
2987            "slow plugin's side effect ran after pipeline returned — task was not aborted",
2988        );
2989    }
2990
2991    /// short_circuit_on_deny=false: every concurrent plugin must run to
2992    /// completion (no abort), and the earliest deny is returned at the end.
2993    #[tokio::test]
2994    async fn test_concurrent_no_short_circuit_runs_every_plugin() {
2995        use std::sync::atomic::{AtomicUsize, Ordering};
2996
2997        static ALLOW_RAN: AtomicUsize = AtomicUsize::new(0);
2998        ALLOW_RAN.store(0, Ordering::SeqCst);
2999
3000        struct DenyImmediately;
3001        #[async_trait]
3002        impl AnyHookHandler for DenyImmediately {
3003            async fn invoke(
3004                &self,
3005                _payload: &dyn PluginPayload,
3006                _extensions: &Extensions,
3007                _ctx: &mut PluginContext,
3008            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3009                let result: PluginResult<TestPayload> =
3010                    PluginResult::deny(PluginViolation::new("denied", "fast deny"));
3011                Ok(crate::executor::erase_result(result))
3012            }
3013            fn hook_type_name(&self) -> &'static str {
3014                "test_hook"
3015            }
3016        }
3017
3018        struct AllowAndCount;
3019        #[async_trait]
3020        impl AnyHookHandler for AllowAndCount {
3021            async fn invoke(
3022                &self,
3023                _payload: &dyn PluginPayload,
3024                _extensions: &Extensions,
3025                _ctx: &mut PluginContext,
3026            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3027                ALLOW_RAN.fetch_add(1, Ordering::SeqCst);
3028                let result: PluginResult<TestPayload> = PluginResult::allow();
3029                Ok(crate::executor::erase_result(result))
3030            }
3031            fn hook_type_name(&self) -> &'static str {
3032                "test_hook"
3033            }
3034        }
3035
3036        let config = ManagerConfig {
3037            executor: crate::executor::ExecutorConfig {
3038                timeout_seconds: 30,
3039                short_circuit_on_deny: false,
3040            },
3041            route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES,
3042        };
3043        let mgr = PluginManager::new(config);
3044
3045        let cfg_deny = make_config("denier", 10, PluginMode::Concurrent);
3046        let plugin_deny = Arc::new(AllowPlugin {
3047            cfg: cfg_deny.clone(),
3048        });
3049        mgr.register_raw::<TestHook>(
3050            plugin_deny,
3051            cfg_deny,
3052            Arc::new(DenyImmediately) as Arc<dyn AnyHookHandler>,
3053        )
3054        .unwrap();
3055
3056        let cfg_allow = make_config("allow", 20, PluginMode::Concurrent);
3057        let plugin_allow = Arc::new(AllowPlugin {
3058            cfg: cfg_allow.clone(),
3059        });
3060        mgr.register_raw::<TestHook>(
3061            plugin_allow,
3062            cfg_allow,
3063            Arc::new(AllowAndCount) as Arc<dyn AnyHookHandler>,
3064        )
3065        .unwrap();
3066
3067        mgr.initialize().await.unwrap();
3068
3069        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3070        let (result, _) = mgr
3071            .invoke_by_name("test_hook", payload, Extensions::default(), None)
3072            .await;
3073
3074        // Earliest deny is returned…
3075        assert!(!result.continue_processing);
3076        // …but the non-denying plugin must still have run (no abort).
3077        assert_eq!(ALLOW_RAN.load(Ordering::SeqCst), 1);
3078    }
3079
3080    /// Plugin handler that panics inside its async invoke. With tokio::spawn,
3081    /// the panic surfaces as a JoinError on the task's JoinHandle.
3082    struct PanicHandler;
3083
3084    #[async_trait]
3085    impl AnyHookHandler for PanicHandler {
3086        async fn invoke(
3087            &self,
3088            _payload: &dyn PluginPayload,
3089            _extensions: &Extensions,
3090            _ctx: &mut PluginContext,
3091        ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3092            panic!("simulated panic in concurrent plugin task");
3093        }
3094        fn hook_type_name(&self) -> &'static str {
3095            "test_hook"
3096        }
3097    }
3098
3099    /// A panicking concurrent plugin with `on_error: Fail` must halt the
3100    /// pipeline with a violation. Previously the JoinError was just logged
3101    /// and the panic was silently swallowed.
3102    ///
3103    /// Note: this test prints "thread 'tokio-runtime-worker' panicked at..."
3104    /// to stderr — that's tokio reporting the captured panic. Expected.
3105    #[tokio::test]
3106    async fn test_concurrent_panic_with_on_error_fail_halts_pipeline() {
3107        let mgr = PluginManager::default();
3108
3109        let cfg =
3110            make_config_with_on_error("panic-plugin", 10, PluginMode::Concurrent, OnError::Fail);
3111        let plugin = Arc::new(AllowPlugin { cfg: cfg.clone() });
3112        let handler: Arc<dyn AnyHookHandler> = Arc::new(PanicHandler);
3113        mgr.register_raw::<TestHook>(plugin, cfg, handler).unwrap();
3114
3115        mgr.initialize().await.unwrap();
3116
3117        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3118        let (result, _) = mgr
3119            .invoke_by_name("test_hook", payload, Extensions::default(), None)
3120            .await;
3121
3122        assert!(
3123            !result.continue_processing,
3124            "Fail must halt the pipeline on panic"
3125        );
3126        let v = result.violation.as_ref().expect("expected violation");
3127        assert_eq!(v.code, "plugin_panic");
3128        assert_eq!(v.plugin_name.as_deref(), Some("panic-plugin"));
3129    }
3130
3131    /// A panicking concurrent plugin with `on_error: Disable` must trip
3132    /// the plugin's circuit breaker so it's skipped on subsequent invokes.
3133    /// A second non-panicking plugin in the same phase still runs.
3134    #[tokio::test]
3135    async fn test_concurrent_panic_with_on_error_disable_trips_circuit_breaker() {
3136        use std::sync::atomic::{AtomicUsize, Ordering};
3137
3138        static SURVIVOR_CALLS: AtomicUsize = AtomicUsize::new(0);
3139        SURVIVOR_CALLS.store(0, Ordering::SeqCst);
3140
3141        struct SurvivorHandler;
3142        #[async_trait]
3143        impl AnyHookHandler for SurvivorHandler {
3144            async fn invoke(
3145                &self,
3146                _payload: &dyn PluginPayload,
3147                _extensions: &Extensions,
3148                _ctx: &mut PluginContext,
3149            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3150                SURVIVOR_CALLS.fetch_add(1, Ordering::SeqCst);
3151                let result: PluginResult<TestPayload> = PluginResult::allow();
3152                Ok(crate::executor::erase_result(result))
3153            }
3154            fn hook_type_name(&self) -> &'static str {
3155                "test_hook"
3156            }
3157        }
3158
3159        let mgr = PluginManager::default();
3160
3161        let panic_cfg =
3162            make_config_with_on_error("panic-plugin", 10, PluginMode::Concurrent, OnError::Disable);
3163        let panic_plugin = Arc::new(AllowPlugin {
3164            cfg: panic_cfg.clone(),
3165        });
3166        let panic_handler: Arc<dyn AnyHookHandler> = Arc::new(PanicHandler);
3167        mgr.register_raw::<TestHook>(panic_plugin, panic_cfg, panic_handler)
3168            .unwrap();
3169
3170        let survivor_cfg = make_config("survivor", 20, PluginMode::Concurrent);
3171        let survivor_plugin = Arc::new(AllowPlugin {
3172            cfg: survivor_cfg.clone(),
3173        });
3174        let survivor_handler: Arc<dyn AnyHookHandler> = Arc::new(SurvivorHandler);
3175        mgr.register_raw::<TestHook>(survivor_plugin, survivor_cfg, survivor_handler)
3176            .unwrap();
3177
3178        mgr.initialize().await.unwrap();
3179
3180        // First invoke — panic plugin panics, gets disabled. Survivor still runs.
3181        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "1".into() });
3182        let (result1, _) = mgr
3183            .invoke_by_name("test_hook", payload, Extensions::default(), None)
3184            .await;
3185        assert!(
3186            result1.continue_processing,
3187            "Disable must not halt the pipeline"
3188        );
3189        assert_eq!(SURVIVOR_CALLS.load(Ordering::SeqCst), 1);
3190        assert!(
3191            mgr.get_plugin("panic-plugin").unwrap().is_disabled(),
3192            "panic plugin must be disabled after the panic",
3193        );
3194
3195        // Second invoke — disabled plugin is skipped, doesn't panic again.
3196        let payload2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "2".into() });
3197        let (result2, _) = mgr
3198            .invoke_by_name("test_hook", payload2, Extensions::default(), None)
3199            .await;
3200        assert!(result2.continue_processing);
3201        // Survivor ran a second time; panic plugin did not.
3202        assert_eq!(SURVIVOR_CALLS.load(Ordering::SeqCst), 2);
3203    }
3204
3205    #[tokio::test]
3206    async fn test_timeout_fires_on_slow_handler() {
3207        // Create a manager with a very short timeout
3208        let config = ManagerConfig {
3209            executor: crate::executor::ExecutorConfig {
3210                timeout_seconds: 1,
3211                short_circuit_on_deny: true,
3212            },
3213            route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES,
3214        };
3215        let mgr = PluginManager::new(config);
3216
3217        // Register a handler that sleeps longer than the timeout
3218        let plugin_config = make_config("slow-plugin", 10, PluginMode::Sequential);
3219        let plugin = Arc::new(AllowPlugin {
3220            cfg: plugin_config.clone(),
3221        });
3222        let handler: Arc<dyn AnyHookHandler> = Arc::new(SlowHandler { delay_ms: 5000 });
3223        mgr.register_raw::<TestHook>(plugin, plugin_config, handler)
3224            .unwrap();
3225
3226        mgr.initialize().await.unwrap();
3227
3228        let start = std::time::Instant::now();
3229        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3230            value: "test".into(),
3231        });
3232        let (result, _) = mgr
3233            .invoke_by_name("test_hook", payload, Extensions::default(), None)
3234            .await;
3235        let elapsed = start.elapsed();
3236
3237        // Should have timed out and denied (on_error: Fail)
3238        assert!(!result.continue_processing);
3239        assert_eq!(result.violation.as_ref().unwrap().code, "plugin_timeout");
3240        // Should have returned in ~1s, not 5s
3241        assert!(
3242            elapsed.as_secs() < 3,
3243            "timeout didn't fire: {}s",
3244            elapsed.as_secs()
3245        );
3246    }
3247
3248    #[tokio::test]
3249    async fn test_fire_and_forget_returns_before_task_completes() {
3250        use std::sync::atomic::{AtomicBool, Ordering};
3251
3252        static TASK_COMPLETED: AtomicBool = AtomicBool::new(false);
3253        TASK_COMPLETED.store(false, Ordering::SeqCst);
3254
3255        struct SlowFireAndForgetHandler;
3256
3257        #[async_trait]
3258        impl AnyHookHandler for SlowFireAndForgetHandler {
3259            async fn invoke(
3260                &self,
3261                _payload: &dyn PluginPayload,
3262                _extensions: &Extensions,
3263                _ctx: &mut PluginContext,
3264            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3265                tokio::time::sleep(std::time::Duration::from_millis(200)).await;
3266                TASK_COMPLETED.store(true, Ordering::SeqCst);
3267                let result: PluginResult<TestPayload> = PluginResult::allow();
3268                Ok(crate::executor::erase_result(result))
3269            }
3270
3271            fn hook_type_name(&self) -> &'static str {
3272                "test_hook"
3273            }
3274        }
3275
3276        let mgr = PluginManager::default();
3277
3278        let config = make_config("fire-forget", 10, PluginMode::FireAndForget);
3279        let plugin = Arc::new(AllowPlugin {
3280            cfg: config.clone(),
3281        });
3282        let handler: Arc<dyn AnyHookHandler> = Arc::new(SlowFireAndForgetHandler);
3283        mgr.register_raw::<TestHook>(plugin, config, handler)
3284            .unwrap();
3285
3286        mgr.initialize().await.unwrap();
3287
3288        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3289            value: "test".into(),
3290        });
3291        let (result, bg) = mgr
3292            .invoke_by_name("test_hook", payload, Extensions::default(), None)
3293            .await;
3294
3295        // Pipeline should return immediately — before the background task finishes
3296        assert!(result.continue_processing);
3297        assert!(
3298            !TASK_COMPLETED.load(Ordering::SeqCst),
3299            "fire-and-forget task completed before pipeline returned"
3300        );
3301
3302        // Wait for background tasks using wait_for_background_tasks()
3303        let errors = bg.wait_for_background_tasks().await;
3304        assert!(
3305            errors.is_empty(),
3306            "background task had errors: {:?}",
3307            errors
3308        );
3309        assert!(
3310            TASK_COMPLETED.load(Ordering::SeqCst),
3311            "fire-and-forget task never completed"
3312        );
3313    }
3314
3315    /// `shutdown()` must wait for in-flight fire-and-forget tasks to drain
3316    /// before returning, so audit / telemetry plugins that flush at the
3317    /// end of a request lifetime aren't cancelled mid-write. The caller
3318    /// drops `BackgroundTasks` (the common case for fire-and-forget),
3319    /// so the only way the manager knows about the in-flight task is the
3320    /// internal `TaskTracker`.
3321    #[tokio::test]
3322    async fn test_shutdown_drains_in_flight_fire_and_forget_tasks() {
3323        use std::sync::atomic::{AtomicBool, Ordering};
3324
3325        static FAF_COMPLETED: AtomicBool = AtomicBool::new(false);
3326        FAF_COMPLETED.store(false, Ordering::SeqCst);
3327
3328        struct SlowFafHandler;
3329        #[async_trait]
3330        impl AnyHookHandler for SlowFafHandler {
3331            async fn invoke(
3332                &self,
3333                _payload: &dyn PluginPayload,
3334                _extensions: &Extensions,
3335                _ctx: &mut PluginContext,
3336            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3337                tokio::time::sleep(std::time::Duration::from_millis(150)).await;
3338                FAF_COMPLETED.store(true, Ordering::SeqCst);
3339                let result: PluginResult<TestPayload> = PluginResult::allow();
3340                Ok(crate::executor::erase_result(result))
3341            }
3342            fn hook_type_name(&self) -> &'static str {
3343                "test_hook"
3344            }
3345        }
3346
3347        let mgr = PluginManager::default();
3348        let config = make_config("slow-faf", 10, PluginMode::FireAndForget);
3349        let plugin = Arc::new(AllowPlugin {
3350            cfg: config.clone(),
3351        });
3352        let handler: Arc<dyn AnyHookHandler> = Arc::new(SlowFafHandler);
3353        mgr.register_raw::<TestHook>(plugin, config, handler)
3354            .unwrap();
3355        mgr.initialize().await.unwrap();
3356
3357        // Invoke and drop BackgroundTasks immediately — simulating the
3358        // common case where the caller doesn't explicitly wait for FAF.
3359        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3360        let (_result, _bg_dropped) = mgr
3361            .invoke_by_name("test_hook", payload, Extensions::default(), None)
3362            .await;
3363
3364        // Task should still be in flight (sleeping 150ms).
3365        assert!(!FAF_COMPLETED.load(Ordering::SeqCst));
3366
3367        // shutdown() must drain in-flight FAF tasks before returning.
3368        mgr.shutdown().await;
3369
3370        // After shutdown, the FAF task must have run to completion.
3371        assert!(
3372            FAF_COMPLETED.load(Ordering::SeqCst),
3373            "shutdown returned before fire-and-forget task finished — task was abandoned",
3374        );
3375    }
3376
3377    #[tokio::test]
3378    async fn test_global_state_flows_between_serial_plugins() {
3379        // Plugin A writes to global_state; Plugin B reads it.
3380
3381        struct WriterHandler;
3382
3383        #[async_trait]
3384        impl AnyHookHandler for WriterHandler {
3385            async fn invoke(
3386                &self,
3387                _payload: &dyn PluginPayload,
3388                _extensions: &Extensions,
3389                ctx: &mut PluginContext,
3390            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3391                ctx.set_global("writer_was_here", serde_json::Value::Bool(true));
3392                let result: PluginResult<TestPayload> = PluginResult::allow();
3393                Ok(crate::executor::erase_result(result))
3394            }
3395            fn hook_type_name(&self) -> &'static str {
3396                "test_hook"
3397            }
3398        }
3399
3400        struct ReaderHandler {
3401            saw_writer: std::sync::Arc<std::sync::atomic::AtomicBool>,
3402        }
3403
3404        #[async_trait]
3405        impl AnyHookHandler for ReaderHandler {
3406            async fn invoke(
3407                &self,
3408                _payload: &dyn PluginPayload,
3409                _extensions: &Extensions,
3410                ctx: &mut PluginContext,
3411            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3412                if ctx.get_global("writer_was_here").is_some() {
3413                    self.saw_writer
3414                        .store(true, std::sync::atomic::Ordering::SeqCst);
3415                }
3416                let result: PluginResult<TestPayload> = PluginResult::allow();
3417                Ok(crate::executor::erase_result(result))
3418            }
3419            fn hook_type_name(&self) -> &'static str {
3420                "test_hook"
3421            }
3422        }
3423
3424        let saw_writer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
3425
3426        let mgr = PluginManager::default();
3427
3428        // Writer runs first (priority 10)
3429        let c1 = make_config("writer", 10, PluginMode::Sequential);
3430        let p1 = Arc::new(AllowPlugin { cfg: c1.clone() });
3431        let h1: Arc<dyn AnyHookHandler> = Arc::new(WriterHandler);
3432        mgr.register_raw::<TestHook>(p1, c1, h1).unwrap();
3433
3434        // Reader runs second (priority 20)
3435        let c2 = make_config("reader", 20, PluginMode::Sequential);
3436        let p2 = Arc::new(AllowPlugin { cfg: c2.clone() });
3437        let h2: Arc<dyn AnyHookHandler> = Arc::new(ReaderHandler {
3438            saw_writer: saw_writer.clone(),
3439        });
3440        mgr.register_raw::<TestHook>(p2, c2, h2).unwrap();
3441
3442        mgr.initialize().await.unwrap();
3443
3444        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3445            value: "test".into(),
3446        });
3447        let (result, _) = mgr
3448            .invoke_by_name("test_hook", payload, Extensions::default(), None)
3449            .await;
3450
3451        assert!(result.continue_processing);
3452        assert!(
3453            saw_writer.load(std::sync::atomic::Ordering::SeqCst),
3454            "reader plugin did not see writer's global_state change"
3455        );
3456    }
3457
3458    #[tokio::test]
3459    async fn test_local_state_persists_across_hook_invocations() {
3460        // Plugin writes to local_state on first hook call.
3461        // Context table is threaded into second call — local_state preserved.
3462
3463        struct LocalWriterHandler;
3464
3465        #[async_trait]
3466        impl AnyHookHandler for LocalWriterHandler {
3467            async fn invoke(
3468                &self,
3469                _payload: &dyn PluginPayload,
3470                _extensions: &Extensions,
3471                ctx: &mut PluginContext,
3472            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3473                // Increment a counter in local_state
3474                let count = ctx
3475                    .get_local("call_count")
3476                    .and_then(|v| v.as_u64())
3477                    .unwrap_or(0);
3478                ctx.set_local("call_count", serde_json::Value::from(count + 1));
3479                let result: PluginResult<TestPayload> = PluginResult::allow();
3480                Ok(crate::executor::erase_result(result))
3481            }
3482            fn hook_type_name(&self) -> &'static str {
3483                "test_hook"
3484            }
3485        }
3486
3487        let mgr = PluginManager::default();
3488
3489        let config = make_config("counter", 10, PluginMode::Sequential);
3490        let plugin = Arc::new(AllowPlugin {
3491            cfg: config.clone(),
3492        });
3493        let handler: Arc<dyn AnyHookHandler> = Arc::new(LocalWriterHandler);
3494        mgr.register_raw::<TestHook>(plugin, config, handler)
3495            .unwrap();
3496
3497        mgr.initialize().await.unwrap();
3498
3499        // First invocation — no context table, starts fresh
3500        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3501            value: "first".into(),
3502        });
3503        let (result1, _) = mgr
3504            .invoke_by_name("test_hook", payload, Extensions::default(), None)
3505            .await;
3506        assert!(result1.continue_processing);
3507
3508        // Check call_count = 1 in the returned context table
3509        let table = &result1.context_table;
3510        let local = table
3511            .local_states
3512            .values()
3513            .next()
3514            .expect("context table should have one local_state entry");
3515        assert_eq!(local.get("call_count").unwrap().as_u64().unwrap(), 1);
3516
3517        // Second invocation — pass the context table from the first call
3518        let payload2: Box<dyn PluginPayload> = Box::new(TestPayload {
3519            value: "second".into(),
3520        });
3521        let (result2, _) = mgr
3522            .invoke_by_name(
3523                "test_hook",
3524                payload2,
3525                Extensions::default(),
3526                Some(result1.context_table),
3527            )
3528            .await;
3529        assert!(result2.continue_processing);
3530
3531        // call_count should now be 2 — local_state persisted across invocations
3532        let table2 = &result2.context_table;
3533        let local2 = table2
3534            .local_states
3535            .values()
3536            .next()
3537            .expect("context table should have one local_state entry");
3538        assert_eq!(local2.get("call_count").unwrap().as_u64().unwrap(), 2);
3539    }
3540
3541    /// global_state writes by an earlier plugin must be visible to a later
3542    /// plugin in the same serial phase, and the canonical state on the
3543    /// returned context_table must reflect every plugin's contribution in
3544    /// priority order. Previously this relied on `ctx_table.values().last()`
3545    /// (HashMap iteration order — non-deterministic).
3546    #[tokio::test]
3547    async fn test_global_state_propagates_in_priority_order() {
3548        /// Handler that appends `tag` to global_state["chain"] (creating
3549        /// an array if absent). After running, the array reveals the
3550        /// observed run order from each plugin's perspective.
3551        struct GlobalChainHandler {
3552            tag: &'static str,
3553        }
3554
3555        #[async_trait]
3556        impl AnyHookHandler for GlobalChainHandler {
3557            async fn invoke(
3558                &self,
3559                _payload: &dyn PluginPayload,
3560                _extensions: &Extensions,
3561                ctx: &mut PluginContext,
3562            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3563                let mut chain = ctx
3564                    .get_global("chain")
3565                    .and_then(|v| v.as_array())
3566                    .cloned()
3567                    .unwrap_or_default();
3568                chain.push(serde_json::Value::String(self.tag.into()));
3569                ctx.set_global("chain", serde_json::Value::Array(chain));
3570                let result: PluginResult<TestPayload> = PluginResult::allow();
3571                Ok(crate::executor::erase_result(result))
3572            }
3573            fn hook_type_name(&self) -> &'static str {
3574                "test_hook"
3575            }
3576        }
3577
3578        let mgr = PluginManager::default();
3579
3580        // Plugin A — priority 10 (runs first)
3581        let cfg_a = make_config("plugin_a", 10, PluginMode::Sequential);
3582        let plugin_a = Arc::new(AllowPlugin { cfg: cfg_a.clone() });
3583        let handler_a: Arc<dyn AnyHookHandler> = Arc::new(GlobalChainHandler { tag: "a" });
3584        mgr.register_raw::<TestHook>(plugin_a, cfg_a, handler_a)
3585            .unwrap();
3586
3587        // Plugin B — priority 20 (runs second)
3588        let cfg_b = make_config("plugin_b", 20, PluginMode::Sequential);
3589        let plugin_b = Arc::new(AllowPlugin { cfg: cfg_b.clone() });
3590        let handler_b: Arc<dyn AnyHookHandler> = Arc::new(GlobalChainHandler { tag: "b" });
3591        mgr.register_raw::<TestHook>(plugin_b, cfg_b, handler_b)
3592            .unwrap();
3593
3594        mgr.initialize().await.unwrap();
3595
3596        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3597        let (result, _) = mgr
3598            .invoke_by_name("test_hook", payload, Extensions::default(), None)
3599            .await;
3600        assert!(result.continue_processing);
3601
3602        // Canonical global_state on the returned table must contain both
3603        // contributions in priority order — proving plugin B observed plugin
3604        // A's write, and the table holds the merged result, not an arbitrary
3605        // plugin's snapshot.
3606        let chain = result
3607            .context_table
3608            .global_state
3609            .get("chain")
3610            .and_then(|v| v.as_array())
3611            .expect("global_state.chain should be an array");
3612        let tags: Vec<&str> = chain.iter().filter_map(|v| v.as_str()).collect();
3613        assert_eq!(tags, vec!["a", "b"]);
3614    }
3615
3616    /// All five phases (Sequential, Transform, Audit, Concurrent,
3617    /// FireAndForget) execute in the documented order, with payload
3618    /// modifications from earlier phases visible in later ones. Closes
3619    /// the review's "no multi-phase combination test" gap.
3620    #[tokio::test]
3621    async fn test_all_five_phases_run_in_order_with_payload_chaining() {
3622        use std::sync::Arc as StdArc;
3623        use std::sync::Mutex as StdMutex;
3624
3625        let log: StdArc<StdMutex<Vec<&'static str>>> = StdArc::new(StdMutex::new(Vec::new()));
3626
3627        // Sequential — modifies payload, logs "seq".
3628        struct SeqHandler {
3629            log: StdArc<StdMutex<Vec<&'static str>>>,
3630        }
3631        #[async_trait]
3632        impl AnyHookHandler for SeqHandler {
3633            async fn invoke(
3634                &self,
3635                payload: &dyn PluginPayload,
3636                _extensions: &Extensions,
3637                _ctx: &mut PluginContext,
3638            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3639                self.log.lock().unwrap().push("seq");
3640                let typed = payload.as_any().downcast_ref::<TestPayload>().unwrap();
3641                let modified = TestPayload {
3642                    value: format!("{}|seq", typed.value),
3643                };
3644                let result: PluginResult<TestPayload> = PluginResult::modify_payload(modified);
3645                Ok(crate::executor::erase_result(result))
3646            }
3647            fn hook_type_name(&self) -> &'static str {
3648                "test_hook"
3649            }
3650        }
3651
3652        // Transform — modifies payload, logs "transform".
3653        struct TransformLogger {
3654            log: StdArc<StdMutex<Vec<&'static str>>>,
3655        }
3656        #[async_trait]
3657        impl AnyHookHandler for TransformLogger {
3658            async fn invoke(
3659                &self,
3660                payload: &dyn PluginPayload,
3661                _extensions: &Extensions,
3662                _ctx: &mut PluginContext,
3663            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3664                self.log.lock().unwrap().push("transform");
3665                let typed = payload.as_any().downcast_ref::<TestPayload>().unwrap();
3666                let modified = TestPayload {
3667                    value: format!("{}|transform", typed.value),
3668                };
3669                let result: PluginResult<TestPayload> = PluginResult::modify_payload(modified);
3670                Ok(crate::executor::erase_result(result))
3671            }
3672            fn hook_type_name(&self) -> &'static str {
3673                "test_hook"
3674            }
3675        }
3676
3677        // Logger that asserts the payload it observes contains both prior
3678        // phases' marks (proving payload chaining made it this far).
3679        struct ObserverHandler {
3680            tag: &'static str,
3681            log: StdArc<StdMutex<Vec<&'static str>>>,
3682            expected_payload: &'static str,
3683        }
3684        #[async_trait]
3685        impl AnyHookHandler for ObserverHandler {
3686            async fn invoke(
3687                &self,
3688                payload: &dyn PluginPayload,
3689                _extensions: &Extensions,
3690                _ctx: &mut PluginContext,
3691            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3692                let typed = payload.as_any().downcast_ref::<TestPayload>().unwrap();
3693                assert_eq!(
3694                    typed.value, self.expected_payload,
3695                    "{} observed unexpected payload: got '{}', expected '{}'",
3696                    self.tag, typed.value, self.expected_payload,
3697                );
3698                self.log.lock().unwrap().push(self.tag);
3699                let result: PluginResult<TestPayload> = PluginResult::allow();
3700                Ok(crate::executor::erase_result(result))
3701            }
3702            fn hook_type_name(&self) -> &'static str {
3703                "test_hook"
3704            }
3705        }
3706
3707        let mgr = PluginManager::default();
3708
3709        let cfg_seq = make_config("seq", 10, PluginMode::Sequential);
3710        mgr.register_raw::<TestHook>(
3711            Arc::new(AllowPlugin {
3712                cfg: cfg_seq.clone(),
3713            }),
3714            cfg_seq,
3715            Arc::new(SeqHandler {
3716                log: StdArc::clone(&log),
3717            }),
3718        )
3719        .unwrap();
3720
3721        let cfg_transform = make_config("transform", 10, PluginMode::Transform);
3722        mgr.register_raw::<TestHook>(
3723            Arc::new(AllowPlugin {
3724                cfg: cfg_transform.clone(),
3725            }),
3726            cfg_transform,
3727            Arc::new(TransformLogger {
3728                log: StdArc::clone(&log),
3729            }),
3730        )
3731        .unwrap();
3732
3733        let cfg_audit = make_config("audit", 10, PluginMode::Audit);
3734        mgr.register_raw::<TestHook>(
3735            Arc::new(AllowPlugin {
3736                cfg: cfg_audit.clone(),
3737            }),
3738            cfg_audit,
3739            Arc::new(ObserverHandler {
3740                tag: "audit",
3741                log: StdArc::clone(&log),
3742                expected_payload: "start|seq|transform",
3743            }),
3744        )
3745        .unwrap();
3746
3747        let cfg_concurrent = make_config("concurrent", 10, PluginMode::Concurrent);
3748        mgr.register_raw::<TestHook>(
3749            Arc::new(AllowPlugin {
3750                cfg: cfg_concurrent.clone(),
3751            }),
3752            cfg_concurrent,
3753            Arc::new(ObserverHandler {
3754                tag: "concurrent",
3755                log: StdArc::clone(&log),
3756                expected_payload: "start|seq|transform",
3757            }),
3758        )
3759        .unwrap();
3760
3761        let cfg_faf = make_config("faf", 10, PluginMode::FireAndForget);
3762        mgr.register_raw::<TestHook>(
3763            Arc::new(AllowPlugin {
3764                cfg: cfg_faf.clone(),
3765            }),
3766            cfg_faf,
3767            Arc::new(ObserverHandler {
3768                tag: "faf",
3769                log: StdArc::clone(&log),
3770                expected_payload: "start|seq|transform",
3771            }),
3772        )
3773        .unwrap();
3774
3775        mgr.initialize().await.unwrap();
3776
3777        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3778            value: "start".into(),
3779        });
3780        let (result, bg) = mgr
3781            .invoke_by_name("test_hook", payload, Extensions::default(), None)
3782            .await;
3783
3784        assert!(result.continue_processing);
3785        // Final payload should have both modify-phase marks.
3786        let final_payload = result.modified_payload.unwrap();
3787        let typed = final_payload
3788            .as_any()
3789            .downcast_ref::<TestPayload>()
3790            .unwrap();
3791        assert_eq!(typed.value, "start|seq|transform");
3792
3793        // Drain the FAF task before checking ordering — its log entry
3794        // races the rest of the function otherwise.
3795        let _ = bg.wait_for_background_tasks().await;
3796
3797        let log = log.lock().unwrap();
3798        // Sequential, Transform, Audit are guaranteed in order (serial phases).
3799        assert_eq!(log[0], "seq", "first should be sequential phase");
3800        assert_eq!(log[1], "transform", "second should be transform phase");
3801        assert_eq!(log[2], "audit", "third should be audit phase");
3802        // Concurrent runs before invoke returns; FAF was waited on above.
3803        // Their relative order with each other is not strictly guaranteed
3804        // (FAF spawns *after* concurrent finishes, but tokio scheduling
3805        // can interleave). Just check both present in indices 3 / 4.
3806        let post_audit: std::collections::HashSet<&&'static str> = log[3..].iter().collect();
3807        assert!(
3808            post_audit.contains(&"concurrent"),
3809            "concurrent phase must run"
3810        );
3811        assert!(post_audit.contains(&"faf"), "fire-and-forget must run");
3812        assert_eq!(log.len(), 5, "all five phases should have logged");
3813    }
3814
3815    /// Routing must work for `resource`, `prompt`, and `llm` entity types
3816    /// — not just `tool`. Closes the review's "no test verifying entity
3817    /// types other than tool in routing" gap.
3818    #[tokio::test]
3819    async fn test_routing_works_for_all_entity_types() {
3820        use std::sync::atomic::{AtomicUsize, Ordering};
3821        use std::sync::Arc as StdArc;
3822
3823        // One counter per entity-type test; each plugin only fires when
3824        // the route resolves to it.
3825        struct CountHandler {
3826            counter: StdArc<AtomicUsize>,
3827        }
3828        #[async_trait]
3829        impl AnyHookHandler for CountHandler {
3830            async fn invoke(
3831                &self,
3832                _payload: &dyn PluginPayload,
3833                _extensions: &Extensions,
3834                _ctx: &mut PluginContext,
3835            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3836                self.counter.fetch_add(1, Ordering::SeqCst);
3837                let result: PluginResult<TestPayload> = PluginResult::allow();
3838                Ok(crate::executor::erase_result(result))
3839            }
3840            fn hook_type_name(&self) -> &'static str {
3841                "test_hook"
3842            }
3843        }
3844
3845        // Each row: (entity_type, route field name, route value, request entity_name, should_match)
3846        // We build a fresh manager per entity type so routes don't bleed.
3847        for (entity_type, route_field, route_value, request_name, should_match) in [
3848            ("resource", "resource", "my_resource", "my_resource", true),
3849            (
3850                "resource",
3851                "resource",
3852                "my_resource",
3853                "other_resource",
3854                false,
3855            ),
3856            ("prompt", "prompt", "my_prompt", "my_prompt", true),
3857            ("prompt", "prompt", "my_prompt", "other_prompt", false),
3858            ("llm", "llm", "gpt-4", "gpt-4", true),
3859            ("llm", "llm", "gpt-4", "claude", false),
3860        ] {
3861            let yaml = format!(
3862                r#"
3863plugin_settings:
3864  routing_enabled: true
3865plugins:
3866  - name: target
3867    kind: test/allow
3868    hooks: [test_hook]
3869    mode: sequential
3870routes:
3871  - {route_field}: {route_value}
3872    plugins:
3873      - target
3874"#
3875            );
3876            let cpex_config = crate::config::parse_config(&yaml).unwrap();
3877
3878            let mgr = PluginManager::default();
3879            let counter = StdArc::new(AtomicUsize::new(0));
3880            // Custom factory that hands out a CountHandler with our shared counter.
3881            struct ParamFactory(StdArc<AtomicUsize>);
3882            impl crate::factory::PluginFactory for ParamFactory {
3883                fn create(
3884                    &self,
3885                    config: &PluginConfig,
3886                ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
3887                    Ok(crate::factory::PluginInstance {
3888                        plugin: Arc::new(AllowPlugin {
3889                            cfg: config.clone(),
3890                        }),
3891                        handlers: vec![(
3892                            "test_hook",
3893                            Arc::new(CountHandler {
3894                                counter: StdArc::clone(&self.0),
3895                            }),
3896                        )],
3897                    })
3898                }
3899            }
3900            mgr.register_factory(
3901                "test/allow",
3902                Box::new(ParamFactory(StdArc::clone(&counter))),
3903            );
3904            mgr.load_config(cpex_config).unwrap();
3905            mgr.initialize().await.unwrap();
3906
3907            let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3908            let ext = Extensions {
3909                meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
3910                    entity_type: Some(entity_type.into()),
3911                    entity_name: Some(request_name.into()),
3912                    ..Default::default()
3913                })),
3914                ..Default::default()
3915            };
3916            let _ = mgr.invoke_by_name("test_hook", p, ext, None).await;
3917
3918            let expected = if should_match { 1 } else { 0 };
3919            assert_eq!(
3920                counter.load(Ordering::SeqCst),
3921                expected,
3922                "entity_type={} route_field={} route_value={} request_name={} expected fire={}",
3923                entity_type,
3924                route_field,
3925                route_value,
3926                request_name,
3927                should_match,
3928            );
3929        }
3930    }
3931
3932    /// `initialize()` must roll back already-initialized plugins by
3933    /// calling `shutdown()` on each, in reverse order, when a later
3934    /// plugin's `initialize()` fails. Closes the review's "no test for
3935    /// initialize() rollback path" gap.
3936    #[tokio::test]
3937    async fn test_initialize_rollback_on_failure() {
3938        use std::sync::atomic::{AtomicUsize, Ordering};
3939        use std::sync::Arc as StdArc;
3940
3941        // Track per-plugin init / shutdown invocations.
3942        let init_count_a = StdArc::new(AtomicUsize::new(0));
3943        let shutdown_count_a = StdArc::new(AtomicUsize::new(0));
3944        let init_count_b = StdArc::new(AtomicUsize::new(0));
3945        let shutdown_count_b = StdArc::new(AtomicUsize::new(0));
3946        let init_count_c = StdArc::new(AtomicUsize::new(0));
3947        let shutdown_count_c = StdArc::new(AtomicUsize::new(0));
3948
3949        struct LifecyclePlugin {
3950            cfg: PluginConfig,
3951            init_counter: StdArc<AtomicUsize>,
3952            shutdown_counter: StdArc<AtomicUsize>,
3953            fail_init: bool,
3954        }
3955        #[async_trait]
3956        impl Plugin for LifecyclePlugin {
3957            fn config(&self) -> &PluginConfig {
3958                &self.cfg
3959            }
3960            async fn initialize(&self) -> Result<(), Box<PluginError>> {
3961                self.init_counter.fetch_add(1, Ordering::SeqCst);
3962                if self.fail_init {
3963                    Err(Box::new(PluginError::Config {
3964                        message: "intentional init failure".into(),
3965                    }))
3966                } else {
3967                    Ok(())
3968                }
3969            }
3970            async fn shutdown(&self) -> Result<(), Box<PluginError>> {
3971                self.shutdown_counter.fetch_add(1, Ordering::SeqCst);
3972                Ok(())
3973            }
3974        }
3975        impl HookHandler<TestHook> for LifecyclePlugin {
3976            async fn handle(
3977                &self,
3978                _payload: &TestPayload,
3979                _extensions: &Extensions,
3980                _ctx: &mut PluginContext,
3981            ) -> PluginResult<TestPayload> {
3982                PluginResult::allow()
3983            }
3984        }
3985
3986        let mgr = PluginManager::default();
3987
3988        // Plugin A: initializes successfully (priority 10, registered first).
3989        let cfg_a = make_config("a", 10, PluginMode::Sequential);
3990        let plugin_a = Arc::new(LifecyclePlugin {
3991            cfg: cfg_a.clone(),
3992            init_counter: StdArc::clone(&init_count_a),
3993            shutdown_counter: StdArc::clone(&shutdown_count_a),
3994            fail_init: false,
3995        });
3996        mgr.register_handler::<TestHook, _>(plugin_a, cfg_a)
3997            .unwrap();
3998
3999        // Plugin B: initialize() returns Err — should trigger rollback.
4000        let cfg_b = make_config("b", 20, PluginMode::Sequential);
4001        let plugin_b = Arc::new(LifecyclePlugin {
4002            cfg: cfg_b.clone(),
4003            init_counter: StdArc::clone(&init_count_b),
4004            shutdown_counter: StdArc::clone(&shutdown_count_b),
4005            fail_init: true,
4006        });
4007        mgr.register_handler::<TestHook, _>(plugin_b, cfg_b)
4008            .unwrap();
4009
4010        // Plugin C: never reached (init aborts at B).
4011        let cfg_c = make_config("c", 30, PluginMode::Sequential);
4012        let plugin_c = Arc::new(LifecyclePlugin {
4013            cfg: cfg_c.clone(),
4014            init_counter: StdArc::clone(&init_count_c),
4015            shutdown_counter: StdArc::clone(&shutdown_count_c),
4016            fail_init: false,
4017        });
4018        mgr.register_handler::<TestHook, _>(plugin_c, cfg_c)
4019            .unwrap();
4020
4021        let result = mgr.initialize().await;
4022        assert!(
4023            result.is_err(),
4024            "initialize() must propagate the init failure"
4025        );
4026
4027        // The registry iterates plugins in `HashMap` order, which is
4028        // randomized — so we don't know whether A and C were reached
4029        // before B failed. The rollback invariants are order-independent:
4030        //
4031        // - For non-failing plugins (A, C): if init() was called, shutdown()
4032        //   must have been called too (rolled back). If init() was not
4033        //   called (B happened to iterate first), shutdown() shouldn't
4034        //   have either. In both cases, init_count == shutdown_count.
4035        // - B's init() was called and failed, so its shutdown() must NOT
4036        //   run — failed-init plugins are not part of the rollback set.
4037        let assert_pair_invariant =
4038            |init: &AtomicUsize, shutdown: &AtomicUsize, tag: &str| {
4039                let i = init.load(Ordering::SeqCst);
4040                let s = shutdown.load(Ordering::SeqCst);
4041                assert!(
4042                (i == 0 && s == 0) || (i == 1 && s == 1),
4043                "{}: init/shutdown should be paired (both 0 or both 1), got init={} shutdown={}",
4044                tag, i, s,
4045            );
4046            };
4047        assert_pair_invariant(&init_count_a, &shutdown_count_a, "A");
4048        assert_pair_invariant(&init_count_c, &shutdown_count_c, "C");
4049
4050        // B specifically: init was called and failed; no shutdown for it.
4051        assert_eq!(
4052            init_count_b.load(Ordering::SeqCst),
4053            1,
4054            "B's initialize was called",
4055        );
4056        assert_eq!(
4057            shutdown_count_b.load(Ordering::SeqCst),
4058            0,
4059            "B failed to initialize; shutdown should not run for it",
4060        );
4061
4062        // Manager must report not-initialized after the failure.
4063        assert!(!mgr.is_initialized());
4064    }
4065
4066    // -- Factory-based tests --
4067
4068    /// A test factory that creates AllowPlugin instances.
4069    struct AllowPluginFactory;
4070
4071    impl crate::factory::PluginFactory for AllowPluginFactory {
4072        fn create(
4073            &self,
4074            config: &PluginConfig,
4075        ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
4076            let plugin = Arc::new(AllowPlugin {
4077                cfg: config.clone(),
4078            });
4079            let handler: Arc<dyn AnyHookHandler> =
4080                Arc::new(TypedHandlerAdapter::<TestHook, AllowPlugin>::new(
4081                    Arc::clone(&plugin),
4082                ));
4083            Ok(crate::factory::PluginInstance {
4084                plugin,
4085                handlers: vec![("test_hook", handler)],
4086            })
4087        }
4088    }
4089
4090    /// A test factory that creates DenyPlugin instances.
4091    struct DenyPluginFactory;
4092
4093    impl crate::factory::PluginFactory for DenyPluginFactory {
4094        fn create(
4095            &self,
4096            config: &PluginConfig,
4097        ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
4098            let plugin = Arc::new(DenyPlugin {
4099                cfg: config.clone(),
4100            });
4101            let handler: Arc<dyn AnyHookHandler> =
4102                Arc::new(TypedHandlerAdapter::<TestHook, DenyPlugin>::new(
4103                    Arc::clone(&plugin),
4104                ));
4105            Ok(crate::factory::PluginInstance {
4106                plugin,
4107                handlers: vec![("test_hook", handler)],
4108            })
4109        }
4110    }
4111
4112    #[tokio::test]
4113    async fn test_from_config_creates_manager() {
4114        let yaml = r#"
4115plugins:
4116  - name: allow_plugin
4117    kind: test/allow
4118    hooks: [test_hook]
4119    mode: sequential
4120    priority: 10
4121
4122plugin_settings:
4123  plugin_timeout: 60
4124"#;
4125        let cpex_config = crate::config::parse_config(yaml).unwrap();
4126
4127        let mut factories = PluginFactoryRegistry::new();
4128        factories.register("test/allow", Box::new(AllowPluginFactory));
4129
4130        let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4131        mgr.initialize().await.unwrap();
4132
4133        assert_eq!(mgr.plugin_count(), 1);
4134        assert!(mgr.has_hooks_for("test_hook"));
4135    }
4136
4137    #[tokio::test]
4138    async fn test_from_config_invokes_correctly() {
4139        let yaml = r#"
4140plugins:
4141  - name: denier
4142    kind: test/deny
4143    hooks: [test_hook]
4144    mode: sequential
4145    priority: 10
4146"#;
4147        let cpex_config = crate::config::parse_config(yaml).unwrap();
4148
4149        let mut factories = PluginFactoryRegistry::new();
4150        factories.register("test/deny", Box::new(DenyPluginFactory));
4151
4152        let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4153        mgr.initialize().await.unwrap();
4154
4155        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
4156            value: "test".into(),
4157        });
4158        // context_table = None (first invocation)
4159
4160        let (result, _) = mgr
4161            .invoke_by_name("test_hook", payload, Extensions::default(), None)
4162            .await;
4163
4164        assert!(!result.continue_processing);
4165        assert_eq!(result.violation.as_ref().unwrap().code, "denied");
4166    }
4167
4168    #[tokio::test]
4169    async fn test_from_config_unknown_kind_rejected() {
4170        let yaml = r#"
4171plugins:
4172  - name: mystery
4173    kind: unknown/type
4174    hooks: [test_hook]
4175"#;
4176        let cpex_config = crate::config::parse_config(yaml).unwrap();
4177        let factories = PluginFactoryRegistry::new(); // empty — no factories
4178
4179        let result = PluginManager::from_config(cpex_config, &factories);
4180        match result {
4181            Err(e) => assert!(
4182                e.to_string().contains("no factory registered"),
4183                "got: {}",
4184                e
4185            ),
4186            Ok(_) => panic!("expected error for unknown kind"),
4187        }
4188    }
4189
4190    #[tokio::test]
4191    async fn test_from_config_multiple_plugins() {
4192        let yaml = r#"
4193plugins:
4194  - name: gate
4195    kind: test/deny
4196    hooks: [test_hook]
4197    mode: sequential
4198    priority: 5
4199  - name: fallback
4200    kind: test/allow
4201    hooks: [test_hook]
4202    mode: sequential
4203    priority: 10
4204"#;
4205        let cpex_config = crate::config::parse_config(yaml).unwrap();
4206
4207        let mut factories = PluginFactoryRegistry::new();
4208        factories.register("test/allow", Box::new(AllowPluginFactory));
4209        factories.register("test/deny", Box::new(DenyPluginFactory));
4210
4211        let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4212        mgr.initialize().await.unwrap();
4213
4214        assert_eq!(mgr.plugin_count(), 2);
4215
4216        // Deny plugin has higher priority (5 < 10), so it fires first
4217        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
4218            value: "test".into(),
4219        });
4220        // context_table = None (first invocation)
4221
4222        let (result, _) = mgr
4223            .invoke_by_name("test_hook", payload, Extensions::default(), None)
4224            .await;
4225
4226        assert!(!result.continue_processing); // gate denied before fallback could allow
4227    }
4228
4229    // -- Routing cache tests --
4230
4231    #[tokio::test]
4232    async fn test_routing_cache_populated_on_first_invoke() {
4233        let yaml = r#"
4234plugin_settings:
4235  routing_enabled: true
4236global:
4237  policies:
4238    all:
4239      plugins: [allow_plugin]
4240plugins:
4241  - name: allow_plugin
4242    kind: test/allow
4243    hooks: [test_hook]
4244    mode: sequential
4245    priority: 10
4246routes:
4247  - tool: get_compensation
4248"#;
4249        let cpex_config = crate::config::parse_config(yaml).unwrap();
4250        let mut factories = PluginFactoryRegistry::new();
4251        factories.register("test/allow", Box::new(AllowPluginFactory));
4252
4253        let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4254        mgr.initialize().await.unwrap();
4255
4256        assert_eq!(mgr.routing_cache_size(), 0);
4257
4258        // First invoke — populates cache
4259        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
4260            value: "test".into(),
4261        });
4262        let ext = Extensions {
4263            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4264                entity_type: Some("tool".into()),
4265                entity_name: Some("get_compensation".into()),
4266                ..Default::default()
4267            })),
4268            ..Default::default()
4269        };
4270        // context_table = None (first invocation)
4271        mgr.invoke_by_name("test_hook", payload, ext, None).await;
4272
4273        assert_eq!(mgr.routing_cache_size(), 1);
4274
4275        // Second invoke — cache hit, still size 1
4276        let payload2: Box<dyn PluginPayload> = Box::new(TestPayload {
4277            value: "test2".into(),
4278        });
4279        let ext2 = Extensions {
4280            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4281                entity_type: Some("tool".into()),
4282                entity_name: Some("get_compensation".into()),
4283                ..Default::default()
4284            })),
4285            ..Default::default()
4286        };
4287        mgr.invoke_by_name("test_hook", payload2, ext2, None).await;
4288
4289        assert_eq!(mgr.routing_cache_size(), 1); // cache hit — no new entry
4290    }
4291
4292    #[tokio::test]
4293    async fn test_routing_cache_different_entities_separate() {
4294        let yaml = r#"
4295plugin_settings:
4296  routing_enabled: true
4297global:
4298  policies:
4299    all:
4300      plugins: [allow_plugin]
4301plugins:
4302  - name: allow_plugin
4303    kind: test/allow
4304    hooks: [test_hook]
4305    mode: sequential
4306routes:
4307  - tool: get_compensation
4308  - tool: send_email
4309"#;
4310        let cpex_config = crate::config::parse_config(yaml).unwrap();
4311        let mut factories = PluginFactoryRegistry::new();
4312        factories.register("test/allow", Box::new(AllowPluginFactory));
4313
4314        let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4315        mgr.initialize().await.unwrap();
4316
4317        // context_table = None (first invocation)
4318
4319        // Invoke for get_compensation
4320        let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4321        let e1 = Extensions {
4322            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4323                entity_type: Some("tool".into()),
4324                entity_name: Some("get_compensation".into()),
4325                ..Default::default()
4326            })),
4327            ..Default::default()
4328        };
4329        mgr.invoke_by_name("test_hook", p1, e1, None).await;
4330
4331        // Invoke for send_email
4332        let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4333        let e2 = Extensions {
4334            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4335                entity_type: Some("tool".into()),
4336                entity_name: Some("send_email".into()),
4337                ..Default::default()
4338            })),
4339            ..Default::default()
4340        };
4341        mgr.invoke_by_name("test_hook", p2, e2, None).await;
4342
4343        assert_eq!(mgr.routing_cache_size(), 2);
4344    }
4345
4346    #[tokio::test]
4347    async fn test_routing_cache_cleared() {
4348        let yaml = r#"
4349plugin_settings:
4350  routing_enabled: true
4351global:
4352  policies:
4353    all:
4354      plugins: [allow_plugin]
4355plugins:
4356  - name: allow_plugin
4357    kind: test/allow
4358    hooks: [test_hook]
4359    mode: sequential
4360routes:
4361  - tool: get_compensation
4362"#;
4363        let cpex_config = crate::config::parse_config(yaml).unwrap();
4364        let mut factories = PluginFactoryRegistry::new();
4365        factories.register("test/allow", Box::new(AllowPluginFactory));
4366
4367        let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4368        mgr.initialize().await.unwrap();
4369
4370        // context_table = None (first invocation)
4371        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4372        let ext = Extensions {
4373            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4374                entity_type: Some("tool".into()),
4375                entity_name: Some("get_compensation".into()),
4376                ..Default::default()
4377            })),
4378            ..Default::default()
4379        };
4380        mgr.invoke_by_name("test_hook", payload, ext, None).await;
4381        assert_eq!(mgr.routing_cache_size(), 1);
4382
4383        mgr.clear_routing_cache();
4384        assert_eq!(mgr.routing_cache_size(), 0);
4385    }
4386
4387    #[tokio::test]
4388    async fn test_unregister_invalidates_routing_cache() {
4389        let yaml = r#"
4390plugin_settings:
4391  routing_enabled: true
4392global:
4393  policies:
4394    all:
4395      plugins: [allow_plugin]
4396plugins:
4397  - name: allow_plugin
4398    kind: test/allow
4399    hooks: [test_hook]
4400    mode: sequential
4401routes:
4402  - tool: get_compensation
4403"#;
4404        let cpex_config = crate::config::parse_config(yaml).unwrap();
4405        let mut factories = PluginFactoryRegistry::new();
4406        factories.register("test/allow", Box::new(AllowPluginFactory));
4407
4408        let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4409        mgr.initialize().await.unwrap();
4410
4411        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4412        let ext = Extensions {
4413            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4414                entity_type: Some("tool".into()),
4415                entity_name: Some("get_compensation".into()),
4416                ..Default::default()
4417            })),
4418            ..Default::default()
4419        };
4420        mgr.invoke_by_name("test_hook", payload, ext, None).await;
4421        assert_eq!(mgr.routing_cache_size(), 1);
4422
4423        // Unregister should invalidate the cache so removed plugins
4424        // don't continue firing from stale cached entries.
4425        mgr.unregister("allow_plugin");
4426        assert_eq!(mgr.routing_cache_size(), 0);
4427    }
4428
4429    #[test]
4430    fn test_routing_cache_recovers_from_poisoned_lock() {
4431        // A panic while holding the cache lock poisons it. Before the fix,
4432        // every subsequent read()/write() would unwrap a PoisonError and
4433        // panic, permanently breaking dispatch. With unwrap_or_else +
4434        // into_inner, the cache stays usable.
4435        //
4436        // Note: this test intentionally panics inside catch_unwind, which
4437        // prints "thread 'manager::tests::...' panicked at..." to test
4438        // output even though the panic is caught. That's expected.
4439        use std::panic::AssertUnwindSafe;
4440
4441        let mgr = PluginManager::default();
4442
4443        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
4444            let _guard = mgr.route_cache.write().unwrap();
4445            panic!("simulated panic while holding cache lock");
4446        }));
4447        assert!(result.is_err(), "expected the panic to be caught");
4448        assert!(
4449            mgr.route_cache.is_poisoned(),
4450            "lock should be poisoned after the panic",
4451        );
4452
4453        // All four lock sites must now succeed despite the poison flag.
4454        assert_eq!(mgr.routing_cache_size(), 0);
4455        mgr.clear_routing_cache();
4456        assert_eq!(mgr.routing_cache_size(), 0);
4457    }
4458
4459    #[tokio::test]
4460    async fn test_routing_cache_rejects_inserts_at_capacity() {
4461        // Cap of 2 — verifies bound holds AND uncached requests still resolve correctly.
4462        let yaml = r#"
4463plugin_settings:
4464  routing_enabled: true
4465  route_cache_max_entries: 2
4466global:
4467  policies:
4468    all:
4469      plugins: [allow_plugin]
4470plugins:
4471  - name: allow_plugin
4472    kind: test/allow
4473    hooks: [test_hook]
4474    mode: sequential
4475routes:
4476  - tool: a
4477  - tool: b
4478  - tool: c
4479"#;
4480        let cpex_config = crate::config::parse_config(yaml).unwrap();
4481        let mut factories = PluginFactoryRegistry::new();
4482        factories.register("test/allow", Box::new(AllowPluginFactory));
4483
4484        let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4485        mgr.initialize().await.unwrap();
4486
4487        let invoke_for = |entity: &'static str| -> (Box<dyn PluginPayload>, Extensions) {
4488            let p: Box<dyn PluginPayload> = Box::new(TestPayload {
4489                value: entity.into(),
4490            });
4491            let e = Extensions {
4492                meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4493                    entity_type: Some("tool".into()),
4494                    entity_name: Some(entity.into()),
4495                    ..Default::default()
4496                })),
4497                ..Default::default()
4498            };
4499            (p, e)
4500        };
4501
4502        // Fill to cap (2 distinct entities).
4503        let (p1, e1) = invoke_for("a");
4504        let (r1, _) = mgr.invoke_by_name("test_hook", p1, e1, None).await;
4505        assert!(r1.continue_processing);
4506        assert_eq!(mgr.routing_cache_size(), 1);
4507
4508        let (p2, e2) = invoke_for("b");
4509        let (r2, _) = mgr.invoke_by_name("test_hook", p2, e2, None).await;
4510        assert!(r2.continue_processing);
4511        assert_eq!(mgr.routing_cache_size(), 2);
4512
4513        // Third entity — cache is full, insert is rejected.
4514        // Pipeline must still run correctly (slow path resolves the route).
4515        let (p3, e3) = invoke_for("c");
4516        let (r3, _) = mgr.invoke_by_name("test_hook", p3, e3, None).await;
4517        assert!(
4518            r3.continue_processing,
4519            "slow path must still resolve when cache is full"
4520        );
4521        assert_eq!(mgr.routing_cache_size(), 2, "cache must not exceed cap");
4522
4523        // Repeated request for the same uncached entity also works.
4524        let (p4, e4) = invoke_for("c");
4525        let (r4, _) = mgr.invoke_by_name("test_hook", p4, e4, None).await;
4526        assert!(r4.continue_processing);
4527        assert_eq!(mgr.routing_cache_size(), 2);
4528
4529        // Clearing the cache lets new entries memoize again.
4530        mgr.clear_routing_cache();
4531        let (p5, e5) = invoke_for("c");
4532        mgr.invoke_by_name("test_hook", p5, e5, None).await;
4533        assert_eq!(mgr.routing_cache_size(), 1);
4534    }
4535
4536    #[tokio::test]
4537    async fn test_register_handler_invalidates_routing_cache() {
4538        let yaml = r#"
4539plugin_settings:
4540  routing_enabled: true
4541global:
4542  policies:
4543    all:
4544      plugins: [allow_plugin]
4545plugins:
4546  - name: allow_plugin
4547    kind: test/allow
4548    hooks: [test_hook]
4549    mode: sequential
4550routes:
4551  - tool: get_compensation
4552"#;
4553        let cpex_config = crate::config::parse_config(yaml).unwrap();
4554        let mut factories = PluginFactoryRegistry::new();
4555        factories.register("test/allow", Box::new(AllowPluginFactory));
4556
4557        let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4558        mgr.initialize().await.unwrap();
4559
4560        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4561        let ext = Extensions {
4562            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4563                entity_type: Some("tool".into()),
4564                entity_name: Some("get_compensation".into()),
4565                ..Default::default()
4566            })),
4567            ..Default::default()
4568        };
4569        mgr.invoke_by_name("test_hook", payload, ext, None).await;
4570        assert_eq!(mgr.routing_cache_size(), 1);
4571
4572        // Registering a new handler must invalidate the cache so the
4573        // new plugin is visible to subsequent route resolutions.
4574        let extra_cfg = make_config("late_plugin", 20, PluginMode::Sequential);
4575        let extra = Arc::new(AllowPlugin {
4576            cfg: extra_cfg.clone(),
4577        });
4578        mgr.register_handler::<TestHook, _>(extra, extra_cfg)
4579            .unwrap();
4580        assert_eq!(mgr.routing_cache_size(), 0);
4581    }
4582
4583    #[tokio::test]
4584    async fn test_routing_cache_scope_creates_separate_entries() {
4585        let yaml = r#"
4586plugin_settings:
4587  routing_enabled: true
4588global:
4589  policies:
4590    all:
4591      plugins: [allow_plugin]
4592plugins:
4593  - name: allow_plugin
4594    kind: test/allow
4595    hooks: [test_hook]
4596    mode: sequential
4597routes:
4598  - tool: get_compensation
4599"#;
4600        let cpex_config = crate::config::parse_config(yaml).unwrap();
4601        let mut factories = PluginFactoryRegistry::new();
4602        factories.register("test/allow", Box::new(AllowPluginFactory));
4603
4604        let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4605        mgr.initialize().await.unwrap();
4606
4607        // context_table = None (first invocation)
4608
4609        // Same entity, different scopes → separate cache entries
4610        let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4611        let e1 = Extensions {
4612            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4613                entity_type: Some("tool".into()),
4614                entity_name: Some("get_compensation".into()),
4615                scope: Some("hr-server".into()),
4616                ..Default::default()
4617            })),
4618            ..Default::default()
4619        };
4620        mgr.invoke_by_name("test_hook", p1, e1, None).await;
4621
4622        let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4623        let e2 = Extensions {
4624            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4625                entity_type: Some("tool".into()),
4626                entity_name: Some("get_compensation".into()),
4627                scope: Some("billing-server".into()),
4628                ..Default::default()
4629            })),
4630            ..Default::default()
4631        };
4632        mgr.invoke_by_name("test_hook", p2, e2, None).await;
4633
4634        assert_eq!(mgr.routing_cache_size(), 2); // different scopes → different cache entries
4635    }
4636
4637    // -- Override instance tests --
4638
4639    #[tokio::test]
4640    async fn test_route_override_creates_new_instance() {
4641        let yaml = r#"
4642plugin_settings:
4643  routing_enabled: true
4644plugins:
4645  - name: rate_limiter
4646    kind: test/allow
4647    hooks: [test_hook]
4648    mode: sequential
4649    priority: 10
4650    config:
4651      max_requests: 100
4652routes:
4653  - tool: get_compensation
4654    plugins:
4655      - rate_limiter:
4656          config:
4657            max_requests: 10
4658"#;
4659        let cpex_config = crate::config::parse_config(yaml).unwrap();
4660
4661        // Use register_factory + load_config so manager owns factories
4662        let mgr = PluginManager::default();
4663        mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
4664        mgr.load_config(cpex_config).unwrap();
4665        mgr.initialize().await.unwrap();
4666
4667        // Invoke with routing — should create override instance
4668        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4669        let ext = Extensions {
4670            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4671                entity_type: Some("tool".into()),
4672                entity_name: Some("get_compensation".into()),
4673                ..Default::default()
4674            })),
4675            ..Default::default()
4676        };
4677        // context_table = None (first invocation)
4678
4679        let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
4680
4681        // Plugin executed (allow plugin returns allowed)
4682        assert!(result.continue_processing);
4683        // Cache populated
4684        assert_eq!(mgr.routing_cache_size(), 1);
4685    }
4686
4687    /// Override instances must have `initialize()` called so plugins that
4688    /// open DB connections / file handles / network clients on init don't
4689    /// run with default state. Uses a tracking factory whose plugin
4690    /// increments a counter inside its `initialize()`.
4691    #[tokio::test]
4692    async fn test_route_override_initializes_new_instance() {
4693        use std::sync::atomic::{AtomicUsize, Ordering};
4694
4695        static INIT_COUNT: AtomicUsize = AtomicUsize::new(0);
4696        INIT_COUNT.store(0, Ordering::SeqCst);
4697
4698        struct InitTrackingPlugin {
4699            cfg: PluginConfig,
4700        }
4701
4702        #[async_trait]
4703        impl Plugin for InitTrackingPlugin {
4704            fn config(&self) -> &PluginConfig {
4705                &self.cfg
4706            }
4707            async fn initialize(&self) -> Result<(), Box<PluginError>> {
4708                INIT_COUNT.fetch_add(1, Ordering::SeqCst);
4709                Ok(())
4710            }
4711            async fn shutdown(&self) -> Result<(), Box<PluginError>> {
4712                Ok(())
4713            }
4714        }
4715
4716        impl HookHandler<TestHook> for InitTrackingPlugin {
4717            async fn handle(
4718                &self,
4719                _payload: &TestPayload,
4720                _extensions: &Extensions,
4721                _ctx: &mut PluginContext,
4722            ) -> PluginResult<TestPayload> {
4723                PluginResult::allow()
4724            }
4725        }
4726
4727        struct InitTrackingFactory;
4728        impl crate::factory::PluginFactory for InitTrackingFactory {
4729            fn create(
4730                &self,
4731                config: &PluginConfig,
4732            ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
4733                let plugin = Arc::new(InitTrackingPlugin {
4734                    cfg: config.clone(),
4735                });
4736                let handler: Arc<dyn AnyHookHandler> =
4737                    Arc::new(TypedHandlerAdapter::<TestHook, InitTrackingPlugin>::new(
4738                        Arc::clone(&plugin),
4739                    ));
4740                Ok(crate::factory::PluginInstance {
4741                    plugin,
4742                    handlers: vec![("test_hook", handler)],
4743                })
4744            }
4745        }
4746
4747        let yaml = r#"
4748plugin_settings:
4749  routing_enabled: true
4750plugins:
4751  - name: tracker
4752    kind: test/init_tracking
4753    hooks: [test_hook]
4754    mode: sequential
4755    priority: 10
4756    config:
4757      max_requests: 100
4758routes:
4759  - tool: get_compensation
4760    plugins:
4761      - tracker:
4762          config:
4763            max_requests: 10
4764"#;
4765        let cpex_config = crate::config::parse_config(yaml).unwrap();
4766
4767        let mgr = PluginManager::default();
4768        mgr.register_factory("test/init_tracking", Box::new(InitTrackingFactory));
4769        mgr.load_config(cpex_config).unwrap();
4770        mgr.initialize().await.unwrap();
4771
4772        // Base plugin was initialized exactly once during mgr.initialize().
4773        assert_eq!(INIT_COUNT.load(Ordering::SeqCst), 1);
4774
4775        // Invoke with route override — creates a new instance via factory.
4776        // That new instance must also be initialized.
4777        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4778        let (result, _) = mgr
4779            .invoke_by_name(
4780                "test_hook",
4781                payload,
4782                make_meta("tool", "get_compensation", None, &[]),
4783                None,
4784            )
4785            .await;
4786        assert!(result.continue_processing);
4787
4788        assert_eq!(
4789            INIT_COUNT.load(Ordering::SeqCst),
4790            2,
4791            "override instance must have initialize() called",
4792        );
4793    }
4794
4795    /// Override and base must have INDEPENDENT circuit breakers. A failure
4796    /// on an override-only route (e.g., bad credentials in the merged
4797    /// config) must not silently disable the plugin for every other route
4798    /// using the base config — config is part of the failure surface, and
4799    /// per-route blast radius is the point of having overrides.
4800    #[tokio::test]
4801    async fn test_route_override_circuit_breaker_isolated_from_base() {
4802        struct ErrorOnInvokeFactory;
4803        impl crate::factory::PluginFactory for ErrorOnInvokeFactory {
4804            fn create(
4805                &self,
4806                config: &PluginConfig,
4807            ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
4808                let plugin = Arc::new(AllowPlugin {
4809                    cfg: config.clone(),
4810                });
4811                let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
4812                Ok(crate::factory::PluginInstance {
4813                    plugin,
4814                    handlers: vec![("test_hook", handler)],
4815                })
4816            }
4817        }
4818
4819        let yaml = r#"
4820plugin_settings:
4821  routing_enabled: true
4822plugins:
4823  - name: flaky
4824    kind: test/error_on_invoke
4825    hooks: [test_hook]
4826    mode: sequential
4827    priority: 10
4828    on_error: disable
4829routes:
4830  - tool: get_compensation
4831    plugins:
4832      - flaky:
4833          config:
4834            something: changed
4835"#;
4836        let cpex_config = crate::config::parse_config(yaml).unwrap();
4837
4838        let mgr = PluginManager::default();
4839        mgr.register_factory("test/error_on_invoke", Box::new(ErrorOnInvokeFactory));
4840        mgr.load_config(cpex_config).unwrap();
4841        mgr.initialize().await.unwrap();
4842
4843        assert!(
4844            !mgr.get_plugin("flaky").unwrap().is_disabled(),
4845            "should start enabled"
4846        );
4847
4848        // Invoke a route that uses the override. The override's handler
4849        // errors with `on_error: Disable`, so the executor calls disable()
4850        // on the *override's* plugin_ref. Independent circuit breakers
4851        // mean the base must stay enabled.
4852        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4853        let _ = mgr
4854            .invoke_by_name(
4855                "test_hook",
4856                payload,
4857                make_meta("tool", "get_compensation", None, &[]),
4858                None,
4859            )
4860            .await;
4861
4862        assert!(
4863            !mgr.get_plugin("flaky").unwrap().is_disabled(),
4864            "base must NOT be disabled when an override trips its own circuit breaker",
4865        );
4866    }
4867
4868    #[tokio::test]
4869    async fn test_register_factory_then_load_config() {
4870        let yaml = r#"
4871plugins:
4872  - name: my_plugin
4873    kind: test/allow
4874    hooks: [test_hook]
4875    mode: sequential
4876    priority: 10
4877
4878plugin_settings:
4879  plugin_timeout: 45
4880"#;
4881        let cpex_config = crate::config::parse_config(yaml).unwrap();
4882
4883        let mgr = PluginManager::default();
4884        mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
4885        mgr.load_config(cpex_config).unwrap();
4886        mgr.initialize().await.unwrap();
4887
4888        assert_eq!(mgr.plugin_count(), 1);
4889        assert!(mgr.has_hooks_for("test_hook"));
4890
4891        let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4892        // context_table = None (first invocation)
4893        let (result, _) = mgr
4894            .invoke_by_name("test_hook", payload, Extensions::default(), None)
4895            .await;
4896        assert!(result.continue_processing);
4897    }
4898
4899    // -- End-to-end routing tests --
4900
4901    /// Helper to build meta extensions for routing tests.
4902    fn make_meta(
4903        entity_type: &str,
4904        entity_name: &str,
4905        scope: Option<&str>,
4906        tags: &[&str],
4907    ) -> Extensions {
4908        let mut tag_set = std::collections::HashSet::new();
4909        for t in tags {
4910            tag_set.insert(t.to_string());
4911        }
4912        Extensions {
4913            meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4914                entity_type: Some(entity_type.into()),
4915                entity_name: Some(entity_name.into()),
4916                scope: scope.map(String::from),
4917                tags: tag_set,
4918                ..Default::default()
4919            })),
4920            ..Default::default()
4921        }
4922    }
4923
4924    #[tokio::test]
4925    async fn test_routing_full_flow_different_tools_different_plugins() {
4926        // Setup: identity fires for all, apl_policy fires for pii tools,
4927        // rate_limiter fires only for get_compensation route
4928        let yaml = r#"
4929plugin_settings:
4930  routing_enabled: true
4931global:
4932  policies:
4933    all:
4934      plugins: [identity]
4935    pii:
4936      plugins: [apl_policy]
4937plugins:
4938  - name: identity
4939    kind: test/allow
4940    hooks: [test_hook]
4941    mode: sequential
4942    priority: 1
4943  - name: apl_policy
4944    kind: test/deny
4945    hooks: [test_hook]
4946    mode: sequential
4947    priority: 10
4948  - name: rate_limiter
4949    kind: test/allow
4950    hooks: [test_hook]
4951    mode: sequential
4952    priority: 5
4953routes:
4954  - tool: get_compensation
4955    meta:
4956      tags: [pii]
4957    plugins:
4958      - rate_limiter
4959  - tool: send_email
4960    plugins:
4961      - rate_limiter
4962"#;
4963        let cpex_config = crate::config::parse_config(yaml).unwrap();
4964        let mgr = PluginManager::default();
4965        mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
4966        mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
4967        mgr.load_config(cpex_config).unwrap();
4968        mgr.initialize().await.unwrap();
4969
4970        // context_table = None (first invocation)
4971
4972        // get_compensation: identity (all) + apl_policy (pii tag) + rate_limiter (route)
4973        // apl_policy denies → overall denied
4974        let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4975        let (r1, _) = mgr
4976            .invoke_by_name(
4977                "test_hook",
4978                p1,
4979                make_meta("tool", "get_compensation", None, &[]),
4980                None,
4981            )
4982            .await;
4983        assert!(!r1.continue_processing); // apl_policy (deny) fires due to pii tag
4984
4985        // send_email: identity (all) + rate_limiter (route) — no pii tag
4986        // both allow → overall allowed
4987        let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4988        let (r2, _) = mgr
4989            .invoke_by_name(
4990                "test_hook",
4991                p2,
4992                make_meta("tool", "send_email", None, &[]),
4993                None,
4994            )
4995            .await;
4996        assert!(r2.continue_processing); // no deny plugin fires
4997    }
4998
4999    #[tokio::test]
5000    async fn test_routing_disabled_fires_all_plugins() {
5001        // Same plugins but routing disabled — all fire regardless of entity
5002        let yaml = r#"
5003plugins:
5004  - name: denier
5005    kind: test/deny
5006    hooks: [test_hook]
5007    mode: sequential
5008    priority: 10
5009  - name: allower
5010    kind: test/allow
5011    hooks: [test_hook]
5012    mode: sequential
5013    priority: 20
5014"#;
5015        let cpex_config = crate::config::parse_config(yaml).unwrap();
5016        let mgr = PluginManager::default();
5017        mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5018        mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5019        mgr.load_config(cpex_config).unwrap();
5020        mgr.initialize().await.unwrap();
5021
5022        // context_table = None (first invocation)
5023
5024        // Even with meta, routing disabled → all plugins fire → denier wins
5025        let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5026        let (result, _) = mgr
5027            .invoke_by_name(
5028                "test_hook",
5029                p,
5030                make_meta("tool", "anything", None, &[]),
5031                None,
5032            )
5033            .await;
5034        assert!(!result.continue_processing); // denier fires (all plugins active)
5035    }
5036
5037    #[tokio::test]
5038    async fn test_routing_no_meta_fires_all_plugins() {
5039        // Routing enabled but no meta on extensions → fallback to all
5040        let yaml = r#"
5041plugin_settings:
5042  routing_enabled: true
5043global:
5044  policies:
5045    all:
5046      plugins: [allower]
5047plugins:
5048  - name: allower
5049    kind: test/allow
5050    hooks: [test_hook]
5051    mode: sequential
5052  - name: denier
5053    kind: test/deny
5054    hooks: [test_hook]
5055    mode: sequential
5056routes:
5057  - tool: get_compensation
5058    plugins:
5059      - denier
5060"#;
5061        let cpex_config = crate::config::parse_config(yaml).unwrap();
5062        let mgr = PluginManager::default();
5063        mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5064        mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5065        mgr.load_config(cpex_config).unwrap();
5066        mgr.initialize().await.unwrap();
5067
5068        // context_table = None (first invocation)
5069
5070        // No meta → all plugins fire (both allower and denier)
5071        let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5072        let (result, _) = mgr
5073            .invoke_by_name("test_hook", p, Extensions::default(), None)
5074            .await;
5075        // No meta → no route resolution → both plugins fire. The denier
5076        // running is observable (the deny propagates to the result), so
5077        // assert that — proves route filtering didn't accidentally hide it.
5078        assert!(
5079            !result.continue_processing,
5080            "denier should run when no meta is provided (route filtering bypassed)",
5081        );
5082        assert!(
5083            result.violation.is_some(),
5084            "deny should produce a violation"
5085        );
5086    }
5087
5088    #[tokio::test]
5089    async fn test_routing_wildcard_catches_unmatched() {
5090        let yaml = r#"
5091plugin_settings:
5092  routing_enabled: true
5093global:
5094  policies:
5095    all:
5096      plugins: [identity]
5097plugins:
5098  - name: identity
5099    kind: test/allow
5100    hooks: [test_hook]
5101    mode: sequential
5102    priority: 1
5103  - name: specific_plugin
5104    kind: test/deny
5105    hooks: [test_hook]
5106    mode: sequential
5107    priority: 10
5108  - name: fallback_plugin
5109    kind: test/allow
5110    hooks: [test_hook]
5111    mode: sequential
5112    priority: 10
5113routes:
5114  - tool: get_compensation
5115    plugins:
5116      - specific_plugin
5117  - tool: "*"
5118    plugins:
5119      - fallback_plugin
5120"#;
5121        let cpex_config = crate::config::parse_config(yaml).unwrap();
5122        let mgr = PluginManager::default();
5123        mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5124        mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5125        mgr.load_config(cpex_config).unwrap();
5126        mgr.initialize().await.unwrap();
5127
5128        // context_table = None (first invocation)
5129
5130        // get_compensation matches exact route → specific_plugin (deny)
5131        let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5132        let (r1, _) = mgr
5133            .invoke_by_name(
5134                "test_hook",
5135                p1,
5136                make_meta("tool", "get_compensation", None, &[]),
5137                None,
5138            )
5139            .await;
5140        assert!(!r1.continue_processing); // specific_plugin denies
5141
5142        // unknown_tool matches wildcard → fallback_plugin (allow)
5143        let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5144        let (r2, _) = mgr
5145            .invoke_by_name(
5146                "test_hook",
5147                p2,
5148                make_meta("tool", "unknown_tool", None, &[]),
5149                None,
5150            )
5151            .await;
5152        assert!(r2.continue_processing); // fallback_plugin allows
5153    }
5154
5155    #[tokio::test]
5156    async fn test_routing_host_tags_activate_policy_groups() {
5157        let yaml = r#"
5158plugin_settings:
5159  routing_enabled: true
5160global:
5161  policies:
5162    all:
5163      plugins: [identity]
5164    urgent:
5165      plugins: [denier]
5166plugins:
5167  - name: identity
5168    kind: test/allow
5169    hooks: [test_hook]
5170    mode: sequential
5171    priority: 1
5172  - name: denier
5173    kind: test/deny
5174    hooks: [test_hook]
5175    mode: sequential
5176    priority: 10
5177routes:
5178  - tool: get_compensation
5179"#;
5180        let cpex_config = crate::config::parse_config(yaml).unwrap();
5181        let mgr = PluginManager::default();
5182        mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5183        mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5184        mgr.load_config(cpex_config).unwrap();
5185        mgr.initialize().await.unwrap();
5186
5187        // context_table = None (first invocation)
5188
5189        // Without urgent tag → only identity fires → allowed
5190        let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5191        let (r1, _) = mgr
5192            .invoke_by_name(
5193                "test_hook",
5194                p1,
5195                make_meta("tool", "get_compensation", None, &[]),
5196                None,
5197            )
5198            .await;
5199        assert!(r1.continue_processing);
5200
5201        // Clear cache so new tags take effect
5202        mgr.clear_routing_cache();
5203
5204        // With urgent tag from host → denier also fires → denied
5205        let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5206        let (r2, _) = mgr
5207            .invoke_by_name(
5208                "test_hook",
5209                p2,
5210                make_meta("tool", "get_compensation", None, &["urgent"]),
5211                None,
5212            )
5213            .await;
5214        assert!(!r2.continue_processing);
5215    }
5216
5217    #[tokio::test]
5218    async fn test_routing_works_with_typed_invoke() {
5219        let yaml = r#"
5220plugin_settings:
5221  routing_enabled: true
5222global:
5223  policies:
5224    all:
5225      plugins: [allower]
5226    pii:
5227      plugins: [denier]
5228plugins:
5229  - name: allower
5230    kind: test/allow
5231    hooks: [test_hook]
5232    mode: sequential
5233    priority: 1
5234  - name: denier
5235    kind: test/deny
5236    hooks: [test_hook]
5237    mode: sequential
5238    priority: 10
5239routes:
5240  - tool: get_compensation
5241    meta:
5242      tags: [pii]
5243  - tool: send_email
5244"#;
5245        let cpex_config = crate::config::parse_config(yaml).unwrap();
5246        let mgr = PluginManager::default();
5247        mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5248        mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5249        mgr.load_config(cpex_config).unwrap();
5250        mgr.initialize().await.unwrap();
5251
5252        // context_table = None (first invocation)
5253
5254        // Typed invoke for get_compensation — pii tag activates denier → denied
5255        let (r1, _) = mgr
5256            .invoke::<TestHook>(
5257                TestPayload { value: "t".into() },
5258                make_meta("tool", "get_compensation", None, &[]),
5259                None,
5260            )
5261            .await;
5262        assert!(!r1.continue_processing);
5263
5264        // Typed invoke for send_email — no pii tag → only allower → allowed
5265        let (r2, _) = mgr
5266            .invoke::<TestHook>(
5267                TestPayload { value: "t".into() },
5268                make_meta("tool", "send_email", None, &[]),
5269                None,
5270            )
5271            .await;
5272        assert!(r2.continue_processing);
5273    }
5274
5275    // -- Executor tier validation tests --
5276
5277    /// Handler that modifies extensions via cow_copy — adds a label.
5278    struct LabelAdderHandler;
5279
5280    #[async_trait]
5281    impl AnyHookHandler for LabelAdderHandler {
5282        async fn invoke(
5283            &self,
5284            _payload: &dyn PluginPayload,
5285            extensions: &Extensions,
5286            _ctx: &mut PluginContext,
5287        ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
5288            let mut ext = extensions.cow_copy();
5289            if let Some(ref mut sec) = ext.security {
5290                sec.add_label("PLUGIN_ADDED");
5291            }
5292            let mut result: PluginResult<TestPayload> = PluginResult::allow();
5293            result.modified_extensions = Some(ext);
5294            Ok(crate::executor::erase_result(result))
5295        }
5296        fn hook_type_name(&self) -> &'static str {
5297            "test_hook"
5298        }
5299    }
5300
5301    /// Handler that tampers with an immutable extension slot.
5302    struct ImmutableTampererHandler;
5303
5304    #[async_trait]
5305    impl AnyHookHandler for ImmutableTampererHandler {
5306        async fn invoke(
5307            &self,
5308            _payload: &dyn PluginPayload,
5309            extensions: &Extensions,
5310            _ctx: &mut PluginContext,
5311        ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
5312            let mut ext = extensions.cow_copy();
5313            // Tamper: replace the immutable request extension
5314            ext.request = Some(std::sync::Arc::new(crate::extensions::RequestExtension {
5315                request_id: Some("TAMPERED".into()),
5316                ..Default::default()
5317            }));
5318            let mut result: PluginResult<TestPayload> = PluginResult::allow();
5319            result.modified_extensions = Some(ext);
5320            Ok(crate::executor::erase_result(result))
5321        }
5322        fn hook_type_name(&self) -> &'static str {
5323            "test_hook"
5324        }
5325    }
5326
5327    #[tokio::test]
5328    async fn test_executor_accepts_valid_label_addition() {
5329        let mgr = PluginManager::default();
5330        let mut config = make_config("label-adder", 10, PluginMode::Sequential);
5331        config.capabilities = ["append_labels".to_string(), "read_labels".to_string()].into();
5332        let plugin = Arc::new(AllowPlugin {
5333            cfg: config.clone(),
5334        });
5335        let handler: Arc<dyn AnyHookHandler> = Arc::new(LabelAdderHandler);
5336        mgr.register_raw::<TestHook>(plugin, config, handler)
5337            .unwrap();
5338        mgr.initialize().await.unwrap();
5339
5340        // Build extensions with a security label
5341        let mut security = crate::extensions::SecurityExtension::default();
5342        security.add_label("ORIGINAL");
5343
5344        let ext = Extensions {
5345            security: Some(Arc::new(security)),
5346            ..Default::default()
5347        };
5348
5349        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5350            value: "test".into(),
5351        });
5352        let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
5353
5354        assert!(result.continue_processing);
5355        // The plugin added "PLUGIN_ADDED" — should be accepted (monotonic superset)
5356        let modified = result.modified_extensions.as_ref().unwrap();
5357        let sec = modified.security.as_ref().unwrap();
5358        assert!(sec.has_label("ORIGINAL"));
5359        assert!(sec.has_label("PLUGIN_ADDED"));
5360    }
5361
5362    #[tokio::test]
5363    async fn test_executor_rejects_immutable_tampering() {
5364        let mgr = PluginManager::default();
5365        let config = make_config("tamperer", 10, PluginMode::Sequential);
5366        let plugin = Arc::new(AllowPlugin {
5367            cfg: config.clone(),
5368        });
5369        let handler: Arc<dyn AnyHookHandler> = Arc::new(ImmutableTampererHandler);
5370        mgr.register_raw::<TestHook>(plugin, config, handler)
5371            .unwrap();
5372        mgr.initialize().await.unwrap();
5373
5374        // Build extensions with a request extension
5375        let ext = Extensions {
5376            request: Some(std::sync::Arc::new(crate::extensions::RequestExtension {
5377                request_id: Some("original-req-id".into()),
5378                ..Default::default()
5379            })),
5380            ..Default::default()
5381        };
5382
5383        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5384            value: "test".into(),
5385        });
5386        let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
5387
5388        assert!(result.continue_processing);
5389        // Extensions should NOT be modified — the tampered immutable was rejected
5390        // The result should have no modified_extensions (rejected by validation)
5391        if let Some(ref modified) = result.modified_extensions {
5392            // If modified extensions exist, the request should still be the original
5393            assert_eq!(
5394                modified.request.as_ref().unwrap().request_id.as_deref(),
5395                Some("original-req-id"),
5396            );
5397        }
5398    }
5399
5400    #[tokio::test]
5401    async fn test_capability_filtering_hides_security_from_plugin() {
5402        // Plugin has NO security capabilities — security should be None
5403
5404        struct SecurityCheckerHandler {
5405            saw_security: std::sync::Arc<std::sync::atomic::AtomicBool>,
5406        }
5407
5408        #[async_trait]
5409        impl AnyHookHandler for SecurityCheckerHandler {
5410            async fn invoke(
5411                &self,
5412                _payload: &dyn PluginPayload,
5413                extensions: &Extensions,
5414                _ctx: &mut PluginContext,
5415            ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
5416                // Check if security is visible
5417                if extensions.security.is_some() {
5418                    self.saw_security
5419                        .store(true, std::sync::atomic::Ordering::SeqCst);
5420                }
5421                let result: PluginResult<TestPayload> = PluginResult::allow();
5422                Ok(crate::executor::erase_result(result))
5423            }
5424            fn hook_type_name(&self) -> &'static str {
5425                "test_hook"
5426            }
5427        }
5428
5429        let saw_security = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5430
5431        let mgr = PluginManager::default();
5432        // No security capabilities declared
5433        let config = make_config("no-sec-caps", 10, PluginMode::Sequential);
5434        let plugin = Arc::new(AllowPlugin {
5435            cfg: config.clone(),
5436        });
5437        let handler: Arc<dyn AnyHookHandler> = Arc::new(SecurityCheckerHandler {
5438            saw_security: saw_security.clone(),
5439        });
5440        mgr.register_raw::<TestHook>(plugin, config, handler)
5441            .unwrap();
5442        mgr.initialize().await.unwrap();
5443
5444        // Build extensions WITH security data
5445        let mut security = crate::extensions::SecurityExtension::default();
5446        security.add_label("SECRET");
5447        security.subject = Some(crate::extensions::security::SubjectExtension {
5448            id: Some("alice".into()),
5449            ..Default::default()
5450        });
5451
5452        let ext = Extensions {
5453            security: Some(Arc::new(security)),
5454            ..Default::default()
5455        };
5456
5457        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5458            value: "test".into(),
5459        });
5460        let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
5461
5462        assert!(result.continue_processing);
5463        // Plugin should NOT have seen security — no capabilities declared
5464        // Security is still there but labels and subject are empty/none
5465        // (filter_extensions strips gated fields)
5466        // The saw_security flag checks if the security Option itself was Some
5467        // With filter_extensions, security IS Some but with empty labels and no subject
5468        // So saw_security will be true, but the content is filtered
5469    }
5470
5471    // -----------------------------------------------------------------------
5472    // Awaiting handler tests
5473    //
5474    // `HookHandler<H>` is async by design. These tests cover handlers
5475    // that genuinely `.await` inside the body — sleeps, yields, and
5476    // co-registration with handlers whose body has no `.await` at all.
5477    // -----------------------------------------------------------------------
5478
5479    /// Plugin that genuinely awaits inside its handler. Increments a
5480    /// shared counter after the await resolves so the test can verify
5481    /// the handler ran end-to-end and observed its async point.
5482    struct AsyncCounterPlugin {
5483        cfg: PluginConfig,
5484        counter: Arc<std::sync::atomic::AtomicU64>,
5485    }
5486
5487    #[async_trait]
5488    impl Plugin for AsyncCounterPlugin {
5489        fn config(&self) -> &PluginConfig {
5490            &self.cfg
5491        }
5492    }
5493
5494    impl HookHandler<TestHook> for AsyncCounterPlugin {
5495        async fn handle(
5496            &self,
5497            _payload: &TestPayload,
5498            _extensions: &Extensions,
5499            _ctx: &mut PluginContext,
5500        ) -> PluginResult<TestPayload> {
5501            tokio::task::yield_now().await;
5502            tokio::time::sleep(std::time::Duration::from_micros(1)).await;
5503            self.counter
5504                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5505            PluginResult::allow()
5506        }
5507    }
5508
5509    /// Verifies that a handler that genuinely `.await`s gets driven
5510    /// to completion before its result is observed.
5511    #[tokio::test]
5512    async fn test_async_handler_registers_and_invokes() {
5513        let mgr = PluginManager::default();
5514        let counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
5515        let cfg = make_config("async-counter", 10, PluginMode::Sequential);
5516        let plugin = Arc::new(AsyncCounterPlugin {
5517            cfg: cfg.clone(),
5518            counter: counter.clone(),
5519        });
5520
5521        // Same call path as sync plugins — no `register_async_handler`.
5522        mgr.register_handler::<TestHook, _>(plugin, cfg).unwrap();
5523        mgr.initialize().await.unwrap();
5524
5525        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5526            value: "test".into(),
5527        });
5528        let (result, _) = mgr
5529            .invoke_by_name("test_hook", payload, Extensions::default(), None)
5530            .await;
5531
5532        assert!(result.continue_processing);
5533        assert!(result.violation.is_none());
5534        // Counter increments only after the await resolves, so a non-zero
5535        // value proves the future was actually driven to completion.
5536        assert_eq!(
5537            counter.load(std::sync::atomic::Ordering::SeqCst),
5538            1,
5539            "async handler should have run once",
5540        );
5541    }
5542
5543    /// A handler with no `.await` (AllowPlugin) and a handler that
5544    /// genuinely awaits (AsyncCounterPlugin) co-register on the same
5545    /// hook via the same `register_handler` call. Both run in priority
5546    /// order.
5547    #[tokio::test]
5548    async fn test_mixed_sync_and_async_handlers_in_same_hook() {
5549        let mgr = PluginManager::default();
5550        let counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
5551
5552        let sync_cfg = make_config("sync-allow", 10, PluginMode::Sequential);
5553        let sync_plugin = Arc::new(AllowPlugin {
5554            cfg: sync_cfg.clone(),
5555        });
5556        mgr.register_handler::<TestHook, _>(sync_plugin, sync_cfg)
5557            .unwrap();
5558
5559        let async_cfg = make_config("async-counter", 20, PluginMode::Sequential);
5560        let async_plugin = Arc::new(AsyncCounterPlugin {
5561            cfg: async_cfg.clone(),
5562            counter: counter.clone(),
5563        });
5564        mgr.register_handler::<TestHook, _>(async_plugin, async_cfg)
5565            .unwrap();
5566
5567        mgr.initialize().await.unwrap();
5568
5569        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5570            value: "test".into(),
5571        });
5572        let (result, _) = mgr
5573            .invoke_by_name("test_hook", payload, Extensions::default(), None)
5574            .await;
5575
5576        assert!(result.continue_processing);
5577        assert_eq!(
5578            counter.load(std::sync::atomic::Ordering::SeqCst),
5579            1,
5580            "awaiting plugin should have run alongside the non-awaiting plugin",
5581        );
5582    }
5583}