Skip to main content

hessra_cap_engine/
resolver.rs

1//! Designation resolvers turn runtime context into the designations the
2//! engine attaches at mint time.
3//!
4//! A [`DesignationResolver`] is consulted by
5//! [`crate::CapabilityEngine::mint_with_context`] after policy evaluation:
6//! the engine asks the resolver for designation values for the current
7//! `(target, operation)` given a [`DesignationContext`], merges the result
8//! with any policy-declared static designations, and validates the union
9//! against the schema's `required_designations`.
10//!
11//! Stock implementations:
12//!
13//! - [`NoopResolver`]: returns no designations. Default if no resolver is
14//!   attached to the engine.
15//! - [`ArgsResolver`]: declarative `(target, arg_field) -> designation_label`
16//!   mappings, reading from `ctx.args`.
17//! - [`WebappResolver`]: reads from an [`AuthSession`] extension and/or
18//!   matches a [`RequestUrl`] extension against `{name}` URL templates.
19//! - [`EventResolver`]: reads from an [`Event`] extension via dot-separated
20//!   JSON paths.
21//! - [`CompositeResolver`]: dispatches to per-target resolvers, with an
22//!   optional default for unknown targets.
23
24use std::any::{Any, TypeId};
25use std::collections::HashMap;
26
27use thiserror::Error;
28
29use crate::types::{Designation, ObjectId, Operation};
30
31/// A resolver that turns a `(target, operation, context)` triple into the
32/// designations the engine will attach to the minted capability.
33///
34/// Implementations should be stateless or use interior mutability; the
35/// engine holds the resolver as `Box<dyn DesignationResolver>` and calls it
36/// concurrently from many mint paths.
37pub trait DesignationResolver: Send + Sync {
38    fn resolve(
39        &self,
40        target: &ObjectId,
41        operation: &Operation,
42        ctx: &DesignationContext,
43    ) -> Result<Vec<Designation>, ResolverError>;
44}
45
46/// Default resolver. Returns no designations regardless of input.
47///
48/// Used when an engine is constructed without an explicit resolver. This
49/// preserves the pre-resolver behavior: `mint_capability` and
50/// `mint_designated_capability` work as before, and `mint_with_context` is
51/// equivalent to passing `&[]` as caller designations.
52#[derive(Debug, Default, Clone, Copy)]
53pub struct NoopResolver;
54
55impl DesignationResolver for NoopResolver {
56    fn resolve(
57        &self,
58        _target: &ObjectId,
59        _operation: &Operation,
60        _ctx: &DesignationContext,
61    ) -> Result<Vec<Designation>, ResolverError> {
62        Ok(Vec::new())
63    }
64}
65
66/// Per-call context handed to a [`DesignationResolver`].
67///
68/// The typed core (`subject`, `args`) covers the most common shapes; the
69/// extension bag carries anything else the caller wants to make available
70/// to resolvers, keyed by Rust type. A `WebappResolver`, for instance,
71/// reads its session via `ctx.get::<AuthSession>()` where `AuthSession`
72/// is whatever the consuming application defines.
73pub struct DesignationContext {
74    /// The principal that will be the subject of the minted capability.
75    pub subject: ObjectId,
76    /// Per-call arguments (e.g., the JSON body of a tool invocation).
77    pub args: Option<serde_json::Value>,
78    extensions: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
79}
80
81impl DesignationContext {
82    /// Build a context for a subject. Args and extensions are unset; chain
83    /// [`Self::with_args`] and [`Self::insert`] as needed.
84    pub fn new(subject: ObjectId) -> Self {
85        Self {
86            subject,
87            args: None,
88            extensions: HashMap::new(),
89        }
90    }
91
92    /// Attach per-call arguments (consuming the context).
93    pub fn with_args(mut self, args: serde_json::Value) -> Self {
94        self.args = Some(args);
95        self
96    }
97
98    /// Insert a typed extension. Replaces any existing value of the same type.
99    pub fn insert<T: Any + Send + Sync>(&mut self, ext: T) {
100        self.extensions.insert(TypeId::of::<T>(), Box::new(ext));
101    }
102
103    /// Fetch a typed extension by its concrete type.
104    pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
105        self.extensions
106            .get(&TypeId::of::<T>())
107            .and_then(|b| b.downcast_ref::<T>())
108    }
109}
110
111impl std::fmt::Debug for DesignationContext {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("DesignationContext")
114            .field("subject", &self.subject)
115            .field("args", &self.args)
116            .field("extensions", &format_args!("{} ext", self.extensions.len()))
117            .finish()
118    }
119}
120
121/// Errors a [`DesignationResolver`] can return.
122#[derive(Error, Debug)]
123pub enum ResolverError {
124    /// The resolver could not find a value for the requested designation
125    /// label, e.g., because an expected arg field was missing.
126    #[error("resolver could not supply designation '{label}': {detail}")]
127    MissingField { label: String, detail: String },
128
129    /// The context was present but not in the shape the resolver needed
130    /// (e.g., expected JSON object, got an array).
131    #[error("resolver context has the wrong shape: {reason}")]
132    InvalidShape { reason: String },
133
134    /// Generic resolver failure for cases that don't fit the above.
135    #[error("resolver failed: {0}")]
136    Other(String),
137}
138
139// ---------------------------------------------------------------------------
140// ArgsResolver
141// ---------------------------------------------------------------------------
142
143/// A resolver that pulls designation values from a JSON `args` object on the
144/// [`DesignationContext`], mapping arg field names to designation labels
145/// per target.
146///
147/// Use [`ArgsResolver::builder`] to start a builder; chain `.for_target(id)`
148/// followed by `.map(arg_field, designation_label)` calls; call `.build()`
149/// to finalize. A single resolver instance can cover any number of targets.
150///
151/// # Example
152///
153/// ```rust,no_run
154/// use hessra_cap_engine::ArgsResolver;
155///
156/// let resolver = ArgsResolver::builder()
157///     .for_target("filesystem:source")
158///     .map("path", "path_prefix")
159///     .for_target("tool:web-search")
160///     .map("query", "query_text")
161///     .build();
162/// ```
163#[derive(Debug, Default, Clone)]
164pub struct ArgsResolver {
165    per_target: HashMap<ObjectId, HashMap<String, String>>,
166}
167
168impl ArgsResolver {
169    /// Begin building a new resolver.
170    pub fn builder() -> ArgsResolverBuilder {
171        ArgsResolverBuilder::default()
172    }
173}
174
175impl DesignationResolver for ArgsResolver {
176    fn resolve(
177        &self,
178        target: &ObjectId,
179        _operation: &Operation,
180        ctx: &DesignationContext,
181    ) -> Result<Vec<Designation>, ResolverError> {
182        let Some(mappings) = self.per_target.get(target) else {
183            // No mappings declared for this target: nothing to contribute.
184            return Ok(Vec::new());
185        };
186        if mappings.is_empty() {
187            return Ok(Vec::new());
188        }
189
190        let args = ctx.args.as_ref().ok_or_else(|| ResolverError::InvalidShape {
191            reason: format!(
192                "ArgsResolver needs ctx.args to resolve designations for target '{target}', but args is None",
193            ),
194        })?;
195
196        let obj = args
197            .as_object()
198            .ok_or_else(|| ResolverError::InvalidShape {
199                reason: "ArgsResolver expects ctx.args to be a JSON object".to_string(),
200            })?;
201
202        let mut out = Vec::with_capacity(mappings.len());
203        for (arg_field, label) in mappings {
204            let value = obj
205                .get(arg_field)
206                .ok_or_else(|| ResolverError::MissingField {
207                    label: label.clone(),
208                    detail: format!("arg field '{arg_field}' not present in ctx.args"),
209                })?;
210            let value_str = match value {
211                serde_json::Value::String(s) => s.clone(),
212                serde_json::Value::Number(n) => n.to_string(),
213                serde_json::Value::Bool(b) => b.to_string(),
214                other => {
215                    return Err(ResolverError::InvalidShape {
216                        reason: format!(
217                            "arg '{arg_field}' for designation '{label}' must be a string, number, or bool, got {}",
218                            describe_json_kind(other),
219                        ),
220                    });
221                }
222            };
223            out.push(Designation {
224                label: label.clone(),
225                value: value_str,
226            });
227        }
228        Ok(out)
229    }
230}
231
232fn describe_json_kind(v: &serde_json::Value) -> &'static str {
233    match v {
234        serde_json::Value::Null => "null",
235        serde_json::Value::Bool(_) => "bool",
236        serde_json::Value::Number(_) => "number",
237        serde_json::Value::String(_) => "string",
238        serde_json::Value::Array(_) => "array",
239        serde_json::Value::Object(_) => "object",
240    }
241}
242
243/// Staged builder for [`ArgsResolver`]. Call `.for_target(id)` to scope
244/// subsequent `.map()` calls to that target.
245#[derive(Debug, Default)]
246pub struct ArgsResolverBuilder {
247    per_target: HashMap<ObjectId, HashMap<String, String>>,
248    current: Option<ObjectId>,
249}
250
251impl ArgsResolverBuilder {
252    /// Set the target for subsequent `.map()` calls. Re-calling with a
253    /// different id switches scope; calling with the same id is a no-op.
254    pub fn for_target(mut self, target: impl Into<ObjectId>) -> Self {
255        let id = target.into();
256        self.per_target.entry(id.clone()).or_default();
257        self.current = Some(id);
258        self
259    }
260
261    /// Map a JSON arg field to a designation label, scoped to the current
262    /// target set by the most recent `.for_target()` call.
263    ///
264    /// # Panics
265    ///
266    /// Panics if called before `.for_target()`. The current target is
267    /// programmer-set state, so a missing target is a programming error
268    /// rather than a runtime condition.
269    pub fn map(mut self, arg_field: impl Into<String>, label: impl Into<String>) -> Self {
270        let current = self
271            .current
272            .as_ref()
273            .expect("ArgsResolverBuilder::map called before for_target()");
274        self.per_target
275            .get_mut(current)
276            .expect("for_target inserted an empty map")
277            .insert(arg_field.into(), label.into());
278        self
279    }
280
281    /// Finalize the builder.
282    pub fn build(self) -> ArgsResolver {
283        ArgsResolver {
284            per_target: self.per_target,
285        }
286    }
287}
288
289// ---------------------------------------------------------------------------
290// CompositeResolver
291// ---------------------------------------------------------------------------
292
293/// A resolver that dispatches to per-target resolvers, with an optional
294/// default for unknown targets.
295///
296/// Use this when different targets need different resolution strategies in
297/// the same engine. For example: `filesystem:source` uses [`ArgsResolver`],
298/// `api:posts` uses [`WebappResolver`].
299///
300/// # Example
301///
302/// ```rust,no_run
303/// use hessra_cap_engine::{ArgsResolver, CompositeResolver, NoopResolver};
304///
305/// let composite = CompositeResolver::builder()
306///     .add(
307///         "filesystem:source",
308///         ArgsResolver::builder()
309///             .for_target("filesystem:source")
310///             .map("path", "path_prefix")
311///             .build(),
312///     )
313///     .with_default(NoopResolver)
314///     .build();
315/// ```
316pub struct CompositeResolver {
317    per_target: HashMap<ObjectId, Box<dyn DesignationResolver>>,
318    default: Option<Box<dyn DesignationResolver>>,
319}
320
321impl CompositeResolver {
322    /// Begin building a composite resolver.
323    pub fn builder() -> CompositeResolverBuilder {
324        CompositeResolverBuilder::default()
325    }
326}
327
328impl std::fmt::Debug for CompositeResolver {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        f.debug_struct("CompositeResolver")
331            .field("targets", &self.per_target.keys().collect::<Vec<_>>())
332            .field("has_default", &self.default.is_some())
333            .finish()
334    }
335}
336
337impl DesignationResolver for CompositeResolver {
338    fn resolve(
339        &self,
340        target: &ObjectId,
341        operation: &Operation,
342        ctx: &DesignationContext,
343    ) -> Result<Vec<Designation>, ResolverError> {
344        if let Some(r) = self.per_target.get(target) {
345            return r.resolve(target, operation, ctx);
346        }
347        if let Some(d) = &self.default {
348            return d.resolve(target, operation, ctx);
349        }
350        Ok(Vec::new())
351    }
352}
353
354/// Builder for [`CompositeResolver`].
355#[derive(Default)]
356pub struct CompositeResolverBuilder {
357    per_target: HashMap<ObjectId, Box<dyn DesignationResolver>>,
358    default: Option<Box<dyn DesignationResolver>>,
359}
360
361impl CompositeResolverBuilder {
362    /// Register a resolver for one specific target. Re-registering replaces
363    /// the previous resolver for that target.
364    pub fn add<R>(mut self, target: impl Into<ObjectId>, resolver: R) -> Self
365    where
366        R: DesignationResolver + 'static,
367    {
368        self.per_target.insert(target.into(), Box::new(resolver));
369        self
370    }
371
372    /// Set the default resolver used when no per-target entry matches.
373    /// Without this, unknown targets resolve to no designations.
374    pub fn with_default<R>(mut self, resolver: R) -> Self
375    where
376        R: DesignationResolver + 'static,
377    {
378        self.default = Some(Box::new(resolver));
379        self
380    }
381
382    /// Finalize the builder.
383    pub fn build(self) -> CompositeResolver {
384        CompositeResolver {
385            per_target: self.per_target,
386            default: self.default,
387        }
388    }
389}
390
391// ---------------------------------------------------------------------------
392// WebappResolver
393// ---------------------------------------------------------------------------
394
395/// A flat string-keyed map of session fields a webapp wants to expose to
396/// resolvers, inserted into [`DesignationContext`] as a typed extension.
397///
398/// Webapps populate an `AuthSession` from their own session struct (cookie
399/// session, JWT claims, etc.) and call `ctx.insert(session)`. The
400/// [`WebappResolver`] reads it back via `ctx.get::<AuthSession>()`.
401#[derive(Debug, Default, Clone)]
402pub struct AuthSession {
403    fields: HashMap<String, String>,
404}
405
406impl AuthSession {
407    pub fn new() -> Self {
408        Self::default()
409    }
410
411    pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
412        self.fields.insert(key.into(), value.into());
413        self
414    }
415
416    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
417        self.fields.insert(key.into(), value.into());
418    }
419
420    pub fn get(&self, key: &str) -> Option<&str> {
421        self.fields.get(key).map(String::as_str)
422    }
423}
424
425/// The current request's URL, inserted into [`DesignationContext`] when the
426/// webapp wants [`WebappResolver`] to extract designations from it via URL
427/// patterns.
428#[derive(Debug, Clone)]
429pub struct RequestUrl(pub String);
430
431/// A resolver designed for the webapp pattern. Pulls designation values from
432/// two sources, both supplied via context extensions:
433///
434/// - [`AuthSession`] for fields the webapp authenticated (tenant, user, etc.).
435/// - [`RequestUrl`] for path segments matched by `{name}` placeholder
436///   patterns.
437///
438/// Both sources are optional per target. The resolver returns whatever it can
439/// successfully extract; configured fields that are missing in the session,
440/// or URL patterns that don't match the request URL, return
441/// [`ResolverError::MissingField`] / [`ResolverError::InvalidShape`].
442///
443/// # Example
444///
445/// ```rust,no_run
446/// use hessra_cap_engine::WebappResolver;
447///
448/// let resolver = WebappResolver::builder()
449///     .for_target("api:posts")
450///     .from_session("tenant_id", "tenant_id")
451///     .from_session("user", "user_subject")
452///     .from_url_pattern("/tenants/{tenant_id}/posts/{resource_id}")
453///     .build();
454/// ```
455#[derive(Debug, Default, Clone)]
456pub struct WebappResolver {
457    per_target: HashMap<ObjectId, WebappTargetMappings>,
458}
459
460#[derive(Debug, Default, Clone)]
461struct WebappTargetMappings {
462    /// Pairs of (session_key, designation_label).
463    session: Vec<(String, String)>,
464    /// URL pattern templates.
465    url_patterns: Vec<UrlPattern>,
466}
467
468#[derive(Debug, Clone)]
469struct UrlPattern {
470    /// Compiled pattern segments.
471    segments: Vec<UrlSegment>,
472}
473
474#[derive(Debug, Clone)]
475enum UrlSegment {
476    Literal(String),
477    Capture(String),
478}
479
480impl UrlPattern {
481    fn parse(pattern: &str) -> Self {
482        let segments = pattern
483            .trim_matches('/')
484            .split('/')
485            .filter(|s| !s.is_empty())
486            .map(|seg| {
487                if seg.starts_with('{') && seg.ends_with('}') {
488                    UrlSegment::Capture(seg[1..seg.len() - 1].to_string())
489                } else {
490                    UrlSegment::Literal(seg.to_string())
491                }
492            })
493            .collect();
494        Self { segments }
495    }
496
497    /// Match the pattern against a URL path; returns the named captures if
498    /// the pattern matches, or `None` if it doesn't.
499    fn match_url(&self, url: &str) -> Option<Vec<(String, String)>> {
500        let url_segments: Vec<&str> = url
501            .trim_matches('/')
502            .split('/')
503            .filter(|s| !s.is_empty())
504            .collect();
505        if url_segments.len() != self.segments.len() {
506            return None;
507        }
508        let mut captures = Vec::new();
509        for (pat, val) in self.segments.iter().zip(url_segments.iter()) {
510            match pat {
511                UrlSegment::Literal(lit) => {
512                    if lit != val {
513                        return None;
514                    }
515                }
516                UrlSegment::Capture(name) => {
517                    captures.push((name.clone(), (*val).to_string()));
518                }
519            }
520        }
521        Some(captures)
522    }
523}
524
525impl WebappResolver {
526    pub fn builder() -> WebappResolverBuilder {
527        WebappResolverBuilder::default()
528    }
529}
530
531impl DesignationResolver for WebappResolver {
532    fn resolve(
533        &self,
534        target: &ObjectId,
535        _operation: &Operation,
536        ctx: &DesignationContext,
537    ) -> Result<Vec<Designation>, ResolverError> {
538        let Some(mappings) = self.per_target.get(target) else {
539            return Ok(Vec::new());
540        };
541        let mut out = Vec::new();
542
543        if !mappings.session.is_empty() {
544            let session = ctx
545                .get::<AuthSession>()
546                .ok_or_else(|| ResolverError::InvalidShape {
547                    reason: format!(
548                        "WebappResolver needs AuthSession in the context for target '{target}'",
549                    ),
550                })?;
551            for (key, label) in &mappings.session {
552                let value = session
553                    .get(key)
554                    .ok_or_else(|| ResolverError::MissingField {
555                        label: label.clone(),
556                        detail: format!("session key '{key}' not present"),
557                    })?;
558                out.push(Designation {
559                    label: label.clone(),
560                    value: value.to_string(),
561                });
562            }
563        }
564
565        if !mappings.url_patterns.is_empty() {
566            let url = ctx.get::<RequestUrl>().ok_or_else(|| ResolverError::InvalidShape {
567                reason: format!(
568                    "WebappResolver has URL patterns for target '{target}' but no RequestUrl in context",
569                ),
570            })?;
571            // Try each pattern in order; first match wins.
572            let mut matched = false;
573            for pattern in &mappings.url_patterns {
574                if let Some(captures) = pattern.match_url(&url.0) {
575                    for (name, value) in captures {
576                        out.push(Designation { label: name, value });
577                    }
578                    matched = true;
579                    break;
580                }
581            }
582            if !matched {
583                return Err(ResolverError::InvalidShape {
584                    reason: format!(
585                        "WebappResolver: no URL pattern for target '{target}' matched request '{}'",
586                        url.0,
587                    ),
588                });
589            }
590        }
591
592        Ok(out)
593    }
594}
595
596/// Builder for [`WebappResolver`].
597#[derive(Debug, Default)]
598pub struct WebappResolverBuilder {
599    per_target: HashMap<ObjectId, WebappTargetMappings>,
600    current: Option<ObjectId>,
601}
602
603impl WebappResolverBuilder {
604    /// Set the target for subsequent `.from_session()` and
605    /// `.from_url_pattern()` calls.
606    pub fn for_target(mut self, target: impl Into<ObjectId>) -> Self {
607        let id = target.into();
608        self.per_target.entry(id.clone()).or_default();
609        self.current = Some(id);
610        self
611    }
612
613    /// Map a session field key to a designation label.
614    pub fn from_session(
615        mut self,
616        session_key: impl Into<String>,
617        label: impl Into<String>,
618    ) -> Self {
619        let current = self
620            .current
621            .as_ref()
622            .expect("WebappResolverBuilder::from_session called before for_target()");
623        self.per_target
624            .get_mut(current)
625            .expect("for_target inserted an empty entry")
626            .session
627            .push((session_key.into(), label.into()));
628        self
629    }
630
631    /// Add a URL pattern to match against the [`RequestUrl`] in the context.
632    /// Each `{name}` placeholder in the pattern becomes a designation with
633    /// the captured value, labeled by the placeholder name.
634    pub fn from_url_pattern(mut self, pattern: impl AsRef<str>) -> Self {
635        let current = self
636            .current
637            .as_ref()
638            .expect("WebappResolverBuilder::from_url_pattern called before for_target()");
639        let parsed = UrlPattern::parse(pattern.as_ref());
640        self.per_target
641            .get_mut(current)
642            .expect("for_target inserted an empty entry")
643            .url_patterns
644            .push(parsed);
645        self
646    }
647
648    pub fn build(self) -> WebappResolver {
649        WebappResolver {
650            per_target: self.per_target,
651        }
652    }
653}
654
655// ---------------------------------------------------------------------------
656// EventResolver
657// ---------------------------------------------------------------------------
658
659/// An event payload (typically an inbound webhook or gateway message)
660/// inserted into [`DesignationContext`] as a typed extension. The
661/// [`EventResolver`] reads fields from this value to produce designations.
662#[derive(Debug, Clone)]
663pub struct Event(pub serde_json::Value);
664
665/// A resolver for event-driven principals: pulls designation values from a
666/// JSON [`Event`] in the context, mapping event field names to designation
667/// labels per target.
668///
669/// Same shape as [`ArgsResolver`] but reads from `ctx.get::<Event>()` rather
670/// than `ctx.args`. Use this when the trigger for a mint is an external event
671/// (Discord gateway message, GitHub webhook) and the relevant designation
672/// values live on the event's payload.
673///
674/// # Example
675///
676/// ```rust,no_run
677/// use hessra_cap_engine::EventResolver;
678///
679/// let resolver = EventResolver::builder()
680///     .for_target("tool:discord-dm")
681///     .map("user.id", "user_id")
682///     .build();
683/// ```
684#[derive(Debug, Default, Clone)]
685pub struct EventResolver {
686    per_target: HashMap<ObjectId, HashMap<String, String>>,
687}
688
689impl EventResolver {
690    pub fn builder() -> EventResolverBuilder {
691        EventResolverBuilder::default()
692    }
693}
694
695impl DesignationResolver for EventResolver {
696    fn resolve(
697        &self,
698        target: &ObjectId,
699        _operation: &Operation,
700        ctx: &DesignationContext,
701    ) -> Result<Vec<Designation>, ResolverError> {
702        let Some(mappings) = self.per_target.get(target) else {
703            return Ok(Vec::new());
704        };
705        if mappings.is_empty() {
706            return Ok(Vec::new());
707        }
708
709        let event = ctx
710            .get::<Event>()
711            .ok_or_else(|| ResolverError::InvalidShape {
712                reason: format!("EventResolver needs Event in the context for target '{target}'",),
713            })?;
714
715        let mut out = Vec::with_capacity(mappings.len());
716        for (event_path, label) in mappings {
717            let value = lookup_json_path(&event.0, event_path).ok_or_else(|| {
718                ResolverError::MissingField {
719                    label: label.clone(),
720                    detail: format!("event path '{event_path}' not present"),
721                }
722            })?;
723            let value_str = match value {
724                serde_json::Value::String(s) => s.clone(),
725                serde_json::Value::Number(n) => n.to_string(),
726                serde_json::Value::Bool(b) => b.to_string(),
727                other => {
728                    return Err(ResolverError::InvalidShape {
729                        reason: format!(
730                            "event path '{event_path}' for designation '{label}' must be a string, number, or bool, got {}",
731                            describe_json_kind(other),
732                        ),
733                    });
734                }
735            };
736            out.push(Designation {
737                label: label.clone(),
738                value: value_str,
739            });
740        }
741        Ok(out)
742    }
743}
744
745/// Look up a dot-separated path inside a JSON value (e.g., `"user.id"`).
746/// Each segment is treated as an object key. Returns `None` if any segment
747/// is missing or any intermediate value is not an object.
748fn lookup_json_path<'a>(root: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
749    let mut current = root;
750    for segment in path.split('.') {
751        current = current.as_object()?.get(segment)?;
752    }
753    Some(current)
754}
755
756/// Builder for [`EventResolver`].
757#[derive(Debug, Default)]
758pub struct EventResolverBuilder {
759    per_target: HashMap<ObjectId, HashMap<String, String>>,
760    current: Option<ObjectId>,
761}
762
763impl EventResolverBuilder {
764    pub fn for_target(mut self, target: impl Into<ObjectId>) -> Self {
765        let id = target.into();
766        self.per_target.entry(id.clone()).or_default();
767        self.current = Some(id);
768        self
769    }
770
771    /// Map an event field path (dot-separated) to a designation label.
772    pub fn map(mut self, event_path: impl Into<String>, label: impl Into<String>) -> Self {
773        let current = self
774            .current
775            .as_ref()
776            .expect("EventResolverBuilder::map called before for_target()");
777        self.per_target
778            .get_mut(current)
779            .expect("for_target inserted an empty map")
780            .insert(event_path.into(), label.into());
781        self
782    }
783
784    pub fn build(self) -> EventResolver {
785        EventResolver {
786            per_target: self.per_target,
787        }
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use serde_json::json;
795
796    fn ctx_with_args(subject: &str, args: serde_json::Value) -> DesignationContext {
797        DesignationContext::new(ObjectId::new(subject)).with_args(args)
798    }
799
800    #[test]
801    fn noop_resolver_returns_empty() {
802        let r = NoopResolver;
803        let ctx = DesignationContext::new(ObjectId::new("agent:jake"));
804        let out = r
805            .resolve(
806                &ObjectId::new("filesystem:source"),
807                &Operation::new("read"),
808                &ctx,
809            )
810            .expect("noop");
811        assert!(out.is_empty());
812    }
813
814    #[test]
815    fn args_resolver_maps_declared_fields() {
816        let resolver = ArgsResolver::builder()
817            .for_target("filesystem:source")
818            .map("path", "path_prefix")
819            .build();
820
821        let ctx = ctx_with_args("agent:jake", json!({ "path": "code/hessra/" }));
822        let out = resolver
823            .resolve(
824                &ObjectId::new("filesystem:source"),
825                &Operation::new("read"),
826                &ctx,
827            )
828            .expect("resolve");
829        assert_eq!(out.len(), 1);
830        assert_eq!(out[0].label, "path_prefix");
831        assert_eq!(out[0].value, "code/hessra/");
832    }
833
834    #[test]
835    fn args_resolver_missing_field_errors() {
836        let resolver = ArgsResolver::builder()
837            .for_target("filesystem:source")
838            .map("path", "path_prefix")
839            .build();
840
841        let ctx = ctx_with_args("agent:jake", json!({ "other": "x" }));
842        let err = resolver
843            .resolve(
844                &ObjectId::new("filesystem:source"),
845                &Operation::new("read"),
846                &ctx,
847            )
848            .expect_err("must miss");
849        match err {
850            ResolverError::MissingField { label, .. } => assert_eq!(label, "path_prefix"),
851            other => panic!("wrong variant: {other:?}"),
852        }
853    }
854
855    #[test]
856    fn args_resolver_unknown_target_returns_empty() {
857        let resolver = ArgsResolver::builder()
858            .for_target("filesystem:source")
859            .map("path", "path_prefix")
860            .build();
861
862        let ctx = ctx_with_args("agent:jake", json!({}));
863        let out = resolver
864            .resolve(
865                &ObjectId::new("tool:other-thing"),
866                &Operation::new("invoke"),
867                &ctx,
868            )
869            .expect("unknown target");
870        assert!(out.is_empty());
871    }
872
873    #[test]
874    fn args_resolver_multi_target() {
875        let resolver = ArgsResolver::builder()
876            .for_target("filesystem:source")
877            .map("path", "path_prefix")
878            .for_target("tool:discord-dm")
879            .map("user_id", "user_id")
880            .build();
881
882        let ctx = ctx_with_args("agent:jake", json!({ "user_id": "u-42" }));
883        let out = resolver
884            .resolve(
885                &ObjectId::new("tool:discord-dm"),
886                &Operation::new("send"),
887                &ctx,
888            )
889            .expect("resolve");
890        assert_eq!(out.len(), 1);
891        assert_eq!(out[0].label, "user_id");
892        assert_eq!(out[0].value, "u-42");
893    }
894
895    #[test]
896    fn args_resolver_rejects_non_object_args() {
897        let resolver = ArgsResolver::builder()
898            .for_target("filesystem:source")
899            .map("path", "path_prefix")
900            .build();
901
902        let ctx = ctx_with_args("agent:jake", json!(["not", "an", "object"]));
903        let err = resolver
904            .resolve(
905                &ObjectId::new("filesystem:source"),
906                &Operation::new("read"),
907                &ctx,
908            )
909            .expect_err("must reject non-object");
910        assert!(matches!(err, ResolverError::InvalidShape { .. }));
911    }
912
913    #[test]
914    fn args_resolver_rejects_missing_args() {
915        let resolver = ArgsResolver::builder()
916            .for_target("filesystem:source")
917            .map("path", "path_prefix")
918            .build();
919
920        // Context without args set.
921        let ctx = DesignationContext::new(ObjectId::new("agent:jake"));
922        let err = resolver
923            .resolve(
924                &ObjectId::new("filesystem:source"),
925                &Operation::new("read"),
926                &ctx,
927            )
928            .expect_err("must reject missing args");
929        assert!(matches!(err, ResolverError::InvalidShape { .. }));
930    }
931
932    #[test]
933    fn args_resolver_supports_numeric_and_bool_values() {
934        let resolver = ArgsResolver::builder()
935            .for_target("api:thing")
936            .map("count", "count_label")
937            .map("flag", "flag_label")
938            .build();
939
940        let ctx = ctx_with_args("agent:jake", json!({ "count": 7, "flag": true }));
941        let out = resolver
942            .resolve(&ObjectId::new("api:thing"), &Operation::new("call"), &ctx)
943            .expect("resolve");
944        let by_label: HashMap<_, _> = out
945            .iter()
946            .map(|d| (d.label.as_str(), d.value.as_str()))
947            .collect();
948        assert_eq!(by_label["count_label"], "7");
949        assert_eq!(by_label["flag_label"], "true");
950    }
951
952    #[test]
953    fn context_extensions_round_trip_typed_value() {
954        struct Session {
955            tenant: String,
956        }
957
958        let mut ctx = DesignationContext::new(ObjectId::new("agent:jake"));
959        ctx.insert(Session {
960            tenant: "acme".to_string(),
961        });
962
963        let session = ctx.get::<Session>().expect("present");
964        assert_eq!(session.tenant, "acme");
965
966        // Querying for a type that wasn't inserted returns None.
967        assert!(ctx.get::<u32>().is_none());
968    }
969
970    #[test]
971    #[should_panic(expected = "for_target")]
972    fn map_before_for_target_panics() {
973        let _ = ArgsResolver::builder().map("x", "y");
974    }
975
976    // =====================================================================
977    // CompositeResolver
978    // =====================================================================
979
980    #[test]
981    fn composite_dispatches_to_per_target_resolver() {
982        let fs_resolver = ArgsResolver::builder()
983            .for_target("filesystem:source")
984            .map("path", "path_prefix")
985            .build();
986
987        let composite = CompositeResolver::builder()
988            .add("filesystem:source", fs_resolver)
989            .build();
990
991        let ctx = ctx_with_args("agent:jake", json!({ "path": "code/hessra/" }));
992        let out = composite
993            .resolve(
994                &ObjectId::new("filesystem:source"),
995                &Operation::new("read"),
996                &ctx,
997            )
998            .expect("resolve");
999        assert_eq!(out.len(), 1);
1000        assert_eq!(out[0].label, "path_prefix");
1001    }
1002
1003    #[test]
1004    fn composite_unknown_target_returns_empty_when_no_default() {
1005        let composite = CompositeResolver::builder()
1006            .add(
1007                "filesystem:source",
1008                ArgsResolver::builder()
1009                    .for_target("filesystem:source")
1010                    .map("path", "path_prefix")
1011                    .build(),
1012            )
1013            .build();
1014
1015        let ctx = ctx_with_args("agent:jake", json!({}));
1016        let out = composite
1017            .resolve(
1018                &ObjectId::new("tool:other"),
1019                &Operation::new("invoke"),
1020                &ctx,
1021            )
1022            .expect("resolve");
1023        assert!(out.is_empty());
1024    }
1025
1026    #[test]
1027    fn composite_default_handles_unknown_targets() {
1028        struct ConstResolver;
1029        impl DesignationResolver for ConstResolver {
1030            fn resolve(
1031                &self,
1032                _t: &ObjectId,
1033                _op: &Operation,
1034                _ctx: &DesignationContext,
1035            ) -> Result<Vec<Designation>, ResolverError> {
1036                Ok(vec![Designation {
1037                    label: "default_label".into(),
1038                    value: "default_value".into(),
1039                }])
1040            }
1041        }
1042
1043        let composite = CompositeResolver::builder()
1044            .add(
1045                "filesystem:source",
1046                ArgsResolver::builder()
1047                    .for_target("filesystem:source")
1048                    .map("path", "path_prefix")
1049                    .build(),
1050            )
1051            .with_default(ConstResolver)
1052            .build();
1053
1054        // Unknown target falls through to the default.
1055        let ctx = ctx_with_args("agent:jake", json!({}));
1056        let out = composite
1057            .resolve(&ObjectId::new("tool:other"), &Operation::new("op"), &ctx)
1058            .expect("resolve");
1059        assert_eq!(out.len(), 1);
1060        assert_eq!(out[0].label, "default_label");
1061
1062        // Known target uses the per-target resolver, not the default.
1063        let ctx = ctx_with_args("agent:jake", json!({ "path": "/x" }));
1064        let out = composite
1065            .resolve(
1066                &ObjectId::new("filesystem:source"),
1067                &Operation::new("read"),
1068                &ctx,
1069            )
1070            .expect("resolve");
1071        assert_eq!(out.len(), 1);
1072        assert_eq!(out[0].label, "path_prefix");
1073    }
1074
1075    // =====================================================================
1076    // WebappResolver
1077    // =====================================================================
1078
1079    #[test]
1080    fn webapp_resolver_extracts_session_fields() {
1081        let resolver = WebappResolver::builder()
1082            .for_target("api:posts")
1083            .from_session("tenant_id", "tenant_id")
1084            .from_session("user", "user_subject")
1085            .build();
1086
1087        let session = AuthSession::new()
1088            .with("tenant_id", "acme")
1089            .with("user", "alice");
1090
1091        let mut ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1092        ctx.insert(session);
1093
1094        let out = resolver
1095            .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1096            .expect("resolve");
1097        let by_label: HashMap<_, _> = out
1098            .iter()
1099            .map(|d| (d.label.as_str(), d.value.as_str()))
1100            .collect();
1101        assert_eq!(by_label["tenant_id"], "acme");
1102        assert_eq!(by_label["user_subject"], "alice");
1103    }
1104
1105    #[test]
1106    fn webapp_resolver_missing_session_errors() {
1107        let resolver = WebappResolver::builder()
1108            .for_target("api:posts")
1109            .from_session("tenant_id", "tenant_id")
1110            .build();
1111
1112        let ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1113        let err = resolver
1114            .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1115            .expect_err("must fail without session");
1116        assert!(matches!(err, ResolverError::InvalidShape { .. }));
1117    }
1118
1119    #[test]
1120    fn webapp_resolver_missing_session_field_errors() {
1121        let resolver = WebappResolver::builder()
1122            .for_target("api:posts")
1123            .from_session("tenant_id", "tenant_id")
1124            .build();
1125
1126        let session = AuthSession::new().with("other", "x");
1127        let mut ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1128        ctx.insert(session);
1129
1130        let err = resolver
1131            .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1132            .expect_err("must fail with missing field");
1133        match err {
1134            ResolverError::MissingField { label, .. } => assert_eq!(label, "tenant_id"),
1135            other => panic!("wrong variant: {other:?}"),
1136        }
1137    }
1138
1139    #[test]
1140    fn webapp_resolver_url_pattern_extracts_named_captures() {
1141        let resolver = WebappResolver::builder()
1142            .for_target("api:posts")
1143            .from_url_pattern("/tenants/{tenant_id}/posts/{resource_id}")
1144            .build();
1145
1146        let mut ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1147        ctx.insert(RequestUrl("/tenants/acme/posts/p-42".to_string()));
1148
1149        let out = resolver
1150            .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1151            .expect("resolve");
1152        let by_label: HashMap<_, _> = out
1153            .iter()
1154            .map(|d| (d.label.as_str(), d.value.as_str()))
1155            .collect();
1156        assert_eq!(by_label["tenant_id"], "acme");
1157        assert_eq!(by_label["resource_id"], "p-42");
1158    }
1159
1160    #[test]
1161    fn webapp_resolver_url_pattern_no_match_errors() {
1162        let resolver = WebappResolver::builder()
1163            .for_target("api:posts")
1164            .from_url_pattern("/tenants/{tenant_id}/posts/{resource_id}")
1165            .build();
1166
1167        let mut ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1168        ctx.insert(RequestUrl("/wrong/shape".to_string()));
1169
1170        let err = resolver
1171            .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1172            .expect_err("pattern must not match");
1173        assert!(matches!(err, ResolverError::InvalidShape { .. }));
1174    }
1175
1176    #[test]
1177    fn webapp_resolver_first_matching_pattern_wins() {
1178        // Multiple patterns: longer/more-specific first; shorter as fallback.
1179        let resolver = WebappResolver::builder()
1180            .for_target("api:posts")
1181            .from_url_pattern("/tenants/{tenant_id}/posts/{resource_id}")
1182            .from_url_pattern("/tenants/{tenant_id}/posts")
1183            .build();
1184
1185        let mut ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1186        ctx.insert(RequestUrl("/tenants/acme/posts".to_string()));
1187
1188        let out = resolver
1189            .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1190            .expect("second pattern matches");
1191        assert_eq!(out.len(), 1);
1192        assert_eq!(out[0].label, "tenant_id");
1193        assert_eq!(out[0].value, "acme");
1194    }
1195
1196    #[test]
1197    fn webapp_resolver_combines_session_and_url() {
1198        let resolver = WebappResolver::builder()
1199            .for_target("api:posts")
1200            .from_session("user", "user_subject")
1201            .from_url_pattern("/tenants/{tenant_id}")
1202            .build();
1203
1204        let mut ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1205        ctx.insert(AuthSession::new().with("user", "alice"));
1206        ctx.insert(RequestUrl("/tenants/acme".to_string()));
1207
1208        let out = resolver
1209            .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1210            .expect("resolve");
1211        let by_label: HashMap<_, _> = out
1212            .iter()
1213            .map(|d| (d.label.as_str(), d.value.as_str()))
1214            .collect();
1215        assert_eq!(by_label["user_subject"], "alice");
1216        assert_eq!(by_label["tenant_id"], "acme");
1217    }
1218
1219    // =====================================================================
1220    // EventResolver
1221    // =====================================================================
1222
1223    #[test]
1224    fn event_resolver_extracts_top_level_field() {
1225        let resolver = EventResolver::builder()
1226            .for_target("tool:discord-dm")
1227            .map("user_id", "user_id")
1228            .build();
1229
1230        let mut ctx = DesignationContext::new(ObjectId::new("agent:jake"));
1231        ctx.insert(Event(json!({ "user_id": "u-42" })));
1232
1233        let out = resolver
1234            .resolve(
1235                &ObjectId::new("tool:discord-dm"),
1236                &Operation::new("send"),
1237                &ctx,
1238            )
1239            .expect("resolve");
1240        assert_eq!(out.len(), 1);
1241        assert_eq!(out[0].label, "user_id");
1242        assert_eq!(out[0].value, "u-42");
1243    }
1244
1245    #[test]
1246    fn event_resolver_extracts_dotted_path() {
1247        let resolver = EventResolver::builder()
1248            .for_target("tool:discord-dm")
1249            .map("user.id", "user_id")
1250            .map("channel.id", "channel_id")
1251            .build();
1252
1253        let mut ctx = DesignationContext::new(ObjectId::new("agent:jake"));
1254        ctx.insert(Event(json!({
1255            "user": { "id": "u-42", "name": "alice" },
1256            "channel": { "id": "c-7" },
1257        })));
1258
1259        let out = resolver
1260            .resolve(
1261                &ObjectId::new("tool:discord-dm"),
1262                &Operation::new("send"),
1263                &ctx,
1264            )
1265            .expect("resolve");
1266        let by_label: HashMap<_, _> = out
1267            .iter()
1268            .map(|d| (d.label.as_str(), d.value.as_str()))
1269            .collect();
1270        assert_eq!(by_label["user_id"], "u-42");
1271        assert_eq!(by_label["channel_id"], "c-7");
1272    }
1273
1274    #[test]
1275    fn event_resolver_missing_event_errors() {
1276        let resolver = EventResolver::builder()
1277            .for_target("tool:discord-dm")
1278            .map("user_id", "user_id")
1279            .build();
1280
1281        let ctx = DesignationContext::new(ObjectId::new("agent:jake"));
1282        let err = resolver
1283            .resolve(
1284                &ObjectId::new("tool:discord-dm"),
1285                &Operation::new("send"),
1286                &ctx,
1287            )
1288            .expect_err("must fail without event");
1289        assert!(matches!(err, ResolverError::InvalidShape { .. }));
1290    }
1291
1292    #[test]
1293    fn event_resolver_missing_event_field_errors() {
1294        let resolver = EventResolver::builder()
1295            .for_target("tool:discord-dm")
1296            .map("user_id", "user_id")
1297            .build();
1298
1299        let mut ctx = DesignationContext::new(ObjectId::new("agent:jake"));
1300        ctx.insert(Event(json!({ "other": "x" })));
1301
1302        let err = resolver
1303            .resolve(
1304                &ObjectId::new("tool:discord-dm"),
1305                &Operation::new("send"),
1306                &ctx,
1307            )
1308            .expect_err("must fail with missing path");
1309        match err {
1310            ResolverError::MissingField { label, .. } => assert_eq!(label, "user_id"),
1311            other => panic!("wrong variant: {other:?}"),
1312        }
1313    }
1314}