Skip to main content

termivar_scanner/
api_reasoning.rs

1//! Deterministic JSON, GraphQL, and paired-visibility reasoning.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** always/default.
6//! - **Execution:** Surface B support (API reasoning).
7//! - **Default `termivar scan`:** no.
8//! - **Support:** implemented and tested.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! The profile is transport-neutral and opt-in. It recognizes disclosed API
13//! representations and turns one already-paired visibility comparison into a
14//! reviewable boundary hypothesis. It never combines independent principals,
15//! responses, or UI/API observations and never declares a vulnerability.
16
17use serde::Serialize;
18use termivar_core::{
19    ApiEvidencePredicate, ApiKnowledgePredicate, ApiResponseFormat, ApiSurfaceKind,
20    ApiVisibilityBoundaryKind, ApiVisibilityDimension, ApiVisibilityPairKind, ApiVisibilityResult,
21    ConceptId, EvidenceValue, HttpEvidencePredicate, HypothesisState, HypothesisStrength, Ontology,
22    OntologyAxiom, OntologyConcept, OntologyError, PredicateDescriptor, Probability,
23};
24use thiserror::Error;
25
26use crate::{
27    knowledge::KnowledgeBase,
28    rules::{
29        EvidenceAggregation, EvidenceCalibration, EvidenceSelector, Expression,
30        HypothesisConclusion, KnowledgeLayer, ReasoningRule, RuleEngine, RuleEngineError,
31        RuleWrite,
32    },
33};
34
35/// Number of concepts defined by [`StandardApiReasoning`].
36pub const STANDARD_API_CONCEPT_COUNT: usize = 9;
37
38/// Number of semantic axioms defined by [`StandardApiReasoning`].
39pub const STANDARD_API_AXIOM_COUNT: usize = 6;
40
41/// Number of deterministic rules defined by [`StandardApiReasoning`].
42pub const STANDARD_API_RULE_COUNT: usize = 7;
43
44/// Failures while constructing or installing the standard API profile.
45#[derive(Debug, Error)]
46#[non_exhaustive]
47pub enum StandardApiReasoningError {
48    /// An ontology definition or relationship was invalid or conflicted.
49    #[error(transparent)]
50    Ontology(#[from] OntologyError),
51
52    /// A reasoning rule was invalid or conflicted.
53    #[error(transparent)]
54    Rules(#[from] RuleEngineError),
55
56    /// The shared vocabulary contains an API surface that this profile has not
57    /// explicitly mapped to a deterministic rule.
58    #[error("standard API reasoning profile does not support API surface `{surface}`")]
59    UnsupportedApiSurface {
60        /// Stable vocabulary value that was rejected.
61        surface: String,
62    },
63
64    /// The shared vocabulary contains a visibility pair that this profile has
65    /// not explicitly mapped to a deterministic boundary rule.
66    #[error("standard API reasoning profile does not support visibility pair `{pair}`")]
67    UnsupportedVisibilityPair {
68        /// Stable vocabulary value that was rejected.
69        pair: String,
70    },
71}
72
73/// Counts of definitions added by one idempotent profile installation.
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
75pub struct StandardApiInstallReport {
76    concepts_inserted: usize,
77    axioms_inserted: usize,
78    rules_inserted: usize,
79}
80
81impl StandardApiInstallReport {
82    /// Returns the number of newly registered concepts.
83    pub const fn concepts_inserted(self) -> usize {
84        self.concepts_inserted
85    }
86
87    /// Returns the number of newly registered semantic axioms.
88    pub const fn axioms_inserted(self) -> usize {
89        self.axioms_inserted
90    }
91
92    /// Returns the number of newly registered rules.
93    pub const fn rules_inserted(self) -> usize {
94        self.rules_inserted
95    }
96}
97
98/// Validated deterministic JSON/GraphQL and visibility-boundary rule pack.
99///
100/// Generic JSON produces only a response-format hypothesis. GraphQL needs its
101/// official response media type, a route signal, or an explicit paired
102/// comparison. Visibility conclusions require exactly one atomic
103/// [`termivar_core::ApiVisibilityComparison`] evidence record.
104///
105/// # Examples
106///
107/// ```rust
108/// use termivar_scanner::{KnowledgeBase, RuleEngine, StandardApiReasoning};
109///
110/// let knowledge = KnowledgeBase::new();
111/// let mut rules = RuleEngine::new();
112/// let profile = StandardApiReasoning::new()?;
113/// let installed = profile.install(&knowledge, &mut rules)?;
114///
115/// assert_eq!(installed.rules_inserted(), profile.rules().len());
116/// # Ok::<(), Box<dyn std::error::Error>>(())
117/// ```
118#[derive(Debug, Clone)]
119pub struct StandardApiReasoning {
120    concepts: Vec<OntologyConcept>,
121    axioms: Vec<OntologyAxiom>,
122    rules: Vec<ReasoningRule>,
123}
124
125impl StandardApiReasoning {
126    /// Builds and validates the complete ontology and rule set.
127    pub fn new() -> Result<Self, StandardApiReasoningError> {
128        let concepts = standard_concepts()?;
129        let axioms = standard_axioms()?;
130        let rules = standard_rules()?;
131
132        let mut validation = Ontology::new();
133        for concept in &concepts {
134            validation.add_concept(concept.clone())?;
135        }
136        for axiom in &axioms {
137            validation.add_axiom(axiom.clone())?;
138        }
139
140        debug_assert_eq!(concepts.len(), STANDARD_API_CONCEPT_COUNT);
141        debug_assert_eq!(axioms.len(), STANDARD_API_AXIOM_COUNT);
142        debug_assert_eq!(rules.len(), STANDARD_API_RULE_COUNT);
143        Ok(Self {
144            concepts,
145            axioms,
146            rules,
147        })
148    }
149
150    /// Installs the complete profile atomically and idempotently.
151    ///
152    /// Rule identities are preflighted on a clone before ontology state is
153    /// changed. Ontology installation also uses a prospective clone, so a
154    /// conflict leaves both registries unchanged.
155    pub fn install(
156        &self,
157        knowledge: &KnowledgeBase,
158        engine: &mut RuleEngine,
159    ) -> Result<StandardApiInstallReport, StandardApiReasoningError> {
160        let mut prospective_engine = engine.clone();
161        let mut rules_inserted = 0;
162        for rule in &self.rules {
163            rules_inserted += usize::from(matches!(
164                prospective_engine.register(rule.clone())?,
165                RuleWrite::Inserted
166            ));
167        }
168
169        let (concepts_inserted, axioms_inserted) =
170            knowledge.install_ontology_definitions(&self.concepts, &self.axioms)?;
171        *engine = prospective_engine;
172
173        Ok(StandardApiInstallReport {
174            concepts_inserted,
175            axioms_inserted,
176            rules_inserted,
177        })
178    }
179
180    /// Returns concepts in stable declaration order.
181    pub fn concepts(&self) -> &[OntologyConcept] {
182        &self.concepts
183    }
184
185    /// Returns semantic axioms in stable declaration order.
186    pub fn axioms(&self) -> &[OntologyAxiom] {
187        &self.axioms
188    }
189
190    /// Returns rules in stable declaration order.
191    pub fn rules(&self) -> &[ReasoningRule] {
192        &self.rules
193    }
194}
195
196fn standard_concepts() -> Result<Vec<OntologyConcept>, OntologyError> {
197    [
198        ("application-interface", "Application interface"),
199        ("api-interface", "API interface"),
200        ("api-response-format", "API response format"),
201        ("json", "JSON response format"),
202        ("json-http-api", "JSON HTTP API"),
203        ("graphql-api", "GraphQL API"),
204        ("visibility-boundary", "Visibility boundary"),
205        ("ui-api-visibility-boundary", "UI/API visibility boundary"),
206        (
207            "authorization-context-visibility-boundary",
208            "Authorization-context visibility boundary",
209        ),
210    ]
211    .into_iter()
212    .map(|(id, label)| OntologyConcept::new(ConceptId::new(id)?, label))
213    .collect()
214}
215
216fn standard_axioms() -> Result<Vec<OntologyAxiom>, OntologyError> {
217    let is_a = Ontology::relation_id(Ontology::IS_A)?;
218    [
219        ("api-interface", "application-interface"),
220        ("json", "api-response-format"),
221        ("json-http-api", "api-interface"),
222        ("graphql-api", "api-interface"),
223        ("ui-api-visibility-boundary", "visibility-boundary"),
224        (
225            "authorization-context-visibility-boundary",
226            "visibility-boundary",
227        ),
228    ]
229    .into_iter()
230    .map(|(subject, object)| {
231        Ok(OntologyAxiom::new(
232            ConceptId::new(subject)?,
233            is_a.clone(),
234            ConceptId::new(object)?,
235        ))
236    })
237    .collect()
238}
239
240fn standard_rules() -> Result<Vec<ReasoningRule>, StandardApiReasoningError> {
241    Ok(vec![
242        json_response_rule()?,
243        graphql_media_type_rule()?,
244        graphql_route_rule()?,
245        comparison_surface_rule(ApiSurfaceKind::JsonHttp)?,
246        comparison_surface_rule(ApiSurfaceKind::GraphQl)?,
247        visibility_boundary_rule(ApiVisibilityPairKind::UiApi)?,
248        visibility_boundary_rule(ApiVisibilityPairKind::AuthorizationContext)?,
249    ])
250}
251
252fn json_response_rule() -> Result<ReasoningRule, RuleEngineError> {
253    let json_compatible =
254        HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge();
255    ReasoningRule::new(
256        "api.response.json.media-type",
257        Expression::equals(
258            KnowledgeLayer::Evidence,
259            json_compatible.clone(),
260            EvidenceValue::Boolean(true),
261        ),
262        HypothesisConclusion::new(
263            ApiKnowledgePredicate::RESPONSE_FORMAT.into(),
264            ApiResponseFormat::Json.into(),
265            probability(20)?,
266            HypothesisStrength::Weak,
267            HypothesisState::Supported,
268            vec![calibration(
269                EvidenceSelector::equals(json_compatible, EvidenceValue::Boolean(true)),
270                95,
271                5,
272                "Validated media type uses JSON or a +json structured suffix",
273            )?],
274        )?,
275    )
276}
277
278fn graphql_media_type_rule() -> Result<ReasoningRule, RuleEngineError> {
279    let media_type = HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge();
280    let graphql_media_type = EvidenceValue::Text("application/graphql-response+json".to_owned());
281    ReasoningRule::new(
282        "api.surface.graphql.response-media-type",
283        Expression::equals(
284            KnowledgeLayer::Evidence,
285            media_type.clone(),
286            graphql_media_type.clone(),
287        ),
288        HypothesisConclusion::new(
289            ApiKnowledgePredicate::SURFACE_KIND.into(),
290            ApiSurfaceKind::GraphQl.into(),
291            probability(5)?,
292            HypothesisStrength::Strong,
293            HypothesisState::Supported,
294            vec![calibration(
295                EvidenceSelector::equals(media_type, graphql_media_type),
296                99,
297                1,
298                "Content-Type uses the GraphQL response media type",
299            )?],
300        )?,
301    )
302}
303
304fn graphql_route_rule() -> Result<ReasoningRule, RuleEngineError> {
305    let path_segment = HttpEvidencePredicate::REQUEST_PATH_SEGMENT.into_knowledge();
306    let graphql = EvidenceValue::Text("graphql".to_owned());
307    ReasoningRule::new(
308        "api.surface.graphql.route",
309        Expression::equals(
310            KnowledgeLayer::Evidence,
311            path_segment.clone(),
312            graphql.clone(),
313        ),
314        HypothesisConclusion::new(
315            ApiKnowledgePredicate::SURFACE_KIND.into(),
316            ApiSurfaceKind::GraphQl.into(),
317            probability(5)?,
318            HypothesisStrength::Weak,
319            HypothesisState::Supported,
320            vec![calibration(
321                EvidenceSelector::equals(path_segment, graphql),
322                75,
323                25,
324                "Request path uses a conventional GraphQL route token",
325            )?],
326        )?,
327    )
328}
329
330fn comparison_surface_rule(
331    surface: ApiSurfaceKind,
332) -> Result<ReasoningRule, StandardApiReasoningError> {
333    let predicates = comparison_predicates(surface);
334    ReasoningRule::new(
335        comparison_surface_rule_id(surface.as_str())?,
336        any_comparison_dimension(&predicates)?,
337        HypothesisConclusion::new(
338            ApiKnowledgePredicate::SURFACE_KIND.into(),
339            surface.into(),
340            probability(20)?,
341            HypothesisStrength::Strong,
342            HypothesisState::Supported,
343            comparison_calibrations(
344                &predicates,
345                99,
346                1,
347                "A host-paired comparison explicitly declares this API surface",
348            )?,
349        )?,
350    )
351    .map_err(Into::into)
352}
353
354fn comparison_surface_rule_id(surface: &str) -> Result<&'static str, StandardApiReasoningError> {
355    match surface {
356        "json-http-api" => Ok("api.surface.json.paired-comparison"),
357        "graphql-api" => Ok("api.surface.graphql.paired-comparison"),
358        _ => Err(StandardApiReasoningError::UnsupportedApiSurface {
359            surface: surface.to_owned(),
360        }),
361    }
362}
363
364fn visibility_boundary_rule(
365    pair: ApiVisibilityPairKind,
366) -> Result<ReasoningRule, StandardApiReasoningError> {
367    let predicates = [
368        ApiEvidencePredicate::visibility(
369            ApiSurfaceKind::JsonHttp,
370            pair,
371            ApiVisibilityResult::Different,
372        ),
373        ApiEvidencePredicate::visibility(
374            ApiSurfaceKind::GraphQl,
375            pair,
376            ApiVisibilityResult::Different,
377        ),
378    ];
379    let (id, boundary, likelihood_if_true, rationale) =
380        visibility_boundary_rule_spec(pair.as_str())?;
381    ReasoningRule::new(
382        id,
383        any_comparison_dimension(&predicates)?,
384        HypothesisConclusion::new(
385            ApiKnowledgePredicate::VISIBILITY_BOUNDARY.into(),
386            boundary.into(),
387            probability(10)?,
388            HypothesisStrength::Weak,
389            HypothesisState::Supported,
390            comparison_calibrations(
391                &predicates,
392                likelihood_if_true,
393                100 - likelihood_if_true,
394                rationale,
395            )?,
396        )?,
397    )
398    .map_err(Into::into)
399}
400
401fn visibility_boundary_rule_spec(
402    pair: &str,
403) -> Result<(&'static str, ApiVisibilityBoundaryKind, u8, &'static str), StandardApiReasoningError>
404{
405    match pair {
406        "ui-api" => Ok((
407            "api.visibility.ui-api.paired-difference",
408            ApiVisibilityBoundaryKind::UiApi,
409            98,
410            "One atomic same-resource UI/API comparison observed a visibility difference",
411        )),
412        "authorization-context" => Ok((
413            "api.visibility.authorization-context.paired-difference",
414            ApiVisibilityBoundaryKind::AuthorizationContext,
415            99,
416            "One atomic same-resource authorization-context comparison observed a visibility difference",
417        )),
418        _ => Err(StandardApiReasoningError::UnsupportedVisibilityPair {
419            pair: pair.to_owned(),
420        }),
421    }
422}
423
424fn comparison_predicates(surface: ApiSurfaceKind) -> Vec<PredicateDescriptor> {
425    [
426        ApiVisibilityPairKind::UiApi,
427        ApiVisibilityPairKind::AuthorizationContext,
428    ]
429    .into_iter()
430    .flat_map(|pair| {
431        [
432            ApiVisibilityResult::Different,
433            ApiVisibilityResult::Equivalent,
434        ]
435        .iter()
436        .map(move |result| ApiEvidencePredicate::visibility(surface, pair, *result))
437    })
438    .collect()
439}
440
441fn any_comparison_dimension(
442    predicates: &[PredicateDescriptor],
443) -> Result<Expression, RuleEngineError> {
444    Expression::any(
445        predicates
446            .iter()
447            .flat_map(|predicate| {
448                ApiVisibilityDimension::all().into_iter().map(|dimension| {
449                    Expression::equals(
450                        KnowledgeLayer::Evidence,
451                        predicate.into_knowledge(),
452                        dimension.into(),
453                    )
454                })
455            })
456            .collect(),
457    )
458}
459
460fn comparison_calibrations(
461    predicates: &[PredicateDescriptor],
462    likelihood_if_true: u8,
463    likelihood_if_false: u8,
464    rationale: &str,
465) -> Result<Vec<EvidenceCalibration>, RuleEngineError> {
466    predicates
467        .iter()
468        .flat_map(|predicate| {
469            ApiVisibilityDimension::all().into_iter().map(|dimension| {
470                calibration(
471                    EvidenceSelector::equals(predicate.into_knowledge(), dimension.into()),
472                    likelihood_if_true,
473                    likelihood_if_false,
474                    rationale,
475                )
476            })
477        })
478        .collect()
479}
480
481fn calibration(
482    selector: EvidenceSelector,
483    likelihood_if_true: u8,
484    likelihood_if_false: u8,
485    rationale: &str,
486) -> Result<EvidenceCalibration, RuleEngineError> {
487    Ok(EvidenceCalibration::new(
488        selector,
489        probability(likelihood_if_true)?,
490        probability(likelihood_if_false)?,
491        rationale,
492    )?
493    .with_aggregation(EvidenceAggregation::max_contributions(1)?))
494}
495
496fn probability(percent: u8) -> Result<Probability, RuleEngineError> {
497    Ok(Probability::from_percent(percent)?)
498}
499
500#[cfg(test)]
501mod tests {
502    use termivar_core::{
503        ApiVisibilityComparison, ApiVisibilityDimension, ConfidenceScore, EntityId, Evidence,
504        EvidenceKind, EvidenceSource, Hypothesis, KnowledgePredicate, OntologyConcept,
505        OntologyWrite,
506    };
507
508    use super::*;
509    use crate::{KnowledgeWrite, StandardWebReasoning};
510
511    fn endpoint() -> EntityId {
512        EntityId::new("endpoint:https://example.test/graphql").unwrap()
513    }
514
515    fn evidence(predicate: PredicateDescriptor, value: EvidenceValue) -> Evidence {
516        Evidence::new(
517            endpoint(),
518            EvidenceKind::Http,
519            predicate.into_knowledge(),
520            value,
521            EvidenceSource::new("http.evidence", "test-observation").unwrap(),
522            ConfidenceScore::MAX,
523        )
524    }
525
526    fn text_evidence(predicate: PredicateDescriptor, value: &str) -> Evidence {
527        evidence(predicate, EvidenceValue::Text(value.to_owned()))
528    }
529
530    fn hypotheses(knowledge: &KnowledgeBase, subject: &EntityId) -> Vec<Hypothesis> {
531        knowledge
532            .snapshot_for_subject(subject)
533            .hypotheses()
534            .to_vec()
535    }
536
537    fn hypothesis(
538        hypotheses: &[Hypothesis],
539        predicate: PredicateDescriptor,
540        value: EvidenceValue,
541    ) -> Option<&Hypothesis> {
542        hypotheses.iter().find(|hypothesis| {
543            hypothesis.predicate() == &predicate.into_knowledge() && hypothesis.value() == &value
544        })
545    }
546
547    fn apply(knowledge: &KnowledgeBase, subject: &EntityId) -> Vec<Hypothesis> {
548        let mut engine = RuleEngine::new();
549        StandardApiReasoning::new()
550            .unwrap()
551            .install(knowledge, &mut engine)
552            .unwrap();
553        engine.apply(knowledge, subject).unwrap();
554        hypotheses(knowledge, subject)
555    }
556
557    #[test]
558    fn profile_installs_idempotently_and_composes_with_web_profile() {
559        for api_first in [true, false] {
560            let knowledge = KnowledgeBase::new();
561            let mut engine = RuleEngine::new();
562            let api = StandardApiReasoning::new().unwrap();
563            let web = StandardWebReasoning::new().unwrap();
564
565            if api_first {
566                api.install(&knowledge, &mut engine).unwrap();
567                web.install(&knowledge, &mut engine).unwrap();
568            } else {
569                web.install(&knowledge, &mut engine).unwrap();
570                api.install(&knowledge, &mut engine).unwrap();
571            }
572            let repeated = api.install(&knowledge, &mut engine).unwrap();
573
574            assert_eq!(repeated, StandardApiInstallReport::default());
575            assert_eq!(
576                engine.len(),
577                STANDARD_API_RULE_COUNT + crate::STANDARD_WEB_RULE_COUNT
578            );
579            assert!(knowledge
580                .ontology_is_a(
581                    &ConceptId::new("graphql-api").unwrap(),
582                    &ConceptId::new("application-interface").unwrap(),
583                )
584                .unwrap());
585        }
586    }
587
588    #[test]
589    fn standard_api_profile_rule_manifest_is_exact() {
590        let profile = StandardApiReasoning::new().unwrap();
591        let rule_ids: Vec<_> = profile.rules().iter().map(ReasoningRule::id).collect();
592
593        assert_eq!(
594            rule_ids,
595            [
596                "api.response.json.media-type",
597                "api.surface.graphql.response-media-type",
598                "api.surface.graphql.route",
599                "api.surface.json.paired-comparison",
600                "api.surface.graphql.paired-comparison",
601                "api.visibility.ui-api.paired-difference",
602                "api.visibility.authorization-context.paired-difference",
603            ]
604        );
605        assert_eq!(rule_ids.len(), STANDARD_API_RULE_COUNT);
606    }
607
608    #[test]
609    fn every_standard_api_surface_has_one_stable_rule() {
610        for (surface, expected_id) in [
611            (
612                ApiSurfaceKind::JsonHttp,
613                "api.surface.json.paired-comparison",
614            ),
615            (
616                ApiSurfaceKind::GraphQl,
617                "api.surface.graphql.paired-comparison",
618            ),
619        ] {
620            assert_eq!(comparison_surface_rule(surface).unwrap().id(), expected_id);
621            assert_eq!(
622                comparison_surface_rule_id(surface.as_str()).unwrap(),
623                expected_id
624            );
625        }
626    }
627
628    #[test]
629    fn every_standard_visibility_pair_has_one_stable_boundary_rule() {
630        for (pair, expected_id) in [
631            (
632                ApiVisibilityPairKind::UiApi,
633                "api.visibility.ui-api.paired-difference",
634            ),
635            (
636                ApiVisibilityPairKind::AuthorizationContext,
637                "api.visibility.authorization-context.paired-difference",
638            ),
639        ] {
640            assert_eq!(visibility_boundary_rule(pair).unwrap().id(), expected_id);
641            assert_eq!(
642                visibility_boundary_rule_spec(pair.as_str()).unwrap().0,
643                expected_id
644            );
645        }
646    }
647
648    #[test]
649    fn unknown_surface_key_fails_closed_with_typed_error() {
650        assert!(matches!(
651            comparison_surface_rule_id("future-api-surface"),
652            Err(StandardApiReasoningError::UnsupportedApiSurface { surface })
653                if surface == "future-api-surface"
654        ));
655    }
656
657    #[test]
658    fn unknown_visibility_pair_key_fails_closed_with_typed_error() {
659        assert!(matches!(
660            visibility_boundary_rule_spec("future-visibility-pair"),
661            Err(StandardApiReasoningError::UnsupportedVisibilityPair { pair })
662                if pair == "future-visibility-pair"
663        ));
664    }
665
666    #[test]
667    fn generic_json_never_implies_graphql() {
668        let knowledge = KnowledgeBase::new();
669        knowledge
670            .insert_evidence(evidence(
671                HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE,
672                EvidenceValue::Boolean(true),
673            ))
674            .unwrap();
675
676        let hypotheses = apply(&knowledge, &endpoint());
677
678        assert!(hypothesis(
679            &hypotheses,
680            ApiKnowledgePredicate::RESPONSE_FORMAT,
681            ApiResponseFormat::Json.into(),
682        )
683        .is_some());
684        assert!(hypothesis(
685            &hypotheses,
686            ApiKnowledgePredicate::SURFACE_KIND,
687            ApiSurfaceKind::GraphQl.into(),
688        )
689        .is_none());
690    }
691
692    #[test]
693    fn graphql_media_type_is_strong_and_route_signal_is_weak() {
694        let media_knowledge = KnowledgeBase::new();
695        media_knowledge
696            .insert_evidence(text_evidence(
697                HttpEvidencePredicate::RESPONSE_MEDIA_TYPE,
698                "application/graphql-response+json",
699            ))
700            .unwrap();
701        let media = apply(&media_knowledge, &endpoint());
702        assert_eq!(
703            hypothesis(
704                &media,
705                ApiKnowledgePredicate::SURFACE_KIND,
706                ApiSurfaceKind::GraphQl.into(),
707            )
708            .unwrap()
709            .strength(),
710            HypothesisStrength::Strong
711        );
712
713        let route_knowledge = KnowledgeBase::new();
714        route_knowledge
715            .insert_evidence(text_evidence(
716                HttpEvidencePredicate::REQUEST_PATH_SEGMENT,
717                "graphql",
718            ))
719            .unwrap();
720        let route = apply(&route_knowledge, &endpoint());
721        assert_eq!(
722            hypothesis(
723                &route,
724                ApiKnowledgePredicate::SURFACE_KIND,
725                ApiSurfaceKind::GraphQl.into(),
726            )
727            .unwrap()
728            .strength(),
729            HypothesisStrength::Weak
730        );
731    }
732
733    #[test]
734    fn raw_substrings_and_malformed_comparison_values_are_ignored() {
735        let knowledge = KnowledgeBase::new();
736        knowledge
737            .insert_evidence_batch(vec![
738                text_evidence(
739                    HttpEvidencePredicate::HEADER_CONTENT_TYPE,
740                    "application/jsonp; note=application/graphql-response+json",
741                ),
742                text_evidence(
743                    HttpEvidencePredicate::REQUEST_URL,
744                    "https://example.test/?next=/graphql",
745                ),
746                evidence(
747                    ApiEvidencePredicate::JSON_UI_API_DIFFERENCE,
748                    EvidenceValue::Boolean(true),
749                ),
750            ])
751            .unwrap();
752
753        assert!(apply(&knowledge, &endpoint()).is_empty());
754    }
755
756    fn comparison(
757        pair: ApiVisibilityPairKind,
758        result: ApiVisibilityResult,
759        candidate: &str,
760    ) -> ApiVisibilityComparison {
761        ApiVisibilityComparison::new(
762            "comparison-1",
763            ApiSurfaceKind::JsonHttp,
764            pair,
765            result,
766            ApiVisibilityDimension::Fields,
767            "baseline",
768            candidate,
769            "account-record",
770        )
771        .unwrap()
772    }
773
774    #[test]
775    fn one_atomic_difference_produces_only_the_matching_review_boundary() {
776        let comparison = comparison(
777            ApiVisibilityPairKind::AuthorizationContext,
778            ApiVisibilityResult::Different,
779            "member",
780        );
781        let observation = comparison
782            .to_observation("api.visibility", ConfidenceScore::MAX)
783            .unwrap();
784        let evidence_id = observation.evidence().id().clone();
785        let subject = observation.evidence().subject().clone();
786        let resource_scope = observation.resource_scope().clone();
787        let (evidence, relation) = observation.into_parts();
788        let knowledge = KnowledgeBase::new();
789        assert_eq!(
790            knowledge
791                .insert_evidence_with_relation(evidence, relation)
792                .unwrap(),
793            (KnowledgeWrite::Inserted, KnowledgeWrite::Inserted)
794        );
795
796        let hypotheses = apply(&knowledge, &subject);
797        let boundary = hypothesis(
798            &hypotheses,
799            ApiKnowledgePredicate::VISIBILITY_BOUNDARY,
800            ApiVisibilityBoundaryKind::AuthorizationContext.into(),
801        )
802        .unwrap();
803
804        assert_eq!(boundary.state(), HypothesisState::Supported);
805        assert_eq!(boundary.strength(), HypothesisStrength::Weak);
806        assert_eq!(boundary.belief().evidence().len(), 1);
807        assert_eq!(boundary.belief().evidence()[0].evidence_id(), &evidence_id);
808        assert!(hypothesis(
809            &hypotheses,
810            ApiKnowledgePredicate::VISIBILITY_BOUNDARY,
811            ApiVisibilityBoundaryKind::UiApi.into(),
812        )
813        .is_none());
814        assert!(knowledge.snapshot_for_subject(&subject).facts().is_empty());
815        assert_eq!(knowledge.relations_from(&subject)[0].to(), &resource_scope);
816    }
817
818    #[test]
819    fn equivalent_comparison_never_creates_a_negative_or_boundary_claim() {
820        let comparison = comparison(
821            ApiVisibilityPairKind::UiApi,
822            ApiVisibilityResult::Equivalent,
823            "api",
824        );
825        let evidence = comparison
826            .to_evidence("api.visibility", ConfidenceScore::MAX)
827            .unwrap();
828        let subject = evidence.subject().clone();
829        let knowledge = KnowledgeBase::new();
830        knowledge.insert_evidence(evidence).unwrap();
831
832        let hypotheses = apply(&knowledge, &subject);
833
834        assert!(hypotheses.iter().all(|hypothesis| !matches!(
835            hypothesis.state(),
836            HypothesisState::Confirmed | HypothesisState::Rejected
837        )));
838        assert!(hypotheses.iter().all(|hypothesis| hypothesis.predicate()
839            != &ApiKnowledgePredicate::VISIBILITY_BOUNDARY.into_knowledge()));
840        assert!(hypothesis(
841            &hypotheses,
842            ApiKnowledgePredicate::SURFACE_KIND,
843            ApiSurfaceKind::JsonHttp.into(),
844        )
845        .is_some());
846    }
847
848    #[test]
849    fn comparison_subjects_isolate_principal_contexts() {
850        let member = comparison(
851            ApiVisibilityPairKind::AuthorizationContext,
852            ApiVisibilityResult::Different,
853            "member",
854        )
855        .to_evidence("api.visibility", ConfidenceScore::MAX)
856        .unwrap();
857        let admin = comparison(
858            ApiVisibilityPairKind::AuthorizationContext,
859            ApiVisibilityResult::Equivalent,
860            "admin",
861        )
862        .to_evidence("api.visibility", ConfidenceScore::MAX)
863        .unwrap();
864        assert_ne!(member.subject(), admin.subject());
865        let member_subject = member.subject().clone();
866        let admin_subject = admin.subject().clone();
867        let knowledge = KnowledgeBase::new();
868        knowledge
869            .insert_evidence_batch(vec![member, admin])
870            .unwrap();
871
872        let member_hypotheses = apply(&knowledge, &member_subject);
873        let admin_hypotheses = apply(&knowledge, &admin_subject);
874
875        assert!(hypothesis(
876            &member_hypotheses,
877            ApiKnowledgePredicate::VISIBILITY_BOUNDARY,
878            ApiVisibilityBoundaryKind::AuthorizationContext.into(),
879        )
880        .is_some());
881        assert!(hypothesis(
882            &admin_hypotheses,
883            ApiKnowledgePredicate::VISIBILITY_BOUNDARY,
884            ApiVisibilityBoundaryKind::AuthorizationContext.into(),
885        )
886        .is_none());
887    }
888
889    #[test]
890    fn rule_conflict_preflight_leaves_ontology_unchanged() {
891        let knowledge = KnowledgeBase::new();
892        let mut engine = RuleEngine::new();
893        let profile = StandardApiReasoning::new().unwrap();
894        let conflict_source = HttpEvidencePredicate::HEADER_SERVER.into_knowledge();
895        engine
896            .register(
897                ReasoningRule::new(
898                    profile.rules()[0].id(),
899                    Expression::exists(KnowledgeLayer::Evidence, conflict_source.clone()),
900                    HypothesisConclusion::new(
901                        KnowledgePredicate::new("conflict", "claim").unwrap(),
902                        EvidenceValue::Boolean(true),
903                        probability(50).unwrap(),
904                        HypothesisStrength::Weak,
905                        HypothesisState::Supported,
906                        vec![calibration(
907                            EvidenceSelector::exists(conflict_source),
908                            60,
909                            40,
910                            "test conflict",
911                        )
912                        .unwrap()],
913                    )
914                    .unwrap(),
915                )
916                .unwrap(),
917            )
918            .unwrap();
919        let rules_before = engine.len();
920
921        assert!(profile.install(&knowledge, &mut engine).is_err());
922        assert_eq!(engine.len(), rules_before);
923        assert_eq!(knowledge.ontology_snapshot().stats().concepts, 0);
924    }
925
926    #[test]
927    fn ontology_conflict_leaves_rule_registry_unchanged() {
928        let knowledge = KnowledgeBase::new();
929        assert_eq!(
930            knowledge
931                .register_concept(
932                    OntologyConcept::new(
933                        ConceptId::new("api-interface").unwrap(),
934                        "Conflicting API label",
935                    )
936                    .unwrap(),
937                )
938                .unwrap(),
939            OntologyWrite::Inserted
940        );
941        let mut engine = RuleEngine::new();
942
943        assert!(StandardApiReasoning::new()
944            .unwrap()
945            .install(&knowledge, &mut engine)
946            .is_err());
947        assert!(engine.is_empty());
948    }
949}