Skip to main content

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::constants::{DETAIL_HTTP_BODY, DETAIL_HTTP_HEADERS, DETAIL_HTTP_STATUS};
44use apl_cmf::{extract_args, extract_result, BagBuilder};
45use apl_core::evaluator::Decision;
46use apl_core::plugin_decl::PluginRegistry;
47use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload};
48use apl_core::rules::{CompiledRoute, DenyResponse};
49use apl_core::step::PdpResolver;
50
51use crate::cmf_invoker::CmfPluginInvoker;
52use crate::delegation_invoker::DelegationPluginInvoker;
53use crate::dispatch_plan::DispatchCache;
54use crate::elicitation_invoker::ElicitationPluginInvoker;
55use crate::pdp_router::PdpRouter;
56use crate::session_store::SessionStore;
57
58/// JSON-RPC error code the host emits when a phase suspends on a pending
59/// elicitation: "request not complete — retry echoing the elicitation id."
60/// In the application-reserved JSON-RPC range; carried via
61/// `PluginViolation::proto_error_code` for the host to put on the wire.
62/// The agent SDK keys its pause/resume loop on this code.
63pub const ELICITATION_PENDING_CODE: i64 = -32120;
64
65/// Header an agent echoes on retry to continue a suspended elicitation —
66/// its value is the `elicitation_id` from a prior `-32120`. The handler
67/// seeds it into the bag (`elicitation.id`) before evaluation so the
68/// runtime *checks* the existing elicitation instead of dispatching a new
69/// one. Mirrors how `X-User-Token` carries request-scoped context.
70pub const ELICITATION_ID_HEADER: &str = "X-Policy-Elicitation-Id";
71
72/// JSON-RPC error code emitted when an agent re-checks an approval in
73/// *peek* mode and it has resolved approved: "approved — confirm to apply."
74/// The phase does NOT forward to the tool; the agent confirms with the
75/// requester and re-sends *without* the peek header to actually run it.
76/// Lets a human authorize while the requester separately commits execution.
77pub const ELICITATION_APPROVED_CODE: i64 = -32121;
78
79/// Header an agent sets (alongside `X-Policy-Elicitation-Id`) to *peek* at an
80/// approval — resolve its status without committing the action. Truthy
81/// value ("1"/"true"/anything non-empty) enables it.
82pub const ELICITATION_PEEK_HEADER: &str = "X-Policy-Elicitation-Peek";
83
84/// Which APL phase this handler runs. Pre covers `args` + `policy`; Post
85/// covers `result` + `post_invocation`. Set once at construction and never
86/// changes.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum Phase {
89    Pre,
90    Post,
91}
92
93/// Synthetic plugin that drives APL evaluation for one route + one phase.
94///
95/// Implements `Plugin` (so cpex-core treats it like any other plugin —
96/// mode/capabilities/on_error come from the `PluginConfig` the visitor
97/// supplied at `annotate_route` time) and `AnyHookHandler` (so the
98/// executor dispatches into it through the normal type-erased path).
99pub struct AplRouteHandler {
100    config: PluginConfig,
101    route: Arc<CompiledRoute>,
102    phase: Phase,
103    plugin_registry: Arc<PluginRegistry>,
104    dispatch_cache: Arc<DispatchCache>,
105    session_store: Arc<dyn SessionStore>,
106    /// Weak handle to the manager so we can resolve plugin entries +
107    /// dispatch into them by-name. `Weak` avoids the
108    /// manager↔snapshot↔annotation↔handler cycle.
109    manager: Weak<PluginManager>,
110    /// PDP resolver. APL routes that don't use `pdp(...)` steps never
111    /// touch this. Default is an empty [`PdpRouter`] — any `pdp(...)`
112    /// step against an unregistered dialect returns
113    /// `PdpError::NoResolver`. Hosts that need Cedar, OPA, NeMo, etc.
114    /// install resolvers via [`Self::with_pdp`] or
115    /// [`Self::with_pdp_router`].
116    pdp: Arc<dyn PdpResolver>,
117}
118
119impl AplRouteHandler {
120    /// Build a handler. Visitor calls this twice per route — once for
121    /// each phase — and passes the resulting `Arc` to `annotate_route`.
122    pub fn new(
123        config: PluginConfig,
124        route: Arc<CompiledRoute>,
125        phase: Phase,
126        plugin_registry: Arc<PluginRegistry>,
127        dispatch_cache: Arc<DispatchCache>,
128        session_store: Arc<dyn SessionStore>,
129        manager: Weak<PluginManager>,
130    ) -> Self {
131        Self {
132            config,
133            route,
134            phase,
135            plugin_registry,
136            dispatch_cache,
137            session_store,
138            manager,
139            pdp: Arc::new(PdpRouter::new()),
140        }
141    }
142
143    /// Install a `PdpResolver`. Pass a [`PdpRouter`] when the host needs
144    /// to support multiple dialects (Cedar + OPA + NeMo) on the same
145    /// route — the router dispatches each `pdp(...)` step by dialect.
146    /// Pass a single resolver when only one dialect is in use; APL
147    /// steps for any other dialect will then return
148    /// `PdpError::NoResolver` at evaluation time.
149    pub fn with_pdp(mut self, pdp: Arc<dyn PdpResolver>) -> Self {
150        self.pdp = pdp;
151        self
152    }
153
154    /// Sugar for the common "register many resolvers" path. Builds a
155    /// [`PdpRouter`], registers each resolver into it, then installs the
156    /// router. Equivalent to constructing a `PdpRouter` by hand and
157    /// passing it to [`Self::with_pdp`].
158    pub fn with_pdp_router(
159        mut self,
160        resolvers: impl IntoIterator<Item = Arc<dyn PdpResolver>>,
161    ) -> Self {
162        let mut router = PdpRouter::new();
163        for r in resolvers {
164            router.register(r);
165        }
166        self.pdp = Arc::new(router);
167        self
168    }
169}
170
171#[async_trait]
172impl Plugin for AplRouteHandler {
173    fn config(&self) -> &PluginConfig {
174        &self.config
175    }
176}
177
178#[async_trait]
179impl AnyHookHandler for AplRouteHandler {
180    async fn invoke(
181        &self,
182        payload: &dyn PluginPayload,
183        extensions: &Extensions,
184        _ctx: &mut PluginContext,
185    ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
186        // Downcast to the CMF payload — this handler only registers for
187        // cmf.* hook names, so the executor should always hand us a
188        // MessagePayload. A mismatch indicates a framework wiring bug.
189        let msg_payload = payload
190            .as_any()
191            .downcast_ref::<MessagePayload>()
192            .ok_or_else(|| {
193                Box::new(PluginError::Config {
194                    message: format!(
195                        "AplRouteHandler '{}': payload was not MessagePayload",
196                        self.route.route_key
197                    ),
198                })
199            })?;
200
201        let manager = self.manager.upgrade().ok_or_else(|| {
202            Box::new(PluginError::Config {
203                message: format!(
204                    "AplRouteHandler '{}': PluginManager dropped before invoke",
205                    self.route.route_key
206                ),
207            })
208        })?;
209
210        // Build (or reuse) the dispatch plan for this route. Cache keyed
211        // by `(route_key, manager.config_generation())` — if the manager
212        // has reloaded since the last invoke, the next lookup rebuilds.
213        let plan = self
214            .dispatch_cache
215            .get_or_build(&self.route, &self.plugin_registry, &manager)
216            .await;
217
218        // CmfPluginInvoker carries the request-scoped payload + extensions
219        // under interior mutability so successive plugin calls accumulate
220        // mutations. Hydration + persistence are no-ops when there's no
221        // session id (the common case for the first request in a session).
222        // Wrapped in Arc so it can be erased to `Arc<dyn PluginInvoker>`
223        // for the apl-core entry points (which take `&Arc<dyn PluginInvoker>`
224        // so `dispatch_parallel` can clone an owned, 'static reference into
225        // each spawned branch). Inherent-method calls on `CmfPluginInvoker`
226        // (e.g. `extensions_arc`, `persist_session`) deref through the Arc.
227        // Hydration loads accumulated session labels. A store failure
228        // here happens *before* any policy decision, so we fail the
229        // request closed immediately: deny with a
230        // distinguished violation rather than proceeding as if the
231        // session carried no taint. Sessionless traffic never reaches
232        // the store, so this only denies session-bearing requests.
233        let invoker = match CmfPluginInvoker::for_request(
234            Arc::clone(&manager),
235            extensions.clone(),
236            msg_payload.clone(),
237            plan,
238            Arc::clone(&self.session_store),
239        )
240        .await
241        {
242            Ok(inv) => Arc::new(inv),
243            Err(e) => {
244                tracing::error!(
245                    alarm = "session_store_failure",
246                    op = "load",
247                    route = %self.route.route_key,
248                    error = %e,
249                    "session label load failed; failing request closed"
250                );
251                let mut v = PluginViolation::new(
252                    "session.load_failed",
253                    "session state could not be loaded",
254                );
255                decorate_denial_response(&mut v, self.route.response.as_ref());
256                return Ok(Box::new(ErasedResultFields {
257                    continue_processing: false,
258                    modified_payload: None,
259                    modified_extensions: None,
260                    violation: Some(v),
261                }));
262            },
263        };
264
265        // Build the attribute bag. APL predicates read flat keys; the
266        // BagBuilder bridges typed CPEX extensions into that namespace.
267        // `route.key` lets default/policy-bundle predicates branch on
268        // which route they're attached to.
269        let post_extensions = invoker.current_extensions().await;
270        let mut bag = BagBuilder::new()
271            .with_extensions(&post_extensions)
272            .with_route_key(&self.route.route_key)
273            .build();
274
275        // Phase 5 retry seeding: if the agent echoed an elicitation id (from
276        // a prior `-32120`) in the `X-Policy-Elicitation-Id` header, seed it
277        // into the bag *before* evaluation. `dispatch_elicitation` then takes
278        // the "id present → check" path (poll the existing approval) instead
279        // of dispatching a fresh one. Without this, every retry would open a
280        // new approval and the loop would never resolve.
281        if let Some(elicitation_id) = elicitation_id_from_headers(&post_extensions) {
282            bag.set(apl_core::step::elicitation_bag_keys::ID, elicitation_id);
283        }
284
285        // Build `RoutePayload.args` from the message. Per-content shape:
286        //   * ToolCall      → arguments map (JSON Object)
287        //   * PromptRequest → arguments map (JSON Object)
288        //   * Text-only     → JSON String of concatenated text content
289        //
290        // Field pipelines operate on `args.<name>` paths. Result starts
291        // as Null on Pre (no upstream response yet); the Post phase
292        // would extract from a ToolResult / PromptResult — deferred
293        // until result-side handling lands.
294        let args_value = extract_args_from_message(&msg_payload.message);
295        let mut route_payload = match self.phase {
296            Phase::Pre => RoutePayload::new(args_value),
297            Phase::Post => {
298                // Pull the upstream result out of the message so APL
299                // `result.<field>` predicates and the `result:`
300                // pipeline have something to operate on. Falls back to
301                // `Value::Null` when the message has no ToolResult /
302                // PromptResult / Resource content (e.g. for hooks that
303                // fire on entities without a structured result).
304                let result_value = extract_result_from_message(&msg_payload.message);
305                RoutePayload::with_result(args_value, result_value)
306            },
307        };
308
309        // Flatten the call args into the bag under `args.<path>`. APL's
310        // own args pipelines read from `route_payload.args` directly,
311        // but PDP steps and predicates that reference `${args.X}` /
312        // `args.X` resolve through the bag. Mirroring the args here
313        // makes both consumers see the same vocabulary the
314        // `MessageView` exposes. (Bag-mutation via redact during the
315        // args pipeline isn't reflected back into the bag; that's fine
316        // — args predicates today read from `route_payload.args`, and
317        // the cedar substitution snapshots the pre-args view, which is
318        // what an author writing `cedar:(resource.id: ${args.X})` would
319        // expect.)
320        extract_args(&route_payload.args, &mut bag);
321        // Post phase: also project the upstream result into the bag
322        // under `result.<path>`. This is what enables predicates like
323        // `redact(result.ssn) when !perm.view_ssn` and `require(...)`
324        // gates that branch on the result. Pre phases skip this — the
325        // result is `None` by construction.
326        if matches!(self.phase, Phase::Post) {
327            if let Some(result_value) = route_payload.result.as_ref() {
328                extract_result(result_value, &mut bag);
329            }
330        }
331
332        // Slice B: real delegation invoker, sharing the CMF invoker's
333        // extensions Mutex so a `delegate(...)` step's writes to
334        // raw_credentials / delegation are visible to downstream CMF
335        // plugins and to the post phase. Routes that don't declare
336        // any `Step::Delegate` won't have entries in the plan's
337        // `delegation_entries` map; if such a route accidentally hits
338        // `delegate(...)`, the invoker returns `NotFound` and the
339        // evaluator translates it via the step's `on_error`.
340        let delegations = Arc::new(DelegationPluginInvoker::new(
341            Arc::clone(&manager),
342            invoker.extensions_arc(),
343            invoker.plan_arc(),
344        ));
345
346        // Unsized coercion: `Arc<ConcreteType>` → `Arc<dyn Trait>`. The
347        // erased forms get borrowed into `evaluate_pre`/`evaluate_post`;
348        // `dispatch_parallel` can then `Arc::clone` an owned 'static
349        // reference into each branch closure.
350        // Elicitation bridge — resolves `require_approval(...)` /
351        // `confirm(...)` steps to `ElicitationHook` plugins by name off
352        // the same plan, sharing the request's Extensions so the handler
353        // reads the same identity. Routes with no elicitation steps have
354        // an empty `elicitation_entries` map; an accidental `Effect::Elicit`
355        // then returns `NotFound`, handled by the step's `on_error`.
356        let elicitations = Arc::new(ElicitationPluginInvoker::new(
357            Arc::clone(&manager),
358            invoker.extensions_arc(),
359            invoker.plan_arc(),
360        ));
361
362        let invoker_dyn: Arc<dyn apl_core::step::PluginInvoker> = invoker.clone();
363        let delegations_dyn: Arc<dyn apl_core::step::DelegationInvoker> = delegations.clone();
364        let elicitations_dyn: Arc<dyn apl_core::step::ElicitationInvoker> = elicitations.clone();
365
366        let decision = match self.phase {
367            Phase::Pre => {
368                evaluate_pre(
369                    &self.route,
370                    &mut bag,
371                    &mut route_payload,
372                    &self.pdp,
373                    &invoker_dyn,
374                    &delegations_dyn,
375                    &elicitations_dyn,
376                )
377                .await
378            },
379            Phase::Post => {
380                evaluate_post(
381                    &self.route,
382                    &mut bag,
383                    &mut route_payload,
384                    &self.pdp,
385                    &invoker_dyn,
386                    &delegations_dyn,
387                    &elicitations_dyn,
388                )
389                .await
390            },
391        };
392
393        // Drain Session-scoped taints (from `taint(label, session)` /
394        // pipeline `Stage::Taint`) into `extensions.security.labels`
395        // so the existing label-diff flow inside `persist_session`
396        // picks them up. Message-scoped taints are filtered out by
397        // `apply_session_taints` — they need their own destination
398        // (see TS2). No-op when no taints emitted.
399        invoker.apply_session_taints(&decision.taints).await;
400
401        // Commit any session-scoped labels accumulated during this
402        // request. No-op when there was no session id. The result is
403        // folded into the decision below — captured here because
404        // `continue_processing`/`violation` are computed after persist.
405        let persist_result = invoker.persist_session().await;
406
407        // Surface the final mutated payload + extensions back into the
408        // PipelineResult the executor returns to the host. The host's
409        // body re-serialization picks up edits made by APL pipelines
410        // (e.g. a redact stage that rewrote args.text).
411        let final_payload = invoker.current_payload().await;
412        let final_extensions = invoker.current_extensions().await;
413
414        // Detect whether the args pipeline mutated the payload by
415        // re-extracting from the pre-eval message (msg_payload is
416        // still borrowed) and comparing against the post-eval
417        // route_payload.args. Re-extraction allocates but mirrors the
418        // surrounding pattern and avoids holding a pre-eval clone.
419        let pre_args = extract_args_from_message(&msg_payload.message);
420        // For Post phase, also detect result mutations from `result:`
421        // pipelines. Pre routes don't carry a result so this is None.
422        let pre_result = match self.phase {
423            Phase::Pre => None,
424            Phase::Post => Some(extract_result_from_message(&msg_payload.message)),
425        };
426        let modified_payload: Option<Box<dyn PluginPayload>> = if route_payload.args != pre_args {
427            // An args pipeline (Pre) rewrote a field. Fold the new
428            // args back into a fresh MessagePayload so downstream
429            // readers (the host's body re-serializer) see the
430            // change.
431            let mut updated = final_payload.clone();
432            write_args_back_to_message(&mut updated.message, &route_payload.args);
433            Some(Box::new(updated) as Box<dyn PluginPayload>)
434        } else if matches!(self.phase, Phase::Post)
435            && pre_result
436                .as_ref()
437                .zip(route_payload.result.as_ref())
438                .map(|(prev, current)| prev != current)
439                .unwrap_or(false)
440        {
441            // A `result:` pipeline rewrote a field in the upstream
442            // response. Fold the new result back into the message
443            // so the host's response body re-serializer can write
444            // it out before forwarding downstream.
445            let mut updated = final_payload.clone();
446            if let Some(result_value) = route_payload.result.as_ref() {
447                write_result_back_to_message(&mut updated.message, result_value);
448            }
449            Some(Box::new(updated) as Box<dyn PluginPayload>)
450        } else if msg_payload.message.get_text_content() != final_payload.message.get_text_content()
451        {
452            // A `pre_invocation:` plugin mutated the message directly via
453            // `modify_payload` (not through a field pipeline). Pass
454            // the invoker's view through unchanged.
455            Some(Box::new(final_payload) as Box<dyn PluginPayload>)
456        } else {
457            None
458        };
459
460        let modified_extensions = if extensions_changed(extensions, &final_extensions) {
461            Some(final_extensions.cow_copy())
462        } else {
463            None
464        };
465
466        // A suspended phase reports `Allow` with a pending bundle — it
467        // must NOT forward. Fail closed with a distinguished violation that
468        // carries the elicitation id (mapped to JSON-RPC `-32120`) so the
469        // suspend is visible and the unapproved call never proceeds.
470        let pending_elicitation = decision.pending.clone();
471
472        // Attach the route's transpiled `denyWith` to a violation at each
473        // genuine-denial site (below) via `decorate_denial_response`, rather
474        // than blanket-decorating whatever `violation` is set. This keeps the
475        // custom response off any future non-denial signal (e.g. an
476        // elicitation/retry/confirm violation) that must reach the host with
477        // its own wire shape intact.
478        let (mut continue_processing, mut violation) = match decision.decision {
479            Decision::Allow => (true, None),
480            Decision::Deny {
481                reason,
482                rule_source,
483            } => {
484                let code = if rule_source.is_empty() {
485                    "policy.deny".to_string()
486                } else {
487                    rule_source
488                };
489                let reason = reason.unwrap_or_else(|| "access denied".to_string());
490                let mut v = PluginViolation::new(code, reason);
491                decorate_denial_response(&mut v, self.route.response.as_ref());
492                (false, Some(v))
493            },
494        };
495
496        if let Some(p) = &pending_elicitation {
497            tracing::info!(
498                route = %self.route.route_key,
499                elicitation_id = %p.id,
500                plugin = %p.plugin_name,
501                "policy suspended on pending elicitation; emitting -32120 (retry)"
502            );
503            // The phase suspended awaiting a human. Do NOT forward. Surface
504            // a structured "request not complete — retry echoing this id"
505            // via the protocol error code the host maps to the wire
506            // (JSON-RPC `-32120`). Left undecorated by `denyWith` — it is a
507            // retry signal, not a denial.
508            continue_processing = false;
509            violation = Some(pending_violation(p));
510        }
511
512        // Peek (confirm-then-apply): the agent re-checked an approval but
513        // asked NOT to commit yet (the `X-Policy-Elicitation-Peek` header). If
514        // the elicitation resolved approved (Allow, not pending), report
515        // "approved — confirm to apply" (-32121) and do NOT forward. The
516        // agent then asks the requester, who re-sends without the peek header
517        // to actually run the tool (the plugin replays the cached approval).
518        if continue_processing
519            && elicitation_peek_from_headers(&post_extensions)
520            && bag.get_string(apl_core::step::elicitation_bag_keys::OUTCOME) == Some("approved")
521        {
522            continue_processing = false;
523            violation = Some(approved_peek_violation(&bag));
524        }
525
526        // Append fail-closed with merge precedence:
527        //   - decision Allow + append Err → flip to Deny with a
528        //     distinguished `session.persist_failed` violation.
529        //   - decision Deny + append Err → keep the original policy
530        //     violation (preserve attribution); the request is already
531        //     denied. The append failure surfaces only as the alarm.
532        // The alarm/metric fires on every append failure regardless of
533        // decision, since the dangerous residual is a *selective*
534        // failure (append rejected while reads still succeed).
535        if let Err(e) = persist_result {
536            tracing::error!(
537                alarm = "session_store_failure",
538                op = "append",
539                route = %self.route.route_key,
540                decision_was_allow = continue_processing,
541                error = %e,
542                "session label persist failed; failing request closed"
543            );
544            if continue_processing {
545                continue_processing = false;
546                let mut v = PluginViolation::new(
547                    "session.persist_failed",
548                    "session state could not be persisted",
549                );
550                decorate_denial_response(&mut v, self.route.response.as_ref());
551                violation = Some(v);
552            }
553        }
554
555        Ok(Box::new(ErasedResultFields {
556            continue_processing,
557            modified_payload,
558            modified_extensions,
559            violation,
560        }))
561    }
562
563    fn hook_type_name(&self) -> &'static str {
564        // CmfHook::NAME — kept as a literal here to avoid pulling in the
565        // HookTypeDef trait just for the constant.
566        "cmf"
567    }
568}
569
570// =====================================================================
571// Helpers
572// =====================================================================
573
574/// Attach a route's transpiled `denyWith` (status/body/headers) to a
575/// denial `violation`'s `details` map so the host can render a custom HTTP
576/// denial response. Carried via `details` (not new violation fields) to
577/// keep the violation type stable. `None` response leaves the host default.
578///
579/// Call this only from genuine-denial sites — never blanket-apply it to
580/// whatever violation happens to be set, or a non-denial signal (e.g. an
581/// elicitation/retry/confirm) would get stamped with a `403`-shaped
582/// response the host would render instead of the intended wire signal.
583fn decorate_denial_response(violation: &mut PluginViolation, response: Option<&DenyResponse>) {
584    let Some(resp) = response else {
585        return;
586    };
587    if let Some(status) = resp.status {
588        violation
589            .details
590            .insert(DETAIL_HTTP_STATUS.to_string(), serde_json::json!(status));
591    }
592    if let Some(body) = &resp.body {
593        violation
594            .details
595            .insert(DETAIL_HTTP_BODY.to_string(), serde_json::json!(body));
596    }
597    if !resp.headers.is_empty() {
598        violation.details.insert(
599            DETAIL_HTTP_HEADERS.to_string(),
600            serde_json::json!(resp.headers),
601        );
602    }
603}
604
605/// Rewrite the first text part of `msg` with `new_text`. If there is no
606/// text part, append one. Mirrors what `MessagePayload`'s normal
607/// modify-path does for single-view v0.
608fn rewrite_message_text(msg: &mut cpex_core::cmf::Message, new_text: &str) {
609    for part in msg.content.iter_mut() {
610        if let cpex_core::cmf::ContentPart::Text { text } = part {
611            *text = new_text.to_string();
612            return;
613        }
614    }
615    msg.content.push(cpex_core::cmf::ContentPart::Text {
616        text: new_text.to_string(),
617    });
618}
619
620/// Extract `RoutePayload.args` from a CMF message. v0 maps:
621///   * First `ContentPart::ToolCall`      → `arguments` map (Object)
622///   * First `ContentPart::PromptRequest` → `arguments` map (Object)
623///   * Else (text / no entity parts)      → JSON String of text content
624///
625/// `args.<field>` APL paths target tool / prompt arguments directly.
626/// For text-only messages we fall back to the v0 "args = whole text"
627/// shape so `args.text` predicates keep working.
628fn extract_args_from_message(msg: &cpex_core::cmf::Message) -> Value {
629    use cpex_core::cmf::ContentPart;
630    for part in &msg.content {
631        match part {
632            ContentPart::ToolCall { content } => {
633                return Value::Object(
634                    content
635                        .arguments
636                        .iter()
637                        .map(|(k, v)| (k.clone(), v.clone()))
638                        .collect(),
639                );
640            },
641            ContentPart::PromptRequest { content } => {
642                return Value::Object(
643                    content
644                        .arguments
645                        .iter()
646                        .map(|(k, v)| (k.clone(), v.clone()))
647                        .collect(),
648                );
649            },
650            _ => {},
651        }
652    }
653    Value::String(msg.get_text_content())
654}
655
656/// Inverse of [`extract_args_from_message`]: write `args` back into
657/// `msg`'s first ToolCall / PromptRequest argument map, or — for
658/// text payloads — into the first text part.
659///
660/// Silently no-ops when the args shape doesn't match the message
661/// content shape (e.g. operator pipeline produced a String for what
662/// was originally a ToolCall). The mismatch path is recoverable —
663/// the upstream just sees the original unmodified content rather
664/// than a malformed rewrite.
665fn write_args_back_to_message(msg: &mut cpex_core::cmf::Message, args: &Value) {
666    use cpex_core::cmf::ContentPart;
667    for part in msg.content.iter_mut() {
668        match part {
669            ContentPart::ToolCall { content } => {
670                if let Some(obj) = args.as_object() {
671                    content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
672                }
673                return;
674            },
675            ContentPart::PromptRequest { content } => {
676                if let Some(obj) = args.as_object() {
677                    content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
678                }
679                return;
680            },
681            _ => {},
682        }
683    }
684    // Fall through: no structured entity part — treat as text.
685    if let Some(text) = args.as_str() {
686        rewrite_message_text(msg, text);
687    }
688}
689
690/// Extract `RoutePayload.result` from a CMF message. Mirror of
691/// [`extract_args_from_message`] for the Post phase. v0 maps:
692///   * First `ContentPart::ToolResult` → its `content` JSON value
693///   * Else (text / no structured result part) → JSON String of text
694///
695/// `result.<field>` APL paths target the structured result directly.
696fn extract_result_from_message(msg: &cpex_core::cmf::Message) -> Value {
697    use cpex_core::cmf::ContentPart;
698    for part in &msg.content {
699        if let ContentPart::ToolResult { content } = part {
700            return content.content.clone();
701        }
702    }
703    Value::String(msg.get_text_content())
704}
705
706/// Inverse of [`extract_result_from_message`]: write a mutated
707/// `result` back into the message's first `ContentPart::ToolResult.content`,
708/// or — for text-only messages — into the first text part. The praxis
709/// filter's response-body re-serializer then lifts the new content
710/// out of the ContentPart and folds it back into the JSON-RPC
711/// `result.content[*].text` payload.
712fn write_result_back_to_message(msg: &mut cpex_core::cmf::Message, result: &Value) {
713    use cpex_core::cmf::ContentPart;
714    for part in msg.content.iter_mut() {
715        if let ContentPart::ToolResult { content } = part {
716            content.content = result.clone();
717            return;
718        }
719    }
720    if let Some(text) = result.as_str() {
721        rewrite_message_text(msg, text);
722    }
723}
724
725/// Cheap pointer-equality check across the few mutable extension slots
726/// the executor would care about. False positives (claiming a change
727/// when there isn't one) are cheap — the executor re-validates anyway.
728fn extensions_changed(before: &Extensions, after: &Extensions) -> bool {
729    let security_changed = match (before.security.as_ref(), after.security.as_ref()) {
730        (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
731        (None, None) => false,
732        _ => true,
733    };
734    let delegation_changed = match (before.delegation.as_ref(), after.delegation.as_ref()) {
735        (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
736        (None, None) => false,
737        _ => true,
738    };
739    // `delegate(...)` steps write minted tokens into
740    // `raw_credentials.delegated_tokens` via the shared Mutex —
741    // without this check, a route whose only Extensions mutation is
742    // a delegate (no security / delegation chain edit) looks
743    // unchanged, so the executor never merges the minted token back
744    // and downstream readers (our HttpFilter attaching the token to
745    // the upstream request) see nothing.
746    let raw_creds_changed = match (
747        before.raw_credentials.as_ref(),
748        after.raw_credentials.as_ref(),
749    ) {
750        (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
751        (None, None) => false,
752        _ => true,
753    };
754    security_changed || delegation_changed || raw_creds_changed
755}
756
757// ---------------------------------------------------------------------
758// Phase 5: pending elicitation ↔ wire (`-32120`)
759// ---------------------------------------------------------------------
760
761/// Extract the elicitation id an agent echoes on retry from the
762/// `X-Policy-Elicitation-Id` request header. `None` when absent/empty.
763/// Pure so it's unit-testable without the full handler path.
764fn elicitation_id_from_headers(ext: &Extensions) -> Option<String> {
765    ext.http
766        .as_ref()
767        .and_then(|h| h.get_request_header(ELICITATION_ID_HEADER))
768        .filter(|v| !v.is_empty())
769        .map(str::to_string)
770}
771
772/// True when the agent set `X-Policy-Elicitation-Peek` to a truthy value —
773/// it wants to resolve the approval's status without committing the action.
774fn elicitation_peek_from_headers(ext: &Extensions) -> bool {
775    ext.http
776        .as_ref()
777        .and_then(|h| h.get_request_header(ELICITATION_PEEK_HEADER))
778        .is_some_and(|v| !v.is_empty() && !v.eq_ignore_ascii_case("false") && v != "0")
779}
780
781/// Build the `-32121` "approved — confirm to apply" violation for a peek
782/// that resolved approved. Carries the elicitation id + approver in
783/// `details` so the agent can ask the requester and then re-send (without
784/// the peek header) to actually run the tool.
785fn approved_peek_violation(bag: &apl_core::attributes::AttributeBag) -> PluginViolation {
786    use apl_core::step::elicitation_bag_keys as bk;
787    let mut details: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
788    if let Some(id) = bag.get_string(bk::ID) {
789        details.insert("elicitation_id".into(), Value::String(id.to_string()));
790    }
791    if let Some(approver) = bag.get_string(bk::APPROVER) {
792        details.insert("approver".into(), Value::String(approver.to_string()));
793    }
794    PluginViolation::new(
795        "elicitation.approved",
796        "approved — confirm to apply (re-send without the peek header)".to_string(),
797    )
798    .with_proto_error_code(ELICITATION_APPROVED_CODE)
799    .with_details(details)
800}
801
802/// Build the `-32120` violation for a suspended phase: a distinguished
803/// code, the protocol error code the host maps to the wire, and the
804/// elicitation bundle in `details` so the agent can show who's approving /
805/// when it expires and retry by re-sending the id.
806fn pending_violation(p: &apl_core::step::PendingElicitation) -> PluginViolation {
807    let mut details: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
808    details.insert("elicitation_id".into(), Value::String(p.id.clone()));
809    details.insert("plugin".into(), Value::String(p.plugin_name.clone()));
810    for (key, val) in [
811        ("approver", &p.approver),
812        ("channel", &p.channel),
813        ("expires_at", &p.expires_at),
814        ("intent_id", &p.intent_id),
815    ] {
816        if let Some(v) = val {
817            details.insert(key.into(), Value::String(v.clone()));
818        }
819    }
820    PluginViolation::new(
821        "elicitation.pending",
822        format!(
823            "awaiting approval `{}` via `{}` — retry with this id",
824            p.id, p.plugin_name
825        ),
826    )
827    .with_proto_error_code(ELICITATION_PENDING_CODE)
828    .with_details(details)
829}
830
831#[cfg(test)]
832mod phase5_tests {
833    use super::*;
834    use cpex_core::extensions::HttpExtension;
835    use std::sync::Arc;
836
837    fn pending(id: &str) -> apl_core::step::PendingElicitation {
838        apl_core::step::PendingElicitation {
839            id: id.to_string(),
840            plugin_name: "manager-approver".to_string(),
841            approver: Some("alice".to_string()),
842            intent_id: None,
843            channel: Some("ciba".to_string()),
844            expires_at: Some("2026-12-31T00:00:00Z".to_string()),
845            source: "route.payroll.policy[0]".to_string(),
846        }
847    }
848
849    #[test]
850    fn pending_violation_carries_minus32120_and_bundle() {
851        let v = pending_violation(&pending("elic-1"));
852        assert_eq!(v.proto_error_code, Some(ELICITATION_PENDING_CODE));
853        assert_eq!(v.code, "elicitation.pending");
854        assert_eq!(v.details.get("elicitation_id").unwrap(), "elic-1");
855        assert_eq!(v.details.get("approver").unwrap(), "alice");
856        assert_eq!(v.details.get("channel").unwrap(), "ciba");
857        assert_eq!(v.details.get("expires_at").unwrap(), "2026-12-31T00:00:00Z");
858        // Absent optional → not in details.
859        assert!(!v.details.contains_key("intent_id"));
860    }
861
862    #[test]
863    fn elicitation_id_extracted_from_header_case_insensitively() {
864        let mut http = HttpExtension::default();
865        http.set_request_header("x-policy-elicitation-id", "elic-42");
866        let ext = Extensions {
867            http: Some(Arc::new(http)),
868            ..Extensions::default()
869        };
870        assert_eq!(
871            elicitation_id_from_headers(&ext).as_deref(),
872            Some("elic-42")
873        );
874    }
875
876    #[test]
877    fn no_header_yields_none() {
878        // No http extension at all.
879        assert!(elicitation_id_from_headers(&Extensions::default()).is_none());
880        // Header present but empty → treated as absent.
881        let mut http = HttpExtension::default();
882        http.set_request_header(ELICITATION_ID_HEADER, "");
883        let ext = Extensions {
884            http: Some(Arc::new(http)),
885            ..Extensions::default()
886        };
887        assert!(elicitation_id_from_headers(&ext).is_none());
888    }
889}