Skip to main content

termivar_scanner/
decision_loop.rs

1//! Deterministic orchestration across reasoning, planning, verification, adaptation, and experience.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** default via `scanning`.
6//! - **Execution:** Surface B (deterministic decision runtime).
7//! - **Default `termivar scan`:** yes, through `StandardWebDecisionRuntime`.
8//! - **Support:** implemented and tested.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! The decision loop is a state machine, not an executor. It mutates only the
13//! knowledge, adaptive ledger, and experience records supplied by the host.
14//! Network traffic, plugin execution, delays, and cancellation remain runner
15//! responsibilities represented by explicit [`DecisionLoopCommand`] values.
16
17use std::collections::BTreeSet;
18
19use serde::{Deserialize, Deserializer, Serialize};
20use termivar_core::{EntityId, Outcome};
21use thiserror::Error;
22
23use crate::planner::{ActionSuppressionContext, ScheduledActionAuthorizationError};
24use crate::{
25    AdaptationLedger, AdaptationLimits, AdaptiveDecision, AdaptivePipeline, AdaptivePipelineError,
26    AttackPlan, AttackPlanner, ExperiencePolicy, ExperienceStore, ExperienceStoreError,
27    ExperienceWrite, KnowledgeBase, KnowledgeBaseError, KnowledgeSnapshot, KnowledgeWrite,
28    PayloadStrategyRef, PipelineDirective, PlannerError, PlanningContext,
29    ResolvedVerificationTarget, RuleApplication, RuleEngine, RuleEngineError, VerificationCase,
30    VerificationError, VerificationPipeline, VerificationReport,
31};
32
33mod command;
34mod policy;
35mod receipts;
36mod state;
37
38pub(crate) use command::{command_requiring_host_policy_context, execution_command_action_id};
39pub use command::{DecisionActionOrigin, DecisionLoopCommand, DecisionStopReason};
40#[cfg(test)]
41use policy::transition_from_adaptive;
42pub use policy::DecisionLoop;
43pub use receipts::{DecisionOutcomeReport, DecisionPlanningReport, DecisionReasoningCommitReceipt};
44pub use state::{
45    DecisionLoopState, DecisionSession, DecisionSessionSummary, DecisionSessionTransition,
46};
47
48/// Validation and transition failures raised by the decision loop.
49#[derive(Debug, Error)]
50#[non_exhaustive]
51pub enum DecisionLoopError {
52    /// An action-cycle limit of zero would prevent all execution.
53    #[error("maximum decision-loop action cycles must be greater than zero")]
54    ZeroActionCycles,
55
56    /// A command was submitted while the session was in another state.
57    #[error("cannot {operation} while decision loop is {state}")]
58    InvalidTransition {
59        /// Attempted operation.
60        operation: &'static str,
61        /// Current stable state name.
62        state: &'static str,
63    },
64
65    /// Persisted state contained a case for another subject.
66    #[error("decision session subject {expected} does not match case subject {actual}")]
67    CaseSubjectMismatch {
68        /// Session subject.
69        expected: EntityId,
70        /// Case subject.
71        actual: EntityId,
72    },
73
74    /// Persisted state awaited evidence before issuing any action.
75    #[error("decision session cannot await evidence with zero issued action cycles")]
76    AwaitingWithoutAction,
77
78    /// The monotonic action-cycle counter could not be incremented safely.
79    #[error("decision-loop action cycle counter overflowed")]
80    ActionCycleOverflow,
81
82    /// An adaptively scheduled action declared a distinct verification target
83    /// that was absent from the current immutable snapshot.
84    #[error("scheduled action {action_id} has no eligible verification target")]
85    NoEligibleScheduledVerificationTarget {
86        /// Scheduled action whose target could not be resolved.
87        action_id: String,
88    },
89
90    /// An adaptively scheduled action's own confidence source was absent from
91    /// the current immutable snapshot.
92    #[error("scheduled action {action_id} has no eligible motivation hypothesis")]
93    NoEligibleScheduledMotivationHypothesis {
94        /// Scheduled action whose confidence source could not be resolved.
95        action_id: String,
96    },
97
98    /// Adaptive execution was requested without an explicit current host
99    /// suppression context.
100    #[error("adaptive execution of action {action_id} requires explicit host suppression context")]
101    AdaptiveExecutionRequiresHostPolicyContext {
102        /// Action whose adaptive continuation requires current host policy.
103        action_id: String,
104    },
105
106    /// A decision case or adaptive policy named an unregistered action.
107    #[error("decision action {action_id} is not registered with the planner")]
108    UnregisteredDecisionAction {
109        /// Unknown action identity.
110        action_id: String,
111    },
112
113    /// A persisted case would authorize a broader claim transition than the
114    /// currently registered action policy.
115    #[error("decision case for action {action_id} exceeds current claim authority")]
116    DecisionCaseAuthorityExceeded {
117        /// Action whose transition target no longer authorizes the case claim.
118        action_id: String,
119    },
120
121    /// A registered adaptive action failed current planner authorization.
122    #[error("adaptive action {action_id} is not eligible under current planner policy")]
123    IneligibleAdaptiveAction {
124        /// Registered action excluded by current planner policy.
125        action_id: String,
126    },
127
128    /// Current defense enforcement suppressed an adaptive, retry, or active action.
129    #[error("defense enforcement suppresses action {action_id}")]
130    DefenseSuppressedAction {
131        /// Action rejected under the current defense context.
132        action_id: String,
133    },
134
135    /// Direct adaptive dispatch would skip declared prerequisites.
136    #[error(
137        "adaptive action {action_id} declares prerequisites and must be selected by normal planning"
138    )]
139    AdaptiveActionRequiresPlanning {
140        /// Adaptive action whose dependency order must be planned normally.
141        action_id: String,
142    },
143
144    /// Reasoning failed.
145    #[error(transparent)]
146    Rules(#[from] RuleEngineError),
147
148    /// Planning failed.
149    #[error(transparent)]
150    Planner(#[from] PlannerError),
151
152    /// Verification failed.
153    #[error(transparent)]
154    Verification(#[from] VerificationError),
155
156    /// Adaptive policy evaluation failed.
157    #[error(transparent)]
158    Adaptive(#[from] AdaptivePipelineError),
159
160    /// Experience recording or validation failed.
161    #[error(transparent)]
162    Experience(#[from] ExperienceStoreError),
163
164    /// Knowledge changed after the planner captured its decision snapshot.
165    #[error("planning snapshot became stale: {source}")]
166    StalePlanningSnapshot {
167        /// Exact revision mismatch detected before the session commit.
168        #[source]
169        source: KnowledgeBaseError,
170    },
171
172    /// Reasoning committed hypotheses before a later planning operation failed.
173    #[error("planning failed after reasoning was committed: {source}")]
174    PlanningAfterReasoningCommit {
175        /// Exact rule applications committed before the failure.
176        receipt: Box<DecisionReasoningCommitReceipt>,
177        /// Planner or command-construction failure raised after the commit.
178        #[source]
179        source: Box<DecisionLoopError>,
180    },
181}
182
183impl DecisionLoopError {
184    /// Returns committed reasoning when this failure happened after hypothesis writes.
185    pub fn committed_reasoning(&self) -> Option<&DecisionReasoningCommitReceipt> {
186        match self {
187            Self::PlanningAfterReasoningCommit { receipt, .. } => Some(receipt),
188            _ => None,
189        }
190    }
191
192    /// Takes the committed reasoning receipt without cloning it.
193    pub fn into_committed_reasoning(self) -> Option<DecisionReasoningCommitReceipt> {
194        match self {
195            Self::PlanningAfterReasoningCommit { receipt, .. } => Some(*receipt),
196            _ => None,
197        }
198    }
199}
200
201/// Stable configuration shared by all turns in one decision loop.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
203pub struct DecisionLoopConfig {
204    planning: PlanningContext,
205    adaptation: AdaptationLimits,
206    experience: ExperiencePolicy,
207    max_action_cycles: u32,
208}
209
210impl DecisionLoopConfig {
211    /// Creates a configuration with positive action-cycle bounds.
212    pub fn new(
213        planning: PlanningContext,
214        adaptation: AdaptationLimits,
215        experience: ExperiencePolicy,
216        max_action_cycles: u32,
217    ) -> Result<Self, DecisionLoopError> {
218        if max_action_cycles == 0 {
219            return Err(DecisionLoopError::ZeroActionCycles);
220        }
221        Ok(Self {
222            planning,
223            adaptation,
224            experience,
225            max_action_cycles,
226        })
227    }
228
229    /// Returns planner utility, budget, and risk policy.
230    pub fn planning(self) -> PlanningContext {
231        self.planning
232    }
233
234    /// Returns adaptive transition limits.
235    pub fn adaptation(self) -> AdaptationLimits {
236        self.adaptation
237    }
238
239    /// Returns experience suppression policy.
240    pub fn experience(self) -> ExperiencePolicy {
241        self.experience
242    }
243
244    /// Returns the maximum number of emitted action executions.
245    pub fn max_action_cycles(self) -> u32 {
246        self.max_action_cycles
247    }
248}
249
250pub(crate) struct ActiveEvidenceSnapshots<'a> {
251    baseline: &'a KnowledgeSnapshot,
252    after_probe: &'a KnowledgeSnapshot,
253}
254
255impl<'a> ActiveEvidenceSnapshots<'a> {
256    pub(crate) const fn new(
257        baseline: &'a KnowledgeSnapshot,
258        after_probe: &'a KnowledgeSnapshot,
259    ) -> Self {
260        Self {
261            baseline,
262            after_probe,
263        }
264    }
265}
266
267impl<'de> Deserialize<'de> for DecisionLoopConfig {
268    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
269    where
270        D: Deserializer<'de>,
271    {
272        #[derive(Deserialize)]
273        struct WireConfig {
274            planning: PlanningContext,
275            adaptation: AdaptationLimits,
276            experience: ExperiencePolicy,
277            max_action_cycles: u32,
278        }
279
280        let wire = WireConfig::deserialize(deserializer)?;
281        Self::new(
282            wire.planning,
283            wire.adaptation,
284            wire.experience,
285            wire.max_action_cycles,
286        )
287        .map_err(serde::de::Error::custom)
288    }
289}
290
291#[cfg(test)]
292#[path = "decision_loop/decision_loop_tests.rs"]
293mod tests;