Skip to main content

cpex_core/hooks/
trait_def.rs

1// Location: ./crates/cpex-core/src/hooks/trait_def.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// HookTypeDef trait and PluginResult type.
7//
8// Every hook in the CPEX framework is defined by a marker type that
9// implements HookTypeDef. This associates a typed PluginPayload and
10// PluginResult with a string name used for registry lookup and config.
11//
12// The hook type does NOT declare an access pattern (read-only vs
13// mutating). The plugin's mode (from PluginRef.trusted_config)
14// determines scheduling and authority at runtime. Security invariants
15// come from the types inside the payload (Arc<T>, MonotonicSet,
16// Guarded<T>), not from borrow mechanics.
17//
18// Extensions are always a separate parameter — never part of the
19// payload. This allows capability-filtered views per plugin and
20// independent modification of extensions without copying the payload.
21
22use crate::context::PluginContext;
23use crate::error::PluginViolation;
24use crate::hooks::payload::{Extensions, PluginPayload};
25use crate::plugin::Plugin;
26
27// ---------------------------------------------------------------------------
28// HookTypeDef Trait
29// ---------------------------------------------------------------------------
30
31/// Defines a hook's contract: what goes in and what comes out.
32///
33/// Each hook type is a zero-sized marker struct that implements this
34/// trait. The framework uses the associated types for compile-time
35/// dispatch and the NAME constant for registry lookup.
36///
37/// The hook type does **not** declare an access pattern. The plugin's
38/// mode (from `PluginRef.trusted_config`) determines whether the
39/// executor passes a borrow or a clone:
40///
41/// | Mode            | Receives        | Can Block? | Can Modify? |
42/// |-----------------|-----------------|------------|-------------|
43/// | Sequential      | owned (clone)   | Yes        | Yes         |
44/// | Transform       | owned (clone)   | No         | Yes         |
45/// | Audit           | &Payload        | No         | No          |
46/// | Concurrent      | &Payload        | Yes        | No          |
47/// | FireAndForget   | &Payload        | No         | No          |
48///
49/// # Defining a Hook
50///
51/// Use the [`define_hook!`] macro instead of implementing this trait
52/// manually — the macro generates the marker struct, the trait impl,
53/// and the handler trait in one declaration.
54pub trait HookTypeDef: Send + Sync + 'static {
55    /// The typed payload that handlers receive.
56    /// Must implement [`PluginPayload`] (Clone + Send + Sync + 'static).
57    type Payload: PluginPayload;
58
59    /// The typed result that handlers return.
60    type Result: Send + Sync;
61
62    /// Hook name — used as the registry key and in config YAML.
63    ///
64    /// Multiple hook names can map to the same HookTypeDef (the CMF
65    /// pattern where one handler covers `cmf.tool_pre_invoke`,
66    /// `cmf.llm_input`, etc.). The primary NAME is used for
67    /// single-name registration; additional names are registered
68    /// via `register_for_names()`.
69    const NAME: &'static str;
70}
71
72// ---------------------------------------------------------------------------
73// Hook Handler Trait
74// ---------------------------------------------------------------------------
75
76/// Typed handler for a specific hook type.
77///
78/// Plugin authors implement this trait (alongside [`Plugin`]) to handle
79/// a specific hook. The type parameter `H` ties the handler to a
80/// `HookTypeDef`, ensuring the correct payload and result types at
81/// compile time. The framework creates a type-erased adapter internally
82/// when you register — you never touch `AnyHookHandler` directly.
83///
84/// # Async by design
85///
86/// `handle` is an `async fn`. Plugins that don't need to `.await`
87/// anything still write `async fn handle(...)` and return synchronously
88/// — the compiler emits a trivially-ready future and LLVM inlines it
89/// at the adapter site, so there's no observable runtime cost over a
90/// plain function. Plugins that *do* need to `.await` (fresh JWKS
91/// fetch, RPC to an authz service, dynamic policy lookup) just use
92/// `.await` inside the body.
93///
94/// **Best practice:** even when async is available, prefer pre-loading
95/// state in [`Plugin::initialize`] and reading from cache in `handle`.
96/// Hot-path I/O is the most common source of latency regressions.
97///
98/// # Native AFIT, not `#[async_trait]`
99///
100/// The trait uses native `async fn` (return-position `impl Future`)
101/// rather than `#[async_trait]`. This avoids a per-call heap
102/// allocation: the returned future is monomorphized into the
103/// [`TypedHandlerAdapter`] rather than boxed. The trait is therefore
104/// **not object-safe** — you cannot have `Box<dyn HookHandler<H>>`.
105/// We don't need that; type erasure happens one layer up at
106/// [`AnyHookHandler`].
107///
108/// # Examples
109///
110/// ```rust,ignore
111/// // Synchronous plugin — no .await, no extra cost
112/// impl HookHandler<CmfHook> for AllowPlugin {
113///     async fn handle(
114///         &self,
115///         _payload: &MessagePayload,
116///         _extensions: &Extensions,
117///         _ctx: &mut PluginContext,
118///     ) -> PluginResult<MessagePayload> {
119///         PluginResult::allow()
120///     }
121/// }
122///
123/// // Async plugin — calls .await inside the body
124/// impl HookHandler<MyHook> for AuthzPlugin {
125///     async fn handle(
126///         &self,
127///         payload: &MyPayload,
128///         _extensions: &Extensions,
129///         _ctx: &mut PluginContext,
130///     ) -> PluginResult<MyPayload> {
131///         match self.client.check(&payload.user).await {
132///             Ok(true) => PluginResult::allow(),
133///             _ => PluginResult::deny(/* ... */),
134///         }
135///     }
136/// }
137///
138/// // Registration is the same for both:
139/// manager.register_handler::<MyHook, _>(plugin, config)?;
140/// ```
141///
142/// [`PluginManager::register_handler`]: crate::manager::PluginManager::register_handler
143/// [`AnyHookHandler`]: crate::registry::AnyHookHandler
144/// [`TypedHandlerAdapter`]: crate::hooks::adapter::TypedHandlerAdapter
145pub trait HookHandler<H: HookTypeDef>: Plugin + Send + Sync {
146    /// Handle the hook invocation.
147    ///
148    /// Receives a **borrow** of the typed payload, capability-filtered
149    /// extensions, and per-invocation context. Returns a typed result.
150    ///
151    /// The payload is immutable — Rust's borrow checker prevents
152    /// modification through `&H::Payload`. To modify, the plugin
153    /// must `clone()` the payload (or the fields it needs) and return
154    /// the modified copy in `PluginResult::modify_payload()`. This
155    /// pushes the clone cost to the plugin that actually needs it —
156    /// read-only plugins (validators, auditors) never pay for a copy.
157    ///
158    /// Returns a `Send`-able future so the executor can drive it from
159    /// any worker thread (including the concurrent-phase `JoinSet`).
160    /// `H::Result` is already `Send + Sync` per the `HookTypeDef`
161    /// bound, so the `Send` constraint comes for free for typical
162    /// handlers.
163    fn handle(
164        &self,
165        payload: &H::Payload,
166        extensions: &Extensions,
167        ctx: &mut PluginContext,
168    ) -> impl std::future::Future<Output = H::Result> + Send;
169}
170
171// ---------------------------------------------------------------------------
172// Plugin Result
173// ---------------------------------------------------------------------------
174
175/// Result returned by a hook handler.
176///
177/// Payload and extension modifications are **separate** — this is a
178/// core design decision. Extension-only changes (add a label, set a
179/// header) don't require copying the payload. The payload is only
180/// present in `modified_payload` when message content actually changed.
181///
182/// The executor interprets the result based on the plugin's mode:
183/// - Sequential/Transform: `modified_payload` and `modified_extensions` are accepted.
184/// - Audit/Concurrent/FireAndForget: modifications are discarded.
185/// - Sequential/Concurrent: `continue_processing = false` halts the pipeline.
186/// - Transform/Audit/FireAndForget: blocks are suppressed.
187///
188/// Mirrors Python's `PluginResult[T]` with separate `modified_payload`
189/// and `modified_extensions` fields.
190///
191/// # Examples
192///
193/// ```
194/// use cpex_core::hooks::{PluginPayload, PluginResult};
195/// use cpex_core::error::PluginViolation;
196///
197/// // Define a simple payload
198/// #[derive(Debug, Clone)]
199/// struct TestPayload { value: i32 }
200/// cpex_core::impl_plugin_payload!(TestPayload);
201///
202/// // Allow — no changes
203/// let result: PluginResult<TestPayload> = PluginResult::allow();
204/// assert!(result.continue_processing);
205/// assert!(result.modified_payload.is_none());
206///
207/// // Deny
208/// let result: PluginResult<TestPayload> = PluginResult::deny(
209///     PluginViolation::new("forbidden", "not allowed")
210/// );
211/// assert!(!result.continue_processing);
212/// assert!(result.violation.is_some());
213/// ```
214#[derive(Debug)]
215pub struct PluginResult<P: PluginPayload> {
216    /// Whether the pipeline should continue processing.
217    /// `false` halts the pipeline (deny). Only respected for
218    /// Sequential and Concurrent modes.
219    pub continue_processing: bool,
220
221    /// Modified payload. `None` means no content modification.
222    /// Only accepted from Sequential and Transform mode plugins.
223    pub modified_payload: Option<P>,
224
225    /// Modified extensions. `None` means no extension changes.
226    /// Return an `OwnedExtensions` from `extensions.cow_copy()`.
227    /// The executor validates (immutable unchanged, monotonic superset)
228    /// and merges back into the pipeline's `Extensions`.
229    pub modified_extensions: Option<crate::hooks::payload::OwnedExtensions>,
230
231    /// Policy violation. Present when `continue_processing` is `false`.
232    pub violation: Option<PluginViolation>,
233
234    /// Optional metadata from the plugin (telemetry, diagnostics).
235    /// Not used for scheduling or policy decisions.
236    pub metadata: Option<serde_json::Value>,
237}
238
239impl<P: PluginPayload> PluginResult<P> {
240    /// Allow — payload continues unchanged, no extension changes.
241    pub fn allow() -> Self {
242        Self {
243            continue_processing: true,
244            modified_payload: None,
245            modified_extensions: None,
246
247            violation: None,
248            metadata: None,
249        }
250    }
251
252    /// Deny — pipeline halts with a violation.
253    pub fn deny(violation: PluginViolation) -> Self {
254        Self {
255            continue_processing: false,
256            modified_payload: None,
257            modified_extensions: None,
258
259            violation: Some(violation),
260            metadata: None,
261        }
262    }
263
264    /// Modify payload only — extensions unchanged.
265    pub fn modify_payload(payload: P) -> Self {
266        Self {
267            continue_processing: true,
268            modified_payload: Some(payload),
269            modified_extensions: None,
270
271            violation: None,
272            metadata: None,
273        }
274    }
275
276    /// Modify extensions only — payload unchanged.
277    /// Takes an `OwnedExtensions` from `extensions.cow_copy()`.
278    pub fn modify_extensions(extensions: crate::hooks::payload::OwnedExtensions) -> Self {
279        Self {
280            continue_processing: true,
281            modified_payload: None,
282            modified_extensions: Some(extensions),
283
284            violation: None,
285            metadata: None,
286        }
287    }
288
289    /// Modify both payload and extensions.
290    /// Takes an `OwnedExtensions` from `extensions.cow_copy()`.
291    pub fn modify(payload: P, extensions: crate::hooks::payload::OwnedExtensions) -> Self {
292        Self {
293            continue_processing: true,
294            modified_payload: Some(payload),
295            modified_extensions: Some(extensions),
296
297            violation: None,
298            metadata: None,
299        }
300    }
301
302    /// Whether this result represents a denial.
303    pub fn is_denied(&self) -> bool {
304        !self.continue_processing
305    }
306
307    /// Whether this result carries a modified payload.
308    pub fn is_payload_modified(&self) -> bool {
309        self.modified_payload.is_some()
310    }
311
312    /// Whether this result carries modified extensions.
313    pub fn is_extensions_modified(&self) -> bool {
314        self.modified_extensions.is_some()
315    }
316}
317
318impl<P: PluginPayload> Default for PluginResult<P> {
319    fn default() -> Self {
320        Self::allow()
321    }
322}