Skip to main content

termivar_scanner/defense/
shadow_planning.rs

1//! Side-effect-free, defense-aware shadow planning.
2//!
3//! This layer shows how the current plan *would* change under an observed
4//! defensive posture, without changing anything. It filters one already
5//! authorized plan into a dependency-safe subsequence and computes an
6//! explainable delta against the current plan. It never issues a
7//! request, mutates the planner, runtime, knowledge, or experience state, or
8//! reorders the real plan.
9//!
10//! Defense evidence is not a second planner: it never adds an action and never
11//! raises an action's utility. For each *existing* candidate it only decides to
12//! allow, deprioritize, or suppress. The per-observation recommendation is the
13//! existing [`recommend`] policy — this module reuses it and adds only the
14//! resource-scoped aggregation and the monotonic class mapping.
15
16use std::collections::{BTreeMap, BTreeSet};
17
18use termivar_core::{EntityId, EvidenceId};
19
20use crate::knowledge::KnowledgeSnapshot;
21use crate::planner::{AttackAction, AttackPlan, AttackPlanner, PlannerError, PlanningContext};
22
23use super::policy::{recommend, DefenseResponse};
24use super::state::DefenseState;
25use super::transition::DefenseTransition;
26
27/// Closed classification of how an action interacts with a target.
28///
29/// The suppression layer classifies actions through this typed metadata, never
30/// through action-id or name string matching. The caller supplies the mapping
31/// because only the host knows what each of its actions does; this module owns
32/// the exhaustive class-to-decision mapping.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum DefenseInteractionClass {
36    /// Local analysis, reporting, audit, or human review — no network I/O.
37    LocalOnly,
38    /// Passive discovery that reads without probing behavior.
39    Passive,
40    /// Behavioral observation of the target.
41    Behavioral,
42    /// A differential read comparing two views of the same resource.
43    DifferentialRead,
44    /// An explicit active verification probe.
45    ActiveVerification,
46    /// A mutating or fuzzing interaction.
47    Mutating,
48}
49
50/// What defense recommends doing with one existing candidate.
51///
52/// Ordering is meaningful: a more restrictive decision is greater.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
54pub enum InteractionDecision {
55    /// Keep the action unchanged.
56    Allow,
57    /// Keep the action but lower its priority (a soft, advisory signal).
58    Deprioritize,
59    /// Remove the action from the shadow plan.
60    Suppress,
61}
62
63/// The monotonic, exhaustive mapping from a recommendation and an interaction
64/// class to a decision. This is the single place the mapping lives.
65///
66/// Monotonicity: for a fixed class, the decision never weakens as the
67/// recommendation escalates `Proceed < Observe < Backoff < Reconsider < Halt`.
68/// Defense never allows less than the response requires and never adds work.
69pub const fn decide(
70    response: DefenseResponse,
71    class: DefenseInteractionClass,
72) -> InteractionDecision {
73    use DefenseInteractionClass::{
74        ActiveVerification, Behavioral, DifferentialRead, LocalOnly, Mutating, Passive,
75    };
76    use DefenseResponse::{Backoff, Halt, Observe, Proceed, Reconsider};
77    use InteractionDecision::{Allow, Deprioritize, Suppress};
78
79    match (response, class) {
80        (Proceed, _) => Allow,
81        (_, LocalOnly) => Allow,
82
83        (Observe, ActiveVerification | Mutating) => Deprioritize,
84        (Observe, _) => Allow,
85
86        (Backoff, ActiveVerification | Mutating) => Suppress,
87        (Backoff, DifferentialRead) => Deprioritize,
88        (Backoff, _) => Allow,
89
90        (Reconsider, ActiveVerification | Mutating) => Suppress,
91        (Reconsider, Behavioral | DifferentialRead) => Deprioritize,
92        (Reconsider, Passive) => Allow,
93
94        // Halt suppresses every network-producing class; LocalOnly handled above.
95        (Halt, _) => Suppress,
96    }
97}
98
99/// A stable, replay-safe explanation code for one decision.
100///
101/// Codes are stable identifiers, not free text; [`render_explanation`] maps them
102/// to human-readable prose.
103pub const fn explanation_code(
104    response: DefenseResponse,
105    decision: InteractionDecision,
106) -> &'static str {
107    use DefenseResponse::{Backoff, Halt, Observe, Reconsider};
108    use InteractionDecision::{Deprioritize, Suppress};
109
110    match (response, decision) {
111        (Observe, Deprioritize) => "defense.observe.deprioritize",
112        (Backoff, Deprioritize) => "defense.backoff.deprioritize",
113        (Backoff, Suppress) => "defense.backoff.suppress",
114        (Reconsider, Deprioritize) => "defense.reconsider.deprioritize",
115        (Reconsider, Suppress) => "defense.reconsider.suppress",
116        (Halt, Suppress) => "defense.halt.suppress",
117        _ => "defense.none",
118    }
119}
120
121/// Renders a stable explanation code as human-readable prose.
122pub fn render_explanation(code: &str) -> &'static str {
123    match code {
124        "defense.observe.deprioritize" => {
125            "defensive infrastructure was observed; active work is deprioritized"
126        },
127        "defense.backoff.deprioritize" => {
128            "rate limiting is in effect; differential reads are deprioritized"
129        },
130        "defense.backoff.suppress" => {
131            "rate limiting is in effect; active verification and mutation are suppressed"
132        },
133        "defense.reconsider.deprioritize" => {
134            "the candidate provoked a block; behavioral and differential work is deprioritized"
135        },
136        "defense.reconsider.suppress" => {
137            "the candidate provoked a block; active verification and mutation are suppressed"
138        },
139        "defense.halt.suppress" => {
140            "a standing block or challenge halts all network-producing actions"
141        },
142        "defense.dependency.suppress" => {
143            "an action was suppressed because a required prerequisite was removed"
144        },
145        _ => "no defensive adjustment",
146    }
147}
148
149/// One observed response leg for a resource, with its supporting evidence.
150#[derive(Debug, Clone)]
151pub struct ResourceDefenseObservation<'obs> {
152    state: &'obs DefenseState,
153    transition: Option<&'obs DefenseTransition>,
154    evidence_ids: Vec<EvidenceId>,
155}
156
157impl<'obs> ResourceDefenseObservation<'obs> {
158    /// Records one observation and the evidence ids that support it.
159    pub fn new(
160        state: &'obs DefenseState,
161        transition: Option<&'obs DefenseTransition>,
162        evidence_ids: Vec<EvidenceId>,
163    ) -> Self {
164        Self {
165            state,
166            transition,
167            evidence_ids,
168        }
169    }
170}
171
172/// A resource-scoped, corroborated defense signal.
173///
174/// The response is the aggregate of the per-observation [`recommend`] policy,
175/// with one corroboration rule: a single standing-block recommendation is not
176/// enough to halt, so an uncorroborated `Halt` is treated as `Observe`.
177/// Rate-limit `Backoff` and transition-driven `Reconsider` are self-corroborated
178/// and honored as-is.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct ResourceDefenseSignal {
181    resource: EntityId,
182    response: DefenseResponse,
183    supporting_evidence_ids: Vec<EvidenceId>,
184}
185
186impl ResourceDefenseSignal {
187    /// A neutral signal that recommends proceeding.
188    pub fn proceed(resource: EntityId) -> Self {
189        Self {
190            resource,
191            response: DefenseResponse::Proceed,
192            supporting_evidence_ids: Vec::new(),
193        }
194    }
195
196    /// Aggregates the observations for one resource into a single signal.
197    ///
198    /// Aggregation is order-independent: the response is the maximum of the
199    /// corroborated per-observation recommendations, and the evidence set is
200    /// sorted and de-duplicated.
201    pub fn aggregate(resource: EntityId, observations: &[ResourceDefenseObservation<'_>]) -> Self {
202        let mut responses = Vec::with_capacity(observations.len());
203        let mut evidence_ids = Vec::new();
204        for observation in observations {
205            responses.push(recommend(observation.state, observation.transition));
206            evidence_ids.extend(observation.evidence_ids.iter().cloned());
207        }
208
209        let standing_blocks = responses
210            .iter()
211            .filter(|response| **response == DefenseResponse::Halt)
212            .count();
213        let response = responses
214            .iter()
215            .map(|response| downgrade_uncorroborated_halt(*response, standing_blocks))
216            .max()
217            .unwrap_or(DefenseResponse::Proceed);
218
219        evidence_ids.sort();
220        evidence_ids.dedup();
221
222        Self {
223            resource,
224            response,
225            supporting_evidence_ids: evidence_ids,
226        }
227    }
228
229    /// Returns the resource this signal is scoped to.
230    pub fn resource(&self) -> &EntityId {
231        &self.resource
232    }
233
234    /// Returns the aggregated recommendation.
235    pub const fn response(&self) -> DefenseResponse {
236        self.response
237    }
238
239    /// Returns the sorted, de-duplicated supporting evidence ids.
240    pub fn supporting_evidence_ids(&self) -> &[EvidenceId] {
241        &self.supporting_evidence_ids
242    }
243}
244
245/// A single standing-block recommendation is not enough to halt; it needs at
246/// least two corroborating observations. An uncorroborated `Halt` is treated as
247/// `Observe`. `Backoff` (rate limit) and `Reconsider` (transition) are
248/// self-corroborated and pass through unchanged.
249const fn downgrade_uncorroborated_halt(
250    response: DefenseResponse,
251    standing_blocks: usize,
252) -> DefenseResponse {
253    match response {
254        DefenseResponse::Halt if standing_blocks < 2 => DefenseResponse::Observe,
255        other => other,
256    }
257}
258
259/// One action kept but deprioritized in the shadow plan.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct PlanAdjustment {
262    action_id: String,
263    interaction_class: DefenseInteractionClass,
264    recommendation: DefenseResponse,
265    supporting_evidence_ids: Vec<EvidenceId>,
266    explanation_code: &'static str,
267}
268
269/// One action removed from the shadow plan.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct SuppressedAction {
272    action_id: String,
273    interaction_class: DefenseInteractionClass,
274    recommendation: DefenseResponse,
275    supporting_evidence_ids: Vec<EvidenceId>,
276    explanation_code: &'static str,
277}
278
279macro_rules! adjustment_accessors {
280    ($ty:ty) => {
281        impl $ty {
282            /// Returns the affected action identity.
283            pub fn action_id(&self) -> &str {
284                &self.action_id
285            }
286
287            /// Returns the action's interaction class.
288            pub const fn interaction_class(&self) -> DefenseInteractionClass {
289                self.interaction_class
290            }
291
292            /// Returns the recommendation that drove the decision.
293            pub const fn recommendation(&self) -> DefenseResponse {
294                self.recommendation
295            }
296
297            /// Returns the evidence ids supporting the decision.
298            pub fn supporting_evidence_ids(&self) -> &[EvidenceId] {
299                &self.supporting_evidence_ids
300            }
301
302            /// Returns the stable explanation code.
303            pub const fn explanation_code(&self) -> &'static str {
304                self.explanation_code
305            }
306        }
307    };
308}
309
310adjustment_accessors!(PlanAdjustment);
311adjustment_accessors!(SuppressedAction);
312
313/// The explainable difference the defense signal makes to the current plan.
314#[derive(Debug, Clone, PartialEq, Eq, Default)]
315pub struct ShadowPlanDelta {
316    unchanged: Vec<String>,
317    deprioritized: Vec<PlanAdjustment>,
318    suppressed: Vec<SuppressedAction>,
319}
320
321impl ShadowPlanDelta {
322    /// Returns the current-plan actions left unchanged.
323    pub fn unchanged(&self) -> &[String] {
324        &self.unchanged
325    }
326
327    /// Returns the current-plan actions kept but deprioritized.
328    pub fn deprioritized(&self) -> &[PlanAdjustment] {
329        &self.deprioritized
330    }
331
332    /// Returns the current-plan actions removed from the shadow plan.
333    pub fn suppressed(&self) -> &[SuppressedAction] {
334        &self.suppressed
335    }
336
337    /// Returns whether the signal changed nothing (no suppression or
338    /// deprioritization).
339    pub fn is_empty(&self) -> bool {
340        self.deprioritized.is_empty() && self.suppressed.is_empty()
341    }
342}
343
344/// The current plan, the defense-aware shadow plan, and the delta between them.
345#[derive(Debug, Clone)]
346pub struct DefenseAwareShadowPlan {
347    current: AttackPlan,
348    shadow: AttackPlan,
349    delta: ShadowPlanDelta,
350}
351
352impl DefenseAwareShadowPlan {
353    /// Returns the real current plan (never mutated by this module).
354    pub const fn current(&self) -> &AttackPlan {
355        &self.current
356    }
357
358    /// Returns the read-only, defense-aware shadow plan.
359    pub const fn shadow(&self) -> &AttackPlan {
360        &self.shadow
361    }
362
363    /// Returns the explainable delta.
364    pub const fn delta(&self) -> &ShadowPlanDelta {
365        &self.delta
366    }
367}
368
369/// Computes the current plan, a defense-aware shadow plan, and their delta.
370///
371/// This is pure and side-effect-free: it uses the planner's read-only snapshot
372/// seam, issues no request, and mutates no planner, runtime, knowledge, or
373/// experience state. The signal applies only when it is scoped to `subject` and
374/// recommends more than `Proceed`; otherwise the shadow plan equals the current
375/// plan and the delta is empty. The shadow plan is always an order-preserving,
376/// dependency-safe subsequence — it never replans or introduces a new action.
377pub fn defense_aware_shadow_plan(
378    planner: &AttackPlanner,
379    snapshot: &KnowledgeSnapshot,
380    subject: &EntityId,
381    context: PlanningContext,
382    signal: &ResourceDefenseSignal,
383    classify: impl Fn(&AttackAction) -> DefenseInteractionClass,
384) -> Result<DefenseAwareShadowPlan, PlannerError> {
385    let current = planner.plan_snapshot(snapshot, context)?;
386    if current.subject() != subject {
387        return Ok(DefenseAwareShadowPlan {
388            shadow: current.clone(),
389            current,
390            delta: ShadowPlanDelta::default(),
391        });
392    }
393
394    Ok(defense_aware_shadow_plan_from_current(
395        current, planner, signal, classify,
396    ))
397}
398
399/// Filters an already-authorized plan into its defense-aware shadow.
400///
401/// Keeping this seam separate is important for runtime composition: a host can
402/// pass the exact plan already recorded in its planning report rather than ask
403/// the planner to reproduce a decision from potentially different context.
404/// The returned shadow is therefore always a subsequence of `current`.
405pub(crate) fn defense_aware_shadow_plan_from_current(
406    current: AttackPlan,
407    planner: &AttackPlanner,
408    signal: &ResourceDefenseSignal,
409    classify: impl Fn(&AttackAction) -> DefenseInteractionClass,
410) -> DefenseAwareShadowPlan {
411    let subject = current.subject();
412
413    let applies = signal.resource == *subject && signal.response != DefenseResponse::Proceed;
414
415    let mut class_by_action: BTreeMap<String, DefenseInteractionClass> = BTreeMap::new();
416    let mut suppressed_ids: BTreeSet<String> = BTreeSet::new();
417    if applies {
418        let candidate_ids = current
419            .steps()
420            .iter()
421            .map(|step| step.action_id().to_owned());
422        for action_id in candidate_ids {
423            if let Some(action) = planner.action(&action_id) {
424                let class = classify(action);
425                class_by_action.insert(action_id.clone(), class);
426                if decide(signal.response, class) == InteractionDecision::Suppress {
427                    suppressed_ids.insert(action_id);
428                }
429            }
430        }
431    }
432
433    let shadow = current.clone().into_defense_filtered(&suppressed_ids);
434    let delta = build_delta(&current, &shadow, &class_by_action, signal);
435
436    DefenseAwareShadowPlan {
437        current,
438        shadow,
439        delta,
440    }
441}
442
443fn build_delta(
444    current: &AttackPlan,
445    shadow: &AttackPlan,
446    class_by_action: &BTreeMap<String, DefenseInteractionClass>,
447    signal: &ResourceDefenseSignal,
448) -> ShadowPlanDelta {
449    let mut delta = ShadowPlanDelta::default();
450    let retained: BTreeSet<_> = shadow.steps().iter().map(|step| step.action_id()).collect();
451    for step in current.steps() {
452        let action_id = step.action_id();
453        let Some(class) = class_by_action.get(action_id).copied() else {
454            delta.unchanged.push(action_id.to_owned());
455            continue;
456        };
457        let decision = decide(signal.response, class);
458        if !retained.contains(action_id) {
459            delta.suppressed.push(SuppressedAction {
460                action_id: action_id.to_owned(),
461                interaction_class: class,
462                recommendation: signal.response,
463                supporting_evidence_ids: signal.supporting_evidence_ids.clone(),
464                explanation_code: if decision == InteractionDecision::Suppress {
465                    explanation_code(signal.response, decision)
466                } else {
467                    "defense.dependency.suppress"
468                },
469            });
470            continue;
471        }
472        match decision {
473            InteractionDecision::Allow => delta.unchanged.push(action_id.to_owned()),
474            InteractionDecision::Deprioritize => delta.deprioritized.push(PlanAdjustment {
475                action_id: action_id.to_owned(),
476                interaction_class: class,
477                recommendation: signal.response,
478                supporting_evidence_ids: signal.supporting_evidence_ids.clone(),
479                explanation_code: explanation_code(
480                    signal.response,
481                    InteractionDecision::Deprioritize,
482                ),
483            }),
484            InteractionDecision::Suppress => delta.suppressed.push(SuppressedAction {
485                action_id: action_id.to_owned(),
486                interaction_class: class,
487                recommendation: signal.response,
488                supporting_evidence_ids: signal.supporting_evidence_ids.clone(),
489                explanation_code: explanation_code(signal.response, InteractionDecision::Suppress),
490            }),
491        }
492    }
493    delta
494}
495
496#[cfg(test)]
497mod tests {
498    use std::collections::BTreeSet;
499
500    use termivar_core::{
501        BayesianEvidence, ConfidenceScore, Evidence, EvidenceKind, EvidenceSource, EvidenceValue,
502        Hypothesis, HypothesisState, HypothesisStrength, KnowledgePredicate, Probability,
503    };
504
505    use super::*;
506    use crate::knowledge::KnowledgeBase;
507    use crate::planner::{
508        ActionCost, BenefitScore, HypothesisSelector, RequiredStrength, RiskScore,
509    };
510    use crate::{Expression, KnowledgeLayer};
511
512    fn subject() -> EntityId {
513        EntityId::new("endpoint:https://example.test/api/admin").unwrap()
514    }
515
516    fn other_subject() -> EntityId {
517        EntityId::new("endpoint:https://example.test/public").unwrap()
518    }
519
520    fn stack_predicate() -> KnowledgePredicate {
521        KnowledgePredicate::new("stack", "framework").unwrap()
522    }
523
524    fn stack_value() -> EvidenceValue {
525        EvidenceValue::Text("Laravel".into())
526    }
527
528    fn knowledge_with_hypothesis() -> KnowledgeBase {
529        let knowledge = KnowledgeBase::new();
530        let evidence = Evidence::new(
531            subject(),
532            EvidenceKind::Technology,
533            KnowledgePredicate::new("technology", "framework").unwrap(),
534            stack_value(),
535            EvidenceSource::new("discovery", "framework-header").unwrap(),
536            ConfidenceScore::from_percent(90).unwrap(),
537        );
538        knowledge.insert_evidence(evidence.clone()).unwrap();
539        let mut hypothesis = Hypothesis::with_id(
540            "hypothesis:laravel",
541            subject(),
542            stack_predicate(),
543            stack_value(),
544            Probability::from_percent(50).unwrap(),
545        )
546        .unwrap();
547        hypothesis
548            .observe(
549                BayesianEvidence::new(
550                    evidence.id().clone(),
551                    Probability::from_percent(80).unwrap(),
552                    Probability::from_percent(20).unwrap(),
553                    "framework fingerprint",
554                )
555                .unwrap(),
556            )
557            .unwrap();
558        hypothesis.set_strength(HypothesisStrength::Strong);
559        hypothesis.set_state(HypothesisState::Supported);
560        knowledge.upsert_hypothesis(hypothesis).unwrap();
561        knowledge
562    }
563
564    fn action(id: &str, gain: u8) -> AttackAction {
565        AttackAction::new(
566            id,
567            format!("plugin.{id}"),
568            Expression::equals(KnowledgeLayer::Hypothesis, stack_predicate(), stack_value()),
569            HypothesisSelector::new(
570                stack_predicate(),
571                stack_value(),
572                Probability::from_percent(50).unwrap(),
573                RequiredStrength::Strong,
574            ),
575            BenefitScore::from_percent(gain).unwrap(),
576            ActionCost::new(10).unwrap(),
577            RiskScore::from_percent(20).unwrap(),
578            BTreeSet::new(),
579        )
580        .unwrap()
581    }
582
583    fn class_of(id: &str) -> DefenseInteractionClass {
584        // The host classifies its own actions; the library never string-matches.
585        match id {
586            "local.report" => DefenseInteractionClass::LocalOnly,
587            "passive.discovery" => DefenseInteractionClass::Passive,
588            "behavioral.observe" => DefenseInteractionClass::Behavioral,
589            "differential.read" => DefenseInteractionClass::DifferentialRead,
590            "active.verify" => DefenseInteractionClass::ActiveVerification,
591            "mutating.fuzz" => DefenseInteractionClass::Mutating,
592            other => panic!("unclassified test action {other}"),
593        }
594    }
595
596    fn classify(action: &AttackAction) -> DefenseInteractionClass {
597        class_of(action.id())
598    }
599
600    fn full_planner() -> AttackPlanner {
601        let mut planner = AttackPlanner::new();
602        planner.register(action("local.report", 60)).unwrap();
603        planner.register(action("passive.discovery", 58)).unwrap();
604        planner.register(action("behavioral.observe", 56)).unwrap();
605        planner.register(action("differential.read", 54)).unwrap();
606        planner.register(action("active.verify", 52)).unwrap();
607        planner.register(action("mutating.fuzz", 50)).unwrap();
608        planner
609    }
610
611    fn context() -> PlanningContext {
612        PlanningContext::new(
613            BenefitScore::from_percent(90).unwrap(),
614            1_000,
615            RiskScore::from_percent(80).unwrap(),
616        )
617    }
618
619    fn evidence_id(value: &str) -> EvidenceId {
620        EvidenceId::parse(value).unwrap()
621    }
622
623    fn signal(
624        resource: EntityId,
625        response: DefenseResponse,
626        evidence: &[&str],
627    ) -> ResourceDefenseSignal {
628        // A signal always carries sorted, de-duplicated evidence, exactly as
629        // `aggregate` produces it.
630        let mut supporting_evidence_ids: Vec<EvidenceId> =
631            evidence.iter().map(|id| evidence_id(id)).collect();
632        supporting_evidence_ids.sort();
633        supporting_evidence_ids.dedup();
634        ResourceDefenseSignal {
635            resource,
636            response,
637            supporting_evidence_ids,
638        }
639    }
640
641    fn shadow(signal: &ResourceDefenseSignal) -> DefenseAwareShadowPlan {
642        let knowledge = knowledge_with_hypothesis();
643        let snapshot = knowledge.snapshot_for_subject(&subject());
644        defense_aware_shadow_plan(
645            &full_planner(),
646            &snapshot,
647            &subject(),
648            context(),
649            signal,
650            classify,
651        )
652        .unwrap()
653    }
654
655    fn suppressed_ids(plan: &DefenseAwareShadowPlan) -> BTreeSet<&str> {
656        plan.delta()
657            .suppressed()
658            .iter()
659            .map(SuppressedAction::action_id)
660            .collect()
661    }
662
663    fn deprioritized_ids(plan: &DefenseAwareShadowPlan) -> BTreeSet<&str> {
664        plan.delta()
665            .deprioritized()
666            .iter()
667            .map(PlanAdjustment::action_id)
668            .collect()
669    }
670
671    fn shadow_step_ids(plan: &DefenseAwareShadowPlan) -> BTreeSet<&str> {
672        plan.shadow()
673            .steps()
674            .iter()
675            .map(|step| step.action_id())
676            .collect()
677    }
678
679    #[test]
680    fn decision_mapping_is_monotonic_per_class() {
681        let responses = [
682            DefenseResponse::Proceed,
683            DefenseResponse::Observe,
684            DefenseResponse::Backoff,
685            DefenseResponse::Reconsider,
686            DefenseResponse::Halt,
687        ];
688        for class in [
689            DefenseInteractionClass::LocalOnly,
690            DefenseInteractionClass::Passive,
691            DefenseInteractionClass::Behavioral,
692            DefenseInteractionClass::DifferentialRead,
693            DefenseInteractionClass::ActiveVerification,
694            DefenseInteractionClass::Mutating,
695        ] {
696            let mut previous = InteractionDecision::Allow;
697            for response in responses {
698                let decision = decide(response, class);
699                assert!(
700                    decision >= previous,
701                    "decision weakened for {class:?} at {response:?}"
702                );
703                previous = decision;
704            }
705        }
706        // Defense never suppresses local-only work.
707        for response in responses {
708            assert_eq!(
709                decide(response, DefenseInteractionClass::LocalOnly),
710                InteractionDecision::Allow
711            );
712        }
713    }
714
715    #[test]
716    fn proceed_produces_zero_delta() {
717        let plan = shadow(&signal(subject(), DefenseResponse::Proceed, &[]));
718        assert!(plan.delta().is_empty());
719        assert_eq!(shadow_step_ids(&plan), {
720            let knowledge = knowledge_with_hypothesis();
721            let snapshot = knowledge.snapshot_for_subject(&subject());
722            full_planner()
723                .plan_snapshot(&snapshot, context())
724                .unwrap()
725                .steps()
726                .iter()
727                .map(|step| step.action_id().to_owned())
728                .collect::<BTreeSet<_>>()
729                .iter()
730                .map(String::as_str)
731                .collect::<BTreeSet<_>>()
732        });
733    }
734
735    #[test]
736    fn observe_never_suppresses_passive_actions() {
737        let plan = shadow(&signal(subject(), DefenseResponse::Observe, &["e1"]));
738        assert!(plan.delta().suppressed().is_empty());
739        assert!(deprioritized_ids(&plan).contains("active.verify"));
740        assert!(deprioritized_ids(&plan).contains("mutating.fuzz"));
741        assert!(plan
742            .delta()
743            .unchanged()
744            .contains(&"passive.discovery".to_owned()));
745        assert!(plan
746            .delta()
747            .unchanged()
748            .contains(&"local.report".to_owned()));
749        // Nothing suppressed means the shadow plan keeps every step.
750        assert!(shadow_step_ids(&plan).contains("active.verify"));
751    }
752
753    #[test]
754    fn backoff_suppresses_active_but_preserves_local_analysis() {
755        let plan = shadow(&signal(subject(), DefenseResponse::Backoff, &["e1"]));
756        assert!(suppressed_ids(&plan).contains("active.verify"));
757        assert!(suppressed_ids(&plan).contains("mutating.fuzz"));
758        assert!(deprioritized_ids(&plan).contains("differential.read"));
759        assert!(plan
760            .delta()
761            .unchanged()
762            .contains(&"local.report".to_owned()));
763        assert!(plan
764            .delta()
765            .unchanged()
766            .contains(&"passive.discovery".to_owned()));
767        // Suppressed actions leave the shadow plan; local analysis stays.
768        assert!(!shadow_step_ids(&plan).contains("active.verify"));
769        assert!(!shadow_step_ids(&plan).contains("mutating.fuzz"));
770        assert!(shadow_step_ids(&plan).contains("local.report"));
771    }
772
773    #[test]
774    fn halt_suppresses_network_actions_only() {
775        let plan = shadow(&signal(subject(), DefenseResponse::Halt, &["e1"]));
776        for network in [
777            "passive.discovery",
778            "behavioral.observe",
779            "differential.read",
780            "active.verify",
781            "mutating.fuzz",
782        ] {
783            assert!(
784                suppressed_ids(&plan).contains(network),
785                "{network} not suppressed"
786            );
787            assert!(!shadow_step_ids(&plan).contains(network));
788        }
789        assert!(plan
790            .delta()
791            .unchanged()
792            .contains(&"local.report".to_owned()));
793        assert!(shadow_step_ids(&plan).contains("local.report"));
794    }
795
796    #[test]
797    fn unrelated_resource_defense_does_not_change_plan() {
798        // Defense observed on another resource must not touch this plan.
799        let plan = shadow(&signal(other_subject(), DefenseResponse::Halt, &["e1"]));
800        assert!(plan.delta().is_empty());
801        assert!(shadow_step_ids(&plan).contains("active.verify"));
802    }
803
804    #[test]
805    fn single_403_does_not_suppress_active_actions() {
806        let state = DefenseState::observe(403, &[], "forbidden");
807        let observation = ResourceDefenseObservation::new(&state, None, vec![evidence_id("e1")]);
808        let aggregated = ResourceDefenseSignal::aggregate(subject(), &[observation]);
809        // A single standing block is downgraded below suppression.
810        assert_eq!(aggregated.response(), DefenseResponse::Observe);
811
812        let plan = shadow(&aggregated);
813        assert!(plan.delta().suppressed().is_empty());
814        assert!(deprioritized_ids(&plan).contains("active.verify"));
815    }
816
817    #[test]
818    fn repeated_standing_blocks_escalate_to_halt() {
819        let first = DefenseState::observe(403, &[], "forbidden");
820        let second = DefenseState::observe(406, &[], "not acceptable");
821        let observations = [
822            ResourceDefenseObservation::new(&first, None, vec![evidence_id("e1")]),
823            ResourceDefenseObservation::new(&second, None, vec![evidence_id("e2")]),
824        ];
825        let aggregated = ResourceDefenseSignal::aggregate(subject(), &observations);
826        assert_eq!(aggregated.response(), DefenseResponse::Halt);
827    }
828
829    #[test]
830    fn timeout_does_not_change_shadow_plan() {
831        // A non-response contributes no observation, so the signal is Proceed.
832        let aggregated = ResourceDefenseSignal::aggregate(subject(), &[]);
833        assert_eq!(aggregated.response(), DefenseResponse::Proceed);
834        let plan = shadow(&aggregated);
835        assert!(plan.delta().is_empty());
836    }
837
838    #[test]
839    fn shadow_delta_references_supporting_evidence() {
840        let plan = shadow(&signal(subject(), DefenseResponse::Backoff, &["e2", "e1"]));
841        let expected: Vec<EvidenceId> = vec![evidence_id("e1"), evidence_id("e2")];
842        let suppressed = plan
843            .delta()
844            .suppressed()
845            .iter()
846            .find(|item| item.action_id() == "active.verify")
847            .expect("active.verify suppressed");
848        assert_eq!(suppressed.supporting_evidence_ids(), expected.as_slice());
849        assert_eq!(suppressed.explanation_code(), "defense.backoff.suppress");
850        assert!(!render_explanation(suppressed.explanation_code()).is_empty());
851    }
852
853    #[test]
854    fn shadow_planning_is_deterministic_under_evidence_ordering() {
855        let rate_limited = DefenseState::observe(429, &[], "slow down");
856        let fingerprinted = DefenseState::observe(200, &[("CF-RAY", "x")], "ok");
857        let forward = [
858            ResourceDefenseObservation::new(&rate_limited, None, vec![evidence_id("e1")]),
859            ResourceDefenseObservation::new(&fingerprinted, None, vec![evidence_id("e2")]),
860        ];
861        let reversed = [
862            ResourceDefenseObservation::new(&fingerprinted, None, vec![evidence_id("e2")]),
863            ResourceDefenseObservation::new(&rate_limited, None, vec![evidence_id("e1")]),
864        ];
865        let a = ResourceDefenseSignal::aggregate(subject(), &forward);
866        let b = ResourceDefenseSignal::aggregate(subject(), &reversed);
867        assert_eq!(a, b);
868        assert_eq!(shadow(&a).delta(), shadow(&b).delta());
869    }
870
871    #[test]
872    fn shadow_planner_never_adds_actions_absent_from_current_candidates() {
873        let knowledge = knowledge_with_hypothesis();
874        let snapshot = knowledge.snapshot_for_subject(&subject());
875        let planner = full_planner();
876        let plan = defense_aware_shadow_plan(
877            &planner,
878            &snapshot,
879            &subject(),
880            context(),
881            &signal(subject(), DefenseResponse::Reconsider, &["e1"]),
882            classify,
883        )
884        .unwrap();
885
886        let candidates: BTreeSet<&str> = plan
887            .current()
888            .steps()
889            .iter()
890            .map(|step| step.action_id())
891            .chain(
892                plan.current()
893                    .excluded()
894                    .iter()
895                    .map(|excluded| excluded.action_id()),
896            )
897            .collect();
898        for shadow_step in plan.shadow().steps() {
899            assert!(
900                candidates.contains(shadow_step.action_id()),
901                "shadow introduced a non-candidate action {}",
902                shadow_step.action_id()
903            );
904        }
905    }
906
907    #[test]
908    fn shadow_from_current_never_refills_freed_budget() {
909        let knowledge = knowledge_with_hypothesis();
910        let snapshot = knowledge.snapshot_for_subject(&subject());
911        let mut planner = AttackPlanner::new();
912        planner.register(action("active.verify", 95)).unwrap();
913        planner.register(action("local.report", 90)).unwrap();
914        planner.register(action("passive.discovery", 80)).unwrap();
915        let constrained = PlanningContext::new(
916            BenefitScore::from_percent(90).unwrap(),
917            20,
918            RiskScore::from_percent(80).unwrap(),
919        );
920        let current = planner.plan_snapshot(&snapshot, constrained).unwrap();
921        assert_eq!(
922            current
923                .steps()
924                .iter()
925                .map(|step| step.action_id())
926                .collect::<Vec<_>>(),
927            ["active.verify", "local.report"]
928        );
929        assert!(current
930            .excluded()
931            .iter()
932            .any(|entry| entry.action_id() == "passive.discovery"));
933
934        let result = defense_aware_shadow_plan_from_current(
935            current.clone(),
936            &planner,
937            &signal(subject(), DefenseResponse::Backoff, &["e1"]),
938            |action| {
939                assert_ne!(
940                    action.id(),
941                    "passive.discovery",
942                    "baseline-excluded action reached the defense classifier"
943                );
944                classify(action)
945            },
946        );
947
948        assert_eq!(result.current(), &current);
949        assert_eq!(
950            result
951                .shadow()
952                .steps()
953                .iter()
954                .map(|step| step.action_id())
955                .collect::<Vec<_>>(),
956            ["local.report"]
957        );
958        assert!(!result
959            .shadow()
960            .steps()
961            .iter()
962            .any(|step| step.action_id() == "passive.discovery"));
963    }
964}