Skip to main content

cpex_core/
executor.rs

1// Location: ./crates/cpex-core/src/executor.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// 5-phase plugin execution engine.
7//
8// Dispatches plugins in strict phase order:
9//   SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET
10//
11// Each phase has different authority (block/modify) and scheduling
12// (serial/parallel/background). The executor reads all scheduling
13// decisions from PluginRef.trusted_config — never from the plugin.
14//
15// Extensions are passed separately from the payload and capability-
16// filtered per plugin before dispatch. Extension modifications are
17// merged back independently from payload modifications.
18//
19// Error handling respects the plugin's on_error setting:
20//   - Fail: propagate error, halt pipeline
21//   - Ignore: log error, continue pipeline
22//   - Disable: log error, mark plugin disabled, continue
23//
24// Mirrors the Python framework's PluginExecutor in
25// cpex/framework/manager.py.
26
27use std::any::Any;
28use std::fmt;
29use std::sync::Arc;
30use std::time::Duration;
31
32use tokio::time::timeout;
33use tracing::{error, warn};
34
35use crate::context::PluginContextTable;
36use crate::error::PluginError;
37use crate::extensions::filter_extensions;
38use crate::hooks::payload::{Extensions, PluginPayload, WriteToken};
39use crate::plugin::OnError;
40use crate::registry::{group_by_mode, HookEntry};
41
42// ---------------------------------------------------------------------------
43// Executor Configuration
44// ---------------------------------------------------------------------------
45
46/// Configuration for the executor.
47#[derive(Debug, Clone)]
48pub struct ExecutorConfig {
49    /// Maximum execution time per plugin in seconds.
50    pub timeout_seconds: u64,
51
52    /// Whether to halt on the first deny in concurrent mode.
53    pub short_circuit_on_deny: bool,
54}
55
56impl Default for ExecutorConfig {
57    fn default() -> Self {
58        Self {
59            timeout_seconds: 30,
60            short_circuit_on_deny: true,
61        }
62    }
63}
64
65// ---------------------------------------------------------------------------
66// Pipeline Result
67// ---------------------------------------------------------------------------
68
69/// Aggregate result from a full hook invocation across all phases.
70///
71/// Wraps the final payload, extensions, any violation, and the
72/// context table. Immutable by design — policy decisions cannot be
73/// tampered with after the executor returns them.
74///
75/// The caller should pass `context_table` into the next hook
76/// invocation to preserve per-plugin local state across hooks in
77/// the same request lifecycle.
78///
79/// Background tasks are returned separately as [`BackgroundTasks`]
80/// to keep the policy result immutable.
81#[derive(Debug)]
82pub struct PipelineResult {
83    /// Whether the pipeline should continue processing.
84    /// `false` means a plugin denied — the pipeline was halted.
85    pub continue_processing: bool,
86
87    /// The final payload after all modifications (type-erased).
88    /// `None` if the pipeline was denied before any modifications.
89    pub modified_payload: Option<Box<dyn PluginPayload>>,
90
91    /// The final extensions after all modifications.
92    /// `None` if no plugin modified extensions.
93    pub modified_extensions: Option<Extensions>,
94
95    /// The violation that caused a deny, if any.
96    pub violation: Option<crate::error::PluginViolation>,
97
98    /// Errors from plugins that ran with `on_error: ignore` or
99    /// `on_error: disable`. These plugins didn't halt the pipeline
100    /// (their on_error policy said to continue), but the caller
101    /// should still know the errors happened so it can log them in
102    /// a structured way, retry the affected plugin, or alert.
103    /// Empty when no plugin errored on a non-halt path.
104    /// Fire-and-forget errors live in `BackgroundTasks` instead.
105    pub errors: Vec<crate::error::PluginErrorRecord>,
106
107    /// Optional metadata aggregated from plugins (telemetry, diagnostics).
108    pub metadata: Option<serde_json::Value>,
109
110    /// Plugin contexts indexed by plugin ID. Thread this into the
111    /// next hook invocation to preserve per-plugin `local_state`.
112    pub context_table: PluginContextTable,
113}
114
115impl PipelineResult {
116    /// Pipeline completed — all plugins allowed.
117    pub fn allowed_with(
118        payload: Box<dyn PluginPayload>,
119        extensions: Extensions,
120        context_table: PluginContextTable,
121    ) -> Self {
122        Self {
123            continue_processing: true,
124            modified_payload: Some(payload),
125            modified_extensions: Some(extensions),
126            violation: None,
127            errors: Vec::new(),
128            metadata: None,
129            context_table,
130        }
131    }
132
133    /// Pipeline was denied by a plugin.
134    pub fn denied(
135        violation: crate::error::PluginViolation,
136        extensions: Extensions,
137        context_table: PluginContextTable,
138    ) -> Self {
139        Self {
140            continue_processing: false,
141            modified_payload: None,
142            modified_extensions: Some(extensions),
143            violation: Some(violation),
144            errors: Vec::new(),
145            metadata: None,
146            context_table,
147        }
148    }
149
150    /// Replace the errors vec on a constructed PipelineResult. Used by
151    /// the executor to attach errors collected from `on_error: ignore`
152    /// / `on_error: disable` plugins.
153    pub fn with_errors(mut self, errors: Vec<crate::error::PluginErrorRecord>) -> Self {
154        self.errors = errors;
155        self
156    }
157
158    /// Whether this result represents a denial.
159    pub fn is_denied(&self) -> bool {
160        !self.continue_processing
161    }
162}
163
164// ---------------------------------------------------------------------------
165// Background Tasks
166// ---------------------------------------------------------------------------
167
168/// Handles to fire-and-forget background tasks spawned by the executor.
169///
170/// Returned separately from [`PipelineResult`] so that the policy
171/// result stays immutable. If not awaited, tasks complete on their
172/// own in the background. Call `wait_for_background_tasks()` when you
173/// need to ensure tasks have finished (tests, graceful shutdown,
174/// audit flush).
175pub struct BackgroundTasks {
176    tasks: Vec<(String, tokio::task::JoinHandle<()>)>,
177}
178
179impl BackgroundTasks {
180    /// Create an empty set of background tasks.
181    pub fn empty() -> Self {
182        Self { tasks: Vec::new() }
183    }
184
185    /// Create from a list of (plugin_name, handle) pairs.
186    fn from_handles(tasks: Vec<(String, tokio::task::JoinHandle<()>)>) -> Self {
187        Self { tasks }
188    }
189
190    /// Whether there are any background tasks.
191    pub fn is_empty(&self) -> bool {
192        self.tasks.is_empty()
193    }
194
195    /// Number of background tasks.
196    pub fn len(&self) -> usize {
197        self.tasks.len()
198    }
199
200    /// Wait for all fire-and-forget background tasks to complete.
201    ///
202    /// Returns a list of errors from any tasks that panicked.
203    /// An empty list means all tasks completed successfully.
204    ///
205    /// Consumes `self` — each task handle can only be awaited once.
206    ///
207    /// If not called, background tasks still complete on their own.
208    /// Use this for tests, graceful shutdown, or when you need to
209    /// ensure audit/logging tasks have flushed before proceeding.
210    pub async fn wait_for_background_tasks(self) -> Vec<crate::error::PluginError> {
211        let mut errors = Vec::new();
212        for (plugin_name, handle) in self.tasks {
213            if let Err(e) = handle.await {
214                errors.push(crate::error::PluginError::Execution {
215                    plugin_name,
216                    message: format!("background task panicked: {}", e),
217                    source: None,
218                    code: None,
219                    details: std::collections::HashMap::new(),
220                    proto_error_code: None,
221                });
222            }
223        }
224        errors
225    }
226}
227
228impl fmt::Debug for BackgroundTasks {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        f.debug_struct("BackgroundTasks")
231            .field("count", &self.tasks.len())
232            .finish()
233    }
234}
235
236// ---------------------------------------------------------------------------
237// Executor
238// ---------------------------------------------------------------------------
239
240/// 5-phase plugin execution engine.
241///
242/// Dispatches hooks through the phase pipeline:
243///
244/// ```text
245/// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET
246/// ```
247///
248/// The executor is stateless — all state comes from the arguments.
249/// One executor instance can serve multiple concurrent hook invocations.
250#[derive(Clone)]
251pub struct Executor {
252    config: ExecutorConfig,
253}
254
255impl Executor {
256    /// Create a new executor with the given configuration.
257    pub fn new(config: ExecutorConfig) -> Self {
258        Self { config }
259    }
260
261    /// Execute a hook invocation through the 5-phase pipeline.
262    ///
263    /// # Arguments
264    ///
265    /// * `entries` — HookEntries for this hook, sorted by priority.
266    /// * `payload` — The typed payload (type-erased as Box<dyn PluginPayload>).
267    /// * `extensions` — The full extensions (filtered per plugin before dispatch).
268    /// * `context_table` — Optional context table from a previous hook invocation.
269    ///   If `None`, fresh contexts are created for each plugin.
270    ///
271    /// # Returns
272    ///
273    /// A tuple of:
274    /// - `PipelineResult` — immutable policy result with payload,
275    ///   extensions, violation, and context table.
276    /// - `BackgroundTasks` — handles to fire-and-forget tasks. Call
277    ///   `wait_for_background_tasks()` to await them, or drop to let
278    ///   them complete in the background.
279    pub async fn execute(
280        &self,
281        entries: &[HookEntry],
282        payload: Box<dyn PluginPayload>,
283        extensions: Extensions,
284        context_table: Option<PluginContextTable>,
285        task_tracker: &tokio_util::task::TaskTracker,
286    ) -> (PipelineResult, BackgroundTasks) {
287        let mut ctx_table = context_table.unwrap_or_default();
288
289        if entries.is_empty() {
290            return (
291                PipelineResult::allowed_with(payload, extensions, ctx_table),
292                BackgroundTasks::empty(),
293            );
294        }
295
296        // Group entries by mode (from trusted_config)
297        let (sequential, transform, audit, concurrent, fire_and_forget) = group_by_mode(entries);
298
299        let mut current_payload = payload;
300        let mut current_extensions = extensions;
301        // Accumulator for errors from `on_error: ignore` / `on_error:
302        // disable` plugins across all phases. Surfaced to the caller
303        // via `PipelineResult.errors` so swallowed failures stay
304        // observable. Halt-condition errors (Fail, deny) skip this and
305        // become the violation directly.
306        let mut errors: Vec<crate::error::PluginErrorRecord> = Vec::new();
307
308        // Phase 1: SEQUENTIAL — serial, chained, can block + modify
309        if let Some(v) = self
310            .run_serial_phase(
311                &sequential,
312                &mut current_payload,
313                &mut current_extensions,
314                &mut ctx_table,
315                true, // can_block
316                true, // can_modify
317                "SEQUENTIAL",
318                &mut errors,
319            )
320            .await
321        {
322            return (
323                PipelineResult::denied(v, current_extensions, ctx_table).with_errors(errors),
324                BackgroundTasks::empty(),
325            );
326        }
327
328        // Phase 2: TRANSFORM — serial, chained, can modify, cannot block
329        // can_block=false means denials are suppressed (returns None)
330        self.run_serial_phase(
331            &transform,
332            &mut current_payload,
333            &mut current_extensions,
334            &mut ctx_table,
335            false, // can_block
336            true,  // can_modify
337            "TRANSFORM",
338            &mut errors,
339        )
340        .await;
341
342        // Phase 3: AUDIT — serial, read-only, discard results
343        self.run_ref_phase(
344            &audit,
345            &*current_payload,
346            &current_extensions,
347            &ctx_table,
348            "AUDIT",
349            &mut errors,
350        )
351        .await;
352
353        // Phase 4: CONCURRENT — parallel, can block, cannot modify
354        if let Some(violation) = self
355            .run_concurrent_phase(
356                &concurrent,
357                &*current_payload,
358                &current_extensions,
359                &ctx_table,
360                &mut errors,
361            )
362            .await
363        {
364            return (
365                PipelineResult::denied(violation, current_extensions, ctx_table)
366                    .with_errors(errors),
367                BackgroundTasks::empty(),
368            );
369        }
370
371        // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results.
372        // FAF errors don't go in PipelineResult.errors — they're delivered
373        // via BackgroundTasks::wait_for_background_tasks() instead.
374        let bg_handles = self.spawn_fire_and_forget(
375            &fire_and_forget,
376            &*current_payload,
377            &current_extensions,
378            &ctx_table,
379            task_tracker,
380        );
381
382        (
383            PipelineResult::allowed_with(current_payload, current_extensions, ctx_table)
384                .with_errors(errors),
385            BackgroundTasks::from_handles(bg_handles),
386        )
387    }
388
389    // -----------------------------------------------------------------------
390    // Phase 1 & 2: Serial execution (SEQUENTIAL / TRANSFORM)
391    // -----------------------------------------------------------------------
392
393    /// Run a serial phase — plugins execute one at a time, each seeing
394    /// the (possibly modified) payload from the previous.
395    ///
396    /// The framework retains ownership of the payload. Handlers receive
397    /// a borrow and clone only if they modify. Modified payloads in
398    /// the result replace the current payload.
399    ///
400    /// Each plugin's context is looked up in the context table (preserving
401    /// `local_state` from previous hooks) or created fresh. After execution,
402    /// `global_state` changes are merged back so the next plugin sees them.
403    #[allow(clippy::too_many_arguments)] // internal phase helper — args have distinct types and meaning
404    async fn run_serial_phase(
405        &self,
406        entries: &[HookEntry],
407        payload: &mut Box<dyn PluginPayload>,
408        extensions: &mut Extensions,
409        ctx_table: &mut PluginContextTable,
410        can_block: bool,
411        can_modify: bool,
412        phase_label: &str,
413        errors: &mut Vec<crate::error::PluginErrorRecord>,
414    ) -> Option<crate::error::PluginViolation> {
415        for entry in entries {
416            // Borrow names/ids on the happy path — allocate only when
417            // building a violation or stashing the local_state back into
418            // the table. Previously `name.to_string()` + `id.to_string()`
419            // ran unconditionally on every plugin per invoke.
420            let plugin_name = entry.plugin_ref.name();
421            let plugin_id = entry.plugin_ref.id();
422            let on_error = entry.plugin_ref.trusted_config().on_error;
423
424            // Take this plugin's context out of the table — pulls its stored
425            // local_state and seeds global_state from the canonical store.
426            // Replaces the previous values().last() seed, which was
427            // non-deterministic across HashMap iteration orders.
428            let mut ctx = ctx_table.take_context(plugin_id);
429
430            // Filter extensions per plugin based on declared capabilities.
431            // Produces a filtered view with None for ungated slots.
432            // Also sets write tokens for plugins with write capabilities.
433            let capabilities: std::collections::HashSet<String> = entry
434                .plugin_ref
435                .trusted_config()
436                .capabilities
437                .iter()
438                .cloned()
439                .collect();
440            let mut filtered = filter_extensions(extensions, &capabilities);
441
442            // Set write tokens based on capabilities
443            if capabilities.contains("write_headers") {
444                filtered.http_write_token = Some(WriteToken::new());
445            }
446            if capabilities.contains("append_labels") {
447                filtered.labels_write_token = Some(WriteToken::new());
448            }
449            if capabilities.contains("append_delegation") {
450                filtered.delegation_write_token = Some(WriteToken::new());
451            }
452
453            // Execute with timeout — handler borrows payload, gets filtered extensions
454            let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
455            let result = timeout(
456                timeout_dur,
457                entry.handler.invoke(&**payload, &filtered, &mut ctx),
458            )
459            .await;
460
461            match result {
462                Ok(Ok(result_box)) => {
463                    if let Some(erased) = extract_erased(result_box) {
464                        // Check deny
465                        if !erased.continue_processing && can_block {
466                            if let Some(mut v) = erased.violation {
467                                v.plugin_name = Some(plugin_name.to_string());
468                                return Some(v);
469                            }
470                        }
471
472                        // Accept modifications
473                        if can_modify {
474                            if let Some(mp) = erased.modified_payload {
475                                *payload = mp;
476                            }
477                            if let Some(owned) = erased.modified_extensions {
478                                // Validate tier constraints before accepting
479                                let valid = extensions.validate_immutable(&owned);
480                                if !valid {
481                                    warn!(
482                                        "{} plugin '{}' violated immutable tier — \
483                                         modified an immutable extension slot. \
484                                         Extension changes rejected.",
485                                        phase_label, plugin_name
486                                    );
487                                } else if capabilities.contains("read_labels") {
488                                    // Only enforce monotonic labels if the plugin
489                                    // could see them. A plugin without read_labels
490                                    // has empty labels in its filtered view — that's
491                                    // not a removal.
492                                    if let (Some(ref orig_sec), Some(ref new_sec)) =
493                                        (&extensions.security, &owned.security)
494                                    {
495                                        if !new_sec.labels.is_superset(&orig_sec.labels) {
496                                            warn!(
497                                                "{} plugin '{}' violated monotonic tier — \
498                                                 removed a security label. \
499                                                 Extension changes rejected.",
500                                                phase_label, plugin_name
501                                            );
502                                        } else {
503                                            extensions.merge_owned(owned);
504                                        }
505                                    } else {
506                                        extensions.merge_owned(owned);
507                                    }
508                                } else {
509                                    extensions.merge_owned(owned);
510                                }
511                            }
512                        }
513
514                        // Plugin writes to ctx.global_state are committed back
515                        // to the canonical store via store_context() below.
516                    }
517                    // If extract failed or no modifications — payload unchanged
518                },
519                Ok(Err(e)) => {
520                    error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e);
521                    match on_error {
522                        OnError::Fail if can_block => {
523                            let mut v = crate::error::PluginViolation::new(
524                                "plugin_error",
525                                format!("Plugin '{}' failed: {}", plugin_name, e),
526                            );
527                            v.plugin_name = Some(plugin_name.to_string());
528                            return Some(v);
529                        },
530                        // Any non-halt outcome (Fail-in-non-blocking-phase,
531                        // Ignore, Disable): record the error so the caller
532                        // sees it in PipelineResult.errors instead of
533                        // having to read the warn-log.
534                        OnError::Fail => {
535                            warn!(
536                                "{} plugin '{}' on_error=fail in non-blocking phase — not halting",
537                                phase_label, plugin_name,
538                            );
539                            errors.push((&e).into());
540                        },
541                        OnError::Ignore => {
542                            errors.push((&e).into());
543                        },
544                        OnError::Disable => {
545                            warn!(
546                                "{} plugin '{}' disabled after error",
547                                phase_label, plugin_name
548                            );
549                            errors.push((&e).into());
550                            entry.plugin_ref.disable();
551                        },
552                    }
553                },
554                Err(_) => {
555                    error!("{} plugin '{}' timed out", phase_label, plugin_name);
556                    let timeout_err = crate::error::PluginError::Timeout {
557                        plugin_name: plugin_name.to_string(),
558                        timeout_ms: timeout_dur.as_millis() as u64,
559                        proto_error_code: None,
560                    };
561                    match on_error {
562                        OnError::Fail if can_block => {
563                            let mut v = crate::error::PluginViolation::new(
564                                "plugin_timeout",
565                                format!("Plugin '{}' timed out", plugin_name),
566                            );
567                            v.plugin_name = Some(plugin_name.to_string());
568                            return Some(v);
569                        },
570                        OnError::Fail => {
571                            warn!(
572                                "{} plugin '{}' on_error=fail (timeout) in non-blocking phase — not halting",
573                                phase_label, plugin_name,
574                            );
575                            errors.push((&timeout_err).into());
576                        },
577                        OnError::Ignore => {
578                            errors.push((&timeout_err).into());
579                        },
580                        OnError::Disable => {
581                            warn!(
582                                "{} plugin '{}' disabled after timeout",
583                                phase_label, plugin_name
584                            );
585                            errors.push((&timeout_err).into());
586                            entry.plugin_ref.disable();
587                        },
588                    }
589                },
590            }
591
592            // Commit this plugin's context back to the table — replaces the
593            // canonical global_state with its (possibly modified) copy and
594            // stores the local_state for the next hook invocation. The
595            // global_state move is free; only the local_state insert allocates.
596            ctx_table.store_context(plugin_id, ctx);
597        }
598
599        None // no denial
600    }
601
602    // -----------------------------------------------------------------------
603    // Phase 3 & 5: Read-only execution (AUDIT / FIRE_AND_FORGET)
604    // -----------------------------------------------------------------------
605
606    /// Run a read-only phase — plugins receive &payload, results discarded.
607    async fn run_ref_phase(
608        &self,
609        entries: &[HookEntry],
610        payload: &dyn PluginPayload,
611        extensions: &Extensions,
612        ctx_table: &PluginContextTable,
613        phase_label: &str,
614        errors: &mut Vec<crate::error::PluginErrorRecord>,
615    ) {
616        for entry in entries {
617            let plugin_name = entry.plugin_ref.name().to_string();
618            let plugin_id = entry.plugin_ref.id();
619            let on_error = entry.plugin_ref.trusted_config().on_error;
620            // Read-only phase — snapshot the plugin's local_state and the
621            // canonical global_state, no merge-back.
622            let mut ctx = ctx_table.snapshot_context(plugin_id);
623            // Filter extensions per plugin — read-only, no write tokens.
624            let capabilities: std::collections::HashSet<String> = entry
625                .plugin_ref
626                .trusted_config()
627                .capabilities
628                .iter()
629                .cloned()
630                .collect();
631            let filtered = filter_extensions(extensions, &capabilities);
632            let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
633
634            let result = timeout(
635                timeout_dur,
636                entry.handler.invoke(payload, &filtered, &mut ctx),
637            )
638            .await;
639
640            // Audit / fire-and-forget cannot block, so OnError::Fail can't
641            // halt the pipeline — but OnError::Disable must still take a
642            // repeatedly-failing plugin out of rotation. The previous code
643            // ignored on_error entirely, so Disable plugins kept failing
644            // forever no matter how many invocations errored. All non-halt
645            // failures also push a record into PipelineResult.errors.
646            match result {
647                Ok(Ok(_)) => {}, // read-only — discard result and ext_clone
648                Ok(Err(e)) => {
649                    warn!(
650                        "{} plugin '{}' error (ignored): {}",
651                        phase_label, plugin_name, e
652                    );
653                    errors.push((&e).into());
654                    if matches!(on_error, OnError::Disable) {
655                        warn!(
656                            "{} plugin '{}' disabled after error",
657                            phase_label, plugin_name
658                        );
659                        entry.plugin_ref.disable();
660                    }
661                },
662                Err(_) => {
663                    warn!(
664                        "{} plugin '{}' timed out (ignored)",
665                        phase_label, plugin_name
666                    );
667                    let timeout_err = crate::error::PluginError::Timeout {
668                        plugin_name: plugin_name.clone(),
669                        timeout_ms: timeout_dur.as_millis() as u64,
670                        proto_error_code: None,
671                    };
672                    errors.push((&timeout_err).into());
673                    if matches!(on_error, OnError::Disable) {
674                        warn!(
675                            "{} plugin '{}' disabled after timeout",
676                            phase_label, plugin_name
677                        );
678                        entry.plugin_ref.disable();
679                    }
680                },
681            }
682        }
683    }
684
685    // -----------------------------------------------------------------------
686    // Phase 4: Concurrent (parallel, fail-fast)
687    // -----------------------------------------------------------------------
688
689    /// Run the concurrent phase — plugins execute truly in parallel.
690    /// Returns the first violation if any plugin denies.
691    ///
692    /// Built on `cpex_orchestration::run_branches`, the workspace's
693    /// shared "N async branches with abort-on-deny + per-branch timeout"
694    /// primitive (same crate apl-core's `Effect::Parallel` consumes).
695    /// Each branch returns a small `BranchData` carrying the plugin's
696    /// effective outcome (allow / deny / error). The orchestrator's
697    /// `is_deny` predicate inspects that — including the per-plugin
698    /// `on_error == Fail` case, which is treated as a halting outcome
699    /// so that an erroring/timing-out/panicking Fail-mode plugin
700    /// short-circuits the remaining branches the same way an explicit
701    /// deny does. Post-loop, we walk the outcomes in input order and
702    /// apply each plugin's `on_error` policy (Ignore / Disable) to
703    /// non-halting failures.
704    async fn run_concurrent_phase(
705        &self,
706        entries: &[HookEntry],
707        payload: &dyn PluginPayload,
708        extensions: &Extensions,
709        ctx_table: &PluginContextTable,
710        errors: &mut Vec<crate::error::PluginErrorRecord>,
711    ) -> Option<crate::error::PluginViolation> {
712        use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch};
713
714        if entries.is_empty() {
715            return None;
716        }
717
718        // Per-branch outcome. Carries just enough for post-loop policy
719        // application — plugin name / on_error are looked up via
720        // `entries[idx]` so we don't have to clone them into the
721        // future's captures.
722        enum BranchData {
723            Allow,
724            Deny(Option<crate::error::PluginViolation>),
725            Error(Box<PluginError>),
726        }
727
728        // Clone the payload once so each spawned task can borrow from
729        // an owned, 'static copy. Each task gets its own Arc'd clone.
730        let shared_payload: Arc<Box<dyn PluginPayload>> = Arc::new(payload.clone_boxed());
731        let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
732
733        // Snapshot per-entry on_error decisions BEFORE moving into
734        // futures — `is_deny` needs them at runtime to decide whether
735        // an Error outcome halts (Fail) or is logged (Ignore/Disable).
736        let on_error_by_idx: Vec<OnError> = entries
737            .iter()
738            .map(|e| e.plugin_ref.trusted_config().on_error)
739            .collect();
740
741        // Build branch futures. Each does the timing-bounded handler
742        // invoke and extracts the type-erased result, returning a
743        // `BranchData` that the orchestrator's `is_deny` predicate can
744        // inspect without further type knowledge.
745        let mut branches: Vec<ErasedBranch<BranchData>> = Vec::with_capacity(entries.len());
746        for entry in entries.iter() {
747            let handler = Arc::clone(&entry.handler);
748            let payload_clone = Arc::clone(&shared_payload);
749            let plugin_id = entry.plugin_ref.id();
750            // Snapshot the plugin's local_state and the canonical global_state.
751            // Concurrent plugins do not merge back — each task owns its copy.
752            let mut ctx = ctx_table.snapshot_context(plugin_id);
753            let plugin_name = entry.plugin_ref.name().to_string();
754
755            // Filter per plugin — each may have different capabilities.
756            // Read-only, no write tokens. Wrap in Arc for 'static spawn.
757            let capabilities: std::collections::HashSet<String> = entry
758                .plugin_ref
759                .trusted_config()
760                .capabilities
761                .iter()
762                .cloned()
763                .collect();
764            let filtered = Arc::new(filter_extensions(extensions, &capabilities));
765
766            branches.push(Box::pin(async move {
767                match handler.invoke(&**payload_clone, &filtered, &mut ctx).await {
768                    Ok(result_box) => match extract_erased(result_box) {
769                        Some(erased) if !erased.continue_processing => {
770                            let violation = erased.violation.map(|mut v| {
771                                v.plugin_name = Some(plugin_name);
772                                v
773                            });
774                            BranchData::Deny(violation)
775                        },
776                        // `Some(..)` with continue_processing=true, OR
777                        // `None` (downcast failed — historically logged
778                        // and treated as Allow) both fall through.
779                        _ => BranchData::Allow,
780                    },
781                    Err(e) => BranchData::Error(e),
782                }
783            }));
784        }
785
786        let cfg = BranchConfig {
787            timeout_per_branch: Some(timeout_dur),
788            short_circuit_on_deny: self.config.short_circuit_on_deny,
789        };
790
791        // `is_deny` halts on explicit Deny only. It can't halt on
792        // Error/Timeout/Panic because the predicate sees only the
793        // value, not the branch index, so it can't read the per-entry
794        // `on_error` policy. Halting on those failures is handled in
795        // the post-loop: the first Fail-policy failure becomes the
796        // returned violation, and any in-flight tasks drop when the
797        // JoinSet inside `run_branches` goes out of scope.
798        //
799        // The original implementation called `set.abort_all()` on
800        // Fail-class errors too. The behavioural difference: the
801        // post-loop now waits for all branches to finish (or hit
802        // their own timeout) before returning. For the slow-plugin
803        // abort test that's fine — that test exercises the Deny
804        // path, which still goes through `is_deny` + abort_all.
805        let outcomes = run_branches(branches, cfg, |v: &BranchData| {
806            matches!(v, BranchData::Deny(_))
807        })
808        .await;
809
810        // Post-loop: walk outcomes in input order applying per-plugin
811        // policy. First halting outcome wins.
812        let mut first_violation: Option<crate::error::PluginViolation> = None;
813
814        for (idx, outcome) in outcomes.into_iter().enumerate() {
815            let entry = &entries[idx];
816            let plugin_name = entry.plugin_ref.name();
817            let on_error = on_error_by_idx[idx];
818
819            match outcome {
820                BranchOutcome::Completed(BranchData::Allow) => {},
821                BranchOutcome::Completed(BranchData::Deny(opt_v)) => {
822                    let violation = opt_v.unwrap_or_else(|| {
823                        let mut v = crate::error::PluginViolation::new(
824                            "concurrent_deny",
825                            format!("Plugin '{}' denied", plugin_name),
826                        );
827                        v.plugin_name = Some(plugin_name.to_string());
828                        v
829                    });
830                    if first_violation.is_none() {
831                        first_violation = Some(violation);
832                    }
833                },
834                BranchOutcome::Completed(BranchData::Error(e)) => match on_error {
835                    OnError::Fail => {
836                        if first_violation.is_none() {
837                            let mut v = crate::error::PluginViolation::new(
838                                "plugin_error",
839                                format!("Plugin '{}' failed: {}", plugin_name, e),
840                            );
841                            v.plugin_name = Some(plugin_name.to_string());
842                            first_violation = Some(v);
843                        }
844                    },
845                    OnError::Ignore => {
846                        warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e);
847                        errors.push((&*e).into());
848                    },
849                    OnError::Disable => {
850                        warn!("CONCURRENT plugin '{}' disabled after error", plugin_name);
851                        errors.push((&*e).into());
852                        entry.plugin_ref.disable();
853                    },
854                },
855                BranchOutcome::TimedOut => {
856                    let timeout_err = crate::error::PluginError::Timeout {
857                        plugin_name: plugin_name.to_string(),
858                        timeout_ms: timeout_dur.as_millis() as u64,
859                        proto_error_code: None,
860                    };
861                    match on_error {
862                        OnError::Fail => {
863                            if first_violation.is_none() {
864                                let mut v = crate::error::PluginViolation::new(
865                                    "plugin_timeout",
866                                    format!("Plugin '{}' timed out", plugin_name),
867                                );
868                                v.plugin_name = Some(plugin_name.to_string());
869                                first_violation = Some(v);
870                            }
871                        },
872                        OnError::Ignore => {
873                            warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name);
874                            errors.push((&timeout_err).into());
875                        },
876                        OnError::Disable => {
877                            warn!("CONCURRENT plugin '{}' disabled after timeout", plugin_name);
878                            errors.push((&timeout_err).into());
879                            entry.plugin_ref.disable();
880                        },
881                    }
882                },
883                BranchOutcome::Panicked(s) => {
884                    error!("CONCURRENT plugin '{}' task panicked: {}", plugin_name, s);
885                    let panic_err = crate::error::PluginError::Execution {
886                        plugin_name: plugin_name.to_string(),
887                        message: format!("task panicked: {}", s),
888                        source: None,
889                        code: Some("panic".into()),
890                        details: std::collections::HashMap::new(),
891                        proto_error_code: None,
892                    };
893                    match on_error {
894                        OnError::Fail => {
895                            if first_violation.is_none() {
896                                let mut v = crate::error::PluginViolation::new(
897                                    "plugin_panic",
898                                    format!("Plugin '{}' task panicked: {}", plugin_name, s),
899                                );
900                                v.plugin_name = Some(plugin_name.to_string());
901                                first_violation = Some(v);
902                            }
903                        },
904                        OnError::Ignore => {
905                            warn!("CONCURRENT plugin '{}' panicked (ignored)", plugin_name);
906                            errors.push((&panic_err).into());
907                        },
908                        OnError::Disable => {
909                            warn!("CONCURRENT plugin '{}' disabled after panic", plugin_name);
910                            errors.push((&panic_err).into());
911                            entry.plugin_ref.disable();
912                        },
913                    }
914                },
915                BranchOutcome::Aborted => {
916                    // Cancelled because an earlier branch hit a halt
917                    // condition under short_circuit_on_deny. Intentional
918                    // — no error to record.
919                },
920            }
921        }
922
923        first_violation
924    }
925
926    // -----------------------------------------------------------------------
927    // Phase 5: Fire-and-Forget (background, no await)
928    // -----------------------------------------------------------------------
929
930    /// Spawn fire-and-forget handlers as background tasks.
931    ///
932    /// Each handler runs in its own `tokio::spawn` — the pipeline does
933    /// not wait for them. Errors and timeouts are logged but have no
934    /// effect on the pipeline result.
935    ///
936    /// Returns the plugin name and join handle for each spawned task
937    /// so they can be stored on `PipelineResult` for optional awaiting
938    /// via `wait_for_background_tasks()`.
939    fn spawn_fire_and_forget(
940        &self,
941        entries: &[HookEntry],
942        payload: &dyn PluginPayload,
943        extensions: &Extensions,
944        ctx_table: &PluginContextTable,
945        task_tracker: &tokio_util::task::TaskTracker,
946    ) -> Vec<(String, tokio::task::JoinHandle<()>)> {
947        if entries.is_empty() {
948            return Vec::new();
949        }
950
951        let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
952
953        let mut handles = Vec::with_capacity(entries.len());
954
955        for entry in entries {
956            let plugin_name = entry.plugin_ref.name().to_string();
957            let handler = Arc::clone(&entry.handler);
958            let owned_payload = payload.clone_boxed();
959            // Snapshot per plugin so fire-and-forget tasks see their stored
960            // local_state from prior hooks, not just an empty context.
961            let mut ctx = ctx_table.snapshot_context(entry.plugin_ref.id());
962            let dur = timeout_dur;
963            let name_for_log = plugin_name.clone();
964
965            // Filter per plugin, read-only, no write tokens
966            let capabilities: std::collections::HashSet<String> = entry
967                .plugin_ref
968                .trusted_config()
969                .capabilities
970                .iter()
971                .cloned()
972                .collect();
973            let filtered = Arc::new(filter_extensions(extensions, &capabilities));
974
975            // Spawn through TaskTracker so `PluginManager::shutdown()`
976            // can drain in-flight fire-and-forget tasks before tearing
977            // down. The returned JoinHandle is the same shape as
978            // tokio::spawn's, so callers using BackgroundTasks still
979            // wait_for_background_tasks() over their own handles.
980            let handle = task_tracker.spawn(async move {
981                let result =
982                    timeout(dur, handler.invoke(&*owned_payload, &filtered, &mut ctx)).await;
983
984                match result {
985                    Ok(Ok(_)) => {}, // discard
986                    Ok(Err(e)) => {
987                        warn!(
988                            "FIRE_AND_FORGET plugin '{}' error (ignored): {}",
989                            name_for_log, e
990                        );
991                    },
992                    Err(_) => {
993                        warn!(
994                            "FIRE_AND_FORGET plugin '{}' timed out (ignored)",
995                            name_for_log
996                        );
997                    },
998                }
999            });
1000
1001            handles.push((plugin_name, handle));
1002        }
1003
1004        handles
1005    }
1006}
1007
1008impl Default for Executor {
1009    fn default() -> Self {
1010        Self::new(ExecutorConfig::default())
1011    }
1012}
1013
1014// ---------------------------------------------------------------------------
1015// Internal types
1016// ---------------------------------------------------------------------------
1017
1018// SerialResult removed — run_serial_phase now returns Option<Violation> directly.
1019
1020// ---------------------------------------------------------------------------
1021// Erased Result Extraction
1022// ---------------------------------------------------------------------------
1023
1024/// Common fields extracted from a type-erased PluginResult.
1025///
1026/// Handlers return `Box<dyn Any>` which wraps this struct. The
1027/// executor extracts it via [`extract_erased()`] to read the
1028/// control flow fields without knowing the concrete payload type.
1029pub struct ErasedResultFields {
1030    pub continue_processing: bool,
1031    pub modified_payload: Option<Box<dyn PluginPayload>>,
1032    pub modified_extensions: Option<crate::hooks::payload::OwnedExtensions>,
1033    pub violation: Option<crate::error::PluginViolation>,
1034}
1035
1036/// Extract erased result fields from a type-erased handler result.
1037///
1038/// Takes ownership of the Box — the executor consumes the result.
1039/// Logs a warning if the downcast fails (indicates a handler returned
1040/// the wrong type — a framework bug, not a plugin error).
1041pub fn extract_erased(result: Box<dyn Any + Send + Sync>) -> Option<ErasedResultFields> {
1042    match result.downcast::<ErasedResultFields>() {
1043        Ok(b) => Some(*b),
1044        Err(_) => {
1045            warn!("extract_erased: downcast failed — handler returned unexpected type");
1046            None
1047        },
1048    }
1049}
1050
1051/// Convert a typed `PluginResult<P>` into `ErasedResultFields`.
1052///
1053/// Called by `TypedHandlerAdapter` to bridge between the typed
1054/// result and the executor's type-erased dispatch.
1055pub fn erase_result<P: crate::hooks::PluginPayload>(
1056    result: crate::hooks::PluginResult<P>,
1057) -> Box<dyn Any + Send + Sync> {
1058    Box::new(ErasedResultFields {
1059        continue_processing: result.continue_processing,
1060        modified_payload: result
1061            .modified_payload
1062            .map(|p| Box::new(p) as Box<dyn PluginPayload>),
1063        modified_extensions: result.modified_extensions,
1064        violation: result.violation,
1065    })
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071    use crate::hooks::payload::PluginPayload;
1072    use crate::hooks::PluginResult;
1073
1074    #[derive(Debug, Clone)]
1075    #[allow(dead_code)] // test fixture — typed shape is the point, not field reads
1076    struct TestPayload {
1077        value: String,
1078    }
1079    crate::impl_plugin_payload!(TestPayload);
1080
1081    #[test]
1082    fn test_erase_result_allow() {
1083        let result: PluginResult<TestPayload> = PluginResult::allow();
1084        let erased = erase_result(result);
1085        let fields = extract_erased(erased).unwrap();
1086        assert!(fields.continue_processing);
1087        assert!(fields.violation.is_none());
1088        assert!(fields.modified_payload.is_none());
1089    }
1090
1091    #[test]
1092    fn test_erase_result_deny() {
1093        let result: PluginResult<TestPayload> =
1094            PluginResult::deny(crate::error::PluginViolation::new("test", "denied"));
1095        let erased = erase_result(result);
1096        let fields = extract_erased(erased).unwrap();
1097        assert!(!fields.continue_processing);
1098        assert_eq!(fields.violation.as_ref().unwrap().code, "test");
1099    }
1100
1101    #[test]
1102    fn test_erase_result_modify_payload() {
1103        let result: PluginResult<TestPayload> = PluginResult::modify_payload(TestPayload {
1104            value: "modified".into(),
1105        });
1106        let erased = erase_result(result);
1107        let fields = extract_erased(erased).unwrap();
1108        assert!(fields.continue_processing);
1109        assert!(fields.modified_payload.is_some());
1110    }
1111
1112    #[test]
1113    fn test_erase_result_modify_extensions() {
1114        let mut security = crate::extensions::SecurityExtension::default();
1115        security.add_label("PII");
1116        let ext = Extensions {
1117            security: Some(Arc::new(security)),
1118            ..Default::default()
1119        };
1120        let owned = ext.cow_copy();
1121        let result: PluginResult<TestPayload> = PluginResult::modify_extensions(owned);
1122        let erased = erase_result(result);
1123        let fields = extract_erased(erased).unwrap();
1124        assert!(fields.continue_processing);
1125        assert!(fields.modified_extensions.is_some());
1126        let sec = fields
1127            .modified_extensions
1128            .as_ref()
1129            .unwrap()
1130            .security
1131            .as_ref()
1132            .unwrap();
1133        assert!(sec.has_label("PII"));
1134    }
1135
1136    #[test]
1137    fn test_pipeline_result_allowed() {
1138        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
1139            value: "test".into(),
1140        });
1141        let result =
1142            PipelineResult::allowed_with(payload, Extensions::default(), PluginContextTable::new());
1143        assert!(result.continue_processing);
1144        assert!(result.modified_payload.is_some());
1145        assert!(result.violation.is_none());
1146    }
1147
1148    #[test]
1149    fn test_pipeline_result_denied() {
1150        let violation = crate::error::PluginViolation::new("test", "denied");
1151        let result =
1152            PipelineResult::denied(violation, Extensions::default(), PluginContextTable::new());
1153        assert!(!result.continue_processing);
1154        assert!(result.modified_payload.is_none());
1155        assert!(result.violation.is_some());
1156    }
1157
1158    #[tokio::test]
1159    async fn test_executor_empty_entries() {
1160        let executor = Executor::default();
1161        let tracker = tokio_util::task::TaskTracker::new();
1162        let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
1163            value: "test".into(),
1164        });
1165        let (result, _) = executor
1166            .execute(&[], payload, Extensions::default(), None, &tracker)
1167            .await;
1168        assert!(result.continue_processing);
1169        assert!(result.modified_payload.is_some());
1170    }
1171}