Skip to main content

cpex_core/delegation/
payload.rs

1// Location: ./crates/cpex-core/src/delegation/payload.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// `DelegationPayload` — the unified state struct threaded through the
7// TokenDelegate hook chain. Same input/output split pattern as
8// `IdentityPayload` (slice 2):
9//
10//   * **Input** (private — host-supplied, never mutated by handlers) —
11//     `bearer_token`, `target_name`, `target_type`, `target_audience`,
12//     `required_permissions`, `trust_domain`, `auth_enforced_by`,
13//     `route_attenuation`. Set once at the call site that needs to mint
14//     a downstream credential. Privacy is enforced at the module
15//     boundary: external code reads through accessors and has no
16//     setters or mutable field access.
17//
18//   * **Accumulating output** (`pub` fields) — `delegated_token` and
19//     `delegation_update`. Handlers clone the payload, populate these,
20//     return the updated payload via `PluginResult::modify_payload`.
21//
22// # Where this hook fits
23//
24// IdentityResolve (slice 2) is *inbound* — validates the caller's
25// credentials at request entry, populates `security.subject` /
26// `security.client` / `security.caller_workload`. TokenDelegate is
27// *outbound* — when a plugin (typically a forwarding proxy) needs to
28// make a downstream call to a tool or agent, it asks for an
29// appropriately-scoped credential for that target. A handler (RFC
30// 8693 token exchanger, UCAN minter, passthrough) produces the
31// minted token; the framework stashes it in
32// `Extensions.raw_credentials.delegated_tokens` for the proxy plugin
33// to attach on the upstream request.
34//
35// # Caching
36//
37// Not in this slice. The spec describes a `TokenCacheControl` trait
38// at §9.8 that wraps this hook with `get_or_mint(audience, scopes)`
39// semantics — outbound callers ask the trait for a token; the trait
40// hits the cache first and only dispatches through the hook on cache
41// miss. That layer lives one slice later. For now, every
42// `mgr.invoke_named::<TokenDelegateHook>(...)` re-runs the chain.
43//
44// # Rejection
45//
46// Same as IdentityResolve: handlers reject via
47// `PluginResult::deny(PluginViolation::new(code, reason))`. The
48// executor halts the chain; no later handler runs and the request
49// fails with the violation surfaced to the host. No `rejected` flag
50// on the payload.
51
52use std::collections::HashMap;
53use std::sync::Arc;
54
55use chrono::{DateTime, Utc};
56use serde::{Deserialize, Serialize};
57use zeroize::Zeroizing;
58
59use crate::executor::PipelineResult;
60use crate::extensions::raw_credentials::DelegationMode;
61use crate::extensions::{
62    DelegationExtension, Extensions, RawCredentialsExtension, RawDelegatedToken,
63};
64use crate::impl_plugin_payload;
65
66/// Kind of downstream entity the credential is being minted for.
67/// `Custom(String)` is the escape hatch for host-defined entity
68/// types beyond the well-known shapes.
69#[non_exhaustive]
70#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum TargetType {
73    /// A tool invocation (MCP tool, function call).
74    Tool,
75    /// An agent — another LLM-driven actor.
76    Agent,
77    /// A static resource (file, URL, document store entry).
78    Resource,
79    /// A service (microservice, internal API).
80    Service,
81    /// Operator-defined target kind.
82    #[serde(untagged)]
83    Custom(String),
84}
85
86impl Default for TargetType {
87    fn default() -> Self {
88        TargetType::Tool
89    }
90}
91
92/// Who's responsible for enforcing authorization on the downstream
93/// call. From the `ObjectSecurityProfile` of the target. Determines
94/// whether the gateway brokers credentials (`Caller`), trusts the
95/// target to handle auth itself (`Target`), or both layers enforce
96/// (`Both`).
97#[non_exhaustive]
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum AuthEnforcedBy {
101    /// Caller (the gateway / our process) enforces — typical for
102    /// internal services that trust the gateway's authorization
103    /// decision.
104    Caller,
105    /// Target enforces — typical for external services with their
106    /// own access control. We may still attach credentials but the
107    /// downstream makes the final allow/deny decision.
108    Target,
109    /// Both layers enforce — defense in depth.
110    Both,
111}
112
113impl Default for AuthEnforcedBy {
114    fn default() -> Self {
115        AuthEnforcedBy::Caller
116    }
117}
118
119/// Scope-attenuation config carried from the route DSL. Lets the
120/// route author narrow what the minted credential is allowed to do
121/// beyond the broad authorization the inbound credential carried.
122///
123/// `resource_template` is a templated URI (e.g.
124/// `"hr://employees/{{ args.employee_id }}"`) that the framework
125/// renders against request-time arguments before passing into the
126/// minted token's scope claim. v0 doesn't include a template
127/// renderer — handlers receive the raw template string and render
128/// themselves; a framework-side renderer can come later.
129#[derive(Debug, Clone, Default, Serialize, Deserialize)]
130pub struct AttenuationConfig {
131    /// Specific capabilities the route author wants granted.
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub capabilities: Vec<String>,
134
135    /// URI template for the resource being accessed. Unrendered —
136    /// handlers substitute `{{ args.* }}` placeholders themselves
137    /// using request context.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub resource_template: Option<String>,
140
141    /// Actions allowed on the resource (read / write / delete /
142    /// custom verbs).
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub actions: Vec<String>,
145
146    /// Token lifetime override in seconds. `None` lets the handler
147    /// pick its default.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub ttl_seconds: Option<u64>,
150}
151
152/// State threaded through the TokenDelegate hook chain.
153///
154/// See the module-level docs for the input/output split. Input
155/// fields are private (set once via the constructor + builders,
156/// never mutated). Output fields are `pub` (handlers populate on
157/// clones and return the updated payload).
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct DelegationPayload {
160    // ----- Input (private — caller-supplied, never mutated by handlers) -----
161    /// The caller's current credential — the one a token-exchange
162    /// handler will swap for a downstream-scoped credential. Cleared
163    /// on drop via `Zeroizing`. `#[serde(skip)]` — never appears in
164    /// serialized output.
165    #[serde(skip)]
166    bearer_token: Zeroizing<String>,
167
168    /// Name of the tool / agent / resource being called.
169    target_name: String,
170
171    /// Kind of downstream entity.
172    #[serde(default)]
173    target_type: TargetType,
174
175    /// Audience URI for the target, from route config.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    target_audience: Option<String>,
178
179    /// Required permissions from the target's `ObjectSecurityProfile`.
180    /// Handlers must produce a credential that grants these (or fail).
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    required_permissions: Vec<String>,
183
184    /// Target's trust domain (SPIFFE-style) — useful for handlers
185    /// that mint workload-identity tokens.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    trust_domain: Option<String>,
188
189    /// Who's responsible for enforcing authorization.
190    #[serde(default)]
191    auth_enforced_by: AuthEnforcedBy,
192
193    /// Scope-attenuation config from the route DSL.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    route_attenuation: Option<AttenuationConfig>,
196
197    // ----- Output (pub — handlers populate via direct assignment on clones) -----
198    /// The minted outbound credential. `None` until a handler
199    /// produces one. Carries the raw bytes (cleared on drop), the
200    /// header the proxy plugin should attach it under, the
201    /// audience it was minted for, the effective scopes, and the
202    /// expiry timestamp.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub delegated_token: Option<RawDelegatedToken>,
205
206    /// Chain update — the new hop to append to the running
207    /// `DelegationExtension`. Handlers append themselves to the
208    /// chain so audit / policy can trace who delegated to whom.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub delegation_update: Option<DelegationExtension>,
211
212    /// What kind of principal the minted token represents.
213    /// Handlers populating `delegated_token` should also set this
214    /// so `apply_to_extensions` keys the cache correctly:
215    ///
216    ///   * `OnBehalfOfUser` — token speaks for the original user
217    ///     (RFC 8693 on-behalf-of / actor-token, UCAN delegation).
218    ///     Standard flow; cache key includes the user's subject id.
219    ///   * `AsGateway` — token speaks for the gateway itself.
220    ///     User identity is conveyed through separate context.
221    ///     Cache key falls back to the gateway's identity.
222    ///
223    /// `None` defaults to `OnBehalfOfUser` for backward compatibility
224    /// with handlers that don't yet populate the field. Long-term,
225    /// handlers should always set this explicitly.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub delegation_mode: Option<DelegationMode>,
228
229    /// Resolution timestamp. Audit-useful.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub minted_at: Option<DateTime<Utc>>,
232
233    /// Optional metadata produced by the handler (telemetry,
234    /// diagnostics). Not load-bearing for policy.
235    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
236    pub metadata: HashMap<String, serde_json::Value>,
237}
238
239impl DelegationPayload {
240    /// Construct a payload with the required input fields populated.
241    /// The most common entry point — outbound callers (forwarding
242    /// proxies, etc.) build this once per delegation point. Optional
243    /// input slots are set via the `.with_*` builders below; output
244    /// fields start as `None` / empty and accumulate as handlers run.
245    pub fn new(bearer_token: impl Into<String>, target_name: impl Into<String>) -> Self {
246        Self {
247            bearer_token: Zeroizing::new(bearer_token.into()),
248            target_name: target_name.into(),
249            target_type: TargetType::Tool,
250            target_audience: None,
251            required_permissions: Vec::new(),
252            trust_domain: None,
253            auth_enforced_by: AuthEnforcedBy::Caller,
254            route_attenuation: None,
255            delegated_token: None,
256            delegation_update: None,
257            delegation_mode: None,
258            minted_at: None,
259            metadata: HashMap::new(),
260        }
261    }
262
263    // -------- Input builders --------
264
265    pub fn with_target_type(mut self, t: TargetType) -> Self {
266        self.target_type = t;
267        self
268    }
269
270    pub fn with_target_audience(mut self, aud: impl Into<String>) -> Self {
271        self.target_audience = Some(aud.into());
272        self
273    }
274
275    pub fn with_required_permissions(mut self, perms: Vec<String>) -> Self {
276        self.required_permissions = perms;
277        self
278    }
279
280    pub fn with_trust_domain(mut self, td: impl Into<String>) -> Self {
281        self.trust_domain = Some(td.into());
282        self
283    }
284
285    pub fn with_auth_enforced_by(mut self, who: AuthEnforcedBy) -> Self {
286        self.auth_enforced_by = who;
287        self
288    }
289
290    pub fn with_route_attenuation(mut self, cfg: AttenuationConfig) -> Self {
291        self.route_attenuation = Some(cfg);
292        self
293    }
294
295    // -------- Input read accessors --------
296
297    /// The caller's bearer token — borrowed, no way to move or
298    /// replace the underlying `Zeroizing<String>` through this.
299    pub fn bearer_token(&self) -> &str {
300        &self.bearer_token
301    }
302
303    pub fn target_name(&self) -> &str {
304        &self.target_name
305    }
306
307    pub fn target_type(&self) -> &TargetType {
308        &self.target_type
309    }
310
311    pub fn target_audience(&self) -> Option<&str> {
312        self.target_audience.as_deref()
313    }
314
315    pub fn required_permissions(&self) -> &[String] {
316        &self.required_permissions
317    }
318
319    pub fn trust_domain(&self) -> Option<&str> {
320        self.trust_domain.as_deref()
321    }
322
323    pub fn auth_enforced_by(&self) -> AuthEnforcedBy {
324        self.auth_enforced_by
325    }
326
327    pub fn route_attenuation(&self) -> Option<&AttenuationConfig> {
328        self.route_attenuation.as_ref()
329    }
330
331    // -------- Output helpers --------
332
333    /// Layer another payload's *output* fields onto this one's,
334    /// following "Some replaces None, last write wins per slot."
335    /// Input fields are not touched — the running payload's input
336    /// is canonical for the whole chain.
337    ///
338    /// Metadata is merged (not replaced) — `other`'s keys overlay
339    /// `self`'s, matching the "later handler additively contributes
340    /// telemetry" expectation.
341    pub fn merge(&mut self, other: DelegationPayload) {
342        if other.delegated_token.is_some() {
343            self.delegated_token = other.delegated_token;
344        }
345        if other.delegation_update.is_some() {
346            self.delegation_update = other.delegation_update;
347        }
348        if other.delegation_mode.is_some() {
349            self.delegation_mode = other.delegation_mode;
350        }
351        if other.minted_at.is_some() {
352            self.minted_at = other.minted_at;
353        }
354        for (k, v) in other.metadata {
355            self.metadata.insert(k, v);
356        }
357    }
358
359    // -------- Host-side application helpers --------
360
361    /// Pull the resolved `DelegationPayload` out of a `PipelineResult`
362    /// returned by `mgr.invoke_named::<TokenDelegateHook>(...)`.
363    /// Returns `None` when the pipeline was denied or when the result's
364    /// payload wasn't a `DelegationPayload`. Same contract as
365    /// `IdentityPayload::from_pipeline_result`.
366    pub fn from_pipeline_result(result: &PipelineResult) -> Option<Self> {
367        result
368            .modified_payload
369            .as_ref()
370            .and_then(|p| p.as_any().downcast_ref::<DelegationPayload>())
371            .cloned()
372    }
373
374    /// Apply this payload's resolved output slots back into an
375    /// `Extensions` container. Returns a new `Extensions` ready to
376    /// hand to the outbound proxy plugin that will attach the minted
377    /// credential and forward.
378    ///
379    /// Application rules:
380    ///
381    /// - **`raw_credentials.delegated_tokens`** — if the payload
382    ///   carries a `delegated_token`, it's inserted into the map under
383    ///   a `DelegationKey` derived from the input fields (audience,
384    ///   subject not yet plumbed — see "Open work" below). Pre-existing
385    ///   delegated tokens are preserved.
386    /// - **`delegation`** — `delegation_update` overlays on top of
387    ///   the existing chain (Some replaces None / appends).
388    ///
389    /// # Open work
390    ///
391    /// The `DelegationKey` we synthesize here uses only fields the
392    /// payload knows about — `audience`, `scopes` (derived from the
393    /// effective scopes on the minted token), `mode`. The `subject_id`
394    /// field of `DelegationKey` requires reading the request's
395    /// `Extensions.security.subject.id`; we plumb that lookup here
396    /// rather than asking outbound callers to thread the subject
397    /// through. If `security.subject.id` is absent the key falls back
398    /// to the empty string — flagged via tracing but not fatal,
399    /// because some delegation flows are gateway-as-principal
400    /// (AsGateway mode) and don't need a subject.
401    pub fn apply_to_extensions(&self, mut ext: Extensions) -> Extensions {
402        if let Some(ref token) = self.delegated_token {
403            use crate::extensions::raw_credentials::DelegationKey;
404
405            let subject_id = ext
406                .security
407                .as_ref()
408                .and_then(|s| s.subject.as_ref())
409                .and_then(|s| s.id.clone())
410                .unwrap_or_default();
411
412            // Default to OnBehalfOfUser when the handler didn't
413            // populate `delegation_mode`. Backward-compatible with
414            // handlers from sub-step B; future handlers should
415            // populate the field explicitly.
416            let mode = self
417                .delegation_mode
418                .clone()
419                .unwrap_or(DelegationMode::OnBehalfOfUser);
420            let key = DelegationKey {
421                subject_id,
422                audience: token.audience.clone(),
423                scopes: token.scopes.clone(),
424                mode,
425            };
426
427            let mut raw = ext
428                .raw_credentials
429                .as_ref()
430                .map(|arc| (**arc).clone())
431                .unwrap_or_else(RawCredentialsExtension::default);
432            raw.delegated_tokens.insert(key, token.clone());
433            ext.raw_credentials = Some(Arc::new(raw));
434        }
435
436        if let Some(ref update) = self.delegation_update {
437            // Replace wholesale for v0. A per-hop append semantics
438            // would deep-merge the chain, but `DelegationExtension`'s
439            // append rules live with the type — handlers that want
440            // to add a hop produce a `DelegationExtension` containing
441            // the new hop in its chain.
442            ext.delegation = Some(Arc::new(update.clone()));
443        }
444
445        ext
446    }
447}
448
449impl_plugin_payload!(DelegationPayload);
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::extensions::raw_credentials::RawDelegatedToken;
455
456    #[test]
457    fn bearer_token_does_not_serialize() {
458        let p = DelegationPayload::new("eyJ.caller.tok", "get_compensation");
459        let json = serde_json::to_string(&p).unwrap();
460        assert!(
461            !json.contains("eyJ.caller.tok"),
462            "bearer_token leaked into serialized form: {}",
463            json,
464        );
465        assert!(json.contains("get_compensation"));
466    }
467
468    #[test]
469    fn deserialize_yields_empty_bearer_token() {
470        let json = r#"{"target_name":"get_compensation"}"#;
471        let p: DelegationPayload = serde_json::from_str(json).unwrap();
472        assert_eq!(p.bearer_token(), "");
473        assert_eq!(p.target_name(), "get_compensation");
474    }
475
476    #[test]
477    fn input_builders_chain() {
478        let p = DelegationPayload::new("tok", "get_compensation")
479            .with_target_type(TargetType::Tool)
480            .with_target_audience("https://hr.example.com")
481            .with_required_permissions(vec!["read:compensation".into()])
482            .with_trust_domain("hr.example.com")
483            .with_auth_enforced_by(AuthEnforcedBy::Target)
484            .with_route_attenuation(AttenuationConfig {
485                capabilities: vec!["read:compensation".into()],
486                resource_template: Some("hr://employees/{{ args.employee_id }}".into()),
487                actions: vec!["read".into()],
488                ttl_seconds: Some(60),
489            });
490        assert_eq!(p.bearer_token(), "tok");
491        assert_eq!(p.target_name(), "get_compensation");
492        assert_eq!(p.target_audience(), Some("https://hr.example.com"));
493        assert_eq!(p.required_permissions(), &["read:compensation".to_string()]);
494        assert_eq!(p.trust_domain(), Some("hr.example.com"));
495        assert_eq!(p.auth_enforced_by(), AuthEnforcedBy::Target);
496        let att = p.route_attenuation().unwrap();
497        assert_eq!(att.ttl_seconds, Some(60));
498        assert_eq!(att.actions, vec!["read"]);
499    }
500
501    #[test]
502    fn target_type_custom_round_trips() {
503        let t = TargetType::Custom("workflow".into());
504        let json = serde_json::to_string(&t).unwrap();
505        let back: TargetType = serde_json::from_str(&json).unwrap();
506        assert_eq!(t, back);
507    }
508
509    #[test]
510    fn handler_can_populate_output_on_clone() {
511        // Typical handler pattern: clone running payload, set
512        // delegated_token + delegation_update, return.
513        let original = DelegationPayload::new("caller-tok", "downstream-tool");
514        let mut updated = original.clone();
515        updated.delegated_token = Some(RawDelegatedToken::new(
516            "minted-bytes",
517            "Authorization",
518            "https://api.example.com",
519            vec!["read".into()],
520            Utc::now(),
521        ));
522        // Input survives the clone.
523        assert_eq!(updated.bearer_token(), "caller-tok");
524        assert_eq!(updated.target_name(), "downstream-tool");
525        // Output populated.
526        assert!(updated.delegated_token.is_some());
527        // Original untouched.
528        assert!(original.delegated_token.is_none());
529    }
530
531    #[test]
532    fn merge_overlays_outputs() {
533        let mut base = DelegationPayload::new("tok", "tool");
534        base.metadata.insert("attempt".into(), serde_json::json!(1));
535        let mut overlay = DelegationPayload::new("", "");
536        overlay.delegated_token = Some(RawDelegatedToken::new(
537            "x",
538            "Authorization",
539            "aud",
540            vec![],
541            Utc::now(),
542        ));
543        overlay
544            .metadata
545            .insert("latency_ms".into(), serde_json::json!(42));
546        base.merge(overlay);
547        assert!(base.delegated_token.is_some());
548        // Metadata merged additively — both keys present.
549        assert!(base.metadata.contains_key("attempt"));
550        assert!(base.metadata.contains_key("latency_ms"));
551    }
552
553    #[test]
554    fn apply_to_extensions_writes_delegated_token_keyed_by_audience() {
555        use crate::extensions::raw_credentials::DelegationMode;
556        use crate::extensions::SubjectExtension;
557
558        let mut p = DelegationPayload::new("tok", "get_compensation");
559        p.delegated_token = Some(RawDelegatedToken::new(
560            "minted-jwt",
561            "Authorization",
562            "https://hr.example.com",
563            vec!["read:compensation".into()],
564            Utc::now() + chrono::Duration::seconds(300),
565        ));
566
567        // Pre-existing subject in extensions — DelegationKey.subject_id
568        // should pull from there.
569        let initial_ext = Extensions {
570            security: Some(Arc::new(crate::extensions::SecurityExtension {
571                subject: Some(SubjectExtension {
572                    id: Some("alice".into()),
573                    ..Default::default()
574                }),
575                ..Default::default()
576            })),
577            ..Default::default()
578        };
579
580        let updated = p.apply_to_extensions(initial_ext);
581        let raw = updated.raw_credentials.as_ref().unwrap();
582        assert_eq!(raw.delegated_tokens.len(), 1);
583
584        // Look up by the synthesized key.
585        let expected_key = crate::extensions::raw_credentials::DelegationKey {
586            subject_id: "alice".into(),
587            audience: "https://hr.example.com".into(),
588            scopes: vec!["read:compensation".into()],
589            mode: DelegationMode::OnBehalfOfUser,
590        };
591        assert!(raw.delegated_tokens.contains_key(&expected_key));
592    }
593
594    #[test]
595    fn apply_to_extensions_respects_explicit_delegation_mode() {
596        // Handler that mints an AsGateway-mode token (gateway-as-principal
597        // flow). The key in `delegated_tokens` should carry AsGateway,
598        // not the default OnBehalfOfUser.
599        let mut p = DelegationPayload::new("tok", "tool");
600        p.delegated_token = Some(RawDelegatedToken::new(
601            "gateway-token",
602            "Authorization",
603            "https://downstream.example.com",
604            vec!["service:call".into()],
605            Utc::now(),
606        ));
607        p.delegation_mode = Some(crate::extensions::raw_credentials::DelegationMode::AsGateway);
608
609        let updated = p.apply_to_extensions(Extensions::default());
610        let raw = updated.raw_credentials.as_ref().unwrap();
611        let key = raw.delegated_tokens.keys().next().unwrap();
612        assert!(matches!(
613            key.mode,
614            crate::extensions::raw_credentials::DelegationMode::AsGateway
615        ));
616    }
617
618    #[test]
619    fn apply_to_extensions_defaults_delegation_mode_when_unset() {
620        // Handler that didn't populate delegation_mode — apply should
621        // use OnBehalfOfUser as the safe default.
622        let mut p = DelegationPayload::new("tok", "tool");
623        p.delegated_token = Some(RawDelegatedToken::new(
624            "user-token",
625            "Authorization",
626            "https://aud.example.com",
627            vec!["read".into()],
628            Utc::now(),
629        ));
630        // delegation_mode left None.
631        let updated = p.apply_to_extensions(Extensions::default());
632        let raw = updated.raw_credentials.as_ref().unwrap();
633        let key = raw.delegated_tokens.keys().next().unwrap();
634        assert!(matches!(
635            key.mode,
636            crate::extensions::raw_credentials::DelegationMode::OnBehalfOfUser
637        ));
638    }
639
640    #[test]
641    fn merge_threads_delegation_mode_through_chain() {
642        // Handler A leaves delegation_mode unset; handler B sets it.
643        // After merge, the accumulator should carry handler B's mode.
644        let mut base = DelegationPayload::new("tok", "tool");
645        // base.delegation_mode = None
646        let mut overlay = DelegationPayload::new("", "");
647        overlay.delegation_mode =
648            Some(crate::extensions::raw_credentials::DelegationMode::AsGateway);
649        base.merge(overlay);
650        assert!(matches!(
651            base.delegation_mode,
652            Some(crate::extensions::raw_credentials::DelegationMode::AsGateway)
653        ));
654    }
655
656    #[test]
657    fn apply_to_extensions_falls_back_to_empty_subject_id_when_no_subject() {
658        // Gateway-as-principal flow — no Subject extension present.
659        // The DelegationKey falls back to empty subject_id rather
660        // than panicking; flagged via tracing in production but
661        // not fatal here.
662        let mut p = DelegationPayload::new("tok", "tool");
663        p.delegated_token = Some(RawDelegatedToken::new(
664            "minted",
665            "Authorization",
666            "aud",
667            vec![],
668            Utc::now(),
669        ));
670        let updated = p.apply_to_extensions(Extensions::default());
671        let raw = updated.raw_credentials.as_ref().unwrap();
672        let key = raw.delegated_tokens.keys().next().unwrap();
673        assert_eq!(key.subject_id, "");
674    }
675
676    #[test]
677    fn auth_enforced_by_defaults_to_caller() {
678        let p = DelegationPayload::new("tok", "tool");
679        assert_eq!(p.auth_enforced_by(), AuthEnforcedBy::Caller);
680    }
681
682    #[test]
683    fn target_type_defaults_to_tool() {
684        let p = DelegationPayload::new("tok", "tool");
685        assert_eq!(p.target_type(), &TargetType::Tool);
686    }
687}