Skip to main content

termivar_scanner/
api_observation.rs

1//! Host-facing ingestion and projection for paired API visibility observations.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** always/default.
6//! - **Execution:** Surface B support (paired API visibility workflow); host-facing.
7//! - **Default `termivar scan`:** no.
8//! - **Support:** implemented and tested.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! This module bridges the evidence and decision engines without moving
13//! network, credential, comparison, or planning policy into reasoning. The
14//! authorized host establishes that two views describe the same logical
15//! resource before it constructs an [`termivar_core::ApiVisibilityObservation`].
16
17use crate::{knowledge::MAX_KNOWLEDGE_RELATION_ID_BYTES, rules::RuleEngineError};
18
19mod cursor;
20mod ingest;
21mod model;
22mod query;
23mod review;
24
25pub use cursor::ApiVisibilityReviewCursor;
26pub use ingest::ingest_api_visibility_observation;
27pub use model::{ApiObservationCommitReceipt, ApiObservationError, ApiObservationReceipt};
28pub use query::{
29    api_visibility_reviews_for_resource, api_visibility_reviews_for_resource_v2,
30    ApiVisibilityReviewPage, ApiVisibilityReviewQuery,
31};
32#[cfg(feature = "scanning")]
33pub(crate) use review::api_visibility_review_for_commit;
34pub use review::{ApiVisibilityReview, ApiVisibilityReviewDisposition};
35
36#[cfg(test)]
37use crate::{
38    knowledge::{KnowledgeBase, KnowledgeBaseError, KnowledgeWrite},
39    rules::{hypothesis_id_for_rule, RuleApplication, RuleEngine},
40};
41#[cfg(test)]
42use termivar_core::{
43    ApiEvidencePredicate, ApiKnowledgePredicate, ApiVisibilityBoundaryKind, ApiVisibilityDimension,
44    ApiVisibilityObservation, ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind,
45    EvidenceValue, Hypothesis, KnowledgePredicate, RelationId, RelationKind,
46};
47
48const API_VISIBILITY_RELATION: &str = "api.visibility.resource-scope";
49const API_VISIBILITY_EVIDENCE_KIND: &str = "api.visibility-comparison";
50const API_VISIBILITY_SOURCE_METHOD: &str = "paired-api-visibility";
51const COMPARISON_SUBJECT_PREFIX: &str = "api-comparison:";
52const COMPARISON_EVIDENCE_PREFIX: &str = "api-comparison-evidence:";
53const COMPARISON_RELATION_PREFIX: &str = "api-comparison-scope:";
54const UI_API_BOUNDARY_RULE: &str = "api.visibility.ui-api.paired-difference";
55const AUTHORIZATION_BOUNDARY_RULE: &str = "api.visibility.authorization-context.paired-difference";
56const API_VISIBILITY_REVIEW_CURSOR_PREFIX: &str = "venom-api-review-v2:";
57const API_VISIBILITY_REVIEW_CURSOR_DOMAIN: &[u8] = b"venom.api-visibility.review-cursor.v2\0";
58const API_VISIBILITY_REVIEW_RESOURCE_DIGEST_HEX_BYTES: usize = 64;
59
60/// Default number of incoming resource relations scanned by one review page.
61pub const DEFAULT_API_VISIBILITY_REVIEW_SCAN_LIMIT: u16 = 128;
62/// Hard ceiling for incoming resource relations scanned by one review page.
63pub const HARD_MAX_API_VISIBILITY_REVIEW_SCAN_LIMIT: u16 = 1_024;
64/// Hard byte ceiling for the producer component stored in a reviewable observation.
65pub const MAX_API_VISIBILITY_SOURCE_COMPONENT_BYTES: usize = 256;
66/// Hard byte ceiling for one boundary-hypothesis explanation in a review page.
67pub const MAX_API_VISIBILITY_REVIEW_RATIONALE_BYTES: usize = 1_024;
68/// Hard byte ceiling for one serialized resource-bound review cursor.
69pub const MAX_API_VISIBILITY_REVIEW_CURSOR_BYTES: usize = API_VISIBILITY_REVIEW_CURSOR_PREFIX.len()
70    + API_VISIBILITY_REVIEW_RESOURCE_DIGEST_HEX_BYTES
71    + 1
72    + (MAX_KNOWLEDGE_RELATION_ID_BYTES * 2);
73
74impl ApiObservationError {
75    /// Returns the committed observation receipt when failure happened post-commit.
76    pub fn committed_observation(&self) -> Option<&ApiObservationCommitReceipt> {
77        match self {
78            Self::ReasoningAfterCommit { commit, .. } => Some(commit),
79            Self::ResourceMismatch { .. }
80            | Self::ZeroReviewScanLimit
81            | Self::ReviewScanLimitExceeded { .. }
82            | Self::ReviewCursorTooLong { .. }
83            | Self::ResourceBoundReviewCursorTooLong { .. }
84            | Self::InvalidResourceBoundReviewCursor { .. }
85            | Self::UnsupportedResourceBoundReviewCursorVersion
86            | Self::ResourceBoundReviewCursorMismatch
87            | Self::ObservationLimitExceeded { .. }
88            | Self::Knowledge(_) => None,
89        }
90    }
91
92    /// Takes the committed receipt without cloning it.
93    pub fn into_committed_observation(self) -> Option<ApiObservationCommitReceipt> {
94        match self {
95            Self::ReasoningAfterCommit { commit, .. } => Some(*commit),
96            Self::ResourceMismatch { .. }
97            | Self::ZeroReviewScanLimit
98            | Self::ReviewScanLimitExceeded { .. }
99            | Self::ReviewCursorTooLong { .. }
100            | Self::ResourceBoundReviewCursorTooLong { .. }
101            | Self::InvalidResourceBoundReviewCursor { .. }
102            | Self::UnsupportedResourceBoundReviewCursorVersion
103            | Self::ResourceBoundReviewCursorMismatch
104            | Self::ObservationLimitExceeded { .. }
105            | Self::Knowledge(_) => None,
106        }
107    }
108
109    /// Returns the post-commit reasoning error, when applicable.
110    pub fn reasoning_source(&self) -> Option<&RuleEngineError> {
111        match self {
112            Self::ReasoningAfterCommit { source, .. } => Some(source),
113            Self::ResourceMismatch { .. }
114            | Self::ZeroReviewScanLimit
115            | Self::ReviewScanLimitExceeded { .. }
116            | Self::ReviewCursorTooLong { .. }
117            | Self::ResourceBoundReviewCursorTooLong { .. }
118            | Self::InvalidResourceBoundReviewCursor { .. }
119            | Self::UnsupportedResourceBoundReviewCursorVersion
120            | Self::ResourceBoundReviewCursorMismatch
121            | Self::ObservationLimitExceeded { .. }
122            | Self::Knowledge(_) => None,
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use termivar_core::{
130        ApiSurfaceKind, ApiVisibilityComparison, ApiVisibilityPairKind, ApiVisibilityResult,
131        BayesianEvidence, EvidenceSource, HypothesisState, HypothesisStrength, Probability,
132    };
133
134    use super::*;
135    use crate::{
136        api_reasoning::StandardApiReasoning,
137        rules::{
138            EvidenceCalibration, EvidenceSelector, Expression, HypothesisConclusion,
139            KnowledgeLayer, ReasoningRule,
140        },
141    };
142
143    fn resource() -> EntityId {
144        EntityId::new("resource:account-42").unwrap()
145    }
146
147    fn comparison(
148        id: &str,
149        result: ApiVisibilityResult,
150        pair: ApiVisibilityPairKind,
151    ) -> ApiVisibilityObservation {
152        comparison_with_source(id, result, pair, "host.api-comparator")
153    }
154
155    fn comparison_with_source(
156        id: &str,
157        result: ApiVisibilityResult,
158        pair: ApiVisibilityPairKind,
159        source_component: impl Into<String>,
160    ) -> ApiVisibilityObservation {
161        ApiVisibilityComparison::new(
162            id,
163            ApiSurfaceKind::JsonHttp,
164            pair,
165            result,
166            ApiVisibilityDimension::Fields,
167            "anonymous-view",
168            "member-view",
169            resource().as_str(),
170        )
171        .unwrap()
172        .with_observed_at_ms(1_000)
173        .to_observation(source_component, ConfidenceScore::MAX)
174        .unwrap()
175    }
176
177    fn installed() -> (KnowledgeBase, RuleEngine) {
178        let knowledge = KnowledgeBase::new();
179        let mut rules = RuleEngine::new();
180        StandardApiReasoning::new()
181            .unwrap()
182            .install(&knowledge, &mut rules)
183            .unwrap();
184        (knowledge, rules)
185    }
186
187    fn insert_forged_boundary(
188        knowledge: &KnowledgeBase,
189        evidence: &Evidence,
190        rule_id: &str,
191        boundary: ApiVisibilityBoundaryKind,
192    ) {
193        let mut hypothesis = Hypothesis::with_id(
194            hypothesis_id_for_rule(rule_id, evidence.subject()),
195            evidence.subject().clone(),
196            ApiKnowledgePredicate::VISIBILITY_BOUNDARY.into_knowledge(),
197            EvidenceValue::from(boundary),
198            Probability::from_percent(10).unwrap(),
199        )
200        .unwrap();
201        hypothesis
202            .observe(
203                BayesianEvidence::new(
204                    evidence.id().clone(),
205                    Probability::from_percent(98).unwrap(),
206                    Probability::from_percent(2).unwrap(),
207                    "deliberately forged boundary",
208                )
209                .unwrap(),
210            )
211            .unwrap();
212        hypothesis.set_strength(HypothesisStrength::Weak);
213        hypothesis.set_state(HypothesisState::Supported);
214        knowledge.upsert_hypothesis(hypothesis).unwrap();
215    }
216
217    #[test]
218    fn different_comparison_commits_and_projects_only_the_boundary() {
219        let (knowledge, rules) = installed();
220        let receipt = ingest_api_visibility_observation(
221            comparison(
222                "different",
223                ApiVisibilityResult::Different,
224                ApiVisibilityPairKind::AuthorizationContext,
225            ),
226            &resource(),
227            &knowledge,
228            &rules,
229        )
230        .unwrap();
231
232        assert_eq!(receipt.commit().evidence_write(), KnowledgeWrite::Inserted);
233        assert_eq!(receipt.commit().relation_write(), KnowledgeWrite::Inserted);
234        let page = api_visibility_reviews_for_resource(
235            &knowledge,
236            &resource(),
237            &ApiVisibilityReviewQuery::default(),
238        );
239        let reviews = page.reviews();
240        assert_eq!(reviews.len(), 1);
241        assert_eq!(
242            reviews[0].disposition(),
243            ApiVisibilityReviewDisposition::AwaitHumanReview
244        );
245        assert_eq!(reviews[0].boundary_hypotheses().len(), 1);
246        let boundary = &reviews[0].boundary_hypotheses()[0];
247        assert_eq!(
248            boundary.value(),
249            &EvidenceValue::from(ApiVisibilityBoundaryKind::AuthorizationContext)
250        );
251        assert_eq!(boundary.state(), HypothesisState::Supported);
252        assert!(knowledge
253            .hypotheses_for_subject(receipt.commit().comparison_subject())
254            .iter()
255            .any(|hypothesis| hypothesis.predicate()
256                == &ApiKnowledgePredicate::SURFACE_KIND.into_knowledge()));
257    }
258
259    #[test]
260    fn equivalent_comparison_has_a_surface_but_no_boundary() {
261        let (knowledge, rules) = installed();
262        let receipt = ingest_api_visibility_observation(
263            comparison(
264                "equivalent",
265                ApiVisibilityResult::Equivalent,
266                ApiVisibilityPairKind::UiApi,
267            ),
268            &resource(),
269            &knowledge,
270            &rules,
271        )
272        .unwrap();
273
274        let hypotheses = knowledge.hypotheses_for_subject(receipt.commit().comparison_subject());
275        assert!(hypotheses.iter().any(|hypothesis| hypothesis.predicate()
276            == &ApiKnowledgePredicate::SURFACE_KIND.into_knowledge()));
277        assert!(!hypotheses.iter().any(|hypothesis| hypothesis.predicate()
278            == &ApiKnowledgePredicate::VISIBILITY_BOUNDARY.into_knowledge()));
279        let page = api_visibility_reviews_for_resource(
280            &knowledge,
281            &resource(),
282            &ApiVisibilityReviewQuery::default(),
283        );
284        let reviews = page.reviews();
285        assert_eq!(reviews.len(), 1);
286        assert_eq!(
287            reviews[0].disposition(),
288            ApiVisibilityReviewDisposition::NoDifferenceObserved
289        );
290        assert!(reviews[0].boundary_hypotheses().is_empty());
291    }
292
293    #[test]
294    fn difference_without_reasoning_remains_explicitly_unresolved() {
295        let knowledge = KnowledgeBase::new();
296        let rules = RuleEngine::new();
297        ingest_api_visibility_observation(
298            comparison(
299                "difference-without-rules",
300                ApiVisibilityResult::Different,
301                ApiVisibilityPairKind::AuthorizationContext,
302            ),
303            &resource(),
304            &knowledge,
305            &rules,
306        )
307        .unwrap();
308
309        let page = api_visibility_reviews_for_resource(
310            &knowledge,
311            &resource(),
312            &ApiVisibilityReviewQuery::default(),
313        );
314        assert_eq!(page.reviews().len(), 1);
315        assert!(page.reviews()[0].boundary_hypotheses().is_empty());
316        assert_eq!(
317            page.reviews()[0].disposition(),
318            ApiVisibilityReviewDisposition::UnresolvedDifference
319        );
320    }
321
322    #[test]
323    fn equivalent_evidence_cannot_be_projected_as_a_forged_boundary() {
324        let (knowledge, rules) = installed();
325        let receipt = ingest_api_visibility_observation(
326            comparison(
327                "equivalent-forgery",
328                ApiVisibilityResult::Equivalent,
329                ApiVisibilityPairKind::UiApi,
330            ),
331            &resource(),
332            &knowledge,
333            &rules,
334        )
335        .unwrap();
336        let evidence = knowledge.evidence(receipt.commit().evidence_id()).unwrap();
337        insert_forged_boundary(
338            &knowledge,
339            &evidence,
340            UI_API_BOUNDARY_RULE,
341            ApiVisibilityBoundaryKind::UiApi,
342        );
343
344        let page = api_visibility_reviews_for_resource(
345            &knowledge,
346            &resource(),
347            &ApiVisibilityReviewQuery::default(),
348        );
349        assert_eq!(page.reviews().len(), 1);
350        assert!(page.reviews()[0].boundary_hypotheses().is_empty());
351    }
352
353    #[test]
354    fn authorization_evidence_ignores_a_forged_ui_boundary() {
355        let (knowledge, rules) = installed();
356        let receipt = ingest_api_visibility_observation(
357            comparison(
358                "pair-forgery",
359                ApiVisibilityResult::Different,
360                ApiVisibilityPairKind::AuthorizationContext,
361            ),
362            &resource(),
363            &knowledge,
364            &rules,
365        )
366        .unwrap();
367        let evidence = knowledge.evidence(receipt.commit().evidence_id()).unwrap();
368        insert_forged_boundary(
369            &knowledge,
370            &evidence,
371            UI_API_BOUNDARY_RULE,
372            ApiVisibilityBoundaryKind::UiApi,
373        );
374
375        let page = api_visibility_reviews_for_resource(
376            &knowledge,
377            &resource(),
378            &ApiVisibilityReviewQuery::default(),
379        );
380        let boundaries = page.reviews()[0].boundary_hypotheses();
381        assert_eq!(boundaries.len(), 1);
382        assert_eq!(
383            boundaries[0].value(),
384            &EvidenceValue::from(ApiVisibilityBoundaryKind::AuthorizationContext)
385        );
386    }
387
388    #[test]
389    fn nonweak_boundary_is_not_promoted_to_human_review() {
390        let knowledge = KnowledgeBase::new();
391        let rules = RuleEngine::new();
392        let receipt = ingest_api_visibility_observation(
393            comparison(
394                "strong-boundary-forgery",
395                ApiVisibilityResult::Different,
396                ApiVisibilityPairKind::AuthorizationContext,
397            ),
398            &resource(),
399            &knowledge,
400            &rules,
401        )
402        .unwrap();
403        let evidence = knowledge.evidence(receipt.commit().evidence_id()).unwrap();
404        insert_forged_boundary(
405            &knowledge,
406            &evidence,
407            AUTHORIZATION_BOUNDARY_RULE,
408            ApiVisibilityBoundaryKind::AuthorizationContext,
409        );
410        let mut forged = knowledge
411            .hypotheses_for_subject(evidence.subject())
412            .into_iter()
413            .next()
414            .unwrap();
415        forged.set_strength(HypothesisStrength::Strong);
416        knowledge.upsert_hypothesis(forged).unwrap();
417
418        let page = api_visibility_reviews_for_resource(
419            &knowledge,
420            &resource(),
421            &ApiVisibilityReviewQuery::default(),
422        );
423        assert!(page.reviews()[0].boundary_hypotheses().is_empty());
424        assert_eq!(
425            page.reviews()[0].disposition(),
426            ApiVisibilityReviewDisposition::UnresolvedDifference
427        );
428    }
429
430    #[test]
431    fn exact_replay_is_idempotent_across_storage_and_reasoning() {
432        let (knowledge, rules) = installed();
433        let observation = comparison(
434            "replay",
435            ApiVisibilityResult::Different,
436            ApiVisibilityPairKind::UiApi,
437        );
438        ingest_api_visibility_observation(observation.clone(), &resource(), &knowledge, &rules)
439            .unwrap();
440        let replay =
441            ingest_api_visibility_observation(observation, &resource(), &knowledge, &rules)
442                .unwrap();
443
444        assert_eq!(replay.commit().evidence_write(), KnowledgeWrite::Unchanged);
445        assert_eq!(replay.commit().relation_write(), KnowledgeWrite::Unchanged);
446        assert!(replay
447            .applications()
448            .iter()
449            .filter_map(RuleApplication::write)
450            .all(|write| write == KnowledgeWrite::Unchanged));
451        assert_eq!(
452            api_visibility_reviews_for_resource(
453                &knowledge,
454                &resource(),
455                &ApiVisibilityReviewQuery::default(),
456            )
457            .reviews()
458            .len(),
459            1
460        );
461    }
462
463    #[test]
464    fn observation_and_review_debug_output_redacts_opaque_identifiers() {
465        let (knowledge, rules) = installed();
466        let receipt = ingest_api_visibility_observation(
467            comparison(
468                "debug-redaction",
469                ApiVisibilityResult::Different,
470                ApiVisibilityPairKind::UiApi,
471            ),
472            &resource(),
473            &knowledge,
474            &rules,
475        )
476        .unwrap();
477        let receipt_debug = format!("{receipt:?}");
478        for opaque in [
479            receipt.commit().comparison_subject().as_str(),
480            receipt.commit().resource_scope().as_str(),
481            receipt.commit().evidence_id().as_str(),
482            receipt.commit().relation_id().as_str(),
483        ] {
484            assert!(!receipt_debug.contains(opaque));
485        }
486        assert!(receipt_debug.contains("application_count"));
487        assert!(receipt_debug.contains("<redacted>"));
488
489        let page = api_visibility_reviews_for_resource(
490            &knowledge,
491            &resource(),
492            &ApiVisibilityReviewQuery::default(),
493        );
494        let review = &page.reviews()[0];
495        let review_debug = format!("{review:?}");
496        let page_debug = format!("{page:?}");
497        for debug in [&review_debug, &page_debug] {
498            for opaque in [
499                review.resource_scope().as_str(),
500                review.comparison_subject().as_str(),
501                review.relation_id().as_str(),
502                review.evidence().id().as_str(),
503            ] {
504                assert!(!debug.contains(opaque));
505            }
506            assert!(debug.contains("<redacted>"));
507        }
508    }
509
510    #[test]
511    fn resource_mismatch_fails_before_any_write() {
512        let (knowledge, rules) = installed();
513        let expected = EntityId::new("resource:another-account").unwrap();
514        let error = ingest_api_visibility_observation(
515            comparison(
516                "wrong-resource",
517                ApiVisibilityResult::Different,
518                ApiVisibilityPairKind::UiApi,
519            ),
520            &expected,
521            &knowledge,
522            &rules,
523        )
524        .unwrap_err();
525
526        assert!(matches!(
527            &error,
528            ApiObservationError::ResourceMismatch { .. }
529        ));
530        let display = error.to_string();
531        let debug = format!("{error:?}");
532        for opaque in [expected.as_str(), "resource:account-42"] {
533            assert!(!display.contains(opaque));
534            assert!(!debug.contains(opaque));
535        }
536        assert_eq!(
537            display,
538            "API visibility observation resource does not match expected resource"
539        );
540        assert!(debug.contains("<redacted>"));
541        assert!(error.committed_observation().is_none());
542        let stats = knowledge.stats();
543        assert_eq!(stats.evidence, 0);
544        assert_eq!(stats.relations, 0);
545        assert_eq!(stats.hypotheses, 0);
546    }
547
548    #[test]
549    fn post_commit_reasoning_error_carries_the_commit_receipt() {
550        let knowledge = KnowledgeBase::new();
551        let mut rules = RuleEngine::new();
552        let comparison_predicate = ApiEvidencePredicate::JSON_UI_API_DIFFERENCE.into_knowledge();
553        let unrelated = KnowledgePredicate::new("test", "unrelated").unwrap();
554        rules
555            .register(
556                ReasoningRule::new(
557                    "000.invalid-calibration",
558                    Expression::exists(KnowledgeLayer::Evidence, comparison_predicate),
559                    HypothesisConclusion::new(
560                        KnowledgePredicate::new("test", "result").unwrap(),
561                        EvidenceValue::Boolean(true),
562                        Probability::from_percent(10).unwrap(),
563                        termivar_core::HypothesisStrength::Weak,
564                        HypothesisState::Supported,
565                        vec![EvidenceCalibration::new(
566                            EvidenceSelector::exists(unrelated),
567                            Probability::from_percent(90).unwrap(),
568                            Probability::from_percent(10).unwrap(),
569                            "deliberately cannot bind the matched comparison",
570                        )
571                        .unwrap()],
572                    )
573                    .unwrap(),
574                )
575                .unwrap(),
576            )
577            .unwrap();
578        let error = ingest_api_visibility_observation(
579            comparison(
580                "post-commit-error",
581                ApiVisibilityResult::Different,
582                ApiVisibilityPairKind::UiApi,
583            ),
584            &resource(),
585            &knowledge,
586            &rules,
587        )
588        .unwrap_err();
589
590        assert!(matches!(
591            error.reasoning_source(),
592            Some(RuleEngineError::MissingCalibratedEvidence { .. })
593        ));
594        let commit = error.committed_observation().unwrap();
595        assert_eq!(commit.evidence_write(), KnowledgeWrite::Inserted);
596        assert_eq!(commit.relation_write(), KnowledgeWrite::Inserted);
597        assert!(knowledge.evidence(commit.evidence_id()).is_some());
598        assert!(knowledge.relation(commit.relation_id()).is_some());
599    }
600
601    #[test]
602    fn resource_projection_is_stable_and_ignores_noncanonical_relations() {
603        let (knowledge, rules) = installed();
604        for observation in [
605            comparison(
606                "second",
607                ApiVisibilityResult::Different,
608                ApiVisibilityPairKind::AuthorizationContext,
609            ),
610            comparison(
611                "first",
612                ApiVisibilityResult::Equivalent,
613                ApiVisibilityPairKind::UiApi,
614            ),
615        ] {
616            ingest_api_visibility_observation(observation, &resource(), &knowledge, &rules)
617                .unwrap();
618        }
619
620        let unrelated = Evidence::new(
621            EntityId::new("not-a-comparison").unwrap(),
622            EvidenceKind::Custom(API_VISIBILITY_EVIDENCE_KIND.to_owned()),
623            ApiEvidencePredicate::JSON_UI_API_DIFFERENCE.into_knowledge(),
624            EvidenceValue::from(ApiVisibilityDimension::Fields),
625            EvidenceSource::new("forged", API_VISIBILITY_SOURCE_METHOD).unwrap(),
626            ConfidenceScore::MAX,
627        );
628        let unrelated_id = unrelated.id().clone();
629        let unrelated_subject = unrelated.subject().clone();
630        knowledge.insert_evidence(unrelated).unwrap();
631        knowledge
632            .upsert_relation(termivar_core::KnowledgeRelation::new(
633                unrelated_subject,
634                resource(),
635                RelationKind::Custom(API_VISIBILITY_RELATION.to_owned()),
636                ConfidenceScore::MAX,
637                unrelated_id,
638            ))
639            .unwrap();
640
641        let query = ApiVisibilityReviewQuery::default();
642        let first = api_visibility_reviews_for_resource(&knowledge, &resource(), &query);
643        let second = api_visibility_reviews_for_resource(&knowledge, &resource(), &query);
644        assert_eq!(first, second);
645        assert_eq!(first.reviews().len(), 2);
646        assert!(first
647            .reviews()
648            .windows(2)
649            .all(|pair| pair[0].relation_id() < pair[1].relation_id()));
650        assert!(first.reviews().iter().all(|review| {
651            review.evidence().subject() == review.comparison_subject()
652                && review.resource_scope() == &resource()
653        }));
654    }
655
656    #[test]
657    fn oversized_observation_provenance_is_rejected_before_commit() {
658        let knowledge = KnowledgeBase::new();
659        let rules = RuleEngine::new();
660        let observation = comparison_with_source(
661            "oversized-source",
662            ApiVisibilityResult::Different,
663            ApiVisibilityPairKind::UiApi,
664            "s".repeat(MAX_API_VISIBILITY_SOURCE_COMPONENT_BYTES + 1),
665        );
666
667        assert!(matches!(
668            ingest_api_visibility_observation(observation, &resource(), &knowledge, &rules),
669            Err(ApiObservationError::ObservationLimitExceeded {
670                field: "source.component",
671                ..
672            })
673        ));
674        let stats = knowledge.stats();
675        assert_eq!(stats.evidence, 0);
676        assert_eq!(stats.relations, 0);
677    }
678
679    #[test]
680    fn projection_rejects_oversized_records_from_direct_store_writers() {
681        let knowledge = KnowledgeBase::new();
682        let observation = comparison_with_source(
683            "direct-oversized-source",
684            ApiVisibilityResult::Different,
685            ApiVisibilityPairKind::UiApi,
686            "s".repeat(MAX_API_VISIBILITY_SOURCE_COMPONENT_BYTES + 1),
687        );
688        let (evidence, relation) = observation.into_parts();
689        knowledge
690            .insert_evidence_with_relation(evidence, relation)
691            .unwrap();
692
693        let page = api_visibility_reviews_for_resource(
694            &knowledge,
695            &resource(),
696            &ApiVisibilityReviewQuery::default(),
697        );
698        assert_eq!(page.scanned_relations(), 1);
699        assert!(page.reviews().is_empty());
700    }
701
702    #[test]
703    fn projection_does_not_clone_an_oversized_boundary_rationale() {
704        let (knowledge, rules) = installed();
705        let receipt = ingest_api_visibility_observation(
706            comparison(
707                "oversized-rationale",
708                ApiVisibilityResult::Different,
709                ApiVisibilityPairKind::UiApi,
710            ),
711            &resource(),
712            &knowledge,
713            &rules,
714        )
715        .unwrap();
716        let evidence = knowledge.evidence(receipt.commit().evidence_id()).unwrap();
717        let mut hypothesis = Hypothesis::with_id(
718            hypothesis_id_for_rule(UI_API_BOUNDARY_RULE, evidence.subject()),
719            evidence.subject().clone(),
720            ApiKnowledgePredicate::VISIBILITY_BOUNDARY.into_knowledge(),
721            EvidenceValue::from(ApiVisibilityBoundaryKind::UiApi),
722            Probability::from_percent(10).unwrap(),
723        )
724        .unwrap();
725        hypothesis
726            .observe(
727                BayesianEvidence::new(
728                    evidence.id().clone(),
729                    Probability::from_percent(98).unwrap(),
730                    Probability::from_percent(2).unwrap(),
731                    "r".repeat(MAX_API_VISIBILITY_REVIEW_RATIONALE_BYTES + 1),
732                )
733                .unwrap(),
734            )
735            .unwrap();
736        hypothesis.set_strength(HypothesisStrength::Strong);
737        hypothesis.set_state(HypothesisState::Supported);
738        knowledge.upsert_hypothesis(hypothesis).unwrap();
739
740        let page = api_visibility_reviews_for_resource(
741            &knowledge,
742            &resource(),
743            &ApiVisibilityReviewQuery::default(),
744        );
745        assert_eq!(page.reviews().len(), 1);
746        assert!(page.reviews()[0].boundary_hypotheses().is_empty());
747    }
748
749    #[test]
750    fn review_query_is_strict_and_enforces_the_compiled_ceiling() {
751        assert!(matches!(
752            ApiVisibilityReviewQuery::new(0),
753            Err(ApiObservationError::ZeroReviewScanLimit)
754        ));
755        assert!(matches!(
756            ApiVisibilityReviewQuery::new(HARD_MAX_API_VISIBILITY_REVIEW_SCAN_LIMIT + 1),
757            Err(ApiObservationError::ReviewScanLimitExceeded { .. })
758        ));
759        assert!(
760            serde_json::from_value::<ApiVisibilityReviewQuery>(serde_json::json!({
761                "scan_limit": 0
762            }))
763            .is_err()
764        );
765        assert!(
766            serde_json::from_value::<ApiVisibilityReviewQuery>(serde_json::json!({
767                "scan_limit": 1,
768                "unexpected": true
769            }))
770            .is_err()
771        );
772        let oversized_cursor =
773            RelationId::parse("r".repeat(MAX_KNOWLEDGE_RELATION_ID_BYTES + 1)).unwrap();
774        assert!(matches!(
775            ApiVisibilityReviewQuery::new(1)
776                .unwrap()
777                .after_relation_id(oversized_cursor.clone()),
778            Err(ApiObservationError::ReviewCursorTooLong { .. })
779        ));
780        assert!(
781            serde_json::from_value::<ApiVisibilityReviewQuery>(serde_json::json!({
782                "after_relation_id": oversized_cursor,
783                "scan_limit": 1
784            }))
785            .is_err()
786        );
787
788        let cursor = RelationId::parse("relation:cursor").unwrap();
789        let query = ApiVisibilityReviewQuery::new(7)
790            .unwrap()
791            .after_relation_id(cursor.clone())
792            .unwrap();
793        let decoded: ApiVisibilityReviewQuery =
794            serde_json::from_value(serde_json::to_value(&query).unwrap()).unwrap();
795        assert_eq!(decoded, query);
796        assert_eq!(decoded.after(), Some(&cursor));
797        assert_eq!(decoded.scan_limit(), 7);
798        let debug = format!("{decoded:?}");
799        assert!(!debug.contains(cursor.as_str()));
800        assert!(debug.contains("<redacted>"));
801    }
802
803    #[test]
804    fn resource_bound_cursor_round_trips_and_paginates_same_resource() {
805        let (knowledge, rules) = installed();
806        for id in ["cursor-page-a", "cursor-page-b", "cursor-page-c"] {
807            ingest_api_visibility_observation(
808                comparison(
809                    id,
810                    ApiVisibilityResult::Different,
811                    ApiVisibilityPairKind::UiApi,
812                ),
813                &resource(),
814                &knowledge,
815                &rules,
816            )
817            .unwrap();
818        }
819
820        let first =
821            api_visibility_reviews_for_resource_v2(&knowledge, &resource(), None, 1).unwrap();
822        assert_eq!(first.scanned_relations(), 1);
823        assert_eq!(first.reviews().len(), 1);
824        let cursor = first.next_cursor().unwrap().unwrap();
825        let decoded: ApiVisibilityReviewCursor =
826            serde_json::from_value(serde_json::to_value(&cursor).unwrap()).unwrap();
827        assert_eq!(decoded, cursor);
828        assert_eq!(decoded.version(), 2);
829
830        let second =
831            api_visibility_reviews_for_resource_v2(&knowledge, &resource(), Some(&decoded), 1)
832                .unwrap();
833        assert_eq!(second.scanned_relations(), 1);
834        assert_eq!(second.reviews().len(), 1);
835        assert_ne!(
836            first.reviews()[0].relation_id(),
837            second.reviews()[0].relation_id()
838        );
839    }
840
841    #[test]
842    fn resource_bound_cursor_rejects_cross_resource_replay_without_leaking_ids() {
843        let source = resource();
844        let target = EntityId::new("resource:another-sensitive-account").unwrap();
845        let relation = RelationId::parse("relation:sensitive-position").unwrap();
846        let cursor = ApiVisibilityReviewCursor::new(&source, relation.clone()).unwrap();
847        let error = api_visibility_reviews_for_resource_v2(
848            &KnowledgeBase::new(),
849            &target,
850            Some(&cursor),
851            1,
852        )
853        .unwrap_err();
854
855        assert!(matches!(
856            error,
857            ApiObservationError::ResourceBoundReviewCursorMismatch
858        ));
859        for output in [error.to_string(), format!("{error:?}")] {
860            assert!(!output.contains(source.as_str()));
861            assert!(!output.contains(target.as_str()));
862            assert!(!output.contains(relation.as_str()));
863            assert!(!output.contains(cursor.as_str()));
864        }
865    }
866
867    #[test]
868    fn resource_bound_cursor_rejects_malformed_versioned_and_oversized_tokens() {
869        assert!(matches!(
870            ApiVisibilityReviewCursor::parse("not-a-review-cursor"),
871            Err(ApiObservationError::InvalidResourceBoundReviewCursor { .. })
872        ));
873        assert!(matches!(
874            ApiVisibilityReviewCursor::parse("venom-api-review-v3:payload"),
875            Err(ApiObservationError::UnsupportedResourceBoundReviewCursorVersion)
876        ));
877        assert!(matches!(
878            ApiVisibilityReviewCursor::parse(
879                "x".repeat(MAX_API_VISIBILITY_REVIEW_CURSOR_BYTES + 1)
880            ),
881            Err(ApiObservationError::ResourceBoundReviewCursorTooLong { .. })
882        ));
883
884        let cursor = ApiVisibilityReviewCursor::new(
885            &resource(),
886            RelationId::parse("relation:cursor").unwrap(),
887        )
888        .unwrap();
889        let mut uppercase = cursor.as_str().to_owned();
890        uppercase.pop();
891        uppercase.push('A');
892        assert!(matches!(
893            ApiVisibilityReviewCursor::parse(uppercase),
894            Err(ApiObservationError::InvalidResourceBoundReviewCursor { .. })
895        ));
896        let mut odd = cursor.as_str().to_owned();
897        odd.pop();
898        assert!(
899            serde_json::from_value::<ApiVisibilityReviewCursor>(serde_json::json!(odd)).is_err()
900        );
901    }
902
903    #[test]
904    fn resource_bound_cursor_serialization_is_transparent_and_debug_is_redacted() {
905        let resource = resource();
906        let relation = RelationId::parse("relation:sensitive-cursor").unwrap();
907        let cursor = ApiVisibilityReviewCursor::new(&resource, relation.clone()).unwrap();
908
909        assert_eq!(
910            serde_json::to_value(&cursor).unwrap(),
911            serde_json::Value::String(cursor.as_str().to_owned())
912        );
913        assert!(!cursor.as_str().contains(resource.as_str()));
914        assert!(!cursor.as_str().contains(relation.as_str()));
915        for output in [format!("{cursor:?}"), cursor.to_string()] {
916            assert!(output.contains("<redacted>"));
917            assert!(!output.contains(cursor.as_str()));
918            assert!(!output.contains(resource.as_str()));
919            assert!(!output.contains(relation.as_str()));
920        }
921    }
922
923    #[test]
924    fn legacy_review_query_wire_shape_remains_unchanged() {
925        let cursor = RelationId::parse("relation:legacy-cursor").unwrap();
926        let query = ApiVisibilityReviewQuery::new(7)
927            .unwrap()
928            .after_relation_id(cursor)
929            .unwrap();
930
931        assert_eq!(
932            serde_json::to_value(query).unwrap(),
933            serde_json::json!({
934                "after_relation_id": "relation:legacy-cursor",
935                "scan_limit": 7
936            })
937        );
938    }
939
940    #[test]
941    fn review_pages_advance_by_the_last_scanned_relation() {
942        let (knowledge, rules) = installed();
943        for id in ["page-a", "page-b", "page-c"] {
944            ingest_api_visibility_observation(
945                comparison(
946                    id,
947                    ApiVisibilityResult::Different,
948                    ApiVisibilityPairKind::UiApi,
949                ),
950                &resource(),
951                &knowledge,
952                &rules,
953            )
954            .unwrap();
955        }
956
957        let mut query = ApiVisibilityReviewQuery::new(1).unwrap();
958        let mut seen = Vec::new();
959        loop {
960            let page = api_visibility_reviews_for_resource(&knowledge, &resource(), &query);
961            assert_eq!(page.scanned_relations(), 1);
962            assert_eq!(page.reviews().len(), 1);
963            seen.push(page.reviews()[0].relation_id().clone());
964            let Some(cursor) = page.next_after_relation_id().cloned() else {
965                break;
966            };
967            query = ApiVisibilityReviewQuery::new(1)
968                .unwrap()
969                .after_relation_id(cursor)
970                .unwrap();
971        }
972
973        assert_eq!(seen.len(), 3);
974        assert!(seen.windows(2).all(|pair| pair[0] < pair[1]));
975    }
976
977    #[test]
978    fn rejected_relations_consume_the_page_scan_budget() {
979        let (knowledge, rules) = installed();
980        let invalid = Evidence::new(
981            EntityId::new("invalid-comparison").unwrap(),
982            EvidenceKind::Custom(API_VISIBILITY_EVIDENCE_KIND.to_owned()),
983            ApiEvidencePredicate::JSON_UI_API_DIFFERENCE.into_knowledge(),
984            EvidenceValue::from(ApiVisibilityDimension::Fields),
985            EvidenceSource::new("untrusted", API_VISIBILITY_SOURCE_METHOD).unwrap(),
986            ConfidenceScore::MAX,
987        );
988        let invalid_id = invalid.id().clone();
989        let invalid_subject = invalid.subject().clone();
990        knowledge.insert_evidence(invalid).unwrap();
991        knowledge
992            .upsert_relation(termivar_core::KnowledgeRelation::with_id(
993                RelationId::parse("000-invalid-relation").unwrap(),
994                invalid_subject,
995                resource(),
996                RelationKind::Custom(API_VISIBILITY_RELATION.to_owned()),
997                ConfidenceScore::MAX,
998                invalid_id,
999            ))
1000            .unwrap();
1001        ingest_api_visibility_observation(
1002            comparison(
1003                "valid-after-invalid",
1004                ApiVisibilityResult::Different,
1005                ApiVisibilityPairKind::UiApi,
1006            ),
1007            &resource(),
1008            &knowledge,
1009            &rules,
1010        )
1011        .unwrap();
1012
1013        let first_query = ApiVisibilityReviewQuery::new(1).unwrap();
1014        let first = api_visibility_reviews_for_resource(&knowledge, &resource(), &first_query);
1015        assert_eq!(first.scanned_relations(), 1);
1016        assert!(first.reviews().is_empty());
1017        let cursor = first.next_after_relation_id().cloned().unwrap();
1018        assert_eq!(cursor.as_str(), "000-invalid-relation");
1019        let page_debug = format!("{first:?}");
1020        assert!(!page_debug.contains(cursor.as_str()));
1021        assert!(page_debug.contains("<redacted>"));
1022
1023        let second_query = ApiVisibilityReviewQuery::new(1)
1024            .unwrap()
1025            .after_relation_id(cursor)
1026            .unwrap();
1027        let second = api_visibility_reviews_for_resource(&knowledge, &resource(), &second_query);
1028        assert_eq!(second.scanned_relations(), 1);
1029        assert_eq!(second.reviews().len(), 1);
1030        assert!(second.next_after_relation_id().is_none());
1031    }
1032
1033    #[test]
1034    fn oversized_relation_is_rejected_before_it_can_reach_review_projection() {
1035        let knowledge = KnowledgeBase::new();
1036        let relation = termivar_core::KnowledgeRelation::with_id(
1037            RelationId::parse("r".repeat(crate::knowledge::MAX_KNOWLEDGE_RELATION_ID_BYTES + 1))
1038                .unwrap(),
1039            EntityId::new("comparison:oversized-relation").unwrap(),
1040            resource(),
1041            RelationKind::Custom(API_VISIBILITY_RELATION.to_owned()),
1042            ConfidenceScore::MAX,
1043            EvidenceId::parse("evidence:oversized-relation").unwrap(),
1044        );
1045
1046        assert!(matches!(
1047            knowledge.upsert_relation(relation),
1048            Err(KnowledgeBaseError::RelationLimitExceeded { field: "id", .. })
1049        ));
1050        let page = api_visibility_reviews_for_resource(
1051            &knowledge,
1052            &resource(),
1053            &ApiVisibilityReviewQuery::default(),
1054        );
1055        assert_eq!(page.scanned_relations(), 0);
1056        assert!(page.reviews().is_empty());
1057        assert!(page.next_after_relation_id().is_none());
1058    }
1059}