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}