Skip to main content

cpex_core/hooks/
metadata.rs

1// Location: ./crates/cpex-core/src/hooks/metadata.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Hook routing metadata — answers "what dispatch context does this
7// hook name belong to?"
8//
9// # What this solves
10//
11// cpex-core's `invoke_named::<H>(hook_name, ...)` already routes to
12// the right handlers based on the hook name. But APL's dispatcher
13// (`apl-cpex/src/dispatch_plan.rs`) needs a finer-grained question:
14// when a plugin is registered for MULTIPLE hooks (e.g.
15// `[cmf.tool_pre_invoke, cmf.tool_post_invoke]`), which entry should
16// fire for the current dispatch context?
17//
18// Pre-2026-05-25 dispatch_plan used a naming heuristic — any hook
19// name containing "field", "redact", "scan", or "validate" was
20// classified as field-context, everything else as step-context. Two
21// problems:
22//
23//   1. **Multi-hook bug.** Two step-context hooks on the same plugin
24//      (pre + post) collapsed to "first non-field wins" — silent
25//      wrong dispatch when pre_invocation and post_invocation needed
26//      different entries.
27//   2. **The "field-hook" classification didn't match any real hook.**
28//      No CMF hook actually carries `field` / `redact` / `scan` /
29//      `validate` in its name — the heuristic was anticipating a
30//      convention no plugin uses. APL's field-stage dispatch (from
31//      `args:` / `result:` pipelines) routes to the same hook a
32//      plugin registers under for step dispatch.
33//
34// This module replaces the heuristic with an explicit hook-name →
35// metadata table.
36//
37// # The table
38//
39// Each entry maps a hook name to `HookMetadata`:
40//
41//   * `entity_type` — `Some("tool")`, `Some("llm")`, etc. for hooks
42//     tied to an entity type; `None` for hook families that apply
43//     regardless of entity (`identity.resolve`, `token.delegate`).
44//   * `phase` — `Pre` / `Post` / `Unphased`. APL's evaluator uses
45//     this to pick the right entry for the current phase context.
46//
47// Lookup is the foundation for `apl-cpex::dispatch_plan`'s entry
48// selection. See `docs/apl-hook-family-expansion.md` Layer 1.
49//
50// # Phase semantics
51//
52// APL phases map to hook phases:
53//
54//   * `args:` field stage     → looks for `Pre` hooks
55//   * `pre_invocation:` step       → looks for `Pre` hooks
56//   * `result:` field stage   → looks for `Post` hooks
57//   * `post_invocation:` step      → looks for `Post` hooks
58//
59// A plugin that wants to discriminate "args field stage" from
60// "pre_invocation step" — both Pre context — inspects `PluginContext::hook_name()`
61// itself. The hook-routing layer doesn't slice phase finer than
62// Pre/Post.
63//
64// # Custom hook metadata
65//
66// Hosts and plugin authors can register metadata for custom hook
67// names via [`register_hook_metadata`]. Unregistered hooks return
68// [`HookMetadata::unknown`] from `lookup` — entity_type `None`, phase
69// `Unphased`. That conservative default matches any dispatch context,
70// so custom hooks dispatch on the first registered entry. Authors
71// who want phase-aware behavior must register metadata explicitly.
72
73use std::collections::HashMap;
74use std::sync::{OnceLock, RwLock};
75
76use crate::cmf::constants::{
77    ENTITY_LLM, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_LLM_INPUT,
78    HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE, HOOK_CMF_PROMPT_PRE_INVOKE,
79    HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_TOOL_POST_INVOKE,
80    HOOK_CMF_TOOL_PRE_INVOKE,
81};
82use crate::delegation::HOOK_TOKEN_DELEGATE;
83use crate::identity::HOOK_IDENTITY_RESOLVE;
84
85/// Lifecycle position a hook occupies for dispatcher purposes.
86///
87/// APL's args/pre_invocation phases dispatch to `Pre` hooks; APL's
88/// result/post_invocation phases dispatch to `Post` hooks. Hook families
89/// outside the request-lifecycle model (identity at request entry,
90/// token-delegate inside authorization) use `Unphased` and match any
91/// requested phase.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
93pub enum HookPhase {
94    /// Pre-invocation hook — e.g. `cmf.tool_pre_invoke`,
95    /// `cmf.llm_input`. Dispatched from APL's `args:` field stages
96    /// and `pre_invocation:` steps.
97    Pre,
98    /// Post-invocation hook — e.g. `cmf.tool_post_invoke`,
99    /// `cmf.llm_output`. Dispatched from APL's `result:` field stages
100    /// and `post_invocation:` steps.
101    Post,
102    /// Not phase-bound. Covers hook families that fire once per
103    /// request without an APL phase concept (`identity.resolve`,
104    /// `token.delegate`) AND custom hooks the framework doesn't know
105    /// about. APL's dispatcher matches `Unphased` against any
106    /// requested phase — conservative default that lets unknown
107    /// hooks still dispatch.
108    Unphased,
109}
110
111/// Metadata describing what dispatch context a hook name belongs to.
112/// See module docs.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct HookMetadata {
115    /// Entity type the hook applies to (`"tool"`, `"llm"`, `"prompt"`,
116    /// `"resource"`). `None` means "applies regardless of entity_type"
117    /// — used for hooks that don't tie to MCP's entity-type taxonomy.
118    pub entity_type: Option<&'static str>,
119    /// Lifecycle phase the hook occupies.
120    pub phase: HookPhase,
121}
122
123impl HookMetadata {
124    /// Default — `entity_type: None`, `phase: Unphased`. Used as
125    /// the fallback for hook names not in the registry. The
126    /// `matches` function treats `Unphased` as "matches any phase,"
127    /// so unknown hooks dispatch on the first registered entry.
128    pub const fn unknown() -> Self {
129        Self {
130            entity_type: None,
131            phase: HookPhase::Unphased,
132        }
133    }
134
135    /// Whether this hook's metadata matches a dispatch context.
136    ///
137    /// Matching rules:
138    ///
139    /// - `entity_type`: a hook tied to a specific entity_type
140    ///   (`Some("tool")`) matches only contexts with that entity
141    ///   type. A hook with `entity_type: None` matches any context.
142    ///   A request without an entity_type (`None`) matches any hook
143    ///   — the dispatcher hasn't specified what entity is in play,
144    ///   so we can't filter on it.
145    /// - `phase`: exact match between hook's phase and the requested
146    ///   phase, EXCEPT `Unphased` is a wildcard from either side
147    ///   (lets custom / unregistered hooks dispatch without phase
148    ///   rules).
149    pub fn matches(&self, request_entity_type: Option<&str>, requested_phase: HookPhase) -> bool {
150        let entity_ok = match (self.entity_type, request_entity_type) {
151            (Some(hook_et), Some(req_et)) => hook_et == req_et,
152            (Some(_), None) => true, // request didn't specify; don't filter
153            (None, _) => true,       // hook applies to any entity_type
154        };
155        if !entity_ok {
156            return false;
157        }
158        match (self.phase, requested_phase) {
159            (HookPhase::Unphased, _) | (_, HookPhase::Unphased) => true,
160            (a, b) => a == b,
161        }
162    }
163}
164
165// =====================================================================
166// Built-in registry
167// =====================================================================
168
169/// Built-in hook metadata. Plugin authors and hosts can register
170/// additional entries via [`register_hook_metadata`]. The 8 CMF step
171/// hooks (entity × pre/post) are the complete CMF-routable surface
172/// today; identity + delegation are unphased.
173const BUILTIN_METADATA: &[(&str, HookMetadata)] = &[
174    // CMF tool
175    (
176        HOOK_CMF_TOOL_PRE_INVOKE,
177        HookMetadata {
178            entity_type: Some(ENTITY_TOOL),
179            phase: HookPhase::Pre,
180        },
181    ),
182    (
183        HOOK_CMF_TOOL_POST_INVOKE,
184        HookMetadata {
185            entity_type: Some(ENTITY_TOOL),
186            phase: HookPhase::Post,
187        },
188    ),
189    // CMF llm
190    (
191        HOOK_CMF_LLM_INPUT,
192        HookMetadata {
193            entity_type: Some(ENTITY_LLM),
194            phase: HookPhase::Pre,
195        },
196    ),
197    (
198        HOOK_CMF_LLM_OUTPUT,
199        HookMetadata {
200            entity_type: Some(ENTITY_LLM),
201            phase: HookPhase::Post,
202        },
203    ),
204    // CMF prompt
205    (
206        HOOK_CMF_PROMPT_PRE_INVOKE,
207        HookMetadata {
208            entity_type: Some(ENTITY_PROMPT),
209            phase: HookPhase::Pre,
210        },
211    ),
212    (
213        HOOK_CMF_PROMPT_POST_INVOKE,
214        HookMetadata {
215            entity_type: Some(ENTITY_PROMPT),
216            phase: HookPhase::Post,
217        },
218    ),
219    // CMF resource
220    (
221        HOOK_CMF_RESOURCE_PRE_FETCH,
222        HookMetadata {
223            entity_type: Some(ENTITY_RESOURCE),
224            phase: HookPhase::Pre,
225        },
226    ),
227    (
228        HOOK_CMF_RESOURCE_POST_FETCH,
229        HookMetadata {
230            entity_type: Some(ENTITY_RESOURCE),
231            phase: HookPhase::Post,
232        },
233    ),
234    // Non-CMF families (entity-agnostic, not phase-bound).
235    (
236        HOOK_IDENTITY_RESOLVE,
237        HookMetadata {
238            entity_type: None,
239            phase: HookPhase::Unphased,
240        },
241    ),
242    (
243        HOOK_TOKEN_DELEGATE,
244        HookMetadata {
245            entity_type: None,
246            phase: HookPhase::Unphased,
247        },
248    ),
249];
250
251/// Runtime-registered additions to the metadata table. Hosts /
252/// plugin authors call [`register_hook_metadata`] to populate.
253/// Initialized with the BUILTIN_METADATA on first access.
254fn registry() -> &'static RwLock<HashMap<String, HookMetadata>> {
255    static REGISTRY: OnceLock<RwLock<HashMap<String, HookMetadata>>> = OnceLock::new();
256    REGISTRY.get_or_init(|| {
257        let mut map: HashMap<String, HookMetadata> = HashMap::new();
258        for (name, meta) in BUILTIN_METADATA {
259            map.insert((*name).to_string(), *meta);
260        }
261        RwLock::new(map)
262    })
263}
264
265/// Look up metadata for a hook name. Returns
266/// [`HookMetadata::unknown`] for names not in the registry —
267/// equivalent to "no phase, no entity_type filter," which lets
268/// unregistered hooks still dispatch via the conservative wildcard
269/// in [`HookMetadata::matches`].
270pub fn lookup(hook_name: &str) -> HookMetadata {
271    let r = registry().read().unwrap_or_else(|p| p.into_inner());
272    r.get(hook_name).copied().unwrap_or(HookMetadata::unknown())
273}
274
275/// Register or override metadata for a hook name. Idempotent — a
276/// host re-registering the same hook with the same metadata is fine.
277/// Re-registering with different metadata overwrites the previous
278/// entry; intentional for hosts that need to customize defaults.
279///
280/// Thread-safe; intended to be called at startup. Concurrent calls
281/// are serialized via the registry's `RwLock`.
282pub fn register_hook_metadata(hook_name: impl Into<String>, meta: HookMetadata) {
283    let mut w = registry().write().unwrap_or_else(|p| p.into_inner());
284    w.insert(hook_name.into(), meta);
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn cmf_tool_pre_invoke_is_pre_phase_for_tool_entity() {
293        let meta = lookup(HOOK_CMF_TOOL_PRE_INVOKE);
294        assert_eq!(meta.entity_type, Some(ENTITY_TOOL));
295        assert_eq!(meta.phase, HookPhase::Pre);
296    }
297
298    #[test]
299    fn cmf_llm_output_is_post_phase_for_llm_entity() {
300        let meta = lookup(HOOK_CMF_LLM_OUTPUT);
301        assert_eq!(meta.entity_type, Some(ENTITY_LLM));
302        assert_eq!(meta.phase, HookPhase::Post);
303    }
304
305    #[test]
306    fn identity_resolve_is_unphased_no_entity() {
307        let meta = lookup(HOOK_IDENTITY_RESOLVE);
308        assert_eq!(meta.entity_type, None);
309        assert_eq!(meta.phase, HookPhase::Unphased);
310    }
311
312    #[test]
313    fn token_delegate_is_unphased_no_entity() {
314        let meta = lookup(HOOK_TOKEN_DELEGATE);
315        assert_eq!(meta.entity_type, None);
316        assert_eq!(meta.phase, HookPhase::Unphased);
317    }
318
319    #[test]
320    fn unknown_hook_returns_universal_default() {
321        let meta = lookup("custom.unrecognized_hook");
322        assert_eq!(meta.entity_type, None);
323        assert_eq!(meta.phase, HookPhase::Unphased);
324    }
325
326    #[test]
327    fn matches_filters_by_entity_type_when_set() {
328        let tool_pre = HookMetadata {
329            entity_type: Some(ENTITY_TOOL),
330            phase: HookPhase::Pre,
331        };
332        assert!(tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Pre));
333        assert!(!tool_pre.matches(Some(ENTITY_LLM), HookPhase::Pre));
334    }
335
336    #[test]
337    fn matches_allows_any_entity_when_hook_entity_is_none() {
338        let universal = HookMetadata {
339            entity_type: None,
340            phase: HookPhase::Pre,
341        };
342        assert!(universal.matches(Some(ENTITY_TOOL), HookPhase::Pre));
343        assert!(universal.matches(Some(ENTITY_LLM), HookPhase::Pre));
344        assert!(universal.matches(None, HookPhase::Pre));
345    }
346
347    #[test]
348    fn matches_phase_exactly_unless_unphased() {
349        let tool_pre = HookMetadata {
350            entity_type: Some(ENTITY_TOOL),
351            phase: HookPhase::Pre,
352        };
353        assert!(tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Pre));
354        assert!(!tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Post));
355    }
356
357    #[test]
358    fn matches_unphased_is_wildcard_in_either_direction() {
359        let unphased = HookMetadata {
360            entity_type: None,
361            phase: HookPhase::Unphased,
362        };
363        assert!(unphased.matches(Some(ENTITY_TOOL), HookPhase::Pre));
364        assert!(unphased.matches(Some(ENTITY_LLM), HookPhase::Post));
365
366        let tool_pre = HookMetadata {
367            entity_type: Some(ENTITY_TOOL),
368            phase: HookPhase::Pre,
369        };
370        // Request with Unphased phase matches any registered hook
371        // of the right entity_type.
372        assert!(tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Unphased));
373    }
374
375    #[test]
376    fn matches_request_without_entity_type_doesnt_filter_on_it() {
377        let tool_pre = HookMetadata {
378            entity_type: Some(ENTITY_TOOL),
379            phase: HookPhase::Pre,
380        };
381        // Request didn't specify entity_type — hook still matches.
382        assert!(tool_pre.matches(None, HookPhase::Pre));
383    }
384
385    #[test]
386    fn register_hook_metadata_overrides_default() {
387        let name = "test_custom.overridden_meta";
388        register_hook_metadata(
389            name,
390            HookMetadata {
391                entity_type: Some("custom"),
392                phase: HookPhase::Pre,
393            },
394        );
395        let meta = lookup(name);
396        assert_eq!(meta.entity_type, Some("custom"));
397        assert_eq!(meta.phase, HookPhase::Pre);
398    }
399}