Skip to main content

termivar_scanner/
web_decision.rs

1//! One-shot composition of the standard deterministic web decision stack.
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 composite profile keeps domain reasoning, utility planning, bounded
13//! execution, and verification independently inspectable while providing one
14//! preflighted installation boundary for hosts.
15
16use serde::Serialize;
17use thiserror::Error;
18
19use crate::{
20    http_evidence::HttpRequestBroker, DecisionExecutorRegistry, DecisionLoop, HttpEvidencePolicy,
21    KnowledgeBase, StandardWebAttackInstallReport, StandardWebAttackProfile,
22    StandardWebDiscoveryExecutorProfile, StandardWebDiscoveryInstallReport,
23    StandardWebExecutionError, StandardWebInstallReport, StandardWebPlanningError,
24    StandardWebReasoning, StandardWebReasoningError, StandardWebVerificationError,
25    StandardWebVerificationInstallReport, StandardWebVerificationProfile,
26};
27
28/// Failures while constructing or installing the composite web decision stack.
29#[derive(Debug, Error)]
30#[non_exhaustive]
31pub enum StandardWebDecisionError {
32    /// Standard ontology or reasoning rules failed validation or installation.
33    #[error(transparent)]
34    Reasoning(#[from] StandardWebReasoningError),
35
36    /// Standard planner actions failed validation or installation.
37    #[error(transparent)]
38    Planning(#[from] StandardWebPlanningError),
39
40    /// Scope-bound semantic executors failed construction or installation.
41    #[error(transparent)]
42    Execution(#[from] StandardWebExecutionError),
43
44    /// Passive or active verifier rules failed validation or installation.
45    #[error(transparent)]
46    Verification(#[from] StandardWebVerificationError),
47}
48
49/// Per-layer writes produced by one composite profile installation.
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
51pub struct StandardWebDecisionInstallReport {
52    reasoning: StandardWebInstallReport,
53    planning: StandardWebAttackInstallReport,
54    execution: StandardWebDiscoveryInstallReport,
55    verification: StandardWebVerificationInstallReport,
56}
57
58impl StandardWebDecisionInstallReport {
59    /// Returns ontology and reasoning-rule writes.
60    pub fn reasoning(self) -> StandardWebInstallReport {
61        self.reasoning
62    }
63
64    /// Returns utility-planner action writes.
65    pub fn planning(self) -> StandardWebAttackInstallReport {
66        self.planning
67    }
68
69    /// Returns semantic executor writes.
70    pub fn execution(self) -> StandardWebDiscoveryInstallReport {
71        self.execution
72    }
73
74    /// Returns passive and active verifier-rule writes.
75    pub fn verification(self) -> StandardWebVerificationInstallReport {
76        self.verification
77    }
78}
79
80/// Complete opt-in standard web decision profile.
81///
82/// Construction validates every layer and binds execution to one immutable
83/// [`HttpEvidencePolicy`]. Installation prepares the decision loop and executor
84/// registry on owned clones. Ontology definitions are committed under one
85/// knowledge-base write lock only after every other layer has preflighted.
86///
87/// # Examples
88///
89/// ```rust
90/// use url::Url;
91/// use termivar_scanner::{
92///     AdaptationLimits, BenefitScore, DecisionExecutorRegistry, DecisionLoop,
93///     DecisionLoopConfig, ExperiencePolicy, HttpEvidencePolicy, KnowledgeBase,
94///     PlanningContext, RiskScore, StandardWebDecisionProfile,
95/// };
96///
97/// let target = Url::parse("https://example.test/")?;
98/// let config = DecisionLoopConfig::new(
99///     PlanningContext::new(
100///         BenefitScore::from_percent(80)?,
101///         100,
102///         RiskScore::from_percent(40)?,
103///     ),
104///     AdaptationLimits::default(),
105///     ExperiencePolicy::default(),
106///     8,
107/// )?;
108/// let knowledge = KnowledgeBase::new();
109/// let mut decision_loop = DecisionLoop::new(config);
110/// let mut executors = DecisionExecutorRegistry::new();
111///
112/// StandardWebDecisionProfile::new(HttpEvidencePolicy::for_origin(target)?)?
113///     .install(&knowledge, &mut decision_loop, &mut executors)?;
114/// # Ok::<(), Box<dyn std::error::Error>>(())
115/// ```
116pub struct StandardWebDecisionProfile {
117    reasoning: StandardWebReasoning,
118    planning: StandardWebAttackProfile,
119    execution: StandardWebDiscoveryExecutorProfile,
120    verification: StandardWebVerificationProfile,
121}
122
123impl StandardWebDecisionProfile {
124    /// Builds and validates all four layers before any host state is changed.
125    pub fn new(policy: HttpEvidencePolicy) -> Result<Self, StandardWebDecisionError> {
126        Self::build(StandardWebDiscoveryExecutorProfile::new(policy)?)
127    }
128
129    pub(crate) fn new_with_request_broker(
130        requests: HttpRequestBroker,
131    ) -> Result<Self, StandardWebDecisionError> {
132        Self::build(StandardWebDiscoveryExecutorProfile::new_with_request_broker(requests)?)
133    }
134
135    pub(crate) fn new_with_request_broker_and_assessment_projection(
136        requests: HttpRequestBroker,
137    ) -> Result<Self, StandardWebDecisionError> {
138        Self::build(
139            StandardWebDiscoveryExecutorProfile::new_with_request_broker_and_assessment_projection(
140                requests, true,
141            )?,
142        )
143    }
144
145    fn build(
146        execution: StandardWebDiscoveryExecutorProfile,
147    ) -> Result<Self, StandardWebDecisionError> {
148        Ok(Self {
149            reasoning: StandardWebReasoning::new()?,
150            planning: StandardWebAttackProfile::new()?,
151            execution,
152            verification: StandardWebVerificationProfile::new()?,
153        })
154    }
155
156    /// Installs the full profile with preflighted all-or-nothing semantics.
157    pub fn install(
158        &self,
159        knowledge: &KnowledgeBase,
160        decision_loop: &mut DecisionLoop,
161        executors: &mut DecisionExecutorRegistry,
162    ) -> Result<StandardWebDecisionInstallReport, StandardWebDecisionError> {
163        let mut prospective_loop = decision_loop.clone();
164        let mut prospective_executors = executors.clone();
165
166        let planning = self.planning.install(prospective_loop.planner_mut())?;
167        let verification = self
168            .verification
169            .install(prospective_loop.verification_mut())?;
170        let execution = self.execution.install(&mut prospective_executors)?;
171        let reasoning = self
172            .reasoning
173            .install(knowledge, prospective_loop.rules_mut())?;
174
175        *decision_loop = prospective_loop;
176        *executors = prospective_executors;
177        Ok(StandardWebDecisionInstallReport {
178            reasoning,
179            planning,
180            execution,
181            verification,
182        })
183    }
184
185    /// Returns the standard ontology and fingerprint profile.
186    pub fn reasoning(&self) -> &StandardWebReasoning {
187        &self.reasoning
188    }
189
190    /// Returns the standard utility-planner profile.
191    pub fn planning(&self) -> &StandardWebAttackProfile {
192        &self.planning
193    }
194
195    /// Returns the scope-bound discovery executor profile.
196    pub fn execution(&self) -> &StandardWebDiscoveryExecutorProfile {
197        &self.execution
198    }
199
200    /// Returns the passive and active verifier profile.
201    pub fn verification(&self) -> &StandardWebVerificationProfile {
202        &self.verification
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use std::sync::Arc;
209
210    use termivar_core::{
211        ConceptId, ConfidenceScore, EntityId, Evidence, EvidenceKind, EvidenceSource,
212        EvidenceValue, HypothesisState, KnowledgePredicate, OntologyConcept, OutcomeStatus,
213    };
214    use tokio::{
215        io::{AsyncReadExt, AsyncWriteExt},
216        net::TcpListener,
217    };
218    use url::Url;
219
220    use super::*;
221    use crate::{
222        AdaptationLimits, BenefitScore, DecisionExecutionStage, DecisionLoopConfig,
223        DecisionRunnerAdapter, DecisionRunnerTurn, DecisionSession, ExperiencePolicy,
224        ExperienceStore, HttpEvidenceExecutor, HttpProbeMethod, PlanningContext, RiskScore,
225        StandardWebActionKind, SubjectHttpProbeProvider, STANDARD_WEB_ACTION_COUNT,
226        STANDARD_WEB_AXIOM_COUNT, STANDARD_WEB_CONCEPT_COUNT,
227        STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT, STANDARD_WEB_RULE_COUNT,
228        STANDARD_WEB_VERIFICATION_RULE_COUNT,
229    };
230
231    fn decision_loop() -> DecisionLoop {
232        DecisionLoop::new(
233            DecisionLoopConfig::new(
234                PlanningContext::new(
235                    BenefitScore::from_percent(90).unwrap(),
236                    200,
237                    RiskScore::from_percent(100).unwrap(),
238                ),
239                AdaptationLimits::default(),
240                ExperiencePolicy::new(3).unwrap(),
241                8,
242            )
243            .unwrap(),
244        )
245    }
246
247    fn profile(target: Url) -> StandardWebDecisionProfile {
248        StandardWebDecisionProfile::new(HttpEvidencePolicy::for_origin(target).unwrap()).unwrap()
249    }
250
251    #[test]
252    fn composite_install_is_complete_and_idempotent() {
253        let target = Url::parse("https://example.test/app").unwrap();
254        let profile = profile(target);
255        let knowledge = KnowledgeBase::new();
256        let mut decision_loop = decision_loop();
257        let mut executors = DecisionExecutorRegistry::new();
258
259        let first = profile
260            .install(&knowledge, &mut decision_loop, &mut executors)
261            .unwrap();
262        let second = profile
263            .install(&knowledge, &mut decision_loop, &mut executors)
264            .unwrap();
265
266        assert_eq!(
267            first.reasoning().concepts_inserted(),
268            STANDARD_WEB_CONCEPT_COUNT
269        );
270        assert_eq!(
271            first.reasoning().axioms_inserted(),
272            STANDARD_WEB_AXIOM_COUNT
273        );
274        assert_eq!(first.reasoning().rules_inserted(), STANDARD_WEB_RULE_COUNT);
275        assert_eq!(
276            first.planning().actions_inserted(),
277            STANDARD_WEB_ACTION_COUNT
278        );
279        assert_eq!(
280            first.execution().executors_inserted(),
281            STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT
282        );
283        assert_eq!(
284            first.verification().total_rules_inserted(),
285            STANDARD_WEB_VERIFICATION_RULE_COUNT
286        );
287        assert_eq!(second, StandardWebDecisionInstallReport::default());
288        assert_eq!(decision_loop.rules().len(), STANDARD_WEB_RULE_COUNT);
289        assert_eq!(decision_loop.planner().len(), STANDARD_WEB_ACTION_COUNT);
290        assert_eq!(decision_loop.verification().passive().len(), 16);
291        assert_eq!(decision_loop.verification().active().len(), 16);
292        assert_eq!(executors.len(), STANDARD_WEB_DISCOVERY_EXECUTOR_COUNT);
293    }
294
295    #[test]
296    fn executor_route_conflict_rolls_back_every_layer() {
297        let target = Url::parse("https://example.test/app").unwrap();
298        let policy = HttpEvidencePolicy::for_origin(target.clone()).unwrap();
299        let profile = StandardWebDecisionProfile::new(policy.clone()).unwrap();
300        let knowledge = KnowledgeBase::new();
301        let ontology_before = knowledge.stats().ontology;
302        let mut decision_loop = decision_loop();
303        let mut executors = DecisionExecutorRegistry::new();
304        let custom = HttpEvidenceExecutor::with_id(
305            "host.custom",
306            policy,
307            Arc::new(SubjectHttpProbeProvider::new(HttpProbeMethod::Head)),
308        )
309        .unwrap();
310        executors.register(Arc::new(custom)).unwrap();
311        executors
312            .route_action(
313                DecisionExecutionStage::Passive,
314                StandardWebActionKind::LaravelRouteDiscovery.action_id(),
315                "host.custom",
316            )
317            .unwrap();
318
319        assert!(matches!(
320            profile.install(&knowledge, &mut decision_loop, &mut executors),
321            Err(StandardWebDecisionError::Execution(_))
322        ));
323        assert_eq!(knowledge.stats().ontology, ontology_before);
324        assert!(decision_loop.rules().is_empty());
325        assert!(decision_loop.planner().is_empty());
326        assert!(decision_loop.verification().passive().is_empty());
327        assert!(decision_loop.verification().active().is_empty());
328        assert_eq!(executors.len(), 1);
329        assert!(executors.contains("host.custom"));
330    }
331
332    #[test]
333    fn ontology_conflict_does_not_commit_preflighted_runtime_layers() {
334        let target = Url::parse("https://example.test/app").unwrap();
335        let profile = profile(target);
336        let knowledge = KnowledgeBase::new();
337        knowledge
338            .register_concept(
339                OntologyConcept::new(ConceptId::new("technology").unwrap(), "Conflicting label")
340                    .unwrap(),
341            )
342            .unwrap();
343        let ontology_before = knowledge.stats().ontology;
344        let mut decision_loop = decision_loop();
345        let mut executors = DecisionExecutorRegistry::new();
346
347        assert!(matches!(
348            profile.install(&knowledge, &mut decision_loop, &mut executors),
349            Err(StandardWebDecisionError::Reasoning(_))
350        ));
351        assert_eq!(knowledge.stats().ontology, ontology_before);
352        assert!(decision_loop.rules().is_empty());
353        assert!(decision_loop.planner().is_empty());
354        assert!(decision_loop.verification().passive().is_empty());
355        assert!(executors.is_empty());
356    }
357
358    async fn serve_basic_challenge() -> Url {
359        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
360        let address = listener.local_addr().unwrap();
361        tokio::spawn(async move {
362            let (mut stream, _) = listener.accept().await.unwrap();
363            let mut request = [0_u8; 2048];
364            let bytes = stream.read(&mut request).await.unwrap();
365            assert!(String::from_utf8_lossy(&request[..bytes]).starts_with("HEAD "));
366            stream
367                .write_all(
368                    b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"admin\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
369                )
370                .await
371                .unwrap();
372            stream.shutdown().await.unwrap();
373        });
374        Url::parse(&format!("http://{address}/admin")).unwrap()
375    }
376
377    #[tokio::test]
378    async fn composed_stack_runs_evidence_to_confirmed_outcome() {
379        let target = serve_basic_challenge().await;
380        let subject = EntityId::new(format!("endpoint:{target}")).unwrap();
381        let knowledge = KnowledgeBase::new();
382        knowledge
383            .insert_evidence(Evidence::new(
384                subject.clone(),
385                EvidenceKind::Authentication,
386                KnowledgePredicate::new("http.header", "www-authenticate").unwrap(),
387                EvidenceValue::Text("Basic realm=\"initial\"".to_owned()),
388                EvidenceSource::new("http.evidence", "initial-challenge").unwrap(),
389                ConfidenceScore::MAX,
390            ))
391            .unwrap();
392        let profile = profile(target);
393        let mut decision_loop = decision_loop();
394        let mut executors = DecisionExecutorRegistry::new();
395        profile
396            .install(&knowledge, &mut decision_loop, &mut executors)
397            .unwrap();
398        let mut experience = ExperienceStore::new();
399        let mut session = DecisionSession::new(subject);
400        let planned = decision_loop
401            .plan_next(&knowledge, &experience, &mut session)
402            .unwrap();
403        assert_eq!(
404            planned.plan().steps()[0].action_id(),
405            StandardWebActionKind::HttpBasicAuthBoundary.action_id()
406        );
407
408        let turn = DecisionRunnerAdapter::new(executors)
409            .drive_command(
410                &decision_loop,
411                planned.command(),
412                &knowledge,
413                &mut experience,
414                &mut session,
415            )
416            .await
417            .unwrap();
418        let DecisionRunnerTurn::Outcome { decision, .. } = turn else {
419            panic!("planned execution must produce an outcome turn");
420        };
421
422        assert_eq!(
423            decision.verification().outcome().status(),
424            OutcomeStatus::Success
425        );
426        let hypothesis_id = decision.verification().case().hypothesis_id();
427        assert_eq!(
428            knowledge.hypothesis(hypothesis_id).unwrap().state(),
429            HypothesisState::Confirmed
430        );
431    }
432}