Skip to main content

stygian_charon/playbooks/
schema.rs

1//! Playbook schema types (T85).
2//!
3//! Every type is `serde::Deserialize` so the TOML files in
4//! `crates/stygian-charon/data/playbooks/` round-trip through it
5//! directly. Validation is performed separately by
6//! [`Playbook::validate`] so that operators get **structured errors**
7//! with the field path and the bad value, rather than the
8//! "TOML parse failed" line/column output that serde gives by default.
9
10use serde::{Deserialize, Serialize};
11
12use crate::acquisition::AcquisitionModeHint;
13use crate::playbooks::error::ValidationError;
14use crate::types::{ExecutionMode, SessionMode, TargetClass, TelemetryLevel};
15
16/// Acquisition-mode defaults recommended by the playbook.
17///
18/// These map directly to the input shape of
19/// [`crate::acquisition::RuntimePolicyHints`] — a resolved playbook
20/// can therefore be fed into [`crate::acquisition::map_policy_hints`]
21/// to produce a downstream [`crate::acquisition::AcquisitionPolicy`].
22///
23/// # Example
24///
25/// ```
26/// use stygian_charon::playbooks::AcquisitionDefaults;
27/// use stygian_charon::acquisition::AcquisitionModeHint;
28/// use stygian_charon::types::{ExecutionMode, SessionMode, TargetClass, TelemetryLevel};
29///
30/// let defaults = AcquisitionDefaults::default_for(TargetClass::ContentSite);
31/// assert_eq!(defaults.mode, AcquisitionModeHint::Resilient);
32/// assert_eq!(defaults.execution_mode, ExecutionMode::Http);
33/// assert_eq!(defaults.session_mode, SessionMode::Stateless);
34/// assert_eq!(defaults.telemetry_level, TelemetryLevel::Standard);
35/// ```
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct AcquisitionDefaults {
38    /// Recommended acquisition mode (see [`AcquisitionModeHint`]).
39    pub mode: AcquisitionModeHint,
40    /// Recommended execution mode.
41    pub execution_mode: ExecutionMode,
42    /// Recommended session mode.
43    pub session_mode: SessionMode,
44    /// Recommended telemetry level.
45    pub telemetry_level: TelemetryLevel,
46    /// Suggested sticky-session TTL in seconds (only meaningful when
47    /// `session_mode == SessionMode::Sticky`).
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub sticky_session_ttl_secs: Option<u64>,
50    /// Whether browser warm-up navigation is recommended.
51    #[serde(default)]
52    pub enable_warmup: bool,
53    /// Retry budget for transient failures. Must be `> 0` after
54    /// validation.
55    #[serde(default = "default_retry_budget")]
56    pub retry_budget: u32,
57    /// Base backoff in milliseconds. Must be `> 0` after validation.
58    #[serde(default = "default_backoff_ms")]
59    pub backoff_base_ms: u64,
60}
61
62const fn default_retry_budget() -> u32 {
63    2
64}
65
66const fn default_backoff_ms() -> u64 {
67    250
68}
69
70impl AcquisitionDefaults {
71    /// Build acquisition defaults appropriate for a target class.
72    ///
73    /// The defaults match the values returned by
74    /// [`crate::acquisition::map_policy_hints`] when no overrides are
75    /// supplied, so a playbook's `acquisition` block can be left
76    /// blank and still produce a coherent runner config.
77    #[must_use]
78    pub const fn default_for(target_class: TargetClass) -> Self {
79        match target_class {
80            TargetClass::Api => Self {
81                mode: AcquisitionModeHint::Fast,
82                execution_mode: ExecutionMode::Http,
83                session_mode: SessionMode::Stateless,
84                telemetry_level: TelemetryLevel::Standard,
85                sticky_session_ttl_secs: None,
86                enable_warmup: false,
87                retry_budget: 2,
88                backoff_base_ms: 250,
89            },
90            TargetClass::ContentSite | TargetClass::Unknown => Self {
91                mode: AcquisitionModeHint::Resilient,
92                execution_mode: ExecutionMode::Http,
93                session_mode: SessionMode::Stateless,
94                telemetry_level: TelemetryLevel::Standard,
95                sticky_session_ttl_secs: None,
96                enable_warmup: false,
97                retry_budget: 2,
98                backoff_base_ms: 250,
99            },
100            TargetClass::HighSecurity => Self {
101                mode: AcquisitionModeHint::Hostile,
102                execution_mode: ExecutionMode::Browser,
103                session_mode: SessionMode::Sticky,
104                telemetry_level: TelemetryLevel::Deep,
105                sticky_session_ttl_secs: Some(900),
106                enable_warmup: true,
107                retry_budget: 4,
108                backoff_base_ms: 500,
109            },
110        }
111    }
112}
113
114impl Default for AcquisitionDefaults {
115    fn default() -> Self {
116        Self::default_for(TargetClass::Unknown)
117    }
118}
119
120/// Per-request overrides for the acquisition block. Only fields set
121/// (`Some`) participate in the precedence test; absent fields fall
122/// back to the playbook default and then the global default.
123///
124/// # Example
125///
126/// ```
127/// use stygian_charon::playbooks::AcquisitionOverrides;
128/// use stygian_charon::acquisition::AcquisitionModeHint;
129///
130/// let overrides = AcquisitionOverrides {
131///     mode: Some(AcquisitionModeHint::Hostile),
132///     ..AcquisitionOverrides::default()
133/// };
134/// assert_eq!(overrides.mode, Some(AcquisitionModeHint::Hostile));
135/// ```
136#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
137pub struct AcquisitionOverrides {
138    /// Optional mode override.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub mode: Option<AcquisitionModeHint>,
141    /// Optional execution-mode override.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub execution_mode: Option<ExecutionMode>,
144    /// Optional session-mode override.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub session_mode: Option<SessionMode>,
147    /// Optional telemetry-level override.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub telemetry_level: Option<TelemetryLevel>,
150    /// Optional retry-budget override.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub retry_budget: Option<u32>,
153    /// Optional backoff-base override (ms).
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub backoff_base_ms: Option<u64>,
156    /// Optional warmup-flag override.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub enable_warmup: Option<bool>,
159}
160
161/// Proxy flavour + sticky-session constraints for the playbook.
162///
163/// # Example
164///
165/// ```
166/// use stygian_charon::playbooks::ProxyPreference;
167/// use stygian_charon::types::TargetClass;
168///
169/// let pref = ProxyPreference::default_for(TargetClass::HighSecurity);
170/// assert!(pref.require_sticky);
171/// assert!(pref.max_latency_ms.is_some());
172/// ```
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct ProxyPreference {
175    /// Preferred wire protocol (`"http"`, `"https"`, or `"socks5"`).
176    pub preferred_protocol: String,
177    /// Whether the proxy must hold a sticky IP/identity across the
178    /// session.
179    #[serde(default)]
180    pub require_sticky: bool,
181    /// Whether the proxy must be residential (i.e. not a datacenter).
182    #[serde(default)]
183    pub require_residential: bool,
184    /// Optional upper bound on acceptable proxy latency (ms).
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub max_latency_ms: Option<u64>,
187}
188
189const SUPPORTED_PROXY_PROTOCOLS: &[&str] = &["http", "https", "socks5"];
190
191impl ProxyPreference {
192    /// Build a proxy preference appropriate for a target class.
193    #[must_use]
194    pub fn default_for(target_class: TargetClass) -> Self {
195        match target_class {
196            TargetClass::Api | TargetClass::Unknown => Self {
197                preferred_protocol: "https".to_string(),
198                require_sticky: false,
199                require_residential: false,
200                max_latency_ms: Some(2_000),
201            },
202            TargetClass::ContentSite => Self {
203                preferred_protocol: "https".to_string(),
204                require_sticky: false,
205                require_residential: false,
206                max_latency_ms: Some(1_500),
207            },
208            TargetClass::HighSecurity => Self {
209                preferred_protocol: "https".to_string(),
210                require_sticky: true,
211                require_residential: true,
212                max_latency_ms: Some(800),
213            },
214        }
215    }
216}
217
218impl Default for ProxyPreference {
219    fn default() -> Self {
220        Self::default_for(TargetClass::Unknown)
221    }
222}
223
224/// Pacing knobs (rate, jitter, minimum inter-request interval).
225///
226/// # Example
227///
228/// ```
229/// use stygian_charon::playbooks::PacingProfile;
230/// use stygian_charon::types::TargetClass;
231///
232/// let pacing = PacingProfile::default_for(TargetClass::HighSecurity);
233/// assert!(pacing.rate_limit_rps <= 1.0);
234/// assert!(pacing.min_request_interval_ms >= 1_000);
235/// ```
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237pub struct PacingProfile {
238    /// Sustained requests per second. Must be `> 0`.
239    pub rate_limit_rps: f64,
240    /// Jitter as a fraction of the inter-request interval (0.0–1.0).
241    pub jitter_pct: f64,
242    /// Minimum inter-request interval in milliseconds.
243    pub min_request_interval_ms: u64,
244}
245
246impl PacingProfile {
247    /// Build a pacing profile appropriate for a target class.
248    #[must_use]
249    pub const fn default_for(target_class: TargetClass) -> Self {
250        match target_class {
251            TargetClass::Api => Self {
252                rate_limit_rps: 5.0,
253                jitter_pct: 0.05,
254                min_request_interval_ms: 200,
255            },
256            TargetClass::ContentSite => Self {
257                rate_limit_rps: 3.0,
258                jitter_pct: 0.10,
259                min_request_interval_ms: 300,
260            },
261            TargetClass::HighSecurity => Self {
262                rate_limit_rps: 0.5,
263                jitter_pct: 0.25,
264                min_request_interval_ms: 2_000,
265            },
266            TargetClass::Unknown => Self {
267                rate_limit_rps: 2.0,
268                jitter_pct: 0.10,
269                min_request_interval_ms: 500,
270            },
271        }
272    }
273}
274
275impl Default for PacingProfile {
276    fn default() -> Self {
277        Self::default_for(TargetClass::Unknown)
278    }
279}
280
281/// Escalation ladder the runner should climb on transient failure.
282///
283/// The runner walks the ladder top-to-bottom; the **first** stage
284/// that returns a non-error result wins. `Capped` collapses the
285/// ladder into a single-mode ceiling so the runner only retries at
286/// the original mode plus the listed neighbours.
287///
288/// # Example
289///
290/// ```
291/// use stygian_charon::playbooks::EscalationStrategy;
292/// use stygian_charon::acquisition::AcquisitionModeHint;
293///
294/// let capped = EscalationStrategy::Capped {
295///     ceiling: AcquisitionModeHint::Hostile,
296/// };
297/// let linear = EscalationStrategy::Linear {
298///     steps: vec![AcquisitionModeHint::Fast, AcquisitionModeHint::Hostile],
299/// };
300/// assert_eq!(capped.ceiling(), AcquisitionModeHint::Hostile);
301/// assert_eq!(linear.stages(), vec![AcquisitionModeHint::Fast, AcquisitionModeHint::Hostile]);
302/// ```
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(tag = "kind", rename_all = "snake_case")]
305pub enum EscalationStrategy {
306    /// A single ceiling mode — the runner may escalate up to (and
307    /// including) the ceiling mode but not beyond.
308    Capped {
309        /// Upper bound on the runner's mode escalation.
310        ceiling: AcquisitionModeHint,
311    },
312    /// An explicit ordered list of stages the runner walks.
313    Linear {
314        /// Ordered escalation stages (top-to-bottom).
315        steps: Vec<AcquisitionModeHint>,
316    },
317}
318
319impl EscalationStrategy {
320    /// Upper bound the runner may escalate to.
321    #[must_use]
322    pub fn ceiling(&self) -> AcquisitionModeHint {
323        match self {
324            Self::Capped { ceiling } => *ceiling,
325            Self::Linear { steps } => steps
326                .last()
327                .copied()
328                .unwrap_or(AcquisitionModeHint::Resilient),
329        }
330    }
331
332    /// First stage the runner attempts.
333    #[must_use]
334    pub fn first(&self) -> AcquisitionModeHint {
335        match self {
336            Self::Capped { ceiling } => *ceiling,
337            Self::Linear { steps } => steps
338                .first()
339                .copied()
340                .unwrap_or(AcquisitionModeHint::Resilient),
341        }
342    }
343
344    /// Ordered list of stages the runner walks (deduplicated, order
345    /// preserved).
346    #[must_use]
347    pub fn stages(&self) -> Vec<AcquisitionModeHint> {
348        match self {
349            Self::Capped { ceiling } => vec![*ceiling],
350            Self::Linear { steps } => {
351                let mut seen: Vec<AcquisitionModeHint> = Vec::new();
352                for stage in steps {
353                    if !seen.contains(stage) {
354                        seen.push(*stage);
355                    }
356                }
357                seen
358            }
359        }
360    }
361}
362
363impl Default for EscalationStrategy {
364    fn default() -> Self {
365        Self::Capped {
366            ceiling: AcquisitionModeHint::Resilient,
367        }
368    }
369}
370
371/// Tag describing which tier of the precedence ladder contributed
372/// each field to a [`ResolvedPlaybook`](crate::playbooks::ResolvedPlaybook).
373///
374/// Used by `crate::playbooks::ResolvedPlaybook` source metadata fields so
375/// downstream observers can verify the deterministic precedence is
376/// being honoured.
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
378#[serde(rename_all = "snake_case")]
379pub enum ResolutionSource {
380    /// The value was set by the per-request override (top priority).
381    RequestOverride,
382    /// The value came from the playbook's own default.
383    PlaybookDefault,
384    /// The value fell through to the resolver's global default.
385    GlobalDefault,
386}
387
388/// A single codified playbook for one anti-bot tier.
389///
390/// # Example
391///
392/// ```
393/// use stygian_charon::playbooks::{
394///     AcquisitionDefaults, EscalationStrategy, PacingProfile, Playbook, ProxyPreference,
395/// };
396/// use stygian_charon::acquisition::AcquisitionModeHint;
397/// use stygian_charon::types::TargetClass;
398///
399/// let pb = Playbook {
400///     id: "tier1-static".to_string(),
401///     target_class: TargetClass::ContentSite,
402///     description: "Static content sites".to_string(),
403///     acquisition: AcquisitionDefaults::default_for(TargetClass::ContentSite),
404///     proxy_preference: ProxyPreference::default(),
405///     pacing: PacingProfile::default(),
406///     escalation: EscalationStrategy::Capped { ceiling: AcquisitionModeHint::Resilient },
407/// };
408/// assert!(pb.validate().is_ok());
409/// ```
410#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
411pub struct Playbook {
412    /// Stable identifier (`"tier1-static"`, `"tier1-js"`,
413    /// `"tier2-hostile"`, etc.). Required, non-empty, unique within a
414    /// resolver bundle.
415    pub id: String,
416    /// Target class this playbook belongs to.
417    pub target_class: TargetClass,
418    /// Human-readable description for operator logs.
419    #[serde(default)]
420    pub description: String,
421    /// Acquisition-mode defaults.
422    pub acquisition: AcquisitionDefaults,
423    /// Proxy preference.
424    #[serde(default)]
425    pub proxy_preference: ProxyPreference,
426    /// Pacing profile.
427    #[serde(default)]
428    pub pacing: PacingProfile,
429    /// Escalation strategy.
430    #[serde(default)]
431    pub escalation: EscalationStrategy,
432}
433
434impl Playbook {
435    /// Validate the playbook's internal consistency. Reports the
436    /// first failing field with a structured error that includes
437    /// both the field path and the bad value.
438    ///
439    /// # Errors
440    ///
441    /// Returns [`ValidationError`] on the first inconsistency. The
442    /// error embeds the field path and the bad value so operators
443    /// can locate the offending TOML line without re-running the
444    /// loader.
445    ///
446    /// # Example
447    ///
448    /// ```
449    /// use stygian_charon::playbooks::{
450    ///     AcquisitionDefaults, EscalationStrategy, PacingProfile, Playbook, ProxyPreference,
451    /// };
452    /// use stygian_charon::acquisition::AcquisitionModeHint;
453    /// use stygian_charon::types::{ExecutionMode, SessionMode, TargetClass, TelemetryLevel};
454    ///
455    /// let bad = Playbook {
456    ///     id: String::new(),
457    ///     target_class: TargetClass::ContentSite,
458    ///     description: String::new(),
459    ///     acquisition: AcquisitionDefaults {
460    ///         mode: AcquisitionModeHint::Fast,
461    ///         execution_mode: ExecutionMode::Http,
462    ///         session_mode: SessionMode::Stateless,
463    ///         telemetry_level: TelemetryLevel::Basic,
464    ///         sticky_session_ttl_secs: None,
465    ///         enable_warmup: false,
466    ///         retry_budget: 0,
467    ///         backoff_base_ms: 250,
468    ///     },
469    ///     proxy_preference: ProxyPreference::default(),
470    ///     pacing: PacingProfile::default(),
471    ///     escalation: EscalationStrategy::Capped { ceiling: AcquisitionModeHint::Fast },
472    /// };
473    /// let err = bad.validate().expect_err("id is empty");
474    /// assert!(err.to_string().contains("id"));
475    /// ```
476    pub fn validate(&self) -> Result<(), ValidationError> {
477        if self.id.trim().is_empty() {
478            return Err(ValidationError::invalid_field(
479                self.id.clone(),
480                "id",
481                self.id.clone(),
482                "playbook id must be a non-empty string",
483            ));
484        }
485        validate_acquisition(self)?;
486        validate_proxy_preference(self)?;
487        validate_pacing(self)?;
488        validate_escalation(self)?;
489        Ok(())
490    }
491
492    /// Convenience helper that converts the resolved playbook into a
493    /// [`crate::acquisition::RuntimePolicyHints`] block ready to feed
494    /// into [`crate::acquisition::map_policy_hints`]. The mapping is
495    /// pure and deterministic.
496    ///
497    /// # Example
498    ///
499    /// ```
500    /// use stygian_charon::playbooks::{Playbook, AcquisitionDefaults};
501    /// use stygian_charon::types::TargetClass;
502    ///
503    /// let pb = Playbook {
504    ///     id: "tier1-static".to_string(),
505    ///     target_class: TargetClass::ContentSite,
506    ///     description: String::new(),
507    ///     acquisition: AcquisitionDefaults::default_for(TargetClass::ContentSite),
508    ///     proxy_preference: Default::default(),
509    ///     pacing: Default::default(),
510    ///     escalation: Default::default(),
511    /// };
512    /// let _hints = pb.to_runtime_policy_hints();
513    /// ```
514    #[must_use]
515    pub const fn to_runtime_policy_hints(&self) -> crate::acquisition::RuntimePolicyHints {
516        crate::acquisition::RuntimePolicyHints {
517            execution_mode: Some(self.acquisition.execution_mode),
518            session_mode: Some(self.acquisition.session_mode),
519            telemetry_level: Some(self.acquisition.telemetry_level),
520            risk_score: None,
521            max_retries: Some(self.acquisition.retry_budget),
522            backoff_base_ms: Some(self.acquisition.backoff_base_ms),
523            enable_warmup: Some(self.acquisition.enable_warmup),
524        }
525    }
526}
527
528fn validate_acquisition(pb: &Playbook) -> Result<(), ValidationError> {
529    let acq = &pb.acquisition;
530    if acq.retry_budget == 0 {
531        return Err(ValidationError::invalid_field(
532            pb.id.clone(),
533            "acquisition.retry_budget",
534            acq.retry_budget,
535            "retry_budget must be > 0",
536        ));
537    }
538    if acq.retry_budget > 32 {
539        return Err(ValidationError::invalid_field(
540            pb.id.clone(),
541            "acquisition.retry_budget",
542            acq.retry_budget,
543            "retry_budget must be <= 32",
544        ));
545    }
546    if acq.backoff_base_ms == 0 {
547        return Err(ValidationError::invalid_field(
548            pb.id.clone(),
549            "acquisition.backoff_base_ms",
550            acq.backoff_base_ms,
551            "backoff_base_ms must be > 0",
552        ));
553    }
554    if acq.backoff_base_ms > 60_000 {
555        return Err(ValidationError::invalid_field(
556            pb.id.clone(),
557            "acquisition.backoff_base_ms",
558            acq.backoff_base_ms,
559            "backoff_base_ms must be <= 60_000",
560        ));
561    }
562    if let Some(ttl) = acq.sticky_session_ttl_secs
563        && ttl == 0
564    {
565        return Err(ValidationError::invalid_field(
566            pb.id.clone(),
567            "acquisition.sticky_session_ttl_secs",
568            ttl,
569            "sticky_session_ttl_secs must be > 0 when set",
570        ));
571    }
572    Ok(())
573}
574
575fn validate_proxy_preference(pb: &Playbook) -> Result<(), ValidationError> {
576    let proxy = &pb.proxy_preference;
577    let proto = proxy.preferred_protocol.as_str();
578    if !SUPPORTED_PROXY_PROTOCOLS.contains(&proto) {
579        return Err(ValidationError::invalid_field(
580            pb.id.clone(),
581            "proxy_preference.preferred_protocol",
582            proto,
583            format!("preferred_protocol must be one of {SUPPORTED_PROXY_PROTOCOLS:?}"),
584        ));
585    }
586    if let Some(max_latency) = proxy.max_latency_ms
587        && max_latency == 0
588    {
589        return Err(ValidationError::invalid_field(
590            pb.id.clone(),
591            "proxy_preference.max_latency_ms",
592            max_latency,
593            "max_latency_ms must be > 0 when set",
594        ));
595    }
596    Ok(())
597}
598
599fn validate_pacing(pb: &Playbook) -> Result<(), ValidationError> {
600    let pacing = &pb.pacing;
601    if pacing.rate_limit_rps <= 0.0 {
602        return Err(ValidationError::invalid_field(
603            pb.id.clone(),
604            "pacing.rate_limit_rps",
605            pacing.rate_limit_rps,
606            "rate_limit_rps must be > 0",
607        ));
608    }
609    if pacing.rate_limit_rps > 1000.0 {
610        return Err(ValidationError::invalid_field(
611            pb.id.clone(),
612            "pacing.rate_limit_rps",
613            pacing.rate_limit_rps,
614            "rate_limit_rps must be <= 1000",
615        ));
616    }
617    if !(0.0..=1.0).contains(&pacing.jitter_pct) {
618        return Err(ValidationError::invalid_field(
619            pb.id.clone(),
620            "pacing.jitter_pct",
621            pacing.jitter_pct,
622            "jitter_pct must be in [0.0, 1.0]",
623        ));
624    }
625    if pacing.min_request_interval_ms == 0 {
626        return Err(ValidationError::invalid_field(
627            pb.id.clone(),
628            "pacing.min_request_interval_ms",
629            pacing.min_request_interval_ms,
630            "min_request_interval_ms must be > 0",
631        ));
632    }
633    Ok(())
634}
635
636fn validate_escalation(pb: &Playbook) -> Result<(), ValidationError> {
637    match &pb.escalation {
638        EscalationStrategy::Capped { .. } => Ok(()),
639        EscalationStrategy::Linear { steps } => {
640            if steps.is_empty() {
641                return Err(ValidationError::invalid_field(
642                    pb.id.clone(),
643                    "escalation.steps",
644                    "<empty>",
645                    "linear escalation must contain at least one stage",
646                ));
647            }
648            Ok(())
649        }
650    }
651}
652
653#[cfg(test)]
654#[allow(
655    clippy::unwrap_used,
656    clippy::expect_used,
657    clippy::panic,
658    clippy::indexing_slicing
659)]
660mod tests {
661    use super::*;
662
663    fn ok_playbook() -> Playbook {
664        Playbook {
665            id: "tier1-static".to_string(),
666            target_class: TargetClass::ContentSite,
667            description: "static content".to_string(),
668            acquisition: AcquisitionDefaults::default_for(TargetClass::ContentSite),
669            proxy_preference: ProxyPreference::default_for(TargetClass::ContentSite),
670            pacing: PacingProfile::default_for(TargetClass::ContentSite),
671            escalation: EscalationStrategy::Capped {
672                ceiling: AcquisitionModeHint::Resilient,
673            },
674        }
675    }
676
677    #[test]
678    fn defaults_match_target_class_taxonomy() {
679        let api = AcquisitionDefaults::default_for(TargetClass::Api);
680        assert_eq!(api.mode, AcquisitionModeHint::Fast);
681
682        let high = AcquisitionDefaults::default_for(TargetClass::HighSecurity);
683        assert_eq!(high.mode, AcquisitionModeHint::Hostile);
684        assert_eq!(high.session_mode, SessionMode::Sticky);
685        assert!(high.enable_warmup);
686    }
687
688    #[test]
689    fn valid_playbook_passes_validation() {
690        assert!(ok_playbook().validate().is_ok());
691    }
692
693    #[test]
694    fn empty_id_is_rejected_with_field_path() {
695        let mut pb = ok_playbook();
696        pb.id.clear();
697        let err = pb.validate().expect_err("empty id");
698        assert_eq!(err.field_path(), Some("id"));
699        assert_eq!(err.bad_value(), Some(""));
700        assert!(err.to_string().contains("id"));
701    }
702
703    #[test]
704    fn zero_retry_budget_is_rejected() {
705        let mut pb = ok_playbook();
706        pb.acquisition.retry_budget = 0;
707        let err = pb.validate().expect_err("zero retry budget");
708        assert_eq!(err.field_path(), Some("acquisition.retry_budget"));
709        assert!(err.bad_value().is_some());
710    }
711
712    #[test]
713    fn negative_pacing_rate_is_rejected() {
714        let mut pb = ok_playbook();
715        pb.pacing.rate_limit_rps = -0.5;
716        let err = pb.validate().expect_err("negative pacing");
717        assert_eq!(err.field_path(), Some("pacing.rate_limit_rps"));
718        assert_eq!(err.bad_value(), Some("-0.5"));
719    }
720
721    #[test]
722    fn jitter_out_of_range_is_rejected() {
723        let mut pb = ok_playbook();
724        pb.pacing.jitter_pct = 1.5;
725        let err = pb.validate().expect_err("jitter out of range");
726        assert_eq!(err.field_path(), Some("pacing.jitter_pct"));
727    }
728
729    #[test]
730    fn unknown_proxy_protocol_is_rejected() {
731        let mut pb = ok_playbook();
732        pb.proxy_preference.preferred_protocol = "ftp".to_string();
733        let err = pb.validate().expect_err("unknown protocol");
734        assert_eq!(
735            err.field_path(),
736            Some("proxy_preference.preferred_protocol")
737        );
738        assert_eq!(err.bad_value(), Some("ftp"));
739    }
740
741    #[test]
742    fn empty_linear_escalation_is_rejected() {
743        let mut pb = ok_playbook();
744        pb.escalation = EscalationStrategy::Linear { steps: Vec::new() };
745        let err = pb.validate().expect_err("empty linear");
746        assert_eq!(err.field_path(), Some("escalation.steps"));
747    }
748
749    #[test]
750    fn to_runtime_policy_hints_carries_acquisition_fields() {
751        let pb = ok_playbook();
752        let hints = pb.to_runtime_policy_hints();
753        assert_eq!(hints.execution_mode, Some(pb.acquisition.execution_mode));
754        assert_eq!(hints.session_mode, Some(pb.acquisition.session_mode));
755        assert_eq!(hints.telemetry_level, Some(pb.acquisition.telemetry_level));
756        assert_eq!(hints.max_retries, Some(pb.acquisition.retry_budget));
757        assert_eq!(hints.backoff_base_ms, Some(pb.acquisition.backoff_base_ms));
758        assert_eq!(hints.enable_warmup, Some(pb.acquisition.enable_warmup));
759    }
760
761    #[test]
762    fn escalation_stages_dedup() {
763        let dup = EscalationStrategy::Linear {
764            steps: vec![
765                AcquisitionModeHint::Fast,
766                AcquisitionModeHint::Fast,
767                AcquisitionModeHint::Resilient,
768            ],
769        };
770        assert_eq!(
771            dup.stages(),
772            vec![AcquisitionModeHint::Fast, AcquisitionModeHint::Resilient]
773        );
774    }
775}