1use super::{
4 ActionSuppressionContext, ActiveEvidenceSnapshots, AdaptivePipeline, AttackPlan, AttackPlanner,
5 BTreeSet, DecisionActionOrigin, DecisionLoopCommand, DecisionLoopConfig, DecisionLoopError,
6 DecisionLoopState, DecisionOutcomeReport, DecisionPlanningReport,
7 DecisionReasoningCommitReceipt, DecisionSession, DecisionSessionTransition, DecisionStopReason,
8 ExperiencePolicy, ExperienceStore, KnowledgeBase, KnowledgeSnapshot, KnowledgeWrite, Outcome,
9 PayloadStrategyRef, PipelineDirective, PlanningContext, ResolvedVerificationTarget, RuleEngine,
10 ScheduledActionAuthorizationError, VerificationCase, VerificationPipeline, VerificationReport,
11};
12
13#[derive(Debug, Clone)]
39pub struct DecisionLoop {
40 config: DecisionLoopConfig,
41 rules: RuleEngine,
42 planner: AttackPlanner,
43 verification: VerificationPipeline,
44 adaptive: AdaptivePipeline,
45}
46
47impl DecisionLoop {
48 pub fn new(config: DecisionLoopConfig) -> Self {
50 Self {
51 config,
52 rules: RuleEngine::new(),
53 planner: AttackPlanner::new(),
54 verification: VerificationPipeline::default(),
55 adaptive: AdaptivePipeline::new(),
56 }
57 }
58
59 pub fn with_components(
61 config: DecisionLoopConfig,
62 rules: RuleEngine,
63 planner: AttackPlanner,
64 verification: VerificationPipeline,
65 adaptive: AdaptivePipeline,
66 ) -> Self {
67 Self {
68 config,
69 rules,
70 planner,
71 verification,
72 adaptive,
73 }
74 }
75
76 pub fn config(&self) -> DecisionLoopConfig {
78 self.config
79 }
80
81 pub fn rules(&self) -> &RuleEngine {
83 &self.rules
84 }
85
86 pub fn rules_mut(&mut self) -> &mut RuleEngine {
88 &mut self.rules
89 }
90
91 pub fn planner(&self) -> &AttackPlanner {
93 &self.planner
94 }
95
96 pub fn planner_mut(&mut self) -> &mut AttackPlanner {
98 &mut self.planner
99 }
100
101 pub fn verification(&self) -> &VerificationPipeline {
103 &self.verification
104 }
105
106 pub fn verification_mut(&mut self) -> &mut VerificationPipeline {
108 &mut self.verification
109 }
110
111 pub fn adaptive(&self) -> &AdaptivePipeline {
113 &self.adaptive
114 }
115
116 pub fn adaptive_mut(&mut self) -> &mut AdaptivePipeline {
118 &mut self.adaptive
119 }
120
121 pub fn plan_next(
123 &self,
124 knowledge: &KnowledgeBase,
125 experience: &ExperienceStore,
126 session: &mut DecisionSession,
127 ) -> Result<DecisionPlanningReport, DecisionLoopError> {
128 self.plan_next_with_suppressed_actions(knowledge, experience, session, &BTreeSet::new())
129 }
130
131 pub fn plan_next_with_suppressed_actions(
139 &self,
140 knowledge: &KnowledgeBase,
141 experience: &ExperienceStore,
142 session: &mut DecisionSession,
143 host_suppressed_actions: &BTreeSet<String>,
144 ) -> Result<DecisionPlanningReport, DecisionLoopError> {
145 self.plan_next_with_suppressed_actions_before_commit(
146 knowledge,
147 experience,
148 session,
149 host_suppressed_actions,
150 |_| {},
151 )
152 }
153
154 pub(crate) fn plan_next_with_action_suppressions(
155 &self,
156 knowledge: &KnowledgeBase,
157 experience: &ExperienceStore,
158 session: &mut DecisionSession,
159 host_suppressions: &ActionSuppressionContext,
160 ) -> Result<DecisionPlanningReport, DecisionLoopError> {
161 self.plan_next_with_action_suppressions_before_commit(
162 knowledge,
163 experience,
164 session,
165 host_suppressions,
166 |_| {},
167 )
168 }
169
170 pub(super) fn plan_next_with_suppressed_actions_before_commit<F>(
171 &self,
172 knowledge: &KnowledgeBase,
173 experience: &ExperienceStore,
174 session: &mut DecisionSession,
175 host_suppressed_actions: &BTreeSet<String>,
176 before_session_commit: F,
177 ) -> Result<DecisionPlanningReport, DecisionLoopError>
178 where
179 F: FnMut(&KnowledgeSnapshot),
180 {
181 self.plan_next_with_action_suppressions_before_commit(
182 knowledge,
183 experience,
184 session,
185 &ActionSuppressionContext::policy_only(host_suppressed_actions),
186 before_session_commit,
187 )
188 }
189
190 fn plan_next_with_action_suppressions_before_commit<F>(
191 &self,
192 knowledge: &KnowledgeBase,
193 experience: &ExperienceStore,
194 session: &mut DecisionSession,
195 host_suppressions: &ActionSuppressionContext,
196 mut before_session_commit: F,
197 ) -> Result<DecisionPlanningReport, DecisionLoopError>
198 where
199 F: FnMut(&KnowledgeSnapshot),
200 {
201 require_state(session, "plan", |state| {
202 matches!(state, DecisionLoopState::Ready)
203 })?;
204 if session.action_cycles >= self.config.max_action_cycles {
205 let mut candidate_session = session.clone();
206 let reason = DecisionStopReason::ActionCycleLimit;
207 candidate_session.state = DecisionLoopState::Halted { reason };
208 let snapshot = knowledge.snapshot_for_subject(candidate_session.subject());
209 let suppressions = combined_suppressions(
210 experience,
211 &candidate_session,
212 self.config.experience,
213 host_suppressions,
214 );
215 let (policy_authorized_plan, plan) = self
216 .planner
217 .plan_snapshot_with_action_suppressions_and_baseline(
218 &snapshot,
219 self.config.planning,
220 &suppressions,
221 )?;
222 let report = DecisionPlanningReport {
223 rule_applications: Vec::new(),
224 plan,
225 policy_authorized_plan,
226 suppressed_actions: suppressions.policy_suppressed_actions().clone(),
227 session_transition: DecisionSessionTransition::new(
228 session.transition_summary(),
229 candidate_session.transition_summary(),
230 ),
231 command: DecisionLoopCommand::Halt { reason },
232 };
233 before_session_commit(&snapshot);
234 knowledge
235 .commit_if_snapshot_current(&snapshot, || *session = candidate_session)
236 .map_err(|source| DecisionLoopError::StalePlanningSnapshot { source })?;
237 return Ok(report);
238 }
239
240 let applications = self.rules.apply(knowledge, session.subject())?;
241 let snapshot = knowledge.snapshot_for_subject(session.subject());
242 let reasoning_changed = applications.iter().any(|application| {
243 application
244 .write()
245 .is_some_and(|write| write != KnowledgeWrite::Unchanged)
246 });
247 let mut candidate_session = session.clone();
248 let planning = (|| -> Result<
249 (
250 AttackPlan,
251 AttackPlan,
252 BTreeSet<String>,
253 DecisionSessionTransition,
254 DecisionLoopCommand,
255 ),
256 DecisionLoopError,
257 > {
258 let suppressions = combined_suppressions(
259 experience,
260 &candidate_session,
261 self.config.experience,
262 host_suppressions,
263 );
264 let (policy_authorized_plan, plan) = self
265 .planner
266 .plan_snapshot_with_action_suppressions_and_baseline(
267 &snapshot,
268 self.config.planning,
269 &suppressions,
270 )?;
271 let command = if let Some(step) = plan.steps().first() {
272 let case = next_case(
273 &candidate_session,
274 step.action_id(),
275 step.confidence_hypothesis_id(),
276 step.verification_target(),
277 step.payload_strategy().cloned(),
278 "planned",
279 )?;
280 issue_action(
281 &mut candidate_session,
282 self.config.max_action_cycles,
283 case,
284 Some(step.executor().to_owned()),
285 DecisionActionOrigin::Planned,
286 None,
287 )
288 } else {
289 let reason = DecisionStopReason::NoEligibleAction;
290 candidate_session.state = DecisionLoopState::Halted { reason };
291 DecisionLoopCommand::Halt { reason }
292 };
293 let session_transition = DecisionSessionTransition::new(
294 session.transition_summary(),
295 candidate_session.transition_summary(),
296 );
297 Ok((
298 policy_authorized_plan,
299 plan,
300 suppressions.policy_suppressed_actions().clone(),
301 session_transition,
302 command,
303 ))
304 })();
305
306 match planning {
307 Ok((policy_authorized_plan, plan, suppressed_actions, session_transition, command)) => {
308 before_session_commit(&snapshot);
309 let commit = knowledge.commit_if_snapshot_current(&snapshot, || {
310 *session = candidate_session;
311 });
312 match commit {
313 Ok(()) => Ok(DecisionPlanningReport {
314 rule_applications: applications,
315 plan,
316 policy_authorized_plan,
317 suppressed_actions,
318 session_transition,
319 command,
320 }),
321 Err(source) if reasoning_changed => {
322 Err(DecisionLoopError::PlanningAfterReasoningCommit {
323 receipt: Box::new(DecisionReasoningCommitReceipt {
324 subject: session.subject().clone(),
325 planner_subject_revision: snapshot.subject_revision(),
326 planner_ontology_revision: snapshot.ontology_revision(),
327 rule_applications: applications,
328 }),
329 source: Box::new(DecisionLoopError::StalePlanningSnapshot { source }),
330 })
331 },
332 Err(source) => Err(DecisionLoopError::StalePlanningSnapshot { source }),
333 }
334 },
335 Err(source) if reasoning_changed => {
336 Err(DecisionLoopError::PlanningAfterReasoningCommit {
337 receipt: Box::new(DecisionReasoningCommitReceipt {
338 subject: session.subject().clone(),
339 planner_subject_revision: snapshot.subject_revision(),
340 planner_ontology_revision: snapshot.ontology_revision(),
341 rule_applications: applications,
342 }),
343 source: Box::new(source),
344 })
345 },
346 Err(source) => Err(source),
347 }
348 }
349
350 pub fn submit_passive(
352 &self,
353 knowledge: &KnowledgeBase,
354 experience: &mut ExperienceStore,
355 session: &mut DecisionSession,
356 ) -> Result<DecisionOutcomeReport, DecisionLoopError> {
357 self.submit_passive_with_optional_suppressions(knowledge, experience, session, None)
358 }
359
360 pub fn submit_passive_with_suppressed_actions(
366 &self,
367 knowledge: &KnowledgeBase,
368 experience: &mut ExperienceStore,
369 session: &mut DecisionSession,
370 host_suppressed_actions: &BTreeSet<String>,
371 ) -> Result<DecisionOutcomeReport, DecisionLoopError> {
372 let suppressions = ActionSuppressionContext::policy_only(host_suppressed_actions);
373 self.submit_passive_with_action_suppressions(knowledge, experience, session, &suppressions)
374 }
375
376 pub(crate) fn submit_passive_with_action_suppressions(
377 &self,
378 knowledge: &KnowledgeBase,
379 experience: &mut ExperienceStore,
380 session: &mut DecisionSession,
381 host_suppressions: &ActionSuppressionContext,
382 ) -> Result<DecisionOutcomeReport, DecisionLoopError> {
383 self.submit_passive_with_optional_suppressions(
384 knowledge,
385 experience,
386 session,
387 Some(host_suppressions),
388 )
389 }
390
391 fn submit_passive_with_optional_suppressions(
392 &self,
393 knowledge: &KnowledgeBase,
394 experience: &mut ExperienceStore,
395 session: &mut DecisionSession,
396 host_suppressions: Option<&ActionSuppressionContext>,
397 ) -> Result<DecisionOutcomeReport, DecisionLoopError> {
398 let case = match session.state() {
399 DecisionLoopState::AwaitingPassive { case } => case.clone(),
400 state => {
401 return Err(DecisionLoopError::InvalidTransition {
402 operation: "submit passive evidence",
403 state: state.name(),
404 })
405 },
406 };
407 let snapshot = knowledge.snapshot_for_subject(session.subject());
408 self.validate_outstanding_case_authority(&snapshot, &case)?;
409 let verification = self
410 .verification
411 .passive()
412 .verify_snapshot(&case, &snapshot)?;
413 self.finalize_outcome(
414 knowledge,
415 experience,
416 session,
417 verification,
418 &snapshot,
419 host_suppressions,
420 )
421 }
422
423 pub fn submit_active(
425 &self,
426 knowledge: &KnowledgeBase,
427 experience: &mut ExperienceStore,
428 session: &mut DecisionSession,
429 baseline: &KnowledgeSnapshot,
430 after_probe: &KnowledgeSnapshot,
431 ) -> Result<DecisionOutcomeReport, DecisionLoopError> {
432 self.submit_active_with_optional_suppressions(
433 knowledge,
434 experience,
435 session,
436 baseline,
437 after_probe,
438 None,
439 )
440 }
441
442 pub fn submit_active_with_suppressed_actions(
447 &self,
448 knowledge: &KnowledgeBase,
449 experience: &mut ExperienceStore,
450 session: &mut DecisionSession,
451 baseline: &KnowledgeSnapshot,
452 after_probe: &KnowledgeSnapshot,
453 host_suppressed_actions: &BTreeSet<String>,
454 ) -> Result<DecisionOutcomeReport, DecisionLoopError> {
455 let suppressions = ActionSuppressionContext::policy_only(host_suppressed_actions);
456 self.submit_active_with_action_suppressions(
457 knowledge,
458 experience,
459 session,
460 ActiveEvidenceSnapshots::new(baseline, after_probe),
461 &suppressions,
462 )
463 }
464
465 pub(crate) fn submit_active_with_action_suppressions(
466 &self,
467 knowledge: &KnowledgeBase,
468 experience: &mut ExperienceStore,
469 session: &mut DecisionSession,
470 snapshots: ActiveEvidenceSnapshots<'_>,
471 host_suppressions: &ActionSuppressionContext,
472 ) -> Result<DecisionOutcomeReport, DecisionLoopError> {
473 self.submit_active_with_optional_suppressions(
474 knowledge,
475 experience,
476 session,
477 snapshots.baseline,
478 snapshots.after_probe,
479 Some(host_suppressions),
480 )
481 }
482
483 fn submit_active_with_optional_suppressions(
484 &self,
485 knowledge: &KnowledgeBase,
486 experience: &mut ExperienceStore,
487 session: &mut DecisionSession,
488 baseline: &KnowledgeSnapshot,
489 after_probe: &KnowledgeSnapshot,
490 host_suppressions: Option<&ActionSuppressionContext>,
491 ) -> Result<DecisionOutcomeReport, DecisionLoopError> {
492 let case = match session.state() {
493 DecisionLoopState::AwaitingActive { case } => case.clone(),
494 state => {
495 return Err(DecisionLoopError::InvalidTransition {
496 operation: "submit active evidence",
497 state: state.name(),
498 })
499 },
500 };
501 self.validate_outstanding_case_authority(after_probe, &case)?;
502 let verification =
503 self.verification
504 .active()
505 .verify_snapshots(&case, baseline, after_probe)?;
506 self.finalize_outcome(
507 knowledge,
508 experience,
509 session,
510 verification,
511 after_probe,
512 host_suppressions,
513 )
514 }
515
516 fn finalize_outcome(
517 &self,
518 knowledge: &KnowledgeBase,
519 experience: &mut ExperienceStore,
520 session: &mut DecisionSession,
521 verification: VerificationReport,
522 snapshot: &KnowledgeSnapshot,
523 host_suppressions: Option<&ActionSuppressionContext>,
524 ) -> Result<DecisionOutcomeReport, DecisionLoopError> {
525 let before = session.transition_summary();
526 let outcome = verification.outcome();
527 let mut candidate_experience = experience.clone();
528 let experience_write = candidate_experience.observe(outcome.clone())?;
529 let empty_host_suppressions = ActionSuppressionContext::default();
530 let suppressions = combined_suppressions(
531 &candidate_experience,
532 session,
533 self.config.experience,
534 host_suppressions.unwrap_or(&empty_host_suppressions),
535 );
536 let mut candidate_session = session.clone();
537 let adaptive = self.adaptive.decide_and_record_with_suppressed_actions(
543 outcome,
544 snapshot,
545 &mut candidate_session.adaptation,
546 self.config.adaptation,
547 suppressions.policy_suppressed_actions(),
548 )?;
549 if host_suppressions.is_none()
550 && !matches!(
551 adaptive.directive(),
552 PipelineDirective::Complete
553 | PipelineDirective::AwaitHumanReview
554 | PipelineDirective::Halt
555 )
556 {
557 let action_id = match adaptive.directive() {
558 PipelineDirective::ScheduleAction { action_id } => action_id.as_str(),
559 _ => outcome.action_id(),
560 };
561 return Err(
562 DecisionLoopError::AdaptiveExecutionRequiresHostPolicyContext {
563 action_id: action_id.to_owned(),
564 },
565 );
566 }
567 let authorization_snapshot = verification.prospective_snapshot(snapshot)?;
568 let command = transition_from_adaptive(
569 &mut candidate_session,
570 self.config.max_action_cycles,
571 &self.planner,
572 self.config.planning,
573 &authorization_snapshot,
574 verification.case(),
575 outcome,
576 adaptive.directive(),
577 &suppressions,
578 )?;
579 let hypothesis_write = verification.apply(knowledge)?;
580 let session_transition =
581 DecisionSessionTransition::new(before, candidate_session.transition_summary());
582
583 *experience = candidate_experience;
584 *session = candidate_session;
585 Ok(DecisionOutcomeReport {
586 verification,
587 adaptive,
588 experience_write,
589 hypothesis_write,
590 session_transition,
591 command,
592 })
593 }
594
595 fn validate_outstanding_case_authority(
596 &self,
597 snapshot: &KnowledgeSnapshot,
598 case: &VerificationCase,
599 ) -> Result<(), DecisionLoopError> {
600 let action = self.planner.action(case.action_id()).ok_or_else(|| {
601 DecisionLoopError::UnregisteredDecisionAction {
602 action_id: case.action_id().to_owned(),
603 }
604 })?;
605 if !case.applies_hypothesis_transition() {
606 return Ok(());
607 }
608 let authorized_target = action
609 .confidence_source()
610 .select(snapshot.hypotheses())
611 .and_then(|motivation| {
612 action
613 .verification_target()
614 .resolve(snapshot.hypotheses(), motivation.id())
615 })
616 .and_then(|target| target.hypothesis_id().map(str::to_owned));
617 if authorized_target.as_deref() != Some(case.hypothesis_id()) {
618 return Err(DecisionLoopError::DecisionCaseAuthorityExceeded {
619 action_id: case.action_id().to_owned(),
620 });
621 }
622 Ok(())
623 }
624
625 pub(crate) fn validate_execution_command_authority(
626 &self,
627 knowledge: &KnowledgeBase,
628 command: &DecisionLoopCommand,
629 ) -> Result<(), DecisionLoopError> {
630 match command {
631 DecisionLoopCommand::ExecuteAction { case, origin, .. }
632 if !matches!(origin, DecisionActionOrigin::Bootstrap) =>
633 {
634 let snapshot = knowledge.snapshot_for_subject(case.subject());
635 self.validate_outstanding_case_authority(&snapshot, case)
636 },
637 DecisionLoopCommand::CollectActiveEvidence { case } => {
638 let snapshot = knowledge.snapshot_for_subject(case.subject());
639 self.validate_outstanding_case_authority(&snapshot, case)
640 },
641 DecisionLoopCommand::ExecuteAction { .. }
642 | DecisionLoopCommand::Replan
643 | DecisionLoopCommand::Complete { .. }
644 | DecisionLoopCommand::AwaitHumanReview { .. }
645 | DecisionLoopCommand::Halt { .. } => Ok(()),
646 }
647 }
648}
649
650fn require_state(
651 session: &DecisionSession,
652 operation: &'static str,
653 predicate: impl FnOnce(&DecisionLoopState) -> bool,
654) -> Result<(), DecisionLoopError> {
655 if predicate(session.state()) {
656 Ok(())
657 } else {
658 Err(DecisionLoopError::InvalidTransition {
659 operation,
660 state: session.state.name(),
661 })
662 }
663}
664
665fn combined_suppressions(
666 experience: &ExperienceStore,
667 session: &DecisionSession,
668 policy: ExperiencePolicy,
669 host_suppressions: &ActionSuppressionContext,
670) -> ActionSuppressionContext {
671 let mut suppressions = experience.suppressed_actions(session.subject(), policy);
672 suppressions.extend(session.adaptation.suppressed_actions().iter().cloned());
673 suppressions.extend(
674 host_suppressions
675 .policy_suppressed_actions()
676 .iter()
677 .cloned(),
678 );
679 ActionSuppressionContext::new(
680 suppressions,
681 host_suppressions.defense_suppressed_actions().clone(),
682 )
683}
684
685fn next_case(
686 session: &DecisionSession,
687 action_id: &str,
688 motivation_hypothesis_id: &str,
689 verification_target: &ResolvedVerificationTarget,
690 payload_strategy: Option<PayloadStrategyRef>,
691 origin: &str,
692) -> Result<VerificationCase, DecisionLoopError> {
693 let (hypothesis_id, applies_hypothesis_transition) = match verification_target {
694 ResolvedVerificationTarget::Hypothesis(hypothesis_id) => (hypothesis_id.as_str(), true),
695 ResolvedVerificationTarget::KnowledgeOnly => (motivation_hypothesis_id, false),
696 };
697 next_case_with_policy(
698 session,
699 action_id,
700 hypothesis_id,
701 applies_hypothesis_transition,
702 payload_strategy,
703 origin,
704 )
705}
706
707fn next_case_with_policy(
708 session: &DecisionSession,
709 action_id: &str,
710 hypothesis_id: &str,
711 applies_hypothesis_transition: bool,
712 payload_strategy: Option<PayloadStrategyRef>,
713 origin: &str,
714) -> Result<VerificationCase, DecisionLoopError> {
715 let next_cycle = session
716 .action_cycles
717 .checked_add(1)
718 .ok_or(DecisionLoopError::ActionCycleOverflow)?;
719 let case = VerificationCase::new(
720 format!("case:decision:{next_cycle}:{origin}:{action_id}"),
721 session.subject.clone(),
722 action_id,
723 hypothesis_id,
724 )?
725 .with_payload_strategy(payload_strategy);
726 Ok(if applies_hypothesis_transition {
727 case
728 } else {
729 case.without_hypothesis_transition()
730 })
731}
732
733fn issue_action(
734 session: &mut DecisionSession,
735 max_action_cycles: u32,
736 case: VerificationCase,
737 executor: Option<String>,
738 origin: DecisionActionOrigin,
739 delay_ms: Option<u64>,
740) -> DecisionLoopCommand {
741 if session.action_cycles >= max_action_cycles {
742 let reason = DecisionStopReason::ActionCycleLimit;
743 session.state = DecisionLoopState::Halted { reason };
744 return DecisionLoopCommand::Halt { reason };
745 }
746 session.action_cycles += 1;
747 session.state = DecisionLoopState::AwaitingPassive { case: case.clone() };
748 DecisionLoopCommand::ExecuteAction {
749 case,
750 executor,
751 origin,
752 delay_ms,
753 }
754}
755
756#[allow(clippy::too_many_arguments)]
757pub(super) fn transition_from_adaptive(
758 session: &mut DecisionSession,
759 max_action_cycles: u32,
760 planner: &AttackPlanner,
761 planning_context: PlanningContext,
762 snapshot: &KnowledgeSnapshot,
763 current_case: &VerificationCase,
764 outcome: &Outcome,
765 directive: &PipelineDirective,
766 suppressions: &ActionSuppressionContext,
767) -> Result<DecisionLoopCommand, DecisionLoopError> {
768 match directive {
769 PipelineDirective::Complete => {
770 session.state = DecisionLoopState::Completed;
771 Ok(DecisionLoopCommand::Complete {
772 case: current_case.clone(),
773 })
774 },
775 PipelineDirective::ScheduleAction { action_id } => {
776 if session.action_cycles >= max_action_cycles {
777 let reason = DecisionStopReason::ActionCycleLimit;
778 session.state = DecisionLoopState::Halted { reason };
779 return Ok(DecisionLoopCommand::Halt { reason });
780 }
781 if suppressions
782 .defense_suppressed_actions()
783 .contains(action_id)
784 {
785 session.state = DecisionLoopState::Ready;
786 return Ok(DecisionLoopCommand::Replan);
787 }
788 let step = authorize_adaptive_action(
789 planner,
790 snapshot,
791 planning_context,
792 suppressions,
793 action_id,
794 true,
795 )?;
796 let case = next_case(
797 session,
798 step.action_id(),
799 step.confidence_hypothesis_id(),
800 step.verification_target(),
801 step.payload_strategy().cloned(),
802 "adaptive",
803 )?;
804 Ok(issue_action(
805 session,
806 max_action_cycles,
807 case,
808 Some(step.executor().to_owned()),
809 DecisionActionOrigin::Adaptive,
810 None,
811 ))
812 },
813 PipelineDirective::Replan { .. } => {
814 session.state = DecisionLoopState::Ready;
815 Ok(DecisionLoopCommand::Replan)
816 },
817 PipelineDirective::Throttle {
818 delay_ms,
819 retry_current_action: true,
820 } => {
821 if session.action_cycles >= max_action_cycles {
822 let reason = DecisionStopReason::ActionCycleLimit;
823 session.state = DecisionLoopState::Halted { reason };
824 return Ok(DecisionLoopCommand::Halt { reason });
825 }
826 if suppressions
827 .defense_suppressed_actions()
828 .contains(outcome.action_id())
829 {
830 session.state = DecisionLoopState::Ready;
831 return Ok(DecisionLoopCommand::Replan);
832 }
833 let step = authorize_adaptive_action(
834 planner,
835 snapshot,
836 planning_context,
837 suppressions,
838 outcome.action_id(),
839 false,
840 )?;
841 let case = next_case_with_policy(
842 session,
843 outcome.action_id(),
844 current_case.hypothesis_id(),
845 current_case.applies_hypothesis_transition(),
846 current_case.payload_strategy().cloned(),
847 "retry",
848 )?;
849 Ok(issue_action(
850 session,
851 max_action_cycles,
852 case,
853 Some(step.executor().to_owned()),
854 DecisionActionOrigin::Retry,
855 Some(*delay_ms),
856 ))
857 },
858 PipelineDirective::Throttle {
859 retry_current_action: false,
860 ..
861 } => {
862 session.state = DecisionLoopState::Ready;
863 Ok(DecisionLoopCommand::Replan)
864 },
865 PipelineDirective::AwaitActiveVerification => {
866 if suppressions
867 .defense_suppressed_actions()
868 .contains(current_case.action_id())
869 {
870 session.state = DecisionLoopState::Ready;
871 return Ok(DecisionLoopCommand::Replan);
872 }
873 authorize_adaptive_action(
874 planner,
875 snapshot,
876 planning_context,
877 suppressions,
878 current_case.action_id(),
879 false,
880 )?;
881 let case = current_case.clone();
882 session.state = DecisionLoopState::AwaitingActive { case: case.clone() };
883 Ok(DecisionLoopCommand::CollectActiveEvidence { case })
884 },
885 PipelineDirective::AwaitHumanReview => {
886 let case = current_case.clone();
887 let reason = DecisionStopReason::HumanReview;
888 session.state = DecisionLoopState::Halted { reason };
889 Ok(DecisionLoopCommand::AwaitHumanReview { case })
890 },
891 PipelineDirective::Halt => {
892 let reason = DecisionStopReason::AdaptationLimit;
893 session.state = DecisionLoopState::Halted { reason };
894 Ok(DecisionLoopCommand::Halt { reason })
895 },
896 }
897}
898
899fn authorize_adaptive_action(
900 planner: &AttackPlanner,
901 snapshot: &KnowledgeSnapshot,
902 planning_context: PlanningContext,
903 suppressions: &ActionSuppressionContext,
904 action_id: &str,
905 preserve_scheduled_target_errors: bool,
906) -> Result<crate::PlanStep, DecisionLoopError> {
907 planner
908 .authorize_scheduled_action_with_context(
909 snapshot,
910 planning_context,
911 suppressions,
912 action_id,
913 )
914 .map_err(|error| match error {
915 ScheduledActionAuthorizationError::Planner(source) => {
916 DecisionLoopError::Planner(source)
917 },
918 ScheduledActionAuthorizationError::Unregistered { action_id } => {
919 DecisionLoopError::UnregisteredDecisionAction { action_id }
920 },
921 ScheduledActionAuthorizationError::HasPrerequisites { action_id } => {
922 DecisionLoopError::AdaptiveActionRequiresPlanning { action_id }
923 },
924 ScheduledActionAuthorizationError::Excluded { action_id, reason } => {
925 if reason == crate::ExclusionReason::DefenseSuppressed {
926 return DecisionLoopError::DefenseSuppressedAction { action_id };
927 }
928 if preserve_scheduled_target_errors {
929 match reason {
930 crate::ExclusionReason::NoEligibleHypothesis => {
931 return DecisionLoopError::NoEligibleScheduledMotivationHypothesis {
932 action_id,
933 };
934 },
935 crate::ExclusionReason::NoEligibleVerificationTarget => {
936 return DecisionLoopError::NoEligibleScheduledVerificationTarget {
937 action_id,
938 };
939 },
940 _ => {},
941 }
942 }
943 DecisionLoopError::IneligibleAdaptiveAction { action_id }
944 },
945 })
946}