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}