Skip to main content

praxis_policy_core/
engine.rs

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