cpex-core 0.2.2

CPEX plugin runtime core — PluginManager, executor, hooks, and config.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
// Location: ./crates/cpex-core/src/registry.rs
// Copyright 2025
// SPDX-License-Identifier: Apache-2.0
// Authors: Teryl Taylor
//
// Plugin and hook registries.
//
// PluginRef wraps a plugin implementation with the manager's
// authoritative config. The config comes from the config loader,
// NOT from the plugin — the plugin never provides its own config
// to the manager. This prevents a plugin from tampering with its
// own priority, mode, or capabilities.
//
// Trust flows one direction:
//   config loader → manager → PluginRef → executor
// The plugin is just a recipient, not a source.
//
// The registry supports two registration paths:
//
// 1. **Typed** (`register::<H>()`) — for Rust plugins implementing
//    a handler trait generated by define_hook!. The handler is stored
//    type-erased alongside the PluginRef. At dispatch time, the typed
//    path (`invoke::<H>()`) downcasts back; the dynamic path
//    (`invoke_by_name()`) calls through the type-erased interface.
//
// 2. **Name-based** (`register_for_names::<H>()`) — same handler
//    registered under multiple hook names (the CMF pattern).
//
// Mirrors the Python framework's PluginRef and PluginInstanceRegistry
// in cpex/framework/base.py and cpex/framework/registry.py.

use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};

use std::sync::Arc;
use uuid::Uuid;

use crate::context::PluginContext;
use crate::hooks::payload::{Extensions, PluginPayload};
use crate::hooks::trait_def::HookTypeDef;
use crate::hooks::HookType;
use crate::plugin::{Plugin, PluginConfig, PluginMode};

// ---------------------------------------------------------------------------
// Plugin Ref — trusted wrapper
// ---------------------------------------------------------------------------

/// Manager-owned wrapper that pairs a plugin with its authoritative config.
///
/// The `trusted_config` comes from the config loader / manager — never
/// from the plugin itself. The executor reads all scheduling decisions
/// (mode, priority, hooks, capabilities, on_error) from this config.
///
/// The plugin receives a copy of its config at construction time so it
/// can read its own settings during hook execution. But the manager/executor
/// never reads config back from the plugin.
///
/// Trust flow:
/// ```text
/// config loader → manager → PluginRef.trusted_config → executor
///                        ↘ plugin (receives a copy, cannot influence scheduling)
/// ```
#[derive(Clone)]
pub struct PluginRef {
    /// The plugin implementation.
    plugin: Arc<dyn Plugin>,

    /// Authoritative config from the config loader.
    /// The executor uses this for all scheduling and capability decisions.
    trusted_config: PluginConfig,

    /// Unique identifier assigned by the registry.
    /// Stored as `Uuid` (16 bytes, `Copy`) rather than a 36-char `String`
    /// to avoid heap allocation per registered plugin and to give
    /// downstream `HashMap<Uuid, _>` keys fixed-size hashing.
    id: Uuid,

    /// Runtime circuit breaker — set to true when `on_error: Disable`
    /// triggers. Once set, `mode()` returns `Disabled` and the plugin
    /// is skipped by `group_by_mode()` on all subsequent invocations.
    /// Uses `Arc<AtomicBool>` so clones (in HookEntry) share the same flag.
    disabled: Arc<AtomicBool>,
}

impl PluginRef {
    /// Create a new PluginRef with an independently-sourced config.
    ///
    /// The `trusted_config` must come from the config loader or manager,
    /// NOT from `plugin.config()`. The plugin may hold its own copy
    /// for reading during execute(), but the manager never consults it.
    pub fn new(plugin: Arc<dyn Plugin>, trusted_config: PluginConfig) -> Self {
        Self {
            plugin,
            trusted_config,
            id: Uuid::new_v4(),
            disabled: Arc::new(AtomicBool::new(false)),
        }
    }

    /// The authoritative config used by the executor for all decisions.
    pub fn trusted_config(&self) -> &PluginConfig {
        &self.trusted_config
    }

    /// The plugin implementation (for calling initialize/shutdown).
    pub fn plugin(&self) -> &Arc<dyn Plugin> {
        &self.plugin
    }

    /// Unique identifier assigned at registration.
    /// Returned by value — `Uuid` is `Copy` (16 bytes).
    pub fn id(&self) -> Uuid {
        self.id
    }

    /// Convenience: plugin name from the trusted config.
    pub fn name(&self) -> &str {
        &self.trusted_config.name
    }

    /// Effective mode — returns `Disabled` if the runtime circuit breaker
    /// has tripped, otherwise returns the configured mode.
    ///
    /// `Acquire` on the load pairs with `Release` on the disable() store
    /// so weak-memory-ordering hardware (ARM64) propagates the disable
    /// promptly across threads.
    pub fn mode(&self) -> PluginMode {
        if self.disabled.load(Ordering::Acquire) {
            PluginMode::Disabled
        } else {
            self.trusted_config.mode
        }
    }

    /// Runtime-disable this plugin (one-way circuit breaker).
    ///
    /// Called by the executor when a plugin errors with `on_error: Disable`.
    /// All clones of this PluginRef (in HookEntry, etc.) share the same
    /// `AtomicBool`, so the disable is visible across the system.
    ///
    /// `Release` ordering establishes a happens-before with `Acquire`
    /// loads in `is_disabled()` and `mode()` — required for correctness
    /// on weak-memory hardware (ARM64) where `Relaxed` allows the new
    /// value to remain unobserved by other threads for an unbounded window.
    pub fn disable(&self) {
        self.disabled.store(true, Ordering::Release);
    }

    /// Whether this plugin has been runtime-disabled.
    /// `Acquire` pairs with the `Release` in `disable()` (see `mode()`).
    pub fn is_disabled(&self) -> bool {
        self.disabled.load(Ordering::Acquire)
    }

    /// Convenience: plugin priority from the trusted config.
    pub fn priority(&self) -> i32 {
        self.trusted_config.priority
    }
}

// ---------------------------------------------------------------------------
// Type-Erased Hook Handler
// ---------------------------------------------------------------------------

/// Type-erased interface for calling a hook handler.
///
/// The executor uses this to dispatch hooks without knowing the
/// concrete handler trait at compile time. Each handler wraps a
/// plugin that implements a specific handler trait (e.g.,
/// `CmfHookHandler`) and translates between type-erased payloads
/// and the typed handler method.
///
/// The executor dispatches through this trait for all five phases.
/// The handler receives a borrowed payload — the framework retains
/// ownership. Plugins clone only when modifying.
///
/// `invoke` is async so that plugins can perform I/O (HTTP calls,
/// Redis, vault lookups) without blocking the tokio runtime, and
/// so that `tokio::time::timeout` can actually observe and cancel
/// long-running handlers.
#[async_trait::async_trait]
pub trait AnyHookHandler: Send + Sync {
    /// Call the handler with a borrowed payload.
    ///
    /// Returns an `ErasedResultFields` (see executor module) wrapped
    /// as `Box<dyn Any>`. If the handler modified the payload, the
    /// modified copy is in `ErasedResultFields.modified_payload`.
    async fn invoke(
        &self,
        payload: &dyn PluginPayload,
        extensions: &Extensions,
        ctx: &mut PluginContext,
    ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<crate::error::PluginError>>;

    /// The hook type name this handler was registered for.
    fn hook_type_name(&self) -> &'static str;
}

// ---------------------------------------------------------------------------
// Hook Entry — PluginRef + handler paired together
// ---------------------------------------------------------------------------

/// A registered hook handler paired with its PluginRef.
///
/// The executor uses `plugin_ref` for scheduling decisions (mode,
/// priority, capabilities) and `handler` for actual dispatch.
///
/// `plugin_ref` is `Arc<PluginRef>` so cloning a `HookEntry` is two
/// reference-count bumps rather than a deep clone of the embedded
/// `PluginConfig`. `group_by_mode` (called once per invoke) clones N
/// entries — keeping that cheap matters at high request rates.
#[derive(Clone)]
pub struct HookEntry {
    /// The plugin wrapper with authoritative config.
    pub plugin_ref: Arc<PluginRef>,

    /// The type-erased handler for this specific hook.
    pub handler: Arc<dyn AnyHookHandler>,
}

// ---------------------------------------------------------------------------
// Plugin Registry
// ---------------------------------------------------------------------------

/// Manages registered plugin instances and hook handler mappings.
///
/// Stores `PluginRef` wrappers by name and `HookEntry` (PluginRef +
/// handler) by hook name. The executor reads scheduling decisions
/// from `PluginRef.trusted_config` and dispatches through the
/// type-erased handler.
///
/// Supports two registration patterns:
///
/// - `register::<H>()` — typed registration for a single hook name
///   (derived from `H::NAME`).
/// - `register_for_names::<H>()` — typed registration for multiple
///   hook names (the CMF pattern where one handler covers
///   `cmf.tool_pre_invoke`, `cmf.llm_input`, etc.).
///
/// `Clone` is cheap-ish: the HashMaps duplicate, but their values are all
/// `Arc`-counted (`Arc<PluginRef>`, `Arc<dyn AnyHookHandler>`), so the
/// inner data is shared. Used by `PluginManager`'s `ArcSwap` snapshot
/// pattern, where every mutating method clones the registry, mutates the
/// clone, and atomically swaps in a new snapshot.
#[derive(Clone)]
pub struct PluginRegistry {
    /// Plugins keyed by name (for lookup and lifecycle). Wrapped in `Arc`
    /// so the same instance is shared with every `HookEntry` in
    /// `hook_index` — registering a plugin allocates one `PluginRef`,
    /// not one per hook.
    plugins: HashMap<String, Arc<PluginRef>>,

    /// Hook name → list of HookEntries, sorted by priority.
    hook_index: HashMap<HookType, Vec<HookEntry>>,
}

impl PluginRegistry {
    /// Create an empty registry.
    pub fn new() -> Self {
        Self {
            plugins: HashMap::new(),
            hook_index: HashMap::new(),
        }
    }

    /// Register a typed hook handler for its primary hook name.
    ///
    /// The handler is registered under `H::NAME`. The `config` must
    /// come from the config loader — not from the plugin. The plugin
    /// must implement the handler trait generated by `define_hook!`.
    ///
    /// # Type Parameters
    ///
    /// - `H` — the hook type (implements `HookTypeDef`).
    ///
    /// # Arguments
    ///
    /// - `plugin` — the plugin implementation (must also implement the handler trait).
    /// - `config` — authoritative config from the config loader.
    /// - `handler` — type-erased handler wrapping the plugin's handler trait impl.
    pub fn register<H: HookTypeDef>(
        &mut self,
        plugin: Arc<dyn Plugin>,
        config: PluginConfig,
        handler: Arc<dyn AnyHookHandler>,
    ) -> Result<(), String> {
        self.register_for_names_inner(plugin, config, handler, &[H::NAME])
    }

    /// Register a typed hook handler for multiple hook names.
    ///
    /// This is the CMF pattern — one handler trait impl covers multiple
    /// hook names (`cmf.tool_pre_invoke`, `cmf.llm_input`, etc.).
    ///
    /// # Arguments
    ///
    /// - `plugin` — the plugin implementation.
    /// - `config` — authoritative config from the config loader.
    /// - `handler` — type-erased handler.
    /// - `names` — hook names to register under.
    pub fn register_for_names<H: HookTypeDef>(
        &mut self,
        plugin: Arc<dyn Plugin>,
        config: PluginConfig,
        handler: Arc<dyn AnyHookHandler>,
        names: &[&str],
    ) -> Result<(), String> {
        self.register_for_names_inner(plugin, config, handler, names)
    }

    /// Register a plugin with a handler for multiple hook names.
    ///
    /// Like `register_for_names` but without requiring a `HookTypeDef`
    /// type parameter. Used by the config-driven factory path where
    /// the hook type is not known at compile time — the factory
    /// provides the handler directly.
    pub fn register_for_names_with_handler(
        &mut self,
        plugin: Arc<dyn Plugin>,
        config: PluginConfig,
        handler: Arc<dyn AnyHookHandler>,
        names: &[&str],
    ) -> Result<(), String> {
        self.register_for_names_inner(plugin, config, handler, names)
    }

    /// Register a plugin with multiple handlers, each for a specific hook.
    ///
    /// Used when a plugin implements multiple hook types with different
    /// payloads (e.g., `ToolPreInvoke` and `ToolPostInvoke`). Each
    /// handler is registered under its paired hook name.
    ///
    /// The plugin is registered once in the name index. Each handler
    /// gets its own `HookEntry` in the hook index under the specified name.
    pub fn register_multi_handler(
        &mut self,
        plugin: Arc<dyn Plugin>,
        config: PluginConfig,
        handlers: Vec<(&str, Arc<dyn AnyHookHandler>)>,
    ) -> Result<(), String> {
        let name = config.name.clone();

        if self.plugins.contains_key(&name) {
            return Err(format!("plugin '{}' is already registered", name));
        }

        let plugin_ref = Arc::new(PluginRef::new(plugin, config));

        for (hook_name, handler) in &handlers {
            let hook_type = HookType::new(*hook_name);
            let entry = HookEntry {
                plugin_ref: Arc::clone(&plugin_ref),
                handler: Arc::clone(handler),
            };
            self.hook_index.entry(hook_type).or_default().push(entry);
        }

        // Sort each affected hook's entry list by trusted priority
        for (hook_name, _) in &handlers {
            let hook_type = HookType::new(*hook_name);
            if let Some(entries) = self.hook_index.get_mut(&hook_type) {
                entries.sort_by_key(|e| e.plugin_ref.priority());
            }
        }

        self.plugins.insert(name, plugin_ref);
        Ok(())
    }

    /// Internal: register handler under one or more hook names.
    fn register_for_names_inner(
        &mut self,
        plugin: Arc<dyn Plugin>,
        config: PluginConfig,
        handler: Arc<dyn AnyHookHandler>,
        names: &[&str],
    ) -> Result<(), String> {
        let name = config.name.clone();

        if self.plugins.contains_key(&name) {
            return Err(format!("plugin '{}' is already registered", name));
        }

        let plugin_ref = Arc::new(PluginRef::new(plugin, config));

        // Add to hook index for each specified hook name
        for hook_name in names {
            let hook_type = HookType::new(*hook_name);
            let entry = HookEntry {
                plugin_ref: Arc::clone(&plugin_ref),
                handler: Arc::clone(&handler),
            };
            self.hook_index.entry(hook_type).or_default().push(entry);
        }

        // Sort each affected hook's entry list by trusted priority
        for hook_name in names {
            let hook_type = HookType::new(*hook_name);
            if let Some(entries) = self.hook_index.get_mut(&hook_type) {
                entries.sort_by_key(|e| e.plugin_ref.priority());
            }
        }

        self.plugins.insert(name, plugin_ref);
        Ok(())
    }

    /// Unregister a plugin by name.
    ///
    /// Removes the PluginRef from the name index and all HookEntries
    /// from the hook index. Returns the (Arc-wrapped) PluginRef if found.
    pub fn unregister(&mut self, name: &str) -> Option<Arc<PluginRef>> {
        let plugin_ref = self.plugins.remove(name)?;

        // Remove from hook index
        for entries in self.hook_index.values_mut() {
            entries.retain(|e| e.plugin_ref.name() != name);
        }

        // Clean up empty hook entries
        self.hook_index.retain(|_, entries| !entries.is_empty());

        Some(plugin_ref)
    }

    /// Look up a PluginRef by name. Returns an `Arc` clone so callers
    /// don't hold borrows on internal storage — works with snapshot-based
    /// dispatch where the registry may sit behind a transient guard.
    pub fn get(&self, name: &str) -> Option<Arc<PluginRef>> {
        self.plugins.get(name).map(Arc::clone)
    }

    /// Returns all HookEntries for a given hook name, sorted by priority.
    ///
    /// Returns an empty slice if no plugins are registered for the hook.
    pub fn entries_for_hook(&self, hook_type: &HookType) -> &[HookEntry] {
        self.hook_index
            .get(hook_type)
            .map(|v| v.as_slice())
            .unwrap_or(&[])
    }

    /// Whether any plugins are registered for the given hook name.
    pub fn has_hooks_for(&self, hook_type: &HookType) -> bool {
        self.hook_index
            .get(hook_type)
            .map(|v| !v.is_empty())
            .unwrap_or(false)
    }

    /// Total number of registered plugins.
    pub fn plugin_count(&self) -> usize {
        self.plugins.len()
    }

    /// All registered plugin names. Returns owned `String`s so callers
    /// don't hold borrows on internal storage — works with snapshot-based
    /// dispatch where the registry may sit behind a transient guard.
    pub fn plugin_names(&self) -> Vec<String> {
        self.plugins.keys().cloned().collect()
    }

    /// Returns every (hook_name, HookEntry) pair where the entry's plugin
    /// matches the given name. Used by external orchestrators that need
    /// to build pre-resolved dispatch lineups for a single plugin across
    /// every hook it registered to (e.g. apl-cpex deciding which entry
    /// handles step-style invocations vs field-style invocations for the
    /// same plugin). Owned tuples — no borrows held on the registry.
    pub fn entries_for_plugin(&self, plugin_name: &str) -> Vec<(String, HookEntry)> {
        let mut out = Vec::new();
        for (hook_type, entries) in &self.hook_index {
            for entry in entries {
                if entry.plugin_ref.name() == plugin_name {
                    out.push((hook_type.as_str().to_string(), entry.clone()));
                }
            }
        }
        out
    }
}

impl Default for PluginRegistry {
    fn default() -> Self {
        Self::new()
    }
}

// ---------------------------------------------------------------------------
// Group HookEntries by mode (used by the executor)
// ---------------------------------------------------------------------------

/// Groups a list of HookEntries by their execution mode.
///
/// Reads mode from `plugin_ref.trusted_config` — never from the plugin.
/// Returns a tuple of five vectors in execution order:
/// (sequential, transform, audit, concurrent, fire_and_forget).
/// Disabled plugins are excluded.
pub type GroupedHookEntries = (
    Vec<HookEntry>,
    Vec<HookEntry>,
    Vec<HookEntry>,
    Vec<HookEntry>,
    Vec<HookEntry>,
);

pub fn group_by_mode(entries: &[HookEntry]) -> GroupedHookEntries {
    let mut sequential = Vec::new();
    let mut transform = Vec::new();
    let mut audit = Vec::new();
    let mut concurrent = Vec::new();
    let mut fire_and_forget = Vec::new();

    for entry in entries {
        match entry.plugin_ref.mode() {
            PluginMode::Sequential => sequential.push(entry.clone()),
            PluginMode::Transform => transform.push(entry.clone()),
            PluginMode::Audit => audit.push(entry.clone()),
            PluginMode::Concurrent => concurrent.push(entry.clone()),
            PluginMode::FireAndForget => fire_and_forget.push(entry.clone()),
            PluginMode::Disabled => {}, // skip
        }
    }

    (sequential, transform, audit, concurrent, fire_and_forget)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::PluginError;
    use crate::hooks::payload::PluginPayload;
    use crate::hooks::PluginResult;
    use async_trait::async_trait;

    // -- Test payload and hook type --

    #[derive(Debug, Clone)]
    #[allow(dead_code)] // test fixture — typed shape is the point, not field reads
    struct TestPayload {
        value: String,
    }
    crate::impl_plugin_payload!(TestPayload);

    // -- Test handler (type-erased wrapper) --

    /// A simple AnyHookHandler that wraps a function for testing.
    struct TestHandler;

    #[async_trait]
    impl AnyHookHandler for TestHandler {
        async fn invoke(
            &self,
            _payload: &dyn PluginPayload,
            _extensions: &Extensions,
            _ctx: &mut PluginContext,
        ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
            let result: PluginResult<TestPayload> = PluginResult::allow();
            Ok(crate::executor::erase_result(result))
        }

        fn hook_type_name(&self) -> &'static str {
            "test_hook"
        }
    }

    // -- Test plugin --

    struct TestPlugin {
        cfg: PluginConfig,
    }

    fn make_config(name: &str, hooks: Vec<&str>, priority: i32) -> PluginConfig {
        PluginConfig {
            name: name.to_string(),
            kind: "test".to_string(),
            description: None,
            author: None,
            version: None,
            hooks: hooks.into_iter().map(String::from).collect(),
            mode: PluginMode::Sequential,
            priority,
            on_error: Default::default(),
            capabilities: Default::default(),
            tags: Vec::new(),
            conditions: Vec::new(),
            config: None,
        }
    }

    impl TestPlugin {
        fn new(cfg: PluginConfig) -> Self {
            Self { cfg }
        }
    }

    #[async_trait]
    impl Plugin for TestPlugin {
        fn config(&self) -> &PluginConfig {
            &self.cfg
        }
        async fn initialize(&self) -> Result<(), Box<PluginError>> {
            Ok(())
        }
        async fn shutdown(&self) -> Result<(), Box<PluginError>> {
            Ok(())
        }
    }

    // -- Tests --

    #[test]
    fn test_register_typed_and_lookup() {
        let mut reg = PluginRegistry::new();
        let config = make_config("test-plugin", vec!["test_hook"], 10);
        let plugin = Arc::new(TestPlugin::new(config.clone()));
        let handler: Arc<dyn AnyHookHandler> = Arc::new(TestHandler);

        // Use register_for_names_inner directly since we don't have a real HookTypeDef
        reg.register_for_names_inner(plugin, config, handler, &["test_hook"])
            .unwrap();

        assert_eq!(reg.plugin_count(), 1);
        assert!(reg.get("test-plugin").is_some());
        assert!(reg.has_hooks_for(&HookType::new("test_hook")));
        assert!(!reg.has_hooks_for(&HookType::new("other_hook")));

        let entries = reg.entries_for_hook(&HookType::new("test_hook"));
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].plugin_ref.name(), "test-plugin");
    }

    #[test]
    fn test_register_for_multiple_names() {
        let mut reg = PluginRegistry::new();
        let config = make_config("cmf-plugin", vec![], 10);
        let plugin = Arc::new(TestPlugin::new(config.clone()));
        let handler: Arc<dyn AnyHookHandler> = Arc::new(TestHandler);

        reg.register_for_names_inner(
            plugin,
            config,
            handler,
            &[
                "cmf.tool_pre_invoke",
                "cmf.tool_post_invoke",
                "cmf.llm_input",
            ],
        )
        .unwrap();

        assert_eq!(reg.plugin_count(), 1);
        assert!(reg.has_hooks_for(&HookType::new("cmf.tool_pre_invoke")));
        assert!(reg.has_hooks_for(&HookType::new("cmf.tool_post_invoke")));
        assert!(reg.has_hooks_for(&HookType::new("cmf.llm_input")));
        assert!(!reg.has_hooks_for(&HookType::new("cmf.llm_output")));
    }

    #[test]
    fn test_duplicate_registration_fails() {
        let mut reg = PluginRegistry::new();
        let c1 = make_config("dup", vec![], 10);
        let c2 = make_config("dup", vec![], 20);
        let p1 = Arc::new(TestPlugin::new(c1.clone()));
        let p2 = Arc::new(TestPlugin::new(c2.clone()));
        let h1: Arc<dyn AnyHookHandler> = Arc::new(TestHandler);
        let h2: Arc<dyn AnyHookHandler> = Arc::new(TestHandler);

        assert!(reg
            .register_for_names_inner(p1, c1, h1, &["hook_a"])
            .is_ok());
        assert!(reg
            .register_for_names_inner(p2, c2, h2, &["hook_a"])
            .is_err());
    }

    #[test]
    fn test_priority_ordering_uses_trusted_config() {
        let mut reg = PluginRegistry::new();
        let c_low = make_config("low", vec![], 100);
        let c_high = make_config("high", vec![], 10);
        let p_low = Arc::new(TestPlugin::new(c_low.clone()));
        let p_high = Arc::new(TestPlugin::new(c_high.clone()));
        let h1: Arc<dyn AnyHookHandler> = Arc::new(TestHandler);
        let h2: Arc<dyn AnyHookHandler> = Arc::new(TestHandler);

        reg.register_for_names_inner(p_low, c_low, h1, &["hook_a"])
            .unwrap();
        reg.register_for_names_inner(p_high, c_high, h2, &["hook_a"])
            .unwrap();

        let entries = reg.entries_for_hook(&HookType::new("hook_a"));
        assert_eq!(entries[0].plugin_ref.name(), "high"); // priority 10 first
        assert_eq!(entries[1].plugin_ref.name(), "low"); // priority 100 second
    }

    #[test]
    fn test_unregister() {
        let mut reg = PluginRegistry::new();
        let config = make_config("removable", vec![], 10);
        let plugin = Arc::new(TestPlugin::new(config.clone()));
        let handler: Arc<dyn AnyHookHandler> = Arc::new(TestHandler);

        reg.register_for_names_inner(plugin, config, handler, &["hook_a"])
            .unwrap();

        assert_eq!(reg.plugin_count(), 1);
        reg.unregister("removable");
        assert_eq!(reg.plugin_count(), 0);
        assert!(!reg.has_hooks_for(&HookType::new("hook_a")));
    }

    #[test]
    fn test_plugin_ref_id_is_unique() {
        let c1 = make_config("a", vec![], 10);
        let c2 = make_config("b", vec![], 10);
        let p1 = Arc::new(TestPlugin::new(c1.clone()));
        let p2 = Arc::new(TestPlugin::new(c2.clone()));
        let ref1 = PluginRef::new(p1, c1);
        let ref2 = PluginRef::new(p2, c2);
        assert_ne!(ref1.id(), ref2.id());
    }

    #[test]
    fn test_tampered_plugin_config_ignored() {
        let trusted = make_config("sneaky", vec![], 100);
        let mut tampered = trusted.clone();
        tampered.priority = 1;
        let plugin = Arc::new(TestPlugin::new(tampered));

        let plugin_ref = PluginRef::new(plugin, trusted);
        assert_eq!(plugin_ref.priority(), 100);
    }

    #[tokio::test]
    async fn test_handler_invoke() {
        let handler = TestHandler;
        let payload = TestPayload {
            value: "test".into(),
        };
        let ext = Extensions::default();
        let mut ctx = PluginContext::new();

        let result = handler
            .invoke(&payload as &dyn PluginPayload, &ext, &mut ctx)
            .await
            .unwrap();
        let fields = crate::executor::extract_erased(result).unwrap();
        assert!(fields.continue_processing);
    }
}