Skip to main content

termivar_scanner/api_observation/
ingest.rs

1use termivar_core::{ApiVisibilityObservation, EntityId, Evidence};
2
3use crate::{
4    api_observation::{
5        model::{ApiObservationCommitReceipt, ApiObservationError, ApiObservationReceipt},
6        MAX_API_VISIBILITY_SOURCE_COMPONENT_BYTES,
7    },
8    knowledge::KnowledgeBase,
9    rules::RuleEngine,
10};
11
12/// Accepts one host-paired visibility observation and applies installed rules.
13///
14/// The expected resource is checked before any write. Evidence and its sole
15/// `api.visibility.resource-scope` relation are then inserted atomically, and
16/// rules are applied to the isolated comparison subject. If reasoning fails,
17/// the observation pair remains committed and the error carries its
18/// receipt; callers must not infer rollback from a failed return value. The
19/// host remains responsible for persistence beyond this in-memory store.
20///
21/// This boundary validates scope and canonical storage shape, not producer
22/// identity or truth. Predicate names and deterministic digests are public and
23/// are not signatures. The host must authenticate the producer, authorize the
24/// comparison, and keep raw credentials and response values outside this API.
25///
26/// # Examples
27///
28/// ```rust
29/// use termivar_core::{
30///     ApiSurfaceKind, ApiVisibilityComparison, ApiVisibilityDimension,
31///     ApiVisibilityPairKind, ApiVisibilityResult, ConfidenceScore, EntityId,
32/// };
33/// use termivar_scanner::{
34///     KnowledgeBase, RuleEngine, StandardApiReasoning,
35///     ingest_api_visibility_observation,
36/// };
37///
38/// let resource = EntityId::new("resource:account-42")?;
39/// let observation = ApiVisibilityComparison::new(
40///     "comparison-17",
41///     ApiSurfaceKind::JsonHttp,
42///     ApiVisibilityPairKind::AuthorizationContext,
43///     ApiVisibilityResult::Different,
44///     ApiVisibilityDimension::Fields,
45///     "anonymous-view",
46///     "member-view",
47///     resource.as_str(),
48/// )?
49/// .with_observed_at_ms(1_800_000_000_000)
50/// .to_observation("host.api-comparator", ConfidenceScore::MAX)?;
51/// let knowledge = KnowledgeBase::new();
52/// let mut rules = RuleEngine::new();
53/// StandardApiReasoning::new()?.install(&knowledge, &mut rules)?;
54///
55/// let receipt = ingest_api_visibility_observation(
56///     observation,
57///     &resource,
58///     &knowledge,
59///     &rules,
60/// )?;
61/// assert_eq!(receipt.commit().resource_scope(), &resource);
62/// # Ok::<(), Box<dyn std::error::Error>>(())
63/// ```
64pub fn ingest_api_visibility_observation(
65    observation: ApiVisibilityObservation,
66    expected_resource: &EntityId,
67    knowledge: &KnowledgeBase,
68    rules: &RuleEngine,
69) -> Result<ApiObservationReceipt, ApiObservationError> {
70    if observation.resource_scope() != expected_resource {
71        return Err(ApiObservationError::ResourceMismatch {
72            expected: expected_resource.clone(),
73            actual: observation.resource_scope().clone(),
74        });
75    }
76    validate_observation_bounds(observation.evidence())?;
77
78    let comparison_subject = observation.evidence().subject().clone();
79    let resource_scope = observation.resource_scope().clone();
80    let evidence_id = observation.evidence().id().clone();
81    let relation_id = observation.scope_relation().id().clone();
82    let (evidence, relation) = observation.into_parts();
83    let (evidence_write, relation_write) =
84        knowledge.insert_evidence_with_relation(evidence, relation)?;
85    let commit = ApiObservationCommitReceipt {
86        comparison_subject,
87        resource_scope,
88        evidence_id,
89        relation_id,
90        evidence_write,
91        relation_write,
92    };
93
94    rules
95        .apply(knowledge, commit.comparison_subject())
96        .map(|applications| ApiObservationReceipt {
97            commit: commit.clone(),
98            applications,
99        })
100        .map_err(|source| ApiObservationError::ReasoningAfterCommit {
101            commit: Box::new(commit),
102            source,
103        })
104}
105
106fn validate_observation_bounds(evidence: &Evidence) -> Result<(), ApiObservationError> {
107    let actual = evidence.source().component().len();
108    if actual > MAX_API_VISIBILITY_SOURCE_COMPONENT_BYTES {
109        return Err(ApiObservationError::ObservationLimitExceeded {
110            field: "source.component",
111            actual,
112            maximum: MAX_API_VISIBILITY_SOURCE_COMPONENT_BYTES,
113        });
114    }
115    Ok(())
116}