apl_cpex/route_handler.rs
1// Location: ./crates/apl-cpex/src/route_handler.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor, Fred Araujo
5//
6// `AplRouteHandler` — synthetic plugin that drives APL evaluation when
7// cpex-core's `filter_entries_by_route` matches an annotated route. Each
8// instance is bound to ONE phase (Pre or Post) so the unified-config
9// `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` hooks can carry
10// distinct handler logic without an in-handler hook-name discriminator.
11//
12// # Why a phase-bound handler
13//
14// The CPEX manager's annotation table is keyed on
15// `(entity_type, entity_name, scope, hook_name)`. The visitor registers
16// one handler per route per phase; the manager picks the right one based
17// on the dispatching hook name. Inside `invoke`, no hook-name plumbing is
18// needed — the handler already knows which phase it's running.
19//
20// # Lifetime / weak manager handle
21//
22// The handler holds `Weak<PluginManager>` because the manager owns the
23// snapshot that owns the annotation that owns the handler — a strong
24// reference would create a cycle. Each `invoke` upgrades to `Arc` for
25// the duration of the call. If the upgrade fails (manager has been
26// dropped) the call returns a configuration error.
27
28use std::sync::{Arc, Weak};
29
30use async_trait::async_trait;
31use serde_json::Value;
32
33use cpex_core::cmf::MessagePayload;
34use cpex_core::context::PluginContext;
35use cpex_core::error::{PluginError, PluginViolation};
36use cpex_core::executor::ErasedResultFields;
37use cpex_core::extensions::Extensions;
38use cpex_core::hooks::PluginPayload;
39use cpex_core::manager::PluginManager;
40use cpex_core::plugin::{Plugin, PluginConfig};
41use cpex_core::registry::AnyHookHandler;
42
43use apl_cmf::{extract_args, extract_result, BagBuilder};
44use apl_core::evaluator::Decision;
45use apl_core::plugin_decl::PluginRegistry;
46use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload};
47use apl_core::rules::CompiledRoute;
48use apl_core::step::PdpResolver;
49
50use crate::cmf_invoker::CmfPluginInvoker;
51use crate::delegation_invoker::DelegationPluginInvoker;
52use crate::dispatch_plan::DispatchCache;
53use crate::pdp_router::PdpRouter;
54use crate::session_store::SessionStore;
55
56/// Which APL phase this handler runs. Pre covers `args` + `policy`; Post
57/// covers `result` + `post_policy`. Set once at construction and never
58/// changes.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Phase {
61 Pre,
62 Post,
63}
64
65/// Synthetic plugin that drives APL evaluation for one route + one phase.
66///
67/// Implements `Plugin` (so cpex-core treats it like any other plugin —
68/// mode/capabilities/on_error come from the `PluginConfig` the visitor
69/// supplied at `annotate_route` time) and `AnyHookHandler` (so the
70/// executor dispatches into it through the normal type-erased path).
71pub struct AplRouteHandler {
72 config: PluginConfig,
73 route: Arc<CompiledRoute>,
74 phase: Phase,
75 plugin_registry: Arc<PluginRegistry>,
76 dispatch_cache: Arc<DispatchCache>,
77 session_store: Arc<dyn SessionStore>,
78 /// Weak handle to the manager so we can resolve plugin entries +
79 /// dispatch into them by-name. `Weak` avoids the
80 /// manager↔snapshot↔annotation↔handler cycle.
81 manager: Weak<PluginManager>,
82 /// PDP resolver. APL routes that don't use `pdp(...)` steps never
83 /// touch this. Default is an empty [`PdpRouter`] — any `pdp(...)`
84 /// step against an unregistered dialect returns
85 /// `PdpError::NoResolver`. Hosts that need Cedar, OPA, NeMo, etc.
86 /// install resolvers via [`Self::with_pdp`] or
87 /// [`Self::with_pdp_router`].
88 pdp: Arc<dyn PdpResolver>,
89}
90
91impl AplRouteHandler {
92 /// Build a handler. Visitor calls this twice per route — once for
93 /// each phase — and passes the resulting `Arc` to `annotate_route`.
94 pub fn new(
95 config: PluginConfig,
96 route: Arc<CompiledRoute>,
97 phase: Phase,
98 plugin_registry: Arc<PluginRegistry>,
99 dispatch_cache: Arc<DispatchCache>,
100 session_store: Arc<dyn SessionStore>,
101 manager: Weak<PluginManager>,
102 ) -> Self {
103 Self {
104 config,
105 route,
106 phase,
107 plugin_registry,
108 dispatch_cache,
109 session_store,
110 manager,
111 pdp: Arc::new(PdpRouter::new()),
112 }
113 }
114
115 /// Install a `PdpResolver`. Pass a [`PdpRouter`] when the host needs
116 /// to support multiple dialects (Cedar + OPA + NeMo) on the same
117 /// route — the router dispatches each `pdp(...)` step by dialect.
118 /// Pass a single resolver when only one dialect is in use; APL
119 /// steps for any other dialect will then return
120 /// `PdpError::NoResolver` at evaluation time.
121 pub fn with_pdp(mut self, pdp: Arc<dyn PdpResolver>) -> Self {
122 self.pdp = pdp;
123 self
124 }
125
126 /// Sugar for the common "register many resolvers" path. Builds a
127 /// [`PdpRouter`], registers each resolver into it, then installs the
128 /// router. Equivalent to constructing a `PdpRouter` by hand and
129 /// passing it to [`Self::with_pdp`].
130 pub fn with_pdp_router(
131 mut self,
132 resolvers: impl IntoIterator<Item = Arc<dyn PdpResolver>>,
133 ) -> Self {
134 let mut router = PdpRouter::new();
135 for r in resolvers {
136 router.register(r);
137 }
138 self.pdp = Arc::new(router);
139 self
140 }
141}
142
143#[async_trait]
144impl Plugin for AplRouteHandler {
145 fn config(&self) -> &PluginConfig {
146 &self.config
147 }
148}
149
150#[async_trait]
151impl AnyHookHandler for AplRouteHandler {
152 async fn invoke(
153 &self,
154 payload: &dyn PluginPayload,
155 extensions: &Extensions,
156 _ctx: &mut PluginContext,
157 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
158 // Downcast to the CMF payload — this handler only registers for
159 // cmf.* hook names, so the executor should always hand us a
160 // MessagePayload. A mismatch indicates a framework wiring bug.
161 let msg_payload = payload
162 .as_any()
163 .downcast_ref::<MessagePayload>()
164 .ok_or_else(|| {
165 Box::new(PluginError::Config {
166 message: format!(
167 "AplRouteHandler '{}': payload was not MessagePayload",
168 self.route.route_key
169 ),
170 })
171 })?;
172
173 let manager = self.manager.upgrade().ok_or_else(|| {
174 Box::new(PluginError::Config {
175 message: format!(
176 "AplRouteHandler '{}': PluginManager dropped before invoke",
177 self.route.route_key
178 ),
179 })
180 })?;
181
182 // Build (or reuse) the dispatch plan for this route. Cache keyed
183 // by `(route_key, manager.config_generation())` — if the manager
184 // has reloaded since the last invoke, the next lookup rebuilds.
185 let plan = self
186 .dispatch_cache
187 .get_or_build(&self.route, &self.plugin_registry, &manager)
188 .await;
189
190 // CmfPluginInvoker carries the request-scoped payload + extensions
191 // under interior mutability so successive plugin calls accumulate
192 // mutations. Hydration + persistence are no-ops when there's no
193 // session id (the common case for the first request in a session).
194 // Wrapped in Arc so it can be erased to `Arc<dyn PluginInvoker>`
195 // for the apl-core entry points (which take `&Arc<dyn PluginInvoker>`
196 // so `dispatch_parallel` can clone an owned, 'static reference into
197 // each spawned branch). Inherent-method calls on `CmfPluginInvoker`
198 // (e.g. `extensions_arc`, `persist_session`) deref through the Arc.
199 // Hydration loads accumulated session labels. A store failure
200 // here happens *before* any policy decision, so we fail the
201 // request closed immediately (R5/R18, F2): deny with a
202 // distinguished violation rather than proceeding as if the
203 // session carried no taint. Sessionless traffic never reaches
204 // the store, so this only denies session-bearing requests.
205 let invoker = match CmfPluginInvoker::for_request(
206 Arc::clone(&manager),
207 extensions.clone(),
208 msg_payload.clone(),
209 plan,
210 Arc::clone(&self.session_store),
211 )
212 .await
213 {
214 Ok(inv) => Arc::new(inv),
215 Err(e) => {
216 tracing::error!(
217 alarm = "session_store_failure",
218 op = "load",
219 route = %self.route.route_key,
220 error = %e,
221 "session label load failed; failing request closed"
222 );
223 return Ok(Box::new(ErasedResultFields {
224 continue_processing: false,
225 modified_payload: None,
226 modified_extensions: None,
227 violation: Some(PluginViolation::new(
228 "session.load_failed",
229 "session state could not be loaded",
230 )),
231 }));
232 },
233 };
234
235 // Build the attribute bag. APL predicates read flat keys; the
236 // BagBuilder bridges typed CPEX extensions into that namespace.
237 // `route.key` lets default/policy-bundle predicates branch on
238 // which route they're attached to.
239 let post_extensions = invoker.current_extensions().await;
240 let mut bag = BagBuilder::new()
241 .with_extensions(&post_extensions)
242 .with_route_key(&self.route.route_key)
243 .build();
244
245 // Build `RoutePayload.args` from the message. Per-content shape:
246 // * ToolCall → arguments map (JSON Object)
247 // * PromptRequest → arguments map (JSON Object)
248 // * Text-only → JSON String of concatenated text content
249 //
250 // Field pipelines operate on `args.<name>` paths. Result starts
251 // as Null on Pre (no upstream response yet); the Post phase
252 // would extract from a ToolResult / PromptResult — deferred
253 // until result-side handling lands.
254 let args_value = extract_args_from_message(&msg_payload.message);
255 let mut route_payload = match self.phase {
256 Phase::Pre => RoutePayload::new(args_value),
257 Phase::Post => {
258 // Pull the upstream result out of the message so APL
259 // `result.<field>` predicates and the `result:`
260 // pipeline have something to operate on. Falls back to
261 // `Value::Null` when the message has no ToolResult /
262 // PromptResult / Resource content (e.g. for hooks that
263 // fire on entities without a structured result).
264 let result_value = extract_result_from_message(&msg_payload.message);
265 RoutePayload::with_result(args_value, result_value)
266 },
267 };
268
269 // Flatten the call args into the bag under `args.<path>`. APL's
270 // own args pipelines read from `route_payload.args` directly,
271 // but PDP steps and predicates that reference `${args.X}` /
272 // `args.X` resolve through the bag. Mirroring the args here
273 // makes both consumers see the same vocabulary the
274 // `MessageView` exposes. (Bag-mutation via redact during the
275 // args pipeline isn't reflected back into the bag; that's fine
276 // — args predicates today read from `route_payload.args`, and
277 // the cedar substitution snapshots the pre-args view, which is
278 // what an author writing `cedar:(resource.id: ${args.X})` would
279 // expect.)
280 extract_args(&route_payload.args, &mut bag);
281 // Post phase: also project the upstream result into the bag
282 // under `result.<path>`. This is what enables predicates like
283 // `redact(result.ssn) when !perm.view_ssn` and `require(...)`
284 // gates that branch on the result. Pre phases skip this — the
285 // result is `None` by construction.
286 if matches!(self.phase, Phase::Post) {
287 if let Some(result_value) = route_payload.result.as_ref() {
288 extract_result(result_value, &mut bag);
289 }
290 }
291
292 // Slice B: real delegation invoker, sharing the CMF invoker's
293 // extensions Mutex so a `delegate(...)` step's writes to
294 // raw_credentials / delegation are visible to downstream CMF
295 // plugins and to the post phase. Routes that don't declare
296 // any `Step::Delegate` won't have entries in the plan's
297 // `delegation_entries` map; if such a route accidentally hits
298 // `delegate(...)`, the invoker returns `NotFound` and the
299 // evaluator translates it via the step's `on_error`.
300 let delegations = Arc::new(DelegationPluginInvoker::new(
301 Arc::clone(&manager),
302 invoker.extensions_arc(),
303 invoker.plan_arc(),
304 ));
305
306 // Unsized coercion: `Arc<ConcreteType>` → `Arc<dyn Trait>`. The
307 // erased forms get borrowed into `evaluate_pre`/`evaluate_post`;
308 // `dispatch_parallel` can then `Arc::clone` an owned 'static
309 // reference into each branch closure.
310 let invoker_dyn: Arc<dyn apl_core::step::PluginInvoker> = invoker.clone();
311 let delegations_dyn: Arc<dyn apl_core::step::DelegationInvoker> = delegations.clone();
312
313 let decision = match self.phase {
314 Phase::Pre => {
315 evaluate_pre(
316 &self.route,
317 &mut bag,
318 &mut route_payload,
319 &self.pdp,
320 &invoker_dyn,
321 &delegations_dyn,
322 )
323 .await
324 },
325 Phase::Post => {
326 evaluate_post(
327 &self.route,
328 &mut bag,
329 &mut route_payload,
330 &self.pdp,
331 &invoker_dyn,
332 &delegations_dyn,
333 )
334 .await
335 },
336 };
337
338 // Drain Session-scoped taints (from `taint(label, session)` /
339 // pipeline `Stage::Taint`) into `extensions.security.labels`
340 // so the existing label-diff flow inside `persist_session`
341 // picks them up. Message-scoped taints are filtered out by
342 // `apply_session_taints` — they need their own destination
343 // (see TS2). No-op when no taints emitted.
344 invoker.apply_session_taints(&decision.taints).await;
345
346 // Commit any session-scoped labels accumulated during this
347 // request. No-op when there was no session id. The result is
348 // folded into the decision below (R18) — captured here because
349 // `continue_processing`/`violation` are computed after persist.
350 let persist_result = invoker.persist_session().await;
351
352 // Surface the final mutated payload + extensions back into the
353 // PipelineResult the executor returns to the host. The host's
354 // body re-serialization picks up edits made by APL pipelines
355 // (e.g. a redact stage that rewrote args.text).
356 let final_payload = invoker.current_payload().await;
357 let final_extensions = invoker.current_extensions().await;
358
359 // Detect whether the args pipeline mutated the payload by
360 // re-extracting from the pre-eval message (msg_payload is
361 // still borrowed) and comparing against the post-eval
362 // route_payload.args. Re-extraction allocates but mirrors the
363 // surrounding pattern and avoids holding a pre-eval clone.
364 let pre_args = extract_args_from_message(&msg_payload.message);
365 // For Post phase, also detect result mutations from `result:`
366 // pipelines. Pre routes don't carry a result so this is None.
367 let pre_result = match self.phase {
368 Phase::Pre => None,
369 Phase::Post => Some(extract_result_from_message(&msg_payload.message)),
370 };
371 let modified_payload: Option<Box<dyn PluginPayload>> = if route_payload.args != pre_args {
372 // An args pipeline (Pre) rewrote a field. Fold the new
373 // args back into a fresh MessagePayload so downstream
374 // readers (the host's body re-serializer) see the
375 // change.
376 let mut updated = final_payload.clone();
377 write_args_back_to_message(&mut updated.message, &route_payload.args);
378 Some(Box::new(updated) as Box<dyn PluginPayload>)
379 } else if matches!(self.phase, Phase::Post)
380 && pre_result
381 .as_ref()
382 .zip(route_payload.result.as_ref())
383 .map(|(prev, current)| prev != current)
384 .unwrap_or(false)
385 {
386 // A `result:` pipeline rewrote a field in the upstream
387 // response. Fold the new result back into the message
388 // so the host's response body re-serializer can write
389 // it out before forwarding downstream.
390 let mut updated = final_payload.clone();
391 if let Some(result_value) = route_payload.result.as_ref() {
392 write_result_back_to_message(&mut updated.message, result_value);
393 }
394 Some(Box::new(updated) as Box<dyn PluginPayload>)
395 } else if msg_payload.message.get_text_content() != final_payload.message.get_text_content()
396 {
397 // A `policy:` plugin mutated the message directly via
398 // `modify_payload` (not through a field pipeline). Pass
399 // the invoker's view through unchanged.
400 Some(Box::new(final_payload) as Box<dyn PluginPayload>)
401 } else {
402 None
403 };
404
405 let modified_extensions = if extensions_changed(extensions, &final_extensions) {
406 Some(final_extensions.cow_copy())
407 } else {
408 None
409 };
410
411 let (mut continue_processing, mut violation) = match decision.decision {
412 Decision::Allow => (true, None),
413 Decision::Deny {
414 reason,
415 rule_source,
416 } => {
417 let code = if rule_source.is_empty() {
418 "policy.deny".to_string()
419 } else {
420 rule_source
421 };
422 let reason = reason.unwrap_or_else(|| "access denied".to_string());
423 (false, Some(PluginViolation::new(code, reason)))
424 },
425 };
426
427 // Append fail-closed (R18) with merge precedence:
428 // - decision Allow + append Err → flip to Deny with a
429 // distinguished `session.persist_failed` violation.
430 // - decision Deny + append Err → keep the original policy
431 // violation (preserve attribution); the request is already
432 // denied. The append failure surfaces only as the alarm.
433 // The alarm/metric fires on every append failure regardless of
434 // decision, since the dangerous residual is a *selective*
435 // failure (append rejected while reads still succeed).
436 if let Err(e) = persist_result {
437 tracing::error!(
438 alarm = "session_store_failure",
439 op = "append",
440 route = %self.route.route_key,
441 decision_was_allow = continue_processing,
442 error = %e,
443 "session label persist failed; failing request closed"
444 );
445 if continue_processing {
446 continue_processing = false;
447 violation = Some(PluginViolation::new(
448 "session.persist_failed",
449 "session state could not be persisted",
450 ));
451 }
452 }
453
454 Ok(Box::new(ErasedResultFields {
455 continue_processing,
456 modified_payload,
457 modified_extensions,
458 violation,
459 }))
460 }
461
462 fn hook_type_name(&self) -> &'static str {
463 // CmfHook::NAME — kept as a literal here to avoid pulling in the
464 // HookTypeDef trait just for the constant.
465 "cmf"
466 }
467}
468
469// =====================================================================
470// Helpers
471// =====================================================================
472
473/// Rewrite the first text part of `msg` with `new_text`. If there is no
474/// text part, append one. Mirrors what `MessagePayload`'s normal
475/// modify-path does for single-view v0.
476fn rewrite_message_text(msg: &mut cpex_core::cmf::Message, new_text: &str) {
477 for part in msg.content.iter_mut() {
478 if let cpex_core::cmf::ContentPart::Text { text } = part {
479 *text = new_text.to_string();
480 return;
481 }
482 }
483 msg.content.push(cpex_core::cmf::ContentPart::Text {
484 text: new_text.to_string(),
485 });
486}
487
488/// Extract `RoutePayload.args` from a CMF message. v0 maps:
489/// * First `ContentPart::ToolCall` → `arguments` map (Object)
490/// * First `ContentPart::PromptRequest` → `arguments` map (Object)
491/// * Else (text / no entity parts) → JSON String of text content
492///
493/// `args.<field>` APL paths target tool / prompt arguments directly.
494/// For text-only messages we fall back to the v0 "args = whole text"
495/// shape so `args.text` predicates keep working.
496fn extract_args_from_message(msg: &cpex_core::cmf::Message) -> Value {
497 use cpex_core::cmf::ContentPart;
498 for part in &msg.content {
499 match part {
500 ContentPart::ToolCall { content } => {
501 return Value::Object(
502 content
503 .arguments
504 .iter()
505 .map(|(k, v)| (k.clone(), v.clone()))
506 .collect(),
507 );
508 },
509 ContentPart::PromptRequest { content } => {
510 return Value::Object(
511 content
512 .arguments
513 .iter()
514 .map(|(k, v)| (k.clone(), v.clone()))
515 .collect(),
516 );
517 },
518 _ => {},
519 }
520 }
521 Value::String(msg.get_text_content())
522}
523
524/// Inverse of [`extract_args_from_message`]: write `args` back into
525/// `msg`'s first ToolCall / PromptRequest argument map, or — for
526/// text payloads — into the first text part.
527///
528/// Silently no-ops when the args shape doesn't match the message
529/// content shape (e.g. operator pipeline produced a String for what
530/// was originally a ToolCall). The mismatch path is recoverable —
531/// the upstream just sees the original unmodified content rather
532/// than a malformed rewrite.
533fn write_args_back_to_message(msg: &mut cpex_core::cmf::Message, args: &Value) {
534 use cpex_core::cmf::ContentPart;
535 for part in msg.content.iter_mut() {
536 match part {
537 ContentPart::ToolCall { content } => {
538 if let Some(obj) = args.as_object() {
539 content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
540 }
541 return;
542 },
543 ContentPart::PromptRequest { content } => {
544 if let Some(obj) = args.as_object() {
545 content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
546 }
547 return;
548 },
549 _ => {},
550 }
551 }
552 // Fall through: no structured entity part — treat as text.
553 if let Some(text) = args.as_str() {
554 rewrite_message_text(msg, text);
555 }
556}
557
558/// Extract `RoutePayload.result` from a CMF message. Mirror of
559/// [`extract_args_from_message`] for the Post phase. v0 maps:
560/// * First `ContentPart::ToolResult` → its `content` JSON value
561/// * Else (text / no structured result part) → JSON String of text
562///
563/// `result.<field>` APL paths target the structured result directly.
564fn extract_result_from_message(msg: &cpex_core::cmf::Message) -> Value {
565 use cpex_core::cmf::ContentPart;
566 for part in &msg.content {
567 if let ContentPart::ToolResult { content } = part {
568 return content.content.clone();
569 }
570 }
571 Value::String(msg.get_text_content())
572}
573
574/// Inverse of [`extract_result_from_message`]: write a mutated
575/// `result` back into the message's first `ContentPart::ToolResult.content`,
576/// or — for text-only messages — into the first text part. The praxis
577/// filter's response-body re-serializer then lifts the new content
578/// out of the ContentPart and folds it back into the JSON-RPC
579/// `result.content[*].text` payload.
580fn write_result_back_to_message(msg: &mut cpex_core::cmf::Message, result: &Value) {
581 use cpex_core::cmf::ContentPart;
582 for part in msg.content.iter_mut() {
583 if let ContentPart::ToolResult { content } = part {
584 content.content = result.clone();
585 return;
586 }
587 }
588 if let Some(text) = result.as_str() {
589 rewrite_message_text(msg, text);
590 }
591}
592
593/// Cheap pointer-equality check across the few mutable extension slots
594/// the executor would care about. False positives (claiming a change
595/// when there isn't one) are cheap — the executor re-validates anyway.
596fn extensions_changed(before: &Extensions, after: &Extensions) -> bool {
597 let security_changed = match (before.security.as_ref(), after.security.as_ref()) {
598 (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
599 (None, None) => false,
600 _ => true,
601 };
602 let delegation_changed = match (before.delegation.as_ref(), after.delegation.as_ref()) {
603 (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
604 (None, None) => false,
605 _ => true,
606 };
607 // `delegate(...)` steps write minted tokens into
608 // `raw_credentials.delegated_tokens` via the shared Mutex —
609 // without this check, a route whose only Extensions mutation is
610 // a delegate (no security / delegation chain edit) looks
611 // unchanged, so the executor never merges the minted token back
612 // and downstream readers (our HttpFilter attaching the token to
613 // the upstream request) see nothing.
614 let raw_creds_changed = match (
615 before.raw_credentials.as_ref(),
616 after.raw_credentials.as_ref(),
617 ) {
618 (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
619 (None, None) => false,
620 _ => true,
621 };
622 security_changed || delegation_changed || raw_creds_changed
623}