Skip to main content

termivar_scanner/decision_loop/
policy.rs

1//! Deterministic reasoning, planning, verification, and adaptation policy.
2
3use 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/// Deterministic coordinator for one evidence-to-command cycle.
14///
15/// # Example
16///
17/// ```rust
18/// use termivar_scanner::{
19///     AdaptationLimits, BenefitScore, DecisionLoop, DecisionLoopConfig, ExperiencePolicy,
20///     PlanningContext, RiskScore,
21/// };
22///
23/// let planning = PlanningContext::new(
24///     BenefitScore::from_percent(80)?,
25///     100,
26///     RiskScore::from_percent(40)?,
27/// );
28/// let config = DecisionLoopConfig::new(
29///     planning,
30///     AdaptationLimits::default(),
31///     ExperiencePolicy::default(),
32///     32,
33/// )?;
34/// let decision_loop = DecisionLoop::new(config);
35/// assert!(decision_loop.planner().is_empty());
36/// # Ok::<(), Box<dyn std::error::Error>>(())
37/// ```
38#[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    /// Creates an empty coordinator with explicit limits.
49    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    /// Creates a coordinator from independently configured subsystems.
60    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    /// Returns the immutable configuration.
77    pub fn config(&self) -> DecisionLoopConfig {
78        self.config
79    }
80
81    /// Returns the reasoning registry.
82    pub fn rules(&self) -> &RuleEngine {
83        &self.rules
84    }
85
86    /// Returns the mutable reasoning registry.
87    pub fn rules_mut(&mut self) -> &mut RuleEngine {
88        &mut self.rules
89    }
90
91    /// Returns the attack planner.
92    pub fn planner(&self) -> &AttackPlanner {
93        &self.planner
94    }
95
96    /// Returns the mutable attack planner.
97    pub fn planner_mut(&mut self) -> &mut AttackPlanner {
98        &mut self.planner
99    }
100
101    /// Returns the verification pipeline.
102    pub fn verification(&self) -> &VerificationPipeline {
103        &self.verification
104    }
105
106    /// Returns the mutable verification pipeline.
107    pub fn verification_mut(&mut self) -> &mut VerificationPipeline {
108        &mut self.verification
109    }
110
111    /// Returns adaptive policy.
112    pub fn adaptive(&self) -> &AdaptivePipeline {
113        &self.adaptive
114    }
115
116    /// Returns mutable adaptive policy.
117    pub fn adaptive_mut(&mut self) -> &mut AdaptivePipeline {
118        &mut self.adaptive
119    }
120
121    /// Applies reasoning, utility planning, and target-scoped suppressions.
122    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    /// Applies reasoning and planning while honoring host policy suppressions.
132    ///
133    /// Explicit suppressions are combined with experience and adaptive-session
134    /// suppressions. They remain visible as policy exclusions in the returned
135    /// planner audit record. A host that later submits passive or active
136    /// evidence must use the matching suppression-aware submission method so
137    /// adaptive execution is reauthorized against current policy.
138    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    /// Evaluates evidence produced by the outstanding action.
351    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    /// Evaluates passive evidence under an explicit current host policy.
361    ///
362    /// Adaptive directives that continue automated work require this explicit
363    /// context, even when the set is empty. This prevents replay or a mixed
364    /// planning/submission API from silently forgetting host authority.
365    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    /// Evaluates evidence produced by an explicit active verification probe.
424    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    /// Evaluates active evidence under an explicit current host policy.
443    ///
444    /// See [`Self::submit_passive_with_suppressed_actions`] for why adaptive
445    /// execution requires an explicit, replay-time host authority context.
446    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        // Defense must not participate in adaptive winner selection: removing
538        // a higher-priority rule there could promote a lower-priority action
539        // that the no-defense baseline never selected. The typed defense set
540        // stays alongside this baseline decision and the transition below can
541        // only suppress its selected schedule/retry/active continuation.
542        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}