Skip to main content

stygian_charon/playbooks/
resolver.rs

1//! Playbook resolver with deterministic precedence (T85).
2//!
3//! The resolver merges three layers into a single
4//! [`ResolvedPlaybook`]:
5//!
6//! 1. **Request override** (top priority) — per-call fields supplied
7//!    by the operator when the runner is invoked.
8//! 2. **Playbook default** — the codified defaults from the matched
9//!    TOML playbook.
10//! 3. **Global default** (bottom priority) — the resolver's own
11//!    fallback when no playbook is registered for a target class.
12//!
13//! The precedence is **deterministic and exhaustively tested**. Each
14//! field in [`ResolvedPlaybook`] carries a [`ResolutionSource`] tag so
15//! observers can verify which layer contributed the value.
16//!
17//! # Example
18//!
19//! ```
20//! use stygian_charon::playbooks::{
21//!     AcquisitionOverrides, PlaybookOverrides, PlaybookResolver,
22//! };
23//! use stygian_charon::acquisition::AcquisitionModeHint;
24//! use stygian_charon::types::{ExecutionMode, SessionMode, TargetClass};
25//!
26//! let resolver = PlaybookResolver::with_builtin_defaults();
27//! let overrides = PlaybookOverrides {
28//!     acquisition: AcquisitionOverrides {
29//!         mode: Some(AcquisitionModeHint::Hostile),
30//!         execution_mode: Some(ExecutionMode::Browser),
31//!         ..AcquisitionOverrides::default()
32//!     },
33//!     ..PlaybookOverrides::default()
34//! };
35//! let resolved = resolver
36//!     .resolve(TargetClass::ContentSite, "tier1-js", &overrides)
37//!     .expect("resolve");
38//! // Request override wins for the fields it sets.
39//! assert_eq!(resolved.acquisition.mode, AcquisitionModeHint::Hostile);
40//! assert_eq!(resolved.acquisition.execution_mode, ExecutionMode::Browser);
41//! // Playbook default fills the rest.
42//! assert_eq!(resolved.acquisition.session_mode, SessionMode::Sticky);
43//! ```
44
45use std::collections::HashMap;
46
47use serde::{Deserialize, Serialize};
48
49use crate::acquisition::AcquisitionModeHint;
50use crate::playbooks::error::ValidationError;
51use crate::playbooks::schema::{
52    AcquisitionDefaults, AcquisitionOverrides, EscalationStrategy, PacingProfile, Playbook,
53    ProxyPreference, ResolutionSource,
54};
55use crate::types::{ExecutionMode, SessionMode, TargetClass, TelemetryLevel};
56
57/// Per-request override bundle used by [`PlaybookResolver::resolve`].
58///
59/// Each field is independent: setting `acquisition.mode` does not
60/// override `acquisition.session_mode`. The empty override
61/// (`PlaybookOverrides::default()`) means "use the playbook default
62/// for every field".
63///
64/// # Example
65///
66/// ```
67/// use stygian_charon::playbooks::{AcquisitionOverrides, PlaybookOverrides};
68/// use stygian_charon::acquisition::AcquisitionModeHint;
69///
70/// let overrides = PlaybookOverrides {
71///     acquisition: AcquisitionOverrides {
72///         mode: Some(AcquisitionModeHint::Hostile),
73///         ..AcquisitionOverrides::default()
74///     },
75///     ..PlaybookOverrides::default()
76/// };
77/// assert_eq!(overrides.acquisition.mode, Some(AcquisitionModeHint::Hostile));
78/// ```
79#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
80pub struct PlaybookOverrides {
81    /// Optional acquisition overrides.
82    #[serde(default)]
83    pub acquisition: AcquisitionOverrides,
84    /// Optional proxy-preference override (full replacement).
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub proxy_preference: Option<ProxyPreference>,
87    /// Optional pacing-profile override (full replacement).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub pacing: Option<PacingProfile>,
90    /// Optional escalation-strategy override (full replacement).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub escalation: Option<EscalationStrategy>,
93}
94
95/// Resolved acquisition block: the merge of overrides, playbook, and
96/// global defaults. Each field carries a [`ResolutionSource`] tag.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct ResolvedAcquisition {
99    /// Resolved acquisition mode.
100    pub mode: AcquisitionModeHint,
101    /// Which tier contributed the mode value.
102    pub mode_source: ResolutionSource,
103    /// Resolved execution mode.
104    pub execution_mode: ExecutionMode,
105    /// Which tier contributed the execution-mode value.
106    pub execution_mode_source: ResolutionSource,
107    /// Resolved session mode.
108    pub session_mode: SessionMode,
109    /// Which tier contributed the session-mode value.
110    pub session_mode_source: ResolutionSource,
111    /// Resolved telemetry level.
112    pub telemetry_level: TelemetryLevel,
113    /// Which tier contributed the telemetry value.
114    pub telemetry_level_source: ResolutionSource,
115    /// Resolved sticky-session TTL (seconds).
116    pub sticky_session_ttl_secs: Option<u64>,
117    /// Which tier contributed the sticky-session TTL.
118    pub sticky_session_ttl_source: ResolutionSource,
119    /// Resolved warmup flag.
120    pub enable_warmup: bool,
121    /// Which tier contributed the warmup flag.
122    pub enable_warmup_source: ResolutionSource,
123    /// Resolved retry budget.
124    pub retry_budget: u32,
125    /// Which tier contributed the retry budget.
126    pub retry_budget_source: ResolutionSource,
127    /// Resolved backoff base (ms).
128    pub backoff_base_ms: u64,
129    /// Which tier contributed the backoff base.
130    pub backoff_base_source: ResolutionSource,
131}
132
133/// Full resolution result.
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct ResolvedPlaybook {
136    /// Id of the matched playbook (or `"unknown"` when the resolver
137    /// fell through to the global default).
138    pub playbook_id: String,
139    /// Target class supplied to the resolver.
140    pub target_class: TargetClass,
141    /// Resolved acquisition block.
142    pub acquisition: ResolvedAcquisition,
143    /// Resolved proxy preference.
144    pub proxy_preference: ProxyPreference,
145    /// Which tier contributed the proxy preference.
146    pub proxy_preference_source: ResolutionSource,
147    /// Resolved pacing profile.
148    pub pacing: PacingProfile,
149    /// Which tier contributed the pacing profile.
150    pub pacing_source: ResolutionSource,
151    /// Resolved escalation strategy.
152    pub escalation: EscalationStrategy,
153    /// Which tier contributed the escalation strategy.
154    pub escalation_source: ResolutionSource,
155}
156
157impl ResolvedPlaybook {
158    /// Map the resolved acquisition block to a
159    /// [`crate::acquisition::RuntimePolicyHints`] ready for
160    /// [`crate::acquisition::map_policy_hints`].
161    ///
162    /// # Example
163    ///
164    /// ```
165    /// use stygian_charon::playbooks::PlaybookResolver;
166    /// use stygian_charon::types::TargetClass;
167    ///
168    /// let resolver = PlaybookResolver::with_builtin_defaults();
169    /// let resolved = resolver
170    ///     .resolve(TargetClass::ContentSite, "tier1-js", &Default::default())
171    ///     .expect("resolve");
172    /// let _hints = resolved.to_runtime_policy_hints();
173    /// ```
174    #[must_use]
175    pub const fn to_runtime_policy_hints(&self) -> crate::acquisition::RuntimePolicyHints {
176        crate::acquisition::RuntimePolicyHints {
177            execution_mode: Some(self.acquisition.execution_mode),
178            session_mode: Some(self.acquisition.session_mode),
179            telemetry_level: Some(self.acquisition.telemetry_level),
180            risk_score: None,
181            max_retries: Some(self.acquisition.retry_budget),
182            backoff_base_ms: Some(self.acquisition.backoff_base_ms),
183            enable_warmup: Some(self.acquisition.enable_warmup),
184        }
185    }
186
187    /// Convenience: render the resolution result into a
188    /// [`crate::acquisition::AcquisitionPolicy`] via the standard
189    /// `map_policy_hints` path.
190    ///
191    /// # Example
192    ///
193    /// ```
194    /// use stygian_charon::playbooks::PlaybookResolver;
195    /// use stygian_charon::types::TargetClass;
196    ///
197    /// let resolver = PlaybookResolver::with_builtin_defaults();
198    /// let resolved = resolver
199    ///     .resolve(TargetClass::ContentSite, "tier1-js", &Default::default())
200    ///     .expect("resolve");
201    /// let _policy = resolved.to_acquisition_policy();
202    /// ```
203    #[must_use]
204    pub fn to_acquisition_policy(&self) -> crate::acquisition::AcquisitionPolicy {
205        crate::acquisition::map_policy_hints(&self.to_runtime_policy_hints())
206    }
207}
208
209/// Playbook registry + precedence resolver.
210///
211/// # Example
212///
213/// ```
214/// use stygian_charon::playbooks::{PlaybookOverrides, PlaybookResolver};
215/// use stygian_charon::types::TargetClass;
216///
217/// let resolver = PlaybookResolver::with_builtin_defaults();
218/// let resolved = resolver
219///     .resolve(
220///         TargetClass::ContentSite,
221///         "tier1-js",
222///         &PlaybookOverrides::default(),
223///     )
224///     .expect("resolve");
225/// assert_eq!(resolved.target_class, TargetClass::ContentSite);
226/// ```
227#[derive(Debug, Clone)]
228pub struct PlaybookResolver {
229    playbooks: HashMap<String, Playbook>,
230    by_target_class: HashMap<TargetClass, String>,
231    global_default: Playbook,
232}
233
234impl PlaybookResolver {
235    /// Create a resolver seeded with the documented baseline
236    /// playbooks (`tier1-static`, `tier1-js`, `tier2-hostile`,
237    /// plus an `unknown` fallback).
238    ///
239    /// The resolver will fail-fast on startup if any embedded
240    /// playbook is invalid; this guarantees the loader's
241    /// `validate()`-first contract.
242    ///
243    /// # Panics
244    ///
245    /// Panics if any embedded baseline TOML fails to parse or
246    /// validate. This is a **compile-time** failure guarded by
247    /// the `compile_check_builtin_playbooks` test in
248    /// `crate::playbooks::builtin`; the panic in production
249    /// surfaces a regression in the embedded data as a hard
250    /// startup error.
251    ///
252    /// # Example
253    ///
254    /// ```
255    /// use stygian_charon::playbooks::PlaybookResolver;
256    ///
257    /// let resolver = PlaybookResolver::with_builtin_defaults();
258    /// assert!(resolver.contains("tier1-static"));
259    /// assert!(resolver.contains("tier1-js"));
260    /// assert!(resolver.contains("tier2-hostile"));
261    /// ```
262    #[must_use]
263    pub fn with_builtin_defaults() -> Self {
264        // The embedded playbooks are validated at compile time
265        // (see `builtin::load_builtin_playbooks`). If the test
266        // suite catches a regression in the embedded TOML, this
267        // function will refuse to build, surfacing the failure at
268        // task completion rather than runtime.
269        let playbooks = crate::playbooks::builtin::builtin_playbooks();
270        #[allow(clippy::expect_used)]
271        Self::from_playbooks(playbooks).expect("builtin playbooks are validated at compile time")
272    }
273
274    /// Build a resolver from a list of pre-validated playbooks. The
275    /// first playbook encountered per target class becomes the
276    /// default for that class; subsequent matches for the same class
277    /// override the lookup (last-write-wins).
278    ///
279    /// # Errors
280    ///
281    /// Returns [`ValidationError::DuplicateId`] when two playbooks
282    /// share the same id, or [`ValidationError`] from each playbook's
283    /// `validate()`.
284    pub fn from_playbooks<I>(playbooks: I) -> Result<Self, ValidationError>
285    where
286        I: IntoIterator<Item = Playbook>,
287    {
288        let mut by_id: HashMap<String, Playbook> = HashMap::new();
289        let mut by_target_class: HashMap<TargetClass, String> = HashMap::new();
290        let mut global_default: Option<Playbook> = None;
291
292        for pb in playbooks {
293            pb.validate()?;
294            if by_id.contains_key(&pb.id) {
295                return Err(ValidationError::DuplicateId { playbook_id: pb.id });
296            }
297            if pb.id == "unknown" {
298                global_default = Some(pb.clone());
299            }
300            by_target_class
301                .entry(pb.target_class)
302                .or_insert_with(|| pb.id.clone());
303            by_id.insert(pb.id.clone(), pb);
304        }
305
306        let global_default = global_default.unwrap_or_else(|| Playbook {
307            id: "unknown".to_string(),
308            target_class: TargetClass::Unknown,
309            description: "Fallback when no playbook matches".to_string(),
310            acquisition: AcquisitionDefaults::default_for(TargetClass::Unknown),
311            proxy_preference: ProxyPreference::default_for(TargetClass::Unknown),
312            pacing: PacingProfile::default_for(TargetClass::Unknown),
313            escalation: EscalationStrategy::default(),
314        });
315
316        Ok(Self {
317            playbooks: by_id,
318            by_target_class,
319            global_default,
320        })
321    }
322
323    /// `true` when the resolver has a playbook with the given id.
324    #[must_use]
325    pub fn contains(&self, id: &str) -> bool {
326        self.playbooks.contains_key(id)
327    }
328
329    /// Ids of all registered playbooks, in sorted order.
330    #[must_use]
331    pub fn playbook_ids(&self) -> Vec<String> {
332        self.playbooks.keys().cloned().collect()
333    }
334
335    /// Resolve a playbook for a `(target_class, playbook_id)` pair
336    /// with per-request overrides. Precedence:
337    /// `request override > playbook default > global default`.
338    ///
339    /// When `playbook_id` is `Some` and registered, the playbook is
340    /// used directly. When `playbook_id` is `None`, the resolver
341    /// looks up the target-class default. When neither resolves,
342    /// the resolver falls through to the global default.
343    ///
344    /// # Errors
345    ///
346    /// Returns [`ValidationError::UnknownPlaybook`] when an explicit
347    /// `playbook_id` is supplied and is not registered.
348    ///
349    /// # Example
350    ///
351    /// ```
352    /// use stygian_charon::playbooks::{PlaybookOverrides, PlaybookResolver};
353    /// use stygian_charon::types::TargetClass;
354    ///
355    /// let resolver = PlaybookResolver::with_builtin_defaults();
356    /// let resolved = resolver
357    ///     .resolve(
358    ///         TargetClass::ContentSite,
359    ///         "tier1-js",
360    ///         &PlaybookOverrides::default(),
361    ///     )
362    ///     .expect("resolve");
363    /// assert_eq!(resolved.playbook_id, "tier1-js");
364    /// ```
365    pub fn resolve(
366        &self,
367        target_class: TargetClass,
368        playbook_id: &str,
369        overrides: &PlaybookOverrides,
370    ) -> Result<ResolvedPlaybook, ValidationError> {
371        let playbook = self.lookup_playbook(target_class, playbook_id)?;
372        Ok(self.merge(playbook, target_class, overrides))
373    }
374
375    /// Like [`resolve`](Self::resolve) but takes an optional
376    /// `playbook_id` and falls through to the target-class default
377    /// (or the global default) when `None`.
378    ///
379    /// # Errors
380    ///
381    /// Returns [`ValidationError::UnknownPlaybook`] when an explicit
382    /// `playbook_id` is supplied and is not registered.
383    ///
384    /// # Example
385    ///
386    /// ```
387    /// use stygian_charon::playbooks::{PlaybookOverrides, PlaybookResolver};
388    /// use stygian_charon::types::TargetClass;
389    ///
390    /// let resolver = PlaybookResolver::with_builtin_defaults();
391    /// let resolved = resolver
392    ///     .resolve_optional(
393    ///         TargetClass::ContentSite,
394    ///         None,
395    ///         &PlaybookOverrides::default(),
396    ///     )
397    ///     .expect("resolve");
398    /// assert!(resolver.contains(&resolved.playbook_id));
399    /// ```
400    pub fn resolve_optional(
401        &self,
402        target_class: TargetClass,
403        playbook_id: Option<&str>,
404        overrides: &PlaybookOverrides,
405    ) -> Result<ResolvedPlaybook, ValidationError> {
406        let playbook = match playbook_id {
407            Some(id) => self.lookup_by_id(id)?,
408            None => self.lookup_by_target_class(target_class),
409        };
410        Ok(self.merge(playbook, target_class, overrides))
411    }
412
413    fn lookup_playbook(
414        &self,
415        target_class: TargetClass,
416        playbook_id: &str,
417    ) -> Result<&Playbook, ValidationError> {
418        if !playbook_id.is_empty() {
419            return self.lookup_by_id(playbook_id);
420        }
421        Ok(self.lookup_by_target_class(target_class))
422    }
423
424    fn lookup_by_id(&self, id: &str) -> Result<&Playbook, ValidationError> {
425        self.playbooks
426            .get(id)
427            .ok_or_else(|| ValidationError::UnknownPlaybook {
428                playbook_id: id.to_string(),
429            })
430    }
431
432    fn lookup_by_target_class(&self, target_class: TargetClass) -> &Playbook {
433        if let Some(id) = self.by_target_class.get(&target_class)
434            && let Some(pb) = self.playbooks.get(id)
435        {
436            return pb;
437        }
438        if let Some(id) = self.by_target_class.get(&TargetClass::Unknown)
439            && let Some(pb) = self.playbooks.get(id)
440        {
441            return pb;
442        }
443        &self.global_default
444    }
445
446    fn merge(
447        &self,
448        playbook: &Playbook,
449        target_class: TargetClass,
450        overrides: &PlaybookOverrides,
451    ) -> ResolvedPlaybook {
452        let default_acq = AcquisitionDefaults::default_for(target_class);
453        let playbook_acq = &playbook.acquisition;
454        let is_global_default = playbook.id == self.global_default.id;
455
456        let pick_mode = overrides.acquisition.mode.unwrap_or(playbook_acq.mode);
457        let pick_execution = overrides
458            .acquisition
459            .execution_mode
460            .unwrap_or(playbook_acq.execution_mode);
461        let pick_session = overrides
462            .acquisition
463            .session_mode
464            .unwrap_or(playbook_acq.session_mode);
465        let pick_telemetry = overrides
466            .acquisition
467            .telemetry_level
468            .unwrap_or(playbook_acq.telemetry_level);
469        let pick_sticky = playbook_acq.sticky_session_ttl_secs;
470        let pick_warmup = overrides
471            .acquisition
472            .enable_warmup
473            .unwrap_or(playbook_acq.enable_warmup);
474        let pick_retry = overrides
475            .acquisition
476            .retry_budget
477            .unwrap_or(playbook_acq.retry_budget);
478        let pick_backoff = overrides
479            .acquisition
480            .backoff_base_ms
481            .unwrap_or(playbook_acq.backoff_base_ms);
482
483        let acquisition = ResolvedAcquisition {
484            mode: pick_mode,
485            mode_source: source_for_mode(
486                overrides,
487                playbook_acq,
488                default_acq.mode,
489                is_global_default,
490            ),
491            execution_mode: pick_execution,
492            execution_mode_source: source_for_scalar(
493                overrides.acquisition.execution_mode.is_some(),
494                is_global_default,
495            ),
496            session_mode: pick_session,
497            session_mode_source: source_for_scalar(
498                overrides.acquisition.session_mode.is_some(),
499                is_global_default,
500            ),
501            telemetry_level: pick_telemetry,
502            telemetry_level_source: source_for_scalar(
503                overrides.acquisition.telemetry_level.is_some(),
504                is_global_default,
505            ),
506            sticky_session_ttl_secs: pick_sticky,
507            sticky_session_ttl_source: source_for_scalar(
508                false,
509                is_global_default || playbook_acq.sticky_session_ttl_secs.is_none(),
510            ),
511            enable_warmup: pick_warmup,
512            enable_warmup_source: source_for_scalar(
513                overrides.acquisition.enable_warmup.is_some(),
514                is_global_default,
515            ),
516            retry_budget: pick_retry,
517            retry_budget_source: source_for_scalar(
518                overrides.acquisition.retry_budget.is_some(),
519                is_global_default,
520            ),
521            backoff_base_ms: pick_backoff,
522            backoff_base_source: source_for_scalar(
523                overrides.acquisition.backoff_base_ms.is_some(),
524                is_global_default,
525            ),
526        };
527
528        ResolvedPlaybook {
529            playbook_id: playbook.id.clone(),
530            target_class,
531            acquisition,
532            proxy_preference: overrides
533                .proxy_preference
534                .clone()
535                .unwrap_or_else(|| playbook.proxy_preference.clone()),
536            proxy_preference_source: if overrides.proxy_preference.is_some() {
537                ResolutionSource::RequestOverride
538            } else if is_global_default {
539                ResolutionSource::GlobalDefault
540            } else {
541                ResolutionSource::PlaybookDefault
542            },
543            pacing: overrides
544                .pacing
545                .clone()
546                .unwrap_or_else(|| playbook.pacing.clone()),
547            pacing_source: tier_source(overrides.pacing.is_some(), is_global_default),
548            escalation: overrides
549                .escalation
550                .clone()
551                .unwrap_or_else(|| playbook.escalation.clone()),
552            escalation_source: tier_source(overrides.escalation.is_some(), is_global_default),
553        }
554    }
555}
556
557const fn source_for_mode(
558    overrides: &PlaybookOverrides,
559    _playbook: &AcquisitionDefaults,
560    _default: AcquisitionModeHint,
561    is_global_default: bool,
562) -> ResolutionSource {
563    if overrides.acquisition.mode.is_some() {
564        ResolutionSource::RequestOverride
565    } else if is_global_default {
566        ResolutionSource::GlobalDefault
567    } else {
568        ResolutionSource::PlaybookDefault
569    }
570}
571
572const fn source_for_scalar(override_set: bool, is_global_default: bool) -> ResolutionSource {
573    if override_set {
574        ResolutionSource::RequestOverride
575    } else if is_global_default {
576        ResolutionSource::GlobalDefault
577    } else {
578        ResolutionSource::PlaybookDefault
579    }
580}
581
582const fn tier_source(override_set: bool, is_global_default: bool) -> ResolutionSource {
583    source_for_scalar(override_set, is_global_default)
584}
585
586#[cfg(test)]
587#[allow(
588    clippy::unwrap_used,
589    clippy::expect_used,
590    clippy::panic,
591    clippy::indexing_slicing,
592    clippy::similar_names
593)]
594mod tests {
595    use super::*;
596
597    fn make_resolver() -> PlaybookResolver {
598        PlaybookResolver::from_playbooks(vec![
599            Playbook {
600                id: "tier1-static".to_string(),
601                target_class: TargetClass::ContentSite,
602                description: String::new(),
603                acquisition: AcquisitionDefaults {
604                    mode: AcquisitionModeHint::Fast,
605                    execution_mode: ExecutionMode::Http,
606                    session_mode: SessionMode::Stateless,
607                    telemetry_level: TelemetryLevel::Basic,
608                    sticky_session_ttl_secs: None,
609                    enable_warmup: false,
610                    retry_budget: 3,
611                    backoff_base_ms: 250,
612                },
613                proxy_preference: ProxyPreference::default_for(TargetClass::ContentSite),
614                pacing: PacingProfile::default_for(TargetClass::ContentSite),
615                escalation: EscalationStrategy::Capped {
616                    ceiling: AcquisitionModeHint::Resilient,
617                },
618            },
619            Playbook {
620                id: "tier1-js".to_string(),
621                target_class: TargetClass::ContentSite,
622                description: String::new(),
623                acquisition: AcquisitionDefaults {
624                    mode: AcquisitionModeHint::Resilient,
625                    execution_mode: ExecutionMode::Browser,
626                    session_mode: SessionMode::Sticky,
627                    telemetry_level: TelemetryLevel::Standard,
628                    sticky_session_ttl_secs: Some(600),
629                    enable_warmup: true,
630                    retry_budget: 5,
631                    backoff_base_ms: 500,
632                },
633                proxy_preference: ProxyPreference::default_for(TargetClass::ContentSite),
634                pacing: PacingProfile::default_for(TargetClass::ContentSite),
635                escalation: EscalationStrategy::Capped {
636                    ceiling: AcquisitionModeHint::Hostile,
637                },
638            },
639            Playbook {
640                id: "tier2-hostile".to_string(),
641                target_class: TargetClass::HighSecurity,
642                description: String::new(),
643                acquisition: AcquisitionDefaults::default_for(TargetClass::HighSecurity),
644                proxy_preference: ProxyPreference::default_for(TargetClass::HighSecurity),
645                pacing: PacingProfile::default_for(TargetClass::HighSecurity),
646                escalation: EscalationStrategy::Capped {
647                    ceiling: AcquisitionModeHint::Hostile,
648                },
649            },
650            Playbook {
651                id: "unknown".to_string(),
652                target_class: TargetClass::Unknown,
653                description: String::new(),
654                acquisition: AcquisitionDefaults::default_for(TargetClass::Unknown),
655                proxy_preference: ProxyPreference::default_for(TargetClass::Unknown),
656                pacing: PacingProfile::default_for(TargetClass::Unknown),
657                escalation: EscalationStrategy::default(),
658            },
659        ])
660        .expect("resolver fixture is valid")
661    }
662
663    #[test]
664    fn duplicate_ids_rejected() {
665        let result = PlaybookResolver::from_playbooks(vec![
666            Playbook {
667                id: "dup".to_string(),
668                target_class: TargetClass::ContentSite,
669                description: String::new(),
670                acquisition: AcquisitionDefaults::default(),
671                proxy_preference: ProxyPreference::default(),
672                pacing: PacingProfile::default(),
673                escalation: EscalationStrategy::default(),
674            },
675            Playbook {
676                id: "dup".to_string(),
677                target_class: TargetClass::Api,
678                description: String::new(),
679                acquisition: AcquisitionDefaults::default(),
680                proxy_preference: ProxyPreference::default(),
681                pacing: PacingProfile::default(),
682                escalation: EscalationStrategy::default(),
683            },
684        ]);
685        assert!(matches!(result, Err(ValidationError::DuplicateId { .. })));
686    }
687
688    #[test]
689    fn invalid_playbook_rejected() {
690        let result = PlaybookResolver::from_playbooks(vec![Playbook {
691            id: "broken".to_string(),
692            target_class: TargetClass::ContentSite,
693            description: String::new(),
694            acquisition: AcquisitionDefaults {
695                retry_budget: 0,
696                ..AcquisitionDefaults::default()
697            },
698            proxy_preference: ProxyPreference::default(),
699            pacing: PacingProfile::default(),
700            escalation: EscalationStrategy::default(),
701        }]);
702        let err = result.expect_err("retry_budget 0 is invalid");
703        assert_eq!(err.field_path(), Some("acquisition.retry_budget"));
704    }
705
706    #[test]
707    fn request_override_wins_over_playbook_default() {
708        let resolver = make_resolver();
709        let overrides = PlaybookOverrides {
710            acquisition: AcquisitionOverrides {
711                retry_budget: Some(99),
712                ..AcquisitionOverrides::default()
713            },
714            ..PlaybookOverrides::default()
715        };
716        let resolved = resolver
717            .resolve(TargetClass::ContentSite, "tier1-static", &overrides)
718            .expect("resolve");
719        assert_eq!(resolved.acquisition.retry_budget, 99);
720        assert_eq!(
721            resolved.acquisition.retry_budget_source,
722            ResolutionSource::RequestOverride
723        );
724    }
725
726    #[test]
727    fn playbook_default_used_when_no_override() {
728        let resolver = make_resolver();
729        let resolved = resolver
730            .resolve(
731                TargetClass::ContentSite,
732                "tier1-js",
733                &PlaybookOverrides::default(),
734            )
735            .expect("resolve");
736        assert_eq!(resolved.acquisition.retry_budget, 5);
737        assert_eq!(
738            resolved.acquisition.retry_budget_source,
739            ResolutionSource::PlaybookDefault
740        );
741        assert!(resolved.acquisition.enable_warmup);
742        assert_eq!(
743            resolved.acquisition.enable_warmup_source,
744            ResolutionSource::PlaybookDefault
745        );
746    }
747
748    #[test]
749    fn global_default_used_when_no_playbook_matches() {
750        let resolver = make_resolver();
751        let resolved = resolver
752            .resolve(TargetClass::Api, "", &PlaybookOverrides::default())
753            .expect("resolve");
754        assert_eq!(resolved.playbook_id, "unknown");
755        assert_eq!(
756            resolved.acquisition.retry_budget_source,
757            ResolutionSource::GlobalDefault
758        );
759    }
760
761    #[test]
762    fn unknown_explicit_id_returns_error() {
763        let resolver = make_resolver();
764        let err = resolver
765            .resolve(
766                TargetClass::ContentSite,
767                "nope",
768                &PlaybookOverrides::default(),
769            )
770            .expect_err("unknown id");
771        assert!(matches!(err, ValidationError::UnknownPlaybook { .. }));
772    }
773
774    #[test]
775    fn override_replaces_proxy_preference_whole() {
776        let resolver = make_resolver();
777        let proxy = ProxyPreference {
778            preferred_protocol: "socks5".to_string(),
779            require_sticky: true,
780            require_residential: true,
781            max_latency_ms: Some(300),
782        };
783        let overrides = PlaybookOverrides {
784            proxy_preference: Some(proxy.clone()),
785            ..PlaybookOverrides::default()
786        };
787        let resolved = resolver
788            .resolve(TargetClass::ContentSite, "tier1-static", &overrides)
789            .expect("resolve");
790        assert_eq!(resolved.proxy_preference, proxy);
791        assert_eq!(
792            resolved.proxy_preference_source,
793            ResolutionSource::RequestOverride
794        );
795    }
796
797    #[test]
798    fn override_replaces_pacing_whole() {
799        let resolver = make_resolver();
800        let pacing = PacingProfile {
801            rate_limit_rps: 7.5,
802            jitter_pct: 0.30,
803            min_request_interval_ms: 150,
804        };
805        let overrides = PlaybookOverrides {
806            pacing: Some(pacing.clone()),
807            ..PlaybookOverrides::default()
808        };
809        let resolved = resolver
810            .resolve(TargetClass::ContentSite, "tier1-static", &overrides)
811            .expect("resolve");
812        assert_eq!(resolved.pacing, pacing);
813        assert_eq!(resolved.pacing_source, ResolutionSource::RequestOverride);
814    }
815
816    #[test]
817    fn resolve_optional_falls_through_to_target_class_default() {
818        let resolver = make_resolver();
819        let resolved = resolver
820            .resolve_optional(
821                TargetClass::HighSecurity,
822                None,
823                &PlaybookOverrides::default(),
824            )
825            .expect("resolve");
826        assert_eq!(resolved.playbook_id, "tier2-hostile");
827    }
828
829    #[test]
830    fn to_acquisition_policy_propagates_fields() {
831        let resolver = make_resolver();
832        let resolved = resolver
833            .resolve(
834                TargetClass::ContentSite,
835                "tier1-js",
836                &PlaybookOverrides::default(),
837            )
838            .expect("resolve");
839        let policy = resolved.to_acquisition_policy();
840        assert_eq!(policy.retry_budget, 5);
841        assert_eq!(policy.backoff_base_ms, 500);
842        assert!(policy.enable_warmup);
843        assert!(policy.sticky_session);
844    }
845}