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}