1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum DefenseInteractionClass {
36 LocalOnly,
38 Passive,
40 Behavioral,
42 DifferentialRead,
44 ActiveVerification,
46 Mutating,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
54pub enum InteractionDecision {
55 Allow,
57 Deprioritize,
59 Suppress,
61}
62
63pub 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, _) => Suppress,
96 }
97}
98
99pub 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
121pub 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#[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 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#[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 pub fn proceed(resource: EntityId) -> Self {
189 Self {
190 resource,
191 response: DefenseResponse::Proceed,
192 supporting_evidence_ids: Vec::new(),
193 }
194 }
195
196 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 pub fn resource(&self) -> &EntityId {
231 &self.resource
232 }
233
234 pub const fn response(&self) -> DefenseResponse {
236 self.response
237 }
238
239 pub fn supporting_evidence_ids(&self) -> &[EvidenceId] {
241 &self.supporting_evidence_ids
242 }
243}
244
245const 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#[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#[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 pub fn action_id(&self) -> &str {
284 &self.action_id
285 }
286
287 pub const fn interaction_class(&self) -> DefenseInteractionClass {
289 self.interaction_class
290 }
291
292 pub const fn recommendation(&self) -> DefenseResponse {
294 self.recommendation
295 }
296
297 pub fn supporting_evidence_ids(&self) -> &[EvidenceId] {
299 &self.supporting_evidence_ids
300 }
301
302 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#[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 pub fn unchanged(&self) -> &[String] {
324 &self.unchanged
325 }
326
327 pub fn deprioritized(&self) -> &[PlanAdjustment] {
329 &self.deprioritized
330 }
331
332 pub fn suppressed(&self) -> &[SuppressedAction] {
334 &self.suppressed
335 }
336
337 pub fn is_empty(&self) -> bool {
340 self.deprioritized.is_empty() && self.suppressed.is_empty()
341 }
342}
343
344#[derive(Debug, Clone)]
346pub struct DefenseAwareShadowPlan {
347 current: AttackPlan,
348 shadow: AttackPlan,
349 delta: ShadowPlanDelta,
350}
351
352impl DefenseAwareShadowPlan {
353 pub const fn current(&self) -> &AttackPlan {
355 &self.current
356 }
357
358 pub const fn shadow(&self) -> &AttackPlan {
360 &self.shadow
361 }
362
363 pub const fn delta(&self) -> &ShadowPlanDelta {
365 &self.delta
366 }
367}
368
369pub 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
399pub(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(¤t, &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 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 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 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 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 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 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 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 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(), ¤t);
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}