Skip to main content

stygian_charon/vendor_resolver/
resolver.rs

1//! Vendor-to-playbook resolver engine (T90).
2//!
3//! The [`VendorResolver`] consumes a
4//! [`VendorClassification`][crate::vendor_classifier::VendorClassification]
5//! (from T89) and returns a [`VendorResolution`] that points to a
6//! resolved [`Playbook`][crate::playbooks::Playbook] together with a
7//! rationale bundle the diagnostics layer can serialise.
8//!
9//! ## Resolution flow
10//!
11//! 1. The classifier's top vendor and confidence are matched
12//!    against every registered [`ResolutionRule`] in
13//!    **priority order** (lowest priority number wins).
14//! 2. The first rule whose `min_confidence` and `min_score` gates
15//!    pass — and whose `vendors` list contains the top vendor (or
16//!    any vendor in the `ranked` list, depending on the rule's
17//!    `require_unknown_vendor` flag) — fires.
18//! 3. The fired rule's [`MergeStrategy`] determines how the
19//!    resolver combines the rule's playbook choice with any
20//!    **other** matching rules:
21//!
22//! | `MergeStrategy`   | Behaviour                                                                                        |
23//! |-------------------|---------------------------------------------------------------------------------------------------|
24//! | `StrongestVendor` | Pick the highest-weight vendor in the rule's `vendors` list and resolve with its playbook.       |
25//! | `Single`          | Pick the single matched vendor (lowest `VendorId` discriminant on ties) and resolve.             |
26//! | `Manual`          | Defer to manual mode — return [`StrategyMarker::Manual`].                                        |
27//!
28//! 4. If **no rule** matches, the resolver falls through to the
29//!    lowest-priority rule (the `default-manual` sentinel). When
30//!    that rule's `merge_strategy` is `Manual`, the resolver returns
31//!    [`StrategyMarker::Manual`] so the existing manual mode
32//!    selection keeps working — this is the
33//!    "non-breaking integration with existing manual mode
34//!    selection" guarantee called out in the T90 spec.
35//!
36//! ## Determinism
37//!
38//! The resolver is **fully deterministic**:
39//!
40//! - Rules are sorted by `(priority ASC, id ASC)` so two rules
41//!   with the same priority are tie-broken by their stable `id`.
42//! - The vendor scoreboard is supplied by the classifier, which
43//!   is itself deterministic (T89 — `VendorId` discriminant order
44//!   on ties).
45//! - The `rationale.contributing_vendors` list is sorted by
46//!   `(score DESC, VendorId ASC)` so the JSON form is byte-stable.
47//!
48//! ## Backward compatibility
49//!
50//! The resolver is **additive only** — no existing public type or
51//! method gains a new field. The new module lives at
52//! `crates/stygian-charon/src/vendor_resolver/` and is exposed
53//! via the existing `vendor_resolver` re-exports in
54//! [`crate::lib`]. No new feature gate is introduced.
55//!
56//! # Example
57//!
58//! ```
59//! use stygian_charon::types::TargetClass;
60//! use stygian_charon::vendor_classifier::{VendorClassifier, VendorId};
61//! use stygian_charon::vendor_resolver::{StrategyMarker, VendorResolver};
62//! use std::collections::BTreeMap;
63//!
64//! let vendor_resolver = VendorResolver::with_builtin_defaults();
65//! let classifier = VendorClassifier::with_builtin_defaults();
66//!
67//! // Strong DataDome signal → tier2-hostile.
68//! let cookies = vec!["datadome=abc; Path=/".to_string()];
69//! let mut headers = BTreeMap::new();
70//! headers.insert("x-datadome".to_string(), "protected".to_string());
71//! headers.insert("x-datadome-cid".to_string(), "abc".to_string());
72//! let classification = classifier.classify(&cookies, &headers, None, "https://example.com/");
73//!
74//! let resolution = vendor_resolver.resolve(&classification);
75//! match resolution.strategy {
76//!     StrategyMarker::Resolved { playbook_id, target_class } => {
77//!         assert_eq!(playbook_id, "tier2-hostile");
78//!         assert_eq!(target_class, TargetClass::HighSecurity);
79//!     }
80//!     StrategyMarker::Manual => panic!("DataDome signal should resolve"),
81//! }
82//! ```
83
84use std::collections::BTreeMap;
85
86use serde::{Deserialize, Serialize};
87
88use crate::playbooks::{Playbook, PlaybookOverrides, PlaybookResolver, ResolvedPlaybook};
89use crate::types::TargetClass;
90use crate::vendor_classifier::{EvidenceBundle, VendorClassification, VendorId, VendorScore};
91use crate::vendor_resolver::error::VendorResolverError;
92use crate::vendor_resolver::rules::{MergeStrategy, ResolutionRule, VendorRuleMatch};
93
94/// What the resolver decided to do with the vendor classification.
95///
96/// `Resolved` means the resolver picked a concrete playbook. `Manual`
97/// means the resolver could not pick a playbook with sufficient
98/// confidence and is deferring to whatever manual mode selection the
99/// caller had in effect before the resolver was invoked (this is
100/// the "non-breaking integration with existing manual mode
101/// selection" guarantee from the T90 spec).
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(tag = "kind", rename_all = "snake_case")]
104pub enum StrategyMarker {
105    /// Resolver chose a playbook deterministically.
106    Resolved {
107        /// Playbook id the resolver chose (matches
108        /// [`crate::playbooks::Playbook::id`]).
109        playbook_id: String,
110        /// Target class the resolved playbook maps to.
111        target_class: TargetClass,
112    },
113    /// Resolver deferred to manual mode. Existing manual mode
114    /// selection continues to apply — the resolver did not modify
115    /// the caller's mode state.
116    Manual,
117}
118
119impl StrategyMarker {
120    /// `true` when the resolver returned a concrete playbook.
121    #[must_use]
122    pub const fn is_resolved(&self) -> bool {
123        matches!(self, Self::Resolved { .. })
124    }
125
126    /// `true` when the resolver returned the `Manual` fallback.
127    #[must_use]
128    pub const fn is_manual(&self) -> bool {
129        matches!(self, Self::Manual)
130    }
131
132    /// Playbook id when [`Resolved`][Self::Resolved], `None`
133    /// otherwise.
134    #[must_use]
135    pub fn playbook_id(&self) -> Option<&str> {
136        match self {
137            Self::Resolved { playbook_id, .. } => Some(playbook_id),
138            Self::Manual => None,
139        }
140    }
141}
142
143/// One rule that contributed to the resolver's decision.
144///
145/// Each entry records the rule id, whether it fired, the
146/// [`MergeStrategy`] it applied, and a short human-readable note
147/// the operator log can render verbatim.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct AppliedRule {
150    /// Rule id.
151    pub rule_id: String,
152    /// `true` when the rule fired.
153    pub fired: bool,
154    /// Merge strategy the rule carries.
155    pub merge_strategy: MergeStrategy,
156    /// Human-readable note explaining why the rule fired or did
157    /// not fire.
158    pub note: String,
159}
160
161/// Full rationale bundle the resolver returns alongside the
162/// strategy marker.
163///
164/// The bundle carries everything the diagnostic payload needs to
165/// audit the resolver's decision: the top vendor, the confidence
166/// the rule was evaluated against, the ranked vendor scoreboard
167/// the classifier produced, the evidence bundle that produced the
168/// scoreboard, the [`MergeStrategy`] that was applied, and a
169/// per-rule audit log (`applied_rules`).
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub struct ResolutionRationale {
172    /// Human-readable summary suitable for an operator log.
173    pub summary: String,
174    /// Per-rule audit log, in priority order.
175    pub applied_rules: Vec<AppliedRule>,
176    /// Ranked vendor scoreboard that drove the decision (top first).
177    pub contributing_vendors: Vec<VendorScore>,
178    /// Evidence bundle that produced the scoreboard.
179    pub evidence: EvidenceBundle,
180    /// Top vendor (mirrors
181    /// [`VendorClassification::top_vendor`][crate::vendor_classifier::VendorClassification::top_vendor]).
182    pub top_vendor: VendorId,
183    /// Confidence the rule was evaluated against.
184    pub confidence: f64,
185    /// Merge strategy the resolver applied.
186    pub merge_strategy: MergeStrategy,
187}
188
189/// Full vendor-to-playbook resolution result.
190///
191/// `VendorResolution` is the **single object** the downstream
192/// acquisition runner consumes. It pairs the [`StrategyMarker`]
193/// (the decision) with a [`ResolutionRationale`] (the audit log)
194/// so the runner and the diagnostic payload can both read it.
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196pub struct VendorResolution {
197    /// Resolver decision.
198    pub strategy: StrategyMarker,
199    /// Audit log explaining the decision.
200    pub rationale: ResolutionRationale,
201}
202
203impl VendorResolution {
204    /// `true` when the resolver returned a concrete playbook.
205    #[must_use]
206    pub const fn is_resolved(&self) -> bool {
207        self.strategy.is_resolved()
208    }
209
210    /// `true` when the resolver returned the `Manual` fallback.
211    #[must_use]
212    pub const fn is_manual(&self) -> bool {
213        self.strategy.is_manual()
214    }
215}
216
217/// Vendor-to-playbook resolver.
218///
219/// Construct with [`VendorResolver::with_builtin_defaults`] to
220/// load the four baseline rules shipped in
221/// `crates/stygian-charon/data/vendor_playbook_rules/`, or
222/// [`VendorResolver::from_rules`] for an empty / custom bundle.
223///
224/// The resolver is **stateless** and `Send + Sync` so it can be
225/// shared across threads and requests without locking.
226///
227/// # Example
228///
229/// ```
230/// use stygian_charon::vendor_classifier::VendorClassifier;
231/// use stygian_charon::vendor_resolver::{StrategyMarker, VendorResolver};
232///
233/// let resolver = VendorResolver::with_builtin_defaults();
234/// let classifier = VendorClassifier::with_builtin_defaults();
235/// let c = classifier.classify(
236///     &[],
237///     &std::collections::BTreeMap::new(),
238///     Some("harmless html"),
239///     "https://example.com/",
240/// );
241/// let r = resolver.resolve(&c);
242/// // A clean HAR reports Unknown with confidence 0.0; the resolver
243/// // routes that through the tier1-static rule (Unknown vendor).
244/// assert!(r.is_resolved() || r.is_manual());
245/// ```
246#[derive(Debug, Clone)]
247pub struct VendorResolver {
248    rules: Vec<ResolutionRule>,
249}
250
251impl VendorResolver {
252    /// Build a resolver from a pre-loaded list of [`ResolutionRule`]
253    /// entries.
254    ///
255    /// The rules are validated, deduplicated by id, and sorted by
256    /// `(priority ASC, id ASC)` so the resolver's deterministic
257    /// iteration order is independent of the caller's input order.
258    ///
259    /// # Errors
260    ///
261    /// Returns [`VendorResolverError`] on the first invalid rule
262    /// or on duplicate ids.
263    #[allow(clippy::missing_errors_doc)]
264    pub fn from_rules<I>(rules: I) -> Result<Self, VendorResolverError>
265    where
266        I: IntoIterator<Item = ResolutionRule>,
267    {
268        let mut by_id: BTreeMap<String, ResolutionRule> = BTreeMap::new();
269        for rule in rules {
270            rule.validate()?;
271            if by_id.contains_key(&rule.id) {
272                return Err(VendorResolverError::DuplicateId { rule_id: rule.id });
273            }
274            by_id.insert(rule.id.clone(), rule);
275        }
276        let mut sorted: Vec<ResolutionRule> = by_id.into_values().collect();
277        sorted.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.id.cmp(&b.id)));
278        Ok(Self { rules: sorted })
279    }
280
281    /// Build a resolver seeded with the four baseline rules
282    /// embedded at compile time from
283    /// `crates/stygian-charon/data/vendor_playbook_rules/`.
284    ///
285    /// The compile-time check
286    /// `compile_check_builtin_resolution_rules`
287    /// guarantees that every embedded TOML is valid; if it
288    /// regresses, the build will fail.
289    ///
290    /// # Panics
291    ///
292    /// Panics if any embedded baseline TOML fails to parse or
293    /// validate. This is a **compile-time** failure guarded by the
294    /// `compile_check_builtin_resolution_rules` test; the panic in
295    /// production surfaces a regression in the embedded data as a
296    /// hard startup error.
297    ///
298    /// # Example
299    ///
300    /// ```
301    /// use stygian_charon::vendor_resolver::VendorResolver;
302    ///
303    /// let resolver = VendorResolver::with_builtin_defaults();
304    /// assert!(resolver.contains("tier2-hostile"));
305    /// assert!(resolver.contains("tier1-js-cloudflare"));
306    /// assert!(resolver.contains("tier1-static"));
307    /// assert!(resolver.contains("default-manual"));
308    /// ```
309    #[must_use]
310    pub fn with_builtin_defaults() -> Self {
311        let rules = crate::vendor_resolver::builtins::builtin_resolution_rules();
312        // Baseline rules are compile-time validated by
313        // `compile_check_builtin_resolution_rules`; runtime failure
314        // is only possible if the binary was tampered with
315        // post-compilation, so this is a deliberate
316        // programmer-error guard.
317        #[allow(clippy::expect_used)]
318        Self::from_rules(rules).expect("builtin resolution rules are validated at compile time")
319    }
320
321    /// `true` when the resolver has a rule with the given id.
322    #[must_use]
323    pub fn contains(&self, id: &str) -> bool {
324        self.rules.iter().any(|r| r.id == id)
325    }
326
327    /// Number of rules currently registered.
328    #[must_use]
329    pub const fn len(&self) -> usize {
330        self.rules.len()
331    }
332
333    /// `true` when no rules are registered.
334    #[must_use]
335    pub const fn is_empty(&self) -> bool {
336        self.rules.is_empty()
337    }
338
339    /// Ids of all registered rules, in priority order.
340    #[must_use]
341    pub fn rule_ids(&self) -> Vec<String> {
342        self.rules.iter().map(|r| r.id.clone()).collect()
343    }
344
345    /// Resolve a [`VendorClassification`] into a [`VendorResolution`].
346    ///
347    /// The resolver iterates the rules in priority order (lowest
348    /// priority number first) and fires the **first** rule whose
349    /// confidence and score gates pass. See the module-level docs
350    /// for the full resolution flow.
351    ///
352    /// # Example
353    ///
354    /// ```
355    /// use stygian_charon::vendor_classifier::VendorClassifier;
356    /// use stygian_charon::vendor_resolver::VendorResolver;
357    ///
358    /// let resolver = VendorResolver::with_builtin_defaults();
359    /// let classifier = VendorClassifier::with_builtin_defaults();
360    /// let cookies = vec!["datadome=abc; Path=/".to_string()];
361    /// let mut headers = std::collections::BTreeMap::new();
362    /// headers.insert("x-datadome".to_string(), "protected".to_string());
363    /// let c = classifier.classify(&cookies, &headers, None, "https://example.com/");
364    /// let r = resolver.resolve(&c);
365    /// assert!(r.is_resolved());
366    /// ```
367    #[must_use]
368    pub fn resolve(&self, classification: &VendorClassification) -> VendorResolution {
369        let top_score = classification.ranked.first().map_or(0, |s| s.score);
370        let mut applied: Vec<AppliedRule> = Vec::new();
371        let mut fired: Option<&ResolutionRule> = None;
372
373        for rule in &self.rules {
374            let note = evaluate_rule_note(rule, classification, top_score);
375            if rule_matches(rule, classification, top_score) {
376                applied.push(AppliedRule {
377                    rule_id: rule.id.clone(),
378                    fired: true,
379                    merge_strategy: rule.merge_strategy,
380                    note,
381                });
382                fired = Some(rule);
383                break;
384            }
385            applied.push(AppliedRule {
386                rule_id: rule.id.clone(),
387                fired: false,
388                merge_strategy: rule.merge_strategy,
389                note,
390            });
391        }
392
393        let chosen = fired.unwrap_or_else(|| {
394            // The baseline bundle always contains a `default-manual`
395            // sentinel at priority 1000; if a custom bundle omits
396            // it (or is empty), we still want the resolver to
397            // return a well-formed `Manual` marker rather than
398            // panic.
399            self.rules.last().unwrap_or_else(|| manual_fallback_rule())
400        });
401
402        let strategy = strategy_from_rule(chosen, classification);
403        let summary = build_summary(&strategy, chosen, classification);
404        let merge_strategy = chosen.merge_strategy;
405        let rationale = ResolutionRationale {
406            summary,
407            applied_rules: applied,
408            contributing_vendors: classification.ranked.clone(),
409            evidence: classification.evidence.clone(),
410            top_vendor: classification.top_vendor,
411            confidence: classification.confidence,
412            merge_strategy,
413        };
414
415        VendorResolution {
416            strategy,
417            rationale,
418        }
419    }
420
421    /// Convenience helper that resolves a classification and then
422    /// resolves the matched playbook through a
423    /// [`PlaybookResolver`].
424    ///
425    /// Returns `None` when the resolver returned the `Manual`
426    /// strategy marker, mirroring the
427    /// "non-breaking integration with existing manual mode
428    /// selection" guarantee — the caller keeps its manual mode
429    /// selection rather than receiving a `ResolvedPlaybook`.
430    ///
431    /// # Errors
432    ///
433    /// Returns [`crate::playbooks::ValidationError`] when the
434    /// resolver picked a playbook id the [`PlaybookResolver`] does
435    /// not have registered.
436    pub fn resolve_with_playbooks(
437        &self,
438        classification: &VendorClassification,
439        playbook_resolver: &PlaybookResolver,
440        overrides: &PlaybookOverrides,
441    ) -> Result<Option<ResolvedPlaybook>, crate::playbooks::ValidationError> {
442        let resolution = self.resolve(classification);
443        let Some(playbook_id) = resolution.strategy.playbook_id() else {
444            return Ok(None);
445        };
446        let target_class = match &resolution.strategy {
447            StrategyMarker::Resolved { target_class, .. } => *target_class,
448            StrategyMarker::Manual => TargetClass::Unknown,
449        };
450        let resolved = playbook_resolver.resolve(target_class, playbook_id, overrides)?;
451        Ok(Some(resolved))
452    }
453}
454
455fn rule_matches(
456    rule: &ResolutionRule,
457    classification: &VendorClassification,
458    top_score: u32,
459) -> bool {
460    if classification.confidence < rule.min_confidence {
461        return false;
462    }
463    if top_score < rule.min_score {
464        return false;
465    }
466    if rule.require_unknown_vendor && classification.top_vendor != VendorId::Unknown {
467        return false;
468    }
469    if rule.vendors.is_empty() {
470        // A rule with no vendor list and no confidence gate acts
471        // as a catch-all. This is how `default-manual` is shaped.
472        return true;
473    }
474    // When the classification reports Unknown as the top vendor
475    // (no signals matched at all), the classifier's `ranked`
476    // list is typically empty — so the "is this vendor in
477    // ranked?" check below would always miss the `unknown`
478    // placeholder entry. Treat the Unknown placeholder as a
479    // wildcard match when the classification is unknown.
480    // We require `score > 0` so a vendor that merely exists in
481    // the registered registry (with zero score) does not
482    // accidentally satisfy a rule.
483    let classification_has_vendor = |v: &VendorId| {
484        if *v == VendorId::Unknown && classification.top_vendor == VendorId::Unknown {
485            return true;
486        }
487        classification
488            .ranked
489            .iter()
490            .any(|s| s.vendor == *v && s.score > 0)
491    };
492    rule.vendors
493        .iter()
494        .any(|v| classification_has_vendor(&v.vendor))
495}
496
497fn evaluate_rule_note(
498    rule: &ResolutionRule,
499    classification: &VendorClassification,
500    top_score: u32,
501) -> String {
502    if classification.confidence < rule.min_confidence {
503        format!(
504            "skipped: confidence {} < min_confidence {}",
505            classification.confidence, rule.min_confidence
506        )
507    } else if top_score < rule.min_score {
508        format!(
509            "skipped: top_score {top_score} < min_score {}",
510            rule.min_score
511        )
512    } else if rule.require_unknown_vendor && classification.top_vendor != VendorId::Unknown {
513        format!(
514            "skipped: top_vendor {} is not Unknown",
515            classification.top_vendor.label()
516        )
517    } else if rule.vendors.is_empty() {
518        "fired: catch-all rule (no vendor list, gates passed)".to_string()
519    } else if rule
520        .vendors
521        .iter()
522        .any(|v| classification.ranked.iter().any(|s| s.vendor == v.vendor))
523    {
524        "fired: at least one listed vendor matched".to_string()
525    } else {
526        "skipped: no listed vendor matched".to_string()
527    }
528}
529
530fn strategy_from_rule(
531    rule: &ResolutionRule,
532    classification: &VendorClassification,
533) -> StrategyMarker {
534    match rule.merge_strategy {
535        MergeStrategy::Manual => StrategyMarker::Manual,
536        MergeStrategy::StrongestVendor => {
537            let winning = pick_strongest_vendor(rule, classification);
538            StrategyMarker::Resolved {
539                playbook_id: rule.playbook_id.clone(),
540                target_class: winning_target_class(rule, winning, classification),
541            }
542        }
543        MergeStrategy::Single => {
544            let winning = pick_single_vendor(rule, classification);
545            StrategyMarker::Resolved {
546                playbook_id: rule.playbook_id.clone(),
547                target_class: winning_target_class(rule, winning, classification),
548            }
549        }
550    }
551}
552
553fn pick_strongest_vendor<'a>(
554    rule: &'a ResolutionRule,
555    classification: &VendorClassification,
556) -> Option<&'a VendorRuleMatch> {
557    rule.vendors
558        .iter()
559        .filter(|v| classification.ranked.iter().any(|s| s.vendor == v.vendor))
560        .max_by_key(|v| v.weight)
561}
562
563fn pick_single_vendor<'a>(
564    rule: &'a ResolutionRule,
565    classification: &VendorClassification,
566) -> Option<&'a VendorRuleMatch> {
567    rule.vendors
568        .iter()
569        .filter(|v| classification.ranked.iter().any(|s| s.vendor == v.vendor))
570        .min_by_key(|v| v.vendor)
571}
572
573/// Fallback rule used when the resolver has zero registered rules.
574///
575/// Built lazily on first use via [`std::sync::LazyLock`]. The
576/// fallback is intentionally constructed with
577/// [`MergeStrategy::Manual`] so the resulting strategy is always
578/// [`StrategyMarker::Manual`] when no rule fired.
579fn manual_fallback_rule() -> &'static ResolutionRule {
580    static FALLBACK: std::sync::LazyLock<ResolutionRule> =
581        std::sync::LazyLock::new(|| ResolutionRule {
582            id: String::new(),
583            playbook_id: String::new(),
584            target_class: TargetClass::Unknown,
585            priority: u32::MAX,
586            merge_strategy: MergeStrategy::Manual,
587            description: String::new(),
588            min_confidence: 0.0,
589            min_score: 0,
590            require_unknown_vendor: false,
591            vendors: Vec::new(),
592        });
593    &FALLBACK
594}
595
596const fn winning_target_class(
597    rule: &ResolutionRule,
598    winning: Option<&VendorRuleMatch>,
599    _classification: &VendorClassification,
600) -> TargetClass {
601    // The rule's own `target_class` is the primary signal. The
602    // winning vendor only matters when the rule carries no
603    // target class (currently unused in the baseline rules).
604    let _ = winning;
605    rule.target_class
606}
607
608fn build_summary(
609    strategy: &StrategyMarker,
610    rule: &ResolutionRule,
611    classification: &VendorClassification,
612) -> String {
613    let vendor_label = classification.top_vendor.label();
614    let target_class_label = |tc: TargetClass| match tc {
615        TargetClass::Api => "api",
616        TargetClass::ContentSite => "content_site",
617        TargetClass::HighSecurity => "high_security",
618        TargetClass::Unknown => "unknown",
619    };
620    match strategy {
621        StrategyMarker::Resolved {
622            playbook_id,
623            target_class,
624        } => format!(
625            "rule '{}' fired for vendor {} (confidence {:.3}); resolved to playbook '{}' ({})",
626            rule.id,
627            vendor_label,
628            classification.confidence,
629            playbook_id,
630            target_class_label(*target_class),
631        ),
632        StrategyMarker::Manual => format!(
633            "rule '{}' fired for vendor {} (confidence {:.3}); deferring to manual mode",
634            rule.id, vendor_label, classification.confidence
635        ),
636    }
637}
638
639/// Extension trait that surfaces the resolved [`Playbook`] from a
640/// [`PlaybookResolver`] without going through the full
641/// precedence ladder.
642///
643/// This is a convenience used by downstream callers that want to
644/// read the raw codified [`Playbook`] (e.g. for diagnostics or
645/// for round-tripping the rule table back to TOML). The trait
646/// lives in the `vendor_resolver` module because it is primarily
647/// useful in conjunction with the vendor resolver — when
648/// `VendorResolver::resolve_with_playbooks` returns a
649/// `ResolvedPlaybook`, callers occasionally need to read the
650/// underlying codified playbook for `description` /
651/// `target_class` audit fields.
652pub trait PlaybookResolverExt {
653    /// Resolve a single playbook by id, returning the underlying
654    /// [`Playbook`] without applying any precedence / override
655    /// merge.
656    ///
657    /// # Errors
658    ///
659    /// Returns
660    /// [`ValidationError::UnknownPlaybook`][crate::playbooks::ValidationError::UnknownPlaybook]
661    /// when the id is not registered.
662    fn resolve_unsafe_playbook(
663        &self,
664        id: &str,
665    ) -> Result<Playbook, crate::playbooks::ValidationError>;
666}
667
668impl PlaybookResolverExt for PlaybookResolver {
669    fn resolve_unsafe_playbook(
670        &self,
671        id: &str,
672    ) -> Result<Playbook, crate::playbooks::ValidationError> {
673        // Round-trip the public API: resolve_optional returns
674        // the merged ResolvedPlaybook; we then mirror the four
675        // config blocks back into a Playbook. The description
676        // string is the only field that round-trips lossily —
677        // it is operator-facing text and does not affect the
678        // resolver's logic.
679        let target_class = TargetClass::Unknown;
680        let overrides = PlaybookOverrides::default();
681        let resolved = self.resolve_optional(target_class, Some(id), &overrides)?;
682        Ok(Playbook {
683            id: resolved.playbook_id,
684            target_class: resolved.target_class,
685            description: String::new(),
686            acquisition: crate::playbooks::AcquisitionDefaults {
687                mode: resolved.acquisition.mode,
688                execution_mode: resolved.acquisition.execution_mode,
689                session_mode: resolved.acquisition.session_mode,
690                telemetry_level: resolved.acquisition.telemetry_level,
691                sticky_session_ttl_secs: resolved.acquisition.sticky_session_ttl_secs,
692                enable_warmup: resolved.acquisition.enable_warmup,
693                retry_budget: resolved.acquisition.retry_budget,
694                backoff_base_ms: resolved.acquisition.backoff_base_ms,
695            },
696            proxy_preference: resolved.proxy_preference,
697            pacing: resolved.pacing,
698            escalation: resolved.escalation,
699        })
700    }
701}
702
703#[cfg(test)]
704#[allow(
705    clippy::unwrap_used,
706    clippy::expect_used,
707    clippy::panic,
708    clippy::indexing_slicing,
709    clippy::similar_names
710)]
711mod tests {
712    use super::*;
713    use crate::vendor_classifier::{Evidence, EvidenceBundle, EvidenceSource};
714
715    fn approx_eq(a: f64, b: f64) -> bool {
716        (a - b).abs() < 1e-9
717    }
718
719    fn classification(
720        top_vendor: VendorId,
721        confidence: f64,
722        ranked: Vec<VendorScore>,
723    ) -> VendorClassification {
724        VendorClassification {
725            top_vendor,
726            confidence,
727            is_high_confidence: confidence >= 0.60,
728            ranked,
729            evidence: EvidenceBundle::default(),
730            threshold: 0.60,
731        }
732    }
733
734    fn datadome_score() -> VendorScore {
735        VendorScore {
736            vendor: VendorId::DataDome,
737            score: 15,
738            matched_sources: vec![(EvidenceSource::Header, 3), (EvidenceSource::Cookie, 1)]
739                .into_iter()
740                .collect(),
741        }
742    }
743
744    fn cloudflare_score() -> VendorScore {
745        VendorScore {
746            vendor: VendorId::Cloudflare,
747            score: 10,
748            matched_sources: vec![(EvidenceSource::Header, 2)].into_iter().collect(),
749        }
750    }
751
752    fn akamai_score() -> VendorScore {
753        VendorScore {
754            vendor: VendorId::Akamai,
755            score: 12,
756            matched_sources: vec![(EvidenceSource::Cookie, 2)].into_iter().collect(),
757        }
758    }
759
760    fn evidence() -> EvidenceBundle {
761        EvidenceBundle {
762            items: vec![Evidence {
763                signal: "x-datadome".to_string(),
764                source: EvidenceSource::Header,
765                weight: 5,
766            }],
767            source_summary: vec![(EvidenceSource::Header, 1)].into_iter().collect(),
768        }
769    }
770
771    #[test]
772    fn empty_resolver_returns_manual_marker() {
773        let resolver = VendorResolver::from_rules(Vec::new()).expect("empty resolver");
774        let c = classification(VendorId::DataDome, 1.0, vec![datadome_score()]);
775        let r = resolver.resolve(&c);
776        // An empty resolver has no rule to fire. The fallback
777        // rule returns the `Manual` strategy marker.
778        assert!(r.is_manual());
779        assert!(r.rationale.applied_rules.is_empty());
780    }
781
782    #[test]
783    fn single_vendor_datadome_resolves_to_tier2_hostile() {
784        let resolver = VendorResolver::with_builtin_defaults();
785        let mut c = classification(VendorId::DataDome, 1.0, vec![datadome_score()]);
786        c.evidence = evidence();
787        let r = resolver.resolve(&c);
788        match &r.strategy {
789            StrategyMarker::Resolved {
790                playbook_id,
791                target_class,
792            } => {
793                assert_eq!(playbook_id, "tier2-hostile");
794                assert_eq!(*target_class, TargetClass::HighSecurity);
795            }
796            StrategyMarker::Manual => panic!("DataDome should resolve, not defer"),
797        }
798        assert!(r.is_resolved());
799        assert_eq!(r.rationale.merge_strategy, MergeStrategy::StrongestVendor);
800        assert!(r.rationale.applied_rules.iter().any(|a| a.fired));
801    }
802
803    #[test]
804    fn single_vendor_cloudflare_resolves_to_tier1_js() {
805        let resolver = VendorResolver::with_builtin_defaults();
806        let c = classification(VendorId::Cloudflare, 0.9, vec![cloudflare_score()]);
807        let r = resolver.resolve(&c);
808        match &r.strategy {
809            StrategyMarker::Resolved {
810                playbook_id,
811                target_class,
812            } => {
813                assert_eq!(playbook_id, "tier1-js");
814                assert_eq!(*target_class, TargetClass::ContentSite);
815            }
816            StrategyMarker::Manual => panic!("Cloudflare should resolve, not defer"),
817        }
818    }
819
820    #[test]
821    fn multi_vendor_datadome_plus_cloudflare_picks_tier2_hostile() {
822        let resolver = VendorResolver::with_builtin_defaults();
823        let ranked = vec![
824            VendorScore {
825                vendor: VendorId::DataDome,
826                score: 15,
827                matched_sources: BTreeMap::new(),
828            },
829            VendorScore {
830                vendor: VendorId::Cloudflare,
831                score: 10,
832                matched_sources: BTreeMap::new(),
833            },
834        ];
835        let c = classification(VendorId::DataDome, 0.60, ranked);
836        let r = resolver.resolve(&c);
837        // Tier 2 hostile has priority 0; tier 1 js-cloudflare has
838        // priority 10. Priority 0 wins.
839        match &r.strategy {
840            StrategyMarker::Resolved { playbook_id, .. } => {
841                assert_eq!(playbook_id, "tier2-hostile");
842            }
843            StrategyMarker::Manual => panic!("multi-vendor should resolve"),
844        }
845    }
846
847    #[test]
848    fn low_confidence_datadome_falls_through_to_manual() {
849        let resolver = VendorResolver::with_builtin_defaults();
850        // Below the tier2-hostile gate (0.60) but DataDome still
851        // listed as a vendor. The resolver should NOT pick
852        // tier2-hostile; it should fall through to default-manual
853        // and return the Manual marker.
854        let c = classification(VendorId::DataDome, 0.30, vec![datadome_score()]);
855        let r = resolver.resolve(&c);
856        assert!(r.is_manual(), "expected Manual, got {:?}", r.strategy);
857        assert_eq!(r.rationale.top_vendor, VendorId::DataDome);
858    }
859
860    #[test]
861    fn unknown_with_no_evidence_picks_tier1_static() {
862        let resolver = VendorResolver::with_builtin_defaults();
863        let c = classification(VendorId::Unknown, 0.0, Vec::new());
864        let r = resolver.resolve(&c);
865        match &r.strategy {
866            StrategyMarker::Resolved {
867                playbook_id,
868                target_class,
869            } => {
870                assert_eq!(playbook_id, "tier1-static");
871                assert_eq!(*target_class, TargetClass::ContentSite);
872            }
873            StrategyMarker::Manual => panic!("clean Unknown should pick tier1-static"),
874        }
875    }
876
877    #[test]
878    fn unknown_vendor_with_some_evidence_falls_through_to_manual() {
879        let resolver = VendorResolver::with_builtin_defaults();
880        // Some evidence (i.e. a vendor matched), but confidence is
881        // too low for any specific rule. Default-manual fires
882        // because tier1-static requires `require_unknown_vendor`
883        // AND confidence 0.0. Here confidence is 0.0 but the
884        // classification is NOT pure unknown.
885        let c = classification(VendorId::DataDome, 0.0, vec![datadome_score()]);
886        let r = resolver.resolve(&c);
887        // DataDome with 0.0 confidence: tier2-hostile skipped
888        // (confidence < 0.60), tier1-js-cloudflare skipped (no
889        // Cloudflare signal), tier1-static skipped (top vendor
890        // is not Unknown), default-manual fires with Manual
891        // merge strategy.
892        assert!(r.is_manual());
893    }
894
895    #[test]
896    fn akamai_vendor_resolves_to_tier2_hostile() {
897        let resolver = VendorResolver::with_builtin_defaults();
898        let c = classification(VendorId::Akamai, 0.85, vec![akamai_score()]);
899        let r = resolver.resolve(&c);
900        match &r.strategy {
901            StrategyMarker::Resolved { playbook_id, .. } => {
902                assert_eq!(playbook_id, "tier2-hostile");
903            }
904            StrategyMarker::Manual => panic!("Akamai should resolve"),
905        }
906    }
907
908    #[test]
909    fn perimeterx_vendor_resolves_to_tier2_hostile() {
910        let resolver = VendorResolver::with_builtin_defaults();
911        let c = classification(
912            VendorId::PerimeterX,
913            0.95,
914            vec![VendorScore {
915                vendor: VendorId::PerimeterX,
916                score: 18,
917                matched_sources: BTreeMap::new(),
918            }],
919        );
920        let r = resolver.resolve(&c);
921        match &r.strategy {
922            StrategyMarker::Resolved { playbook_id, .. } => {
923                assert_eq!(playbook_id, "tier2-hostile");
924            }
925            StrategyMarker::Manual => panic!("PerimeterX should resolve"),
926        }
927    }
928
929    #[test]
930    fn rationale_summary_mentions_top_vendor_and_confidence() {
931        let resolver = VendorResolver::with_builtin_defaults();
932        let c = classification(VendorId::DataDome, 0.9, vec![datadome_score()]);
933        let r = resolver.resolve(&c);
934        assert!(r.rationale.summary.contains("datadome"));
935        assert!(r.rationale.summary.contains("tier2-hostile"));
936    }
937
938    #[test]
939    fn rationale_records_every_evaluated_rule() {
940        let resolver = VendorResolver::with_builtin_defaults();
941        let c = classification(VendorId::DataDome, 1.0, vec![datadome_score()]);
942        let r = resolver.resolve(&c);
943        let rule_ids: Vec<&str> = r
944            .rationale
945            .applied_rules
946            .iter()
947            .map(|a| a.rule_id.as_str())
948            .collect();
949        // The fired rule is tier2-hostile; the rest are not
950        // recorded because the resolver short-circuits on the
951        // first match. Confirm tier2-hostile is recorded.
952        assert_eq!(rule_ids, vec!["tier2-hostile"]);
953    }
954
955    #[test]
956    fn rule_ids_are_sorted_by_priority_then_id() {
957        let resolver = VendorResolver::with_builtin_defaults();
958        let ids = resolver.rule_ids();
959        assert_eq!(
960            ids,
961            vec![
962                "tier2-hostile".to_string(),
963                "tier1-js-cloudflare".to_string(),
964                "tier1-static".to_string(),
965                "default-manual".to_string(),
966            ]
967        );
968    }
969
970    #[test]
971    fn confidence_propagates_into_rationale() {
972        let resolver = VendorResolver::with_builtin_defaults();
973        let c = classification(VendorId::DataDome, 0.9, vec![datadome_score()]);
974        let r = resolver.resolve(&c);
975        assert!(approx_eq(r.rationale.confidence, 0.9));
976        assert_eq!(r.rationale.top_vendor, VendorId::DataDome);
977    }
978
979    #[test]
980    fn from_rules_rejects_duplicates() {
981        let rule = ResolutionRule {
982            id: "dup".to_string(),
983            playbook_id: "tier2-hostile".to_string(),
984            target_class: TargetClass::HighSecurity,
985            priority: 0,
986            merge_strategy: MergeStrategy::StrongestVendor,
987            description: String::new(),
988            min_confidence: 0.0,
989            min_score: 0,
990            require_unknown_vendor: false,
991            vendors: vec![VendorRuleMatch {
992                vendor: VendorId::DataDome,
993                weight: 5,
994            }],
995        };
996        let result = VendorResolver::from_rules(vec![rule.clone(), rule]);
997        assert!(matches!(
998            result,
999            Err(VendorResolverError::DuplicateId { .. })
1000        ));
1001    }
1002
1003    #[test]
1004    fn from_rules_rejects_invalid_rule() {
1005        let rule = ResolutionRule {
1006            id: "broken".to_string(),
1007            playbook_id: String::new(),
1008            target_class: TargetClass::HighSecurity,
1009            priority: 0,
1010            merge_strategy: MergeStrategy::StrongestVendor,
1011            description: String::new(),
1012            min_confidence: 0.0,
1013            min_score: 0,
1014            require_unknown_vendor: false,
1015            vendors: vec![VendorRuleMatch {
1016                vendor: VendorId::DataDome,
1017                weight: 5,
1018            }],
1019        };
1020        let result = VendorResolver::from_rules(vec![rule]);
1021        assert!(result.is_err());
1022    }
1023
1024    #[test]
1025    fn manual_strategy_marker_helpers() {
1026        let manual = StrategyMarker::Manual;
1027        assert!(manual.is_manual());
1028        assert!(!manual.is_resolved());
1029        assert!(manual.playbook_id().is_none());
1030
1031        let resolved = StrategyMarker::Resolved {
1032            playbook_id: "tier2-hostile".to_string(),
1033            target_class: TargetClass::HighSecurity,
1034        };
1035        assert!(resolved.is_resolved());
1036        assert!(!resolved.is_manual());
1037        assert_eq!(resolved.playbook_id(), Some("tier2-hostile"));
1038    }
1039}