Skip to main content

cpex_core/extensions/
container.rs

1// Location: ./crates/cpex-core/src/extensions/container.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Extensions and OwnedExtensions — typed containers for all
7// extension data passed separately from the payload to handlers.
8//
9// Extensions is fully immutable (all Arc<T>) — zero-copy shareable.
10// OwnedExtensions is the plugin's writeable workspace, created by
11// cow_copy(), returned in PluginResult::modify_extensions().
12
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use serde::{Deserialize, Serialize};
17
18use super::agent::AgentExtension;
19use super::completion::CompletionExtension;
20use super::delegation::DelegationExtension;
21use super::framework::FrameworkExtension;
22use super::guarded::{Guarded, WriteToken};
23use super::http::HttpExtension;
24use super::llm::LLMExtension;
25use super::mcp::MCPExtension;
26use super::meta::MetaExtension;
27use super::provenance::ProvenanceExtension;
28use super::raw_credentials::RawCredentialsExtension;
29use super::request::RequestExtension;
30use super::security::SecurityExtension;
31
32// ---------------------------------------------------------------------------
33// Extensions — all Arc, fully immutable, zero-copy shareable
34// ---------------------------------------------------------------------------
35
36/// Typed container for all message extensions.
37///
38/// All slots are `Arc<T>` — fully immutable, zero-copy shareable.
39/// Cloning is all refcount bumps. `filter_extensions()` creates a
40/// filtered view by setting unwanted slots to `None` (still all Arc,
41/// no deep copies). Plugins receive `&Extensions` (zero cost).
42///
43/// To modify, plugins call `cow_copy()` which returns an
44/// `OwnedExtensions` with mutable/monotonic/guarded slots cloned
45/// out of Arc and write tokens propagated.
46///
47/// Mirrors Python's `cpex.framework.extensions.Extensions`.
48#[derive(Debug, Default, Serialize, Deserialize)]
49pub struct Extensions {
50    /// Execution environment and request tracing (immutable).
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub request: Option<Arc<RequestExtension>>,
53
54    /// Agent execution context — session, conversation, lineage (immutable).
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub agent: Option<Arc<AgentExtension>>,
57
58    /// HTTP headers (frozen as Arc — unfrozen in OwnedExtensions).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub http: Option<Arc<HttpExtension>>,
61
62    /// Security — labels, classification, subject (frozen as Arc).
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub security: Option<Arc<SecurityExtension>>,
65
66    /// Delegation chain (frozen as Arc).
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub delegation: Option<Arc<DelegationExtension>>,
69
70    /// Raw credential material — Layer 3 of the credential storage
71    /// model (see `RawCredentialsExtension` docs). Capability-gated;
72    /// `filter_extensions` strips this slot for plugins without
73    /// `read_inbound_credentials` / `read_delegated_tokens`. Token
74    /// fields inside this extension are `#[serde(skip)]`, so any
75    /// serialization (logs, audit dumps, hot-reload snapshots) drops
76    /// secret material even when the slot itself survives. The
77    /// out-of-process consequence — remote / WASM plugins can't see
78    /// raw tokens at all — is intentional and documented on
79    /// `RawCredentialsExtension`.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub raw_credentials: Option<Arc<RawCredentialsExtension>>,
82
83    /// MCP entity metadata (immutable).
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub mcp: Option<Arc<MCPExtension>>,
86
87    /// LLM completion information (immutable).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub completion: Option<Arc<CompletionExtension>>,
90
91    /// Origin and message threading (immutable).
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub provenance: Option<Arc<ProvenanceExtension>>,
94
95    /// Model identity and capabilities (immutable).
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub llm: Option<Arc<LLMExtension>>,
98
99    /// Agentic framework context (immutable).
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub framework: Option<Arc<FrameworkExtension>>,
102
103    /// Host-provided operational metadata (immutable).
104    #[serde(default)]
105    pub meta: Option<Arc<MetaExtension>>,
106
107    /// Custom extensions (frozen as Arc — unfrozen in OwnedExtensions).
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub custom: Option<Arc<HashMap<String, serde_json::Value>>>,
110
111    /// Write tokens — set by the executor per plugin, NOT serialized.
112    /// Used by `cow_copy()` to propagate write access to OwnedExtensions.
113    #[serde(skip)]
114    pub http_write_token: Option<WriteToken>,
115    #[serde(skip)]
116    pub labels_write_token: Option<WriteToken>,
117    #[serde(skip)]
118    pub delegation_write_token: Option<WriteToken>,
119}
120
121impl Clone for Extensions {
122    /// All Arc bumps — zero data copies. Write tokens are NOT cloned.
123    fn clone(&self) -> Self {
124        Self {
125            request: self.request.clone(),
126            agent: self.agent.clone(),
127            http: self.http.clone(),
128            security: self.security.clone(),
129            delegation: self.delegation.clone(),
130            raw_credentials: self.raw_credentials.clone(),
131            mcp: self.mcp.clone(),
132            completion: self.completion.clone(),
133            provenance: self.provenance.clone(),
134            llm: self.llm.clone(),
135            framework: self.framework.clone(),
136            meta: self.meta.clone(),
137            custom: self.custom.clone(),
138            http_write_token: None,
139            labels_write_token: None,
140            delegation_write_token: None,
141        }
142    }
143}
144
145impl Extensions {
146    /// Create a copy-on-write owned copy for modification.
147    ///
148    /// Immutable slots share the same `Arc` (refcount bump, ~1ns).
149    /// Mutable/monotonic/guarded slots are cloned out of Arc into
150    /// owned values — the plugin can modify them directly.
151    /// Write tokens are propagated from the original.
152    ///
153    /// # Usage
154    ///
155    /// ```ignore
156    /// fn handle(&self, payload: &P, ext: &Extensions, ctx: &mut PluginContext) -> PluginResult<P> {
157    ///     let mut owned = ext.cow_copy();
158    ///     owned.security.as_mut().unwrap().add_label("CHECKED");
159    ///     if let Some(ref token) = owned.http_write_token {
160    ///         owned.http.as_mut().unwrap().write(token).set_header("X-Foo", "bar");
161    ///     }
162    ///     PluginResult::modify_extensions(owned)
163    /// }
164    /// ```
165    pub fn cow_copy(&self) -> OwnedExtensions {
166        OwnedExtensions {
167            // Immutable — same Arc pointers
168            request: self.request.clone(),
169            agent: self.agent.clone(),
170            mcp: self.mcp.clone(),
171            completion: self.completion.clone(),
172            provenance: self.provenance.clone(),
173            llm: self.llm.clone(),
174            framework: self.framework.clone(),
175            meta: self.meta.clone(),
176            raw_credentials: self.raw_credentials.clone(),
177
178            // Mutable/monotonic/guarded — cloned out of Arc into owned
179            http: self.http.as_ref().map(|arc| Guarded::new((**arc).clone())),
180            security: self.security.as_ref().map(|arc| (**arc).clone()),
181            delegation: self.delegation.as_ref().map(|arc| (**arc).clone()),
182            custom: self.custom.as_ref().map(|arc| (**arc).clone()),
183
184            // Write tokens — propagated from the original
185            http_write_token: if self.http_write_token.is_some() {
186                Some(WriteToken::new())
187            } else {
188                None
189            },
190            labels_write_token: if self.labels_write_token.is_some() {
191                Some(WriteToken::new())
192            } else {
193                None
194            },
195            delegation_write_token: if self.delegation_write_token.is_some() {
196                Some(WriteToken::new())
197            } else {
198                None
199            },
200        }
201    }
202
203    /// Validate that immutable slots were not tampered with.
204    ///
205    /// A slot that is `None` in modified (because capability filtering
206    /// hid it from the plugin) is always valid — the plugin never saw
207    /// it. Only flag as tampering when both are `Some` with different
208    /// Arc pointers, or when the original is `None` but modified is
209    /// `Some` (the plugin fabricated a slot it shouldn't have).
210    pub fn validate_immutable(&self, modified: &OwnedExtensions) -> bool {
211        fn ptr_eq_opt<T>(a: &Option<Arc<T>>, b: &Option<Arc<T>>) -> bool {
212            match (a, b) {
213                (Some(a), Some(b)) => Arc::ptr_eq(a, b),
214                (None, None) => true,
215                (_, None) => true,        // plugin never saw it — not tampering
216                (None, Some(_)) => false, // plugin fabricated a slot
217            }
218        }
219
220        ptr_eq_opt(&self.request, &modified.request)
221            && ptr_eq_opt(&self.agent, &modified.agent)
222            && ptr_eq_opt(&self.mcp, &modified.mcp)
223            && ptr_eq_opt(&self.completion, &modified.completion)
224            && ptr_eq_opt(&self.provenance, &modified.provenance)
225            && ptr_eq_opt(&self.llm, &modified.llm)
226            && ptr_eq_opt(&self.framework, &modified.framework)
227            && ptr_eq_opt(&self.meta, &modified.meta)
228        // NOTE: `raw_credentials` is INTENTIONALLY excluded from the
229        // immutable check. Framework orchestrators (apl-cpex's
230        // DelegationPluginInvoker) legitimately write
231        // `delegated_tokens.*` via the shared Mutex during route
232        // evaluation, producing a new Arc by the time the synthetic
233        // handler returns. Per-plugin write authority is enforced at
234        // the capability layer (`write_delegated_tokens` /
235        // `write_inbound_credentials`), not at this pointer-equality
236        // gate. Until cap-tier-aware merge lands, treat raw_credentials
237        // as merge-able like `security` and `delegation`.
238    }
239
240    /// Merge an OwnedExtensions back into this Extensions.
241    pub fn merge_owned(&mut self, owned: OwnedExtensions) {
242        self.http = owned.http.map(|g| Arc::new(g.into_inner()));
243        self.security = owned.security.map(Arc::new);
244        self.delegation = owned.delegation.map(Arc::new);
245        self.custom = owned.custom.map(Arc::new);
246        // `raw_credentials` is shared by Arc in `OwnedExtensions` —
247        // plugins don't mutate it directly. But framework orchestrators
248        // (apl-cpex's DelegationPluginInvoker) DO write delegated_tokens
249        // / inbound_tokens through the shared `Arc<Mutex<Extensions>>`
250        // before the synthetic handler returns. We must propagate
251        // those writes back so callers of `invoke_named` see the
252        // minted tokens in `PipelineResult.modified_extensions`.
253        // Without this, `delegate(...)` steps silently lose their
254        // results at the executor merge boundary.
255        if owned.raw_credentials.is_some() {
256            self.raw_credentials = owned.raw_credentials;
257        }
258    }
259}
260
261// ---------------------------------------------------------------------------
262// OwnedExtensions — plugin's writeable workspace
263// ---------------------------------------------------------------------------
264
265/// Owned copy of extensions for plugin modification.
266///
267/// Returned by `Extensions::cow_copy()`. Immutable slots share
268/// the same `Arc` pointers as the original (zero copy). Mutable,
269/// monotonic, and guarded slots are cloned into owned values that
270/// the plugin can modify directly.
271///
272/// Plugins return this in `PluginResult::modify_extensions()`.
273/// The executor validates (immutable unchanged, monotonic superset)
274/// and merges back into the pipeline's `Extensions`.
275///
276/// Hosts never see this type — the executor converts to `Extensions`
277/// before building `PipelineResult`.
278#[derive(Debug)]
279pub struct OwnedExtensions {
280    // Immutable — same Arc pointers as original
281    pub request: Option<Arc<RequestExtension>>,
282    pub agent: Option<Arc<AgentExtension>>,
283    pub mcp: Option<Arc<MCPExtension>>,
284    pub completion: Option<Arc<CompletionExtension>>,
285    pub provenance: Option<Arc<ProvenanceExtension>>,
286    pub llm: Option<Arc<LLMExtension>>,
287    pub framework: Option<Arc<FrameworkExtension>>,
288    pub meta: Option<Arc<MetaExtension>>,
289    /// Raw credentials are shared by Arc here too — write tokens for
290    /// `inbound_tokens` and `delegated_tokens` mutation paths land in
291    /// slice 2 (IdentityResolve) and slice 3 (TokenDelegate). Until
292    /// then, no plugin writes through `OwnedExtensions.raw_credentials`.
293    pub raw_credentials: Option<Arc<RawCredentialsExtension>>,
294
295    // Mutable/monotonic/guarded — owned, modifiable
296    pub http: Option<Guarded<HttpExtension>>,
297    pub security: Option<SecurityExtension>,
298    pub delegation: Option<DelegationExtension>,
299    pub custom: Option<HashMap<String, serde_json::Value>>,
300
301    // Write tokens — propagated from executor
302    pub http_write_token: Option<WriteToken>,
303    pub labels_write_token: Option<WriteToken>,
304    pub delegation_write_token: Option<WriteToken>,
305}
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::extensions::{
310        DelegationExtension, HttpExtension, RequestExtension, SecurityExtension,
311    };
312
313    fn make_extensions() -> Extensions {
314        let mut security = SecurityExtension::default();
315        security.add_label("PII");
316
317        let mut http = HttpExtension::default();
318        http.set_header("Authorization", "Bearer token");
319
320        Extensions {
321            request: Some(Arc::new(RequestExtension {
322                request_id: Some("req-001".into()),
323                ..Default::default()
324            })),
325            security: Some(Arc::new(security)),
326            http: Some(Arc::new(http)),
327            delegation: Some(Arc::new(DelegationExtension::default())),
328            meta: Some(Arc::new(MetaExtension {
329                entity_type: Some("tool".into()),
330                ..Default::default()
331            })),
332            ..Default::default()
333        }
334    }
335
336    #[test]
337    fn test_cow_copy_shares_immutable_arcs() {
338        let ext = make_extensions();
339        let cow = ext.cow_copy();
340
341        // Immutable slots share the same Arc — zero copy
342        assert!(Arc::ptr_eq(
343            ext.request.as_ref().unwrap(),
344            cow.request.as_ref().unwrap()
345        ));
346        assert!(Arc::ptr_eq(
347            ext.meta.as_ref().unwrap(),
348            cow.meta.as_ref().unwrap()
349        ));
350    }
351
352    #[test]
353    fn test_cow_copy_deep_clones_mutable_slots() {
354        let ext = make_extensions();
355        let cow = ext.cow_copy();
356
357        // Mutable/monotonic slots are deep cloned — independent copies
358        assert!(cow.security.is_some());
359        assert!(cow.http.is_some());
360        assert!(cow.delegation.is_some());
361
362        // Modifying the COW copy doesn't affect the original
363        cow.security.as_ref().unwrap().has_label("PII");
364    }
365
366    #[test]
367    fn test_cow_copy_propagates_write_tokens() {
368        let mut ext = make_extensions();
369
370        // No tokens on the original → no tokens on COW
371        let cow_no_tokens = ext.cow_copy();
372        assert!(cow_no_tokens.http_write_token.is_none());
373        assert!(cow_no_tokens.labels_write_token.is_none());
374        assert!(cow_no_tokens.delegation_write_token.is_none());
375
376        // Executor sets tokens based on capabilities
377        ext.http_write_token = Some(WriteToken::new());
378        ext.labels_write_token = Some(WriteToken::new());
379
380        // COW copy propagates only the tokens that exist
381        let cow_with_tokens = ext.cow_copy();
382        assert!(cow_with_tokens.http_write_token.is_some());
383        assert!(cow_with_tokens.labels_write_token.is_some());
384        assert!(cow_with_tokens.delegation_write_token.is_none()); // wasn't set
385    }
386
387    #[test]
388    fn test_cow_copy_write_token_enables_guarded_write() {
389        let mut ext = make_extensions();
390        ext.http_write_token = Some(WriteToken::new());
391
392        let mut cow = ext.cow_copy();
393
394        // Can read without token
395        assert_eq!(
396            cow.http
397                .as_ref()
398                .unwrap()
399                .read()
400                .get_header("Authorization"),
401            Some("Bearer token")
402        );
403
404        // Can write with token from COW
405        let token = cow.http_write_token.as_ref().unwrap();
406        cow.http
407            .as_mut()
408            .unwrap()
409            .write(token)
410            .set_header("X-Custom", "value");
411
412        assert_eq!(
413            cow.http.as_ref().unwrap().read().get_header("X-Custom"),
414            Some("value")
415        );
416
417        // Original unchanged
418        assert!(ext.http.as_ref().unwrap().get_header("X-Custom").is_none());
419    }
420
421    #[test]
422    fn test_cow_copy_monotonic_label_insert() {
423        let mut ext = make_extensions();
424        ext.labels_write_token = Some(WriteToken::new());
425
426        let mut cow = ext.cow_copy();
427
428        // Can add labels on the COW copy
429        cow.security.as_mut().unwrap().add_label("HIPAA");
430        assert!(cow.security.as_ref().unwrap().has_label("HIPAA"));
431
432        // Original unchanged
433        assert!(!ext.security.as_ref().unwrap().has_label("HIPAA"));
434    }
435
436    #[test]
437    fn test_validate_immutable_passes_for_cow() {
438        let ext = make_extensions();
439        let cow = ext.cow_copy();
440
441        // COW copy shares immutable Arcs → validation passes
442        assert!(ext.validate_immutable(&cow));
443    }
444
445    #[test]
446    fn test_validate_immutable_fails_when_tampered() {
447        let ext = make_extensions();
448        let mut cow = ext.cow_copy();
449
450        // Tamper with an immutable slot
451        cow.request = Some(Arc::new(RequestExtension {
452            request_id: Some("TAMPERED".into()),
453            ..Default::default()
454        }));
455
456        // Validation fails — different Arc pointer
457        assert!(!ext.validate_immutable(&cow));
458    }
459
460    #[test]
461    fn test_validate_immutable_both_none_passes() {
462        let ext = Extensions::default();
463        let cow = ext.cow_copy();
464        assert!(ext.validate_immutable(&cow));
465    }
466
467    #[test]
468    fn test_clone_drops_write_tokens() {
469        let mut ext = make_extensions();
470        ext.http_write_token = Some(WriteToken::new());
471        ext.labels_write_token = Some(WriteToken::new());
472        ext.delegation_write_token = Some(WriteToken::new());
473
474        // Regular clone drops all tokens
475        let cloned = ext.clone();
476        assert!(cloned.http_write_token.is_none());
477        assert!(cloned.labels_write_token.is_none());
478        assert!(cloned.delegation_write_token.is_none());
479
480        // cow_copy propagates them
481        let cow = ext.cow_copy();
482        assert!(cow.http_write_token.is_some());
483        assert!(cow.labels_write_token.is_some());
484        assert!(cow.delegation_write_token.is_some());
485    }
486
487    #[test]
488    fn test_cow_copy_modify_multiple_fields() {
489        use crate::extensions::delegation::DelegationHop;
490        use crate::extensions::DelegationExtension;
491
492        // Build extensions with security, http, delegation, custom
493        let mut security = SecurityExtension::default();
494        security.add_label("PII");
495
496        let mut http = HttpExtension::default();
497        http.set_header("Authorization", "Bearer token");
498
499        let mut ext = Extensions {
500            security: Some(Arc::new(security)),
501            http: Some(Arc::new(http)),
502            delegation: Some(Arc::new(DelegationExtension::default())),
503            custom: Some(Arc::new(
504                [("existing".to_string(), serde_json::json!("value"))].into(),
505            )),
506            meta: Some(Arc::new(MetaExtension {
507                entity_type: Some("tool".into()),
508                ..Default::default()
509            })),
510            ..Default::default()
511        };
512
513        // Executor sets all write tokens
514        ext.http_write_token = Some(WriteToken::new());
515        ext.labels_write_token = Some(WriteToken::new());
516        ext.delegation_write_token = Some(WriteToken::new());
517
518        // Plugin does one cow_copy, modifies multiple fields
519        let mut cow = ext.cow_copy();
520
521        // 1. Add security labels (monotonic)
522        cow.security.as_mut().unwrap().add_label("CHECKED");
523        cow.security.as_mut().unwrap().add_label("COMPLIANT");
524
525        // 2. Inject HTTP headers (guarded)
526        let token = cow.http_write_token.as_ref().unwrap();
527        cow.http
528            .as_mut()
529            .unwrap()
530            .write(token)
531            .set_header("X-Checked", "true");
532        cow.http
533            .as_mut()
534            .unwrap()
535            .write(token)
536            .set_header("X-Policy", "v2");
537
538        // 3. Append delegation hop (monotonic)
539        cow.delegation.as_mut().unwrap().append_hop(DelegationHop {
540            subject_id: "service-a".into(),
541            scopes_granted: vec!["read_hr".into()],
542            ..Default::default()
543        });
544
545        // 4. Add custom data (mutable, no token needed)
546        cow.custom
547            .as_mut()
548            .unwrap()
549            .insert("audit.timestamp".into(), serde_json::json!("2026-04-29"));
550
551        // Verify COW copy has all modifications
552        let sec = cow.security.as_ref().unwrap();
553        assert!(sec.has_label("PII")); // original
554        assert!(sec.has_label("CHECKED")); // added
555        assert!(sec.has_label("COMPLIANT")); // added
556
557        let http = cow.http.as_ref().unwrap().read();
558        assert_eq!(http.get_header("Authorization"), Some("Bearer token")); // original
559        assert_eq!(http.get_header("X-Checked"), Some("true")); // added
560        assert_eq!(http.get_header("X-Policy"), Some("v2")); // added
561
562        assert_eq!(cow.delegation.as_ref().unwrap().chain.len(), 1);
563        assert_eq!(
564            cow.delegation.as_ref().unwrap().chain[0].subject_id,
565            "service-a"
566        );
567
568        assert_eq!(
569            cow.custom.as_ref().unwrap().get("existing").unwrap(),
570            "value"
571        );
572        assert_eq!(
573            cow.custom.as_ref().unwrap().get("audit.timestamp").unwrap(),
574            "2026-04-29"
575        );
576
577        // Verify original is unchanged
578        assert!(!ext.security.as_ref().unwrap().has_label("CHECKED"));
579        assert!(ext.http.as_ref().unwrap().get_header("X-Checked").is_none());
580        assert!(ext.delegation.as_ref().unwrap().chain.is_empty());
581        assert!(!ext.custom.as_ref().unwrap().contains_key("audit.timestamp"));
582
583        // Immutable slots still valid
584        assert!(ext.validate_immutable(&cow));
585    }
586
587    #[test]
588    fn test_validate_immutable_passes_when_slot_filtered_out() {
589        // Bug fix regression: when capability filtering hides a slot
590        // from the plugin (e.g., agent=None in owned because plugin
591        // lacks read_agent), validate_immutable must NOT treat that
592        // as tampering.
593        let ext = make_extensions();
594        let mut cow = ext.cow_copy();
595
596        // Simulate capability filtering hiding the agent slot
597        cow.agent = None;
598
599        // Validation should pass — plugin never saw the slot
600        assert!(ext.validate_immutable(&cow));
601    }
602
603    #[test]
604    fn test_validate_immutable_fails_when_slot_fabricated() {
605        // If the original has no agent but the plugin returns one,
606        // that's fabrication — should fail.
607        let ext = Extensions::default(); // no agent
608        let mut cow = ext.cow_copy();
609
610        cow.agent = Some(Arc::new(AgentExtension {
611            agent_id: Some("fabricated".into()),
612            ..Default::default()
613        }));
614
615        assert!(!ext.validate_immutable(&cow));
616    }
617
618    #[test]
619    fn test_validate_immutable_passes_multiple_slots_filtered() {
620        // Multiple immutable slots filtered out — all should pass
621        let ext = make_extensions();
622        let mut cow = ext.cow_copy();
623
624        cow.agent = None;
625        cow.mcp = None;
626        cow.completion = None;
627        cow.framework = None;
628
629        assert!(ext.validate_immutable(&cow));
630    }
631
632    #[test]
633    fn test_merge_owned_preserves_http_response_headers() {
634        // Bug fix regression: merge_owned must preserve response
635        // headers written by a plugin through Guarded write access.
636        let mut http = HttpExtension::default();
637        http.set_request_header("Authorization", "Bearer tok");
638
639        let mut ext = Extensions {
640            http: Some(Arc::new(http)),
641            ..Default::default()
642        };
643        ext.http_write_token = Some(WriteToken::new());
644
645        let mut cow = ext.cow_copy();
646
647        // Plugin writes response headers through the guard
648        let token = cow.http_write_token.as_ref().unwrap();
649        let h = cow.http.as_mut().unwrap().write(token);
650        h.set_response_header("X-Tool-Name", "get_compensation");
651        h.set_response_header("X-Status", "success");
652
653        // Merge back
654        ext.merge_owned(cow);
655
656        // Response headers must be present after merge
657        let merged_http = ext.http.as_ref().unwrap();
658        assert_eq!(
659            merged_http.get_response_header("X-Tool-Name"),
660            Some("get_compensation")
661        );
662        assert_eq!(merged_http.get_response_header("X-Status"), Some("success"));
663        // Original request headers preserved
664        assert_eq!(
665            merged_http.get_request_header("Authorization"),
666            Some("Bearer tok")
667        );
668    }
669
670    #[test]
671    fn test_merge_owned_with_filtered_security() {
672        // A plugin without read_labels gets empty labels in its
673        // filtered view. After cow_copy + merge_owned, the pipeline's
674        // security labels must be preserved (not overwritten with empty).
675        let mut security = SecurityExtension::default();
676        security.add_label("PII");
677        security.add_label("HR");
678
679        let ext = Extensions {
680            security: Some(Arc::new(security)),
681            ..Default::default()
682        };
683
684        // Simulate: plugin has no read_labels, so filtered security
685        // has empty labels. cow_copy of filtered would have empty labels.
686        let mut cow = ext.cow_copy();
687
688        // Plugin's owned security has the labels (from cow_copy of full ext)
689        // But in the real flow, it would be from the filtered ext.
690        // Simulate filtered: clear labels
691        cow.security.as_mut().unwrap().labels = crate::extensions::MonotonicSet::new();
692
693        // merge_owned replaces pipeline security with owned
694        let mut ext_mut = ext.clone();
695        ext_mut.merge_owned(cow);
696
697        // After merge, the security comes from the owned (which had empty labels)
698        // This is expected — the executor's monotonic check should prevent
699        // this case. merge_owned itself is just a field replacement.
700        let merged_sec = ext_mut.security.as_ref().unwrap();
701        assert!(!merged_sec.has_label("PII")); // replaced by owned
702    }
703
704    #[test]
705    fn test_merge_owned_none_http_preserves_pipeline() {
706        // If owned.http is None (plugin had no read_headers capability),
707        // merge_owned replaces with None. The executor should only call
708        // merge_owned when the plugin actually modified something.
709        let mut http = HttpExtension::default();
710        http.set_request_header("X-Original", "value");
711
712        let mut ext = Extensions {
713            http: Some(Arc::new(http)),
714            ..Default::default()
715        };
716
717        let mut cow = ext.cow_copy();
718        cow.http = None; // simulate filtered-out HTTP
719
720        ext.merge_owned(cow);
721
722        // HTTP is now None — this is the raw merge behavior.
723        // The executor guards against this by only calling merge_owned
724        // when the plugin returned modify_extensions.
725        assert!(ext.http.is_none());
726    }
727
728    #[test]
729    fn test_read_only_plugin_zero_cost() {
730        // Plugin that only reads — no cow_copy, no clone
731        let ext = make_extensions();
732
733        // Read security labels
734        let has_pii = ext
735            .security
736            .as_ref()
737            .map(|s| s.has_label("PII"))
738            .unwrap_or(false);
739        assert!(has_pii);
740
741        // Read HTTP headers
742        let auth = ext
743            .http
744            .as_ref()
745            .and_then(|h| h.get_header("Authorization"));
746        assert_eq!(auth, Some("Bearer token"));
747
748        // Read meta
749        let entity = ext.meta.as_ref().and_then(|m| m.entity_type.as_deref());
750        assert_eq!(entity, Some("tool"));
751
752        // No cow_copy called — zero allocations for read-only access
753    }
754}