Skip to main content

vyre_libs/security/
facts.rs

1//! Canonical security-analysis fact and finding proof schema.
2//!
3//! This module is intentionally data-only. The GPU kernels remain the existing
4//! Lego primitives (`flows_to`, `sanitizer_dominates`, `auth_check_dominates`,
5//! `path_reconstruct`, and graph primitives). These types define the stable
6//! source-to-fact and finding-to-evidence contract those kernels consume and
7//! emit through higher-level analysis pipelines.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use serde::{Deserialize, Serialize};
12use vyre_foundation::hashing::update_length_delimited_field as hash_field;
13
14use vyre_spec::{
15    analysis::{AnalysisFactKind, AnalysisFactRecord},
16    soundness::{
17        validate_dynamic_pipeline, DynamicPrimitiveSoundness, DynamicSoundnessViolation,
18        PrecisionContract, Soundness,
19    },
20};
21
22/// Stable fact identifier.
23#[derive(
24    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
25)]
26pub struct FactId(pub u64);
27
28impl FactId {
29    /// Returns true when this id is non-zero.
30    #[must_use]
31    pub const fn is_valid(self) -> bool {
32        self.0 != 0
33    }
34}
35
36/// Source span attached to one fact or proof step.
37#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
38pub struct AnalysisSourceSpan {
39    /// Stable file id from the analysis corpus.
40    pub file_id: u32,
41    /// Start byte offset, inclusive.
42    pub start_byte: u32,
43    /// End byte offset, exclusive.
44    pub end_byte: u32,
45    /// Start line, one-based when known, zero when unknown.
46    pub start_line: u32,
47    /// Start column, one-based when known, zero when unknown.
48    pub start_column: u32,
49    /// End line, one-based when known, zero when unknown.
50    pub end_line: u32,
51    /// End column, one-based when known, zero when unknown.
52    pub end_column: u32,
53}
54
55impl AnalysisSourceSpan {
56    /// Build a byte-only span.
57    #[must_use]
58    pub const fn byte_range(file_id: u32, start_byte: u32, end_byte: u32) -> Self {
59        Self {
60            file_id,
61            start_byte,
62            end_byte,
63            start_line: 0,
64            start_column: 0,
65            end_line: 0,
66            end_column: 0,
67        }
68    }
69
70    /// Validate span ordering.
71    ///
72    /// # Errors
73    /// Returns [`AnalysisFactError`] when the byte range is reversed.
74    pub fn validate(&self, context: &str) -> Result<(), AnalysisFactError> {
75        if self.end_byte < self.start_byte {
76            return Err(AnalysisFactError::InvalidSpan {
77                context: context.to_string(),
78                start_byte: self.start_byte,
79                end_byte: self.end_byte,
80            });
81        }
82        Ok(())
83    }
84}
85
86/// Canonical fact kinds used by the analysis layer.
87#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
88pub enum FactKind {
89    /// Syntax or semantic node fact.
90    Node,
91    /// Graph edge fact.
92    Edge,
93    /// Symbol identity fact.
94    Symbol,
95    /// Call edge or callsite fact.
96    Call,
97    /// Dataflow relation fact.
98    Dataflow,
99    /// Control-flow relation fact.
100    Control,
101    /// Authorization check or policy fact.
102    Auth,
103    /// Sanitizer fact.
104    Sanitizer,
105    /// Security sink fact.
106    Sink,
107    /// Attacker/source fact.
108    Source,
109    /// Type fact.
110    Type,
111    /// Lifetime or ownership fact.
112    Lifetime,
113    /// Concurrency fact.
114    Concurrency,
115    /// Provenance or derivation fact.
116    Provenance,
117}
118
119impl FactKind {
120    /// Stable numeric tag for columnar GPU inputs.
121    #[must_use]
122    pub const fn tag(self) -> u16 {
123        match self {
124            Self::Node => 1,
125            Self::Edge => 2,
126            Self::Symbol => 3,
127            Self::Call => 4,
128            Self::Dataflow => 5,
129            Self::Control => 6,
130            Self::Auth => 7,
131            Self::Sanitizer => 8,
132            Self::Sink => 9,
133            Self::Source => 10,
134            Self::Type => 11,
135            Self::Lifetime => 12,
136            Self::Concurrency => 13,
137            Self::Provenance => 14,
138        }
139    }
140}
141
142/// One normalized analysis fact.
143#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
144pub struct AnalysisFact {
145    /// Stable fact id.
146    pub id: FactId,
147    /// Fact family.
148    pub kind: FactKind,
149    /// Source span for this fact.
150    pub span: AnalysisSourceSpan,
151    /// Primary subject node/symbol/object id.
152    pub subject: u64,
153    /// Optional object node/symbol/object id.
154    pub object: Option<u64>,
155    /// Deterministic payload map. Keys are sorted by `BTreeMap`.
156    pub payload: BTreeMap<String, String>,
157    /// Parent facts used to derive this fact.
158    pub provenance: Vec<FactId>,
159    /// Confidence in basis points, 0..=10000.
160    pub confidence_bps: u16,
161    /// Required reason when confidence is below 10000 or the fact is inferred.
162    pub reason: String,
163}
164
165impl AnalysisFact {
166    /// Build a fact with exact confidence and no parent facts.
167    #[must_use]
168    pub fn exact(id: FactId, kind: FactKind, span: AnalysisSourceSpan, subject: u64) -> Self {
169        Self {
170            id,
171            kind,
172            span,
173            subject,
174            object: None,
175            payload: BTreeMap::new(),
176            provenance: Vec::new(),
177            confidence_bps: 10_000,
178            reason: "exact-parser-fact".to_string(),
179        }
180    }
181
182    /// Validate intrinsic fact fields.
183    ///
184    /// # Errors
185    /// Returns [`AnalysisFactError`] when ids, spans, confidence, payload, or
186    /// self-provenance are invalid.
187    pub fn validate(&self) -> Result<(), AnalysisFactError> {
188        if !self.id.is_valid() {
189            return Err(AnalysisFactError::InvalidFactId { id: self.id });
190        }
191        self.span.validate("fact")?;
192        if self.confidence_bps > 10_000 {
193            return Err(AnalysisFactError::InvalidConfidence {
194                id: self.id,
195                confidence_bps: self.confidence_bps,
196            });
197        }
198        if self.confidence_bps < 10_000 && self.reason.trim().is_empty() {
199            return Err(AnalysisFactError::MissingInferenceReason { id: self.id });
200        }
201        if self.provenance.iter().any(|parent| *parent == self.id) {
202            return Err(AnalysisFactError::SelfProvenance { id: self.id });
203        }
204        for key in self.payload.keys() {
205            if key.trim().is_empty() {
206                return Err(AnalysisFactError::InvalidPayloadKey { id: self.id });
207            }
208        }
209        Ok(())
210    }
211
212    /// Convert this security fact into the structural interchange record.
213    #[must_use]
214    pub fn analysis_record(&self, producer: &str) -> AnalysisFactRecord {
215        let mut record = AnalysisFactRecord::new(
216            producer,
217            analysis_kind_for_security_fact(self.kind),
218            self.id.0,
219            self.subject,
220            Soundness::Exact,
221        )
222        .with_span(self.span.file_id, self.span.start_byte, self.span.end_byte);
223        if let Some(object) = self.object {
224            record = record.with_object(object);
225        }
226        record
227    }
228}
229
230fn analysis_kind_for_security_fact(kind: FactKind) -> AnalysisFactKind {
231    match kind {
232        FactKind::Source => AnalysisFactKind::Source,
233        FactKind::Sink => AnalysisFactKind::Sink,
234        FactKind::Sanitizer => AnalysisFactKind::Sanitizer,
235        FactKind::Dataflow => AnalysisFactKind::Taint,
236        FactKind::Edge | FactKind::Call | FactKind::Control => AnalysisFactKind::GraphEdge,
237        FactKind::Auth => AnalysisFactKind::Dominance,
238        FactKind::Lifetime => AnalysisFactKind::BorrowOrigin,
239        FactKind::Provenance => AnalysisFactKind::Witness,
240        FactKind::Type => AnalysisFactKind::Range,
241        FactKind::Node | FactKind::Symbol | FactKind::Concurrency => AnalysisFactKind::Taint,
242    }
243}
244
245/// Canonical fact table.
246#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
247pub struct AnalysisFactTable {
248    /// Facts in producer order. Conversion to columns sorts by id.
249    pub facts: Vec<AnalysisFact>,
250}
251
252impl AnalysisFactTable {
253    /// Build a fact table from facts.
254    #[must_use]
255    pub fn new(facts: Vec<AnalysisFact>) -> Self {
256        Self { facts }
257    }
258
259    /// Validate table-level uniqueness and provenance references.
260    ///
261    /// # Errors
262    /// Returns [`AnalysisFactError`] when any table invariant is broken.
263    pub fn validate(&self) -> Result<(), AnalysisFactError> {
264        let mut ids = BTreeSet::new();
265        for fact in &self.facts {
266            fact.validate()?;
267            if !ids.insert(fact.id) {
268                return Err(AnalysisFactError::DuplicateFactId { id: fact.id });
269            }
270        }
271        for fact in &self.facts {
272            for parent in &fact.provenance {
273                if !ids.contains(parent) {
274                    return Err(AnalysisFactError::MissingProvenanceParent {
275                        id: fact.id,
276                        parent: *parent,
277                    });
278                }
279            }
280        }
281        Ok(())
282    }
283
284    /// Convert facts into deterministic GPU-ready columns sorted by fact id.
285    ///
286    /// # Errors
287    /// Returns [`AnalysisFactError`] when the fact table is invalid.
288    pub fn to_columnar(&self) -> Result<AnalysisFactColumns, AnalysisFactError> {
289        self.validate()?;
290        let mut facts = self.facts.iter().collect::<Vec<_>>();
291        facts.sort_by_key(|fact| fact.id);
292        let mut columns = AnalysisFactColumns::default();
293        for fact in facts {
294            columns.ids.push(fact.id.0);
295            columns.kinds.push(fact.kind.tag());
296            columns.file_ids.push(fact.span.file_id);
297            columns.start_bytes.push(fact.span.start_byte);
298            columns.end_bytes.push(fact.span.end_byte);
299            columns.subjects.push(fact.subject);
300            columns.objects.push(fact.object.unwrap_or(0));
301            columns.confidence_bps.push(fact.confidence_bps);
302            columns
303                .payload_digests
304                .push(payload_digest(&fact.payload, &fact.reason));
305            columns
306                .provenance_offsets
307                .push(columns.provenance_ids.len() as u32);
308            columns
309                .provenance_ids
310                .extend(fact.provenance.iter().map(|parent| parent.0));
311        }
312        columns
313            .provenance_offsets
314            .push(columns.provenance_ids.len() as u32);
315        Ok(columns)
316    }
317
318    /// Return true when the table contains `id`.
319    #[must_use]
320    pub fn contains(&self, id: FactId) -> bool {
321        self.facts.iter().any(|fact| fact.id == id)
322    }
323
324    /// Return one fact by id.
325    #[must_use]
326    pub fn get(&self, id: FactId) -> Option<&AnalysisFact> {
327        self.facts.iter().find(|fact| fact.id == id)
328    }
329}
330
331/// Columnar fact representation for GPU uploads.
332#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
333pub struct AnalysisFactColumns {
334    /// Fact ids.
335    pub ids: Vec<u64>,
336    /// Stable [`FactKind`] tags.
337    pub kinds: Vec<u16>,
338    /// File ids.
339    pub file_ids: Vec<u32>,
340    /// Start byte offsets.
341    pub start_bytes: Vec<u32>,
342    /// End byte offsets.
343    pub end_bytes: Vec<u32>,
344    /// Subject ids.
345    pub subjects: Vec<u64>,
346    /// Object ids, zero when absent.
347    pub objects: Vec<u64>,
348    /// Confidence values in basis points.
349    pub confidence_bps: Vec<u16>,
350    /// Payload/reason digests.
351    pub payload_digests: Vec<[u8; 32]>,
352    /// Offsets into `provenance_ids`, one extra sentinel at the end.
353    pub provenance_offsets: Vec<u32>,
354    /// Flattened parent fact ids.
355    pub provenance_ids: Vec<u64>,
356}
357
358/// One source-backed step in a finding proof path.
359#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
360pub struct FindingProofStep {
361    /// Fact id used by this proof step.
362    pub fact_id: FactId,
363    /// Source span shown for this proof step.
364    pub span: AnalysisSourceSpan,
365    /// Human-readable role, such as `source`, `edge`, `sanitizer`, or `sink`.
366    pub role: String,
367}
368
369impl FindingProofStep {
370    /// Build one proof step.
371    #[must_use]
372    pub fn new(fact_id: FactId, span: AnalysisSourceSpan, role: impl Into<String>) -> Self {
373        Self {
374            fact_id,
375            span,
376            role: role.into(),
377        }
378    }
379
380    fn validate(&self, table: &AnalysisFactTable) -> Result<(), AnalysisFactError> {
381        if !table.contains(self.fact_id) {
382            return Err(AnalysisFactError::FindingReferencesMissingFact {
383                finding_id: "<proof-step>".to_string(),
384                fact_id: self.fact_id,
385            });
386        }
387        if self.role.trim().is_empty() {
388            return Err(AnalysisFactError::InvalidProofRole {
389                fact_id: self.fact_id,
390            });
391        }
392        self.span.validate("finding proof step")
393    }
394}
395
396/// Fact-backed security finding proof bundle.
397#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
398pub struct FindingProofBundle {
399    /// Stable finding id.
400    pub finding_id: String,
401    /// Query Program or analysis query id that produced the finding.
402    pub query_id: String,
403    /// Backend that produced or verified the finding.
404    pub backend_id: String,
405    /// Driver evidence bundle digest, report digest, or replay id.
406    pub evidence_digest: String,
407    /// Consumer precision contract this finding claims to satisfy.
408    pub precision_contract: PrecisionContract,
409    /// Conservative composed soundness marker for the primitive evidence.
410    pub soundness: Soundness,
411    /// Primitive soundness evidence used by the query.
412    pub primitive_soundness: Vec<DynamicPrimitiveSoundness>,
413    /// Facts used by the finding.
414    pub fact_ids: Vec<FactId>,
415    /// Ordered proof path.
416    pub proof_path: Vec<FindingProofStep>,
417    /// Confidence in basis points, 0..=10000.
418    pub confidence_bps: u16,
419    /// Operator-facing explanation.
420    pub reason: String,
421}
422
423/// Request for converting a sanitized source-to-sink query result into a finding proof.
424#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
425pub struct SourceToSinkFindingRequest {
426    /// Stable finding id.
427    pub finding_id: String,
428    /// Query Program or analysis query id that produced the hit scalar.
429    pub query_id: String,
430    /// Backend that produced or verified the hit scalar.
431    pub backend_id: String,
432    /// Driver evidence bundle digest, report digest, or replay id.
433    pub evidence_digest: String,
434    /// Consumer precision contract this finding must satisfy.
435    pub precision_contract: PrecisionContract,
436    /// Source fact id.
437    pub source_fact_id: FactId,
438    /// Sink fact id.
439    pub sink_fact_id: FactId,
440    /// Dataflow/control/call/edge facts used to prove the path.
441    pub path_fact_ids: Vec<FactId>,
442    /// Sanitizer facts considered by the query.
443    pub sanitizer_fact_ids: Vec<FactId>,
444    /// Scalar result returned by the sanitized source-to-sink query.
445    pub query_hit: u32,
446    /// Confidence in basis points, 0..=10000.
447    pub confidence_bps: u16,
448    /// Operator-facing reason for the finding.
449    pub reason: String,
450}
451
452impl FindingProofBundle {
453    /// Validate that a finding is backed by the supplied fact table.
454    ///
455    /// # Errors
456    /// Returns [`AnalysisFactError`] when required identity fields are empty,
457    /// confidence is invalid, any referenced fact is missing, or the proof path
458    /// is empty.
459    pub fn validate_against(&self, table: &AnalysisFactTable) -> Result<(), AnalysisFactError> {
460        table.validate()?;
461        if self.finding_id.trim().is_empty() {
462            return Err(AnalysisFactError::InvalidFindingIdentity {
463                field: "finding_id",
464            });
465        }
466        if self.query_id.trim().is_empty() {
467            return Err(AnalysisFactError::InvalidFindingIdentity { field: "query_id" });
468        }
469        if self.backend_id.trim().is_empty() {
470            return Err(AnalysisFactError::InvalidFindingIdentity {
471                field: "backend_id",
472            });
473        }
474        if self.evidence_digest.trim().is_empty() {
475            return Err(AnalysisFactError::InvalidFindingIdentity {
476                field: "evidence_digest",
477            });
478        }
479        if self.reason.trim().is_empty() {
480            return Err(AnalysisFactError::InvalidFindingIdentity { field: "reason" });
481        }
482        if self.primitive_soundness.is_empty() {
483            return Err(AnalysisFactError::FindingHasNoSoundnessEvidence {
484                finding_id: self.finding_id.clone(),
485            });
486        }
487        let joined = validate_dynamic_pipeline(self.precision_contract, &self.primitive_soundness)
488            .map_err(|violation| AnalysisFactError::FindingSoundnessViolation {
489                finding_id: self.finding_id.clone(),
490                violation,
491            })?;
492        if joined != self.soundness {
493            return Err(AnalysisFactError::FindingSoundnessMismatch {
494                finding_id: self.finding_id.clone(),
495                declared: self.soundness,
496                computed: joined,
497            });
498        }
499        if self.confidence_bps > 10_000 {
500            return Err(AnalysisFactError::InvalidFindingConfidence {
501                finding_id: self.finding_id.clone(),
502                confidence_bps: self.confidence_bps,
503            });
504        }
505        if self.fact_ids.is_empty() {
506            return Err(AnalysisFactError::FindingHasNoFacts {
507                finding_id: self.finding_id.clone(),
508            });
509        }
510        if self.proof_path.is_empty() {
511            return Err(AnalysisFactError::FindingHasNoProofPath {
512                finding_id: self.finding_id.clone(),
513            });
514        }
515        for fact_id in &self.fact_ids {
516            if !table.contains(*fact_id) {
517                return Err(AnalysisFactError::FindingReferencesMissingFact {
518                    finding_id: self.finding_id.clone(),
519                    fact_id: *fact_id,
520                });
521            }
522        }
523        for step in &self.proof_path {
524            step.validate(table).map_err(|error| match error {
525                AnalysisFactError::FindingReferencesMissingFact { fact_id, .. } => {
526                    AnalysisFactError::FindingReferencesMissingFact {
527                        finding_id: self.finding_id.clone(),
528                        fact_id,
529                    }
530                }
531                other => other,
532            })?;
533        }
534        Ok(())
535    }
536}
537
538/// Convert a sanitized source-to-sink query result into a fact-backed finding.
539///
540/// `query_hit == 0` means the query found no unsanitized source-to-sink path,
541/// so this function returns `Ok(None)` after validating the referenced source,
542/// sink, path, and sanitizer facts. `query_hit != 0` emits a
543/// [`FindingProofBundle`] and validates it against the same fact table.
544///
545/// # Errors
546/// Returns [`AnalysisFactError`] when any referenced fact is missing, has the
547/// wrong kind for its role, or the emitted finding bundle is incomplete.
548pub fn finding_from_sanitized_source_to_sink_query(
549    table: &AnalysisFactTable,
550    request: SourceToSinkFindingRequest,
551) -> Result<Option<FindingProofBundle>, AnalysisFactError> {
552    table.validate()?;
553    let source = require_fact_kind(table, request.source_fact_id, "source", &[FactKind::Source])?;
554    let sink = require_fact_kind(table, request.sink_fact_id, "sink", &[FactKind::Sink])?;
555    for fact_id in &request.path_fact_ids {
556        let _ = require_fact_kind(
557            table,
558            *fact_id,
559            "path",
560            &[
561                FactKind::Dataflow,
562                FactKind::Edge,
563                FactKind::Call,
564                FactKind::Control,
565            ],
566        )?;
567    }
568    for fact_id in &request.sanitizer_fact_ids {
569        let _ = require_fact_kind(table, *fact_id, "sanitizer", &[FactKind::Sanitizer])?;
570    }
571    if request.query_hit == 0 {
572        return Ok(None);
573    }
574    let primitive_soundness =
575        vec![
576            DynamicPrimitiveSoundness::new(request.query_id.clone(), Soundness::MayOver)
577                .with_sanitizer_filter(),
578        ];
579    let soundness = validate_dynamic_pipeline(request.precision_contract, &primitive_soundness)
580        .map_err(|violation| AnalysisFactError::FindingSoundnessViolation {
581            finding_id: request.finding_id.clone(),
582            violation,
583        })?;
584
585    let mut fact_ids = Vec::new();
586    push_unique_fact(&mut fact_ids, source.id);
587    for fact_id in &request.path_fact_ids {
588        push_unique_fact(&mut fact_ids, *fact_id);
589    }
590    for fact_id in &request.sanitizer_fact_ids {
591        push_unique_fact(&mut fact_ids, *fact_id);
592    }
593    push_unique_fact(&mut fact_ids, sink.id);
594
595    let mut proof_path = Vec::new();
596    proof_path.push(FindingProofStep::new(
597        source.id,
598        source.span.clone(),
599        "source",
600    ));
601    for fact_id in &request.path_fact_ids {
602        if let Some(fact) = table.get(*fact_id) {
603            proof_path.push(FindingProofStep::new(
604                fact.id,
605                fact.span.clone(),
606                "dataflow-path",
607            ));
608        }
609    }
610    for fact_id in &request.sanitizer_fact_ids {
611        if let Some(fact) = table.get(*fact_id) {
612            proof_path.push(FindingProofStep::new(
613                fact.id,
614                fact.span.clone(),
615                "sanitizer-considered",
616            ));
617        }
618    }
619    proof_path.push(FindingProofStep::new(sink.id, sink.span.clone(), "sink"));
620
621    let bundle = FindingProofBundle {
622        finding_id: request.finding_id,
623        query_id: request.query_id,
624        backend_id: request.backend_id,
625        evidence_digest: request.evidence_digest,
626        precision_contract: request.precision_contract,
627        soundness,
628        primitive_soundness,
629        fact_ids,
630        proof_path,
631        confidence_bps: request.confidence_bps,
632        reason: request.reason,
633    };
634    bundle.validate_against(table)?;
635    Ok(Some(bundle))
636}
637
638fn require_fact_kind<'a>(
639    table: &'a AnalysisFactTable,
640    fact_id: FactId,
641    role: &'static str,
642    expected: &'static [FactKind],
643) -> Result<&'a AnalysisFact, AnalysisFactError> {
644    let fact =
645        table
646            .get(fact_id)
647            .ok_or_else(|| AnalysisFactError::FindingReferencesMissingFact {
648                finding_id: format!("<{role}>"),
649                fact_id,
650            })?;
651    if !expected.contains(&fact.kind) {
652        return Err(AnalysisFactError::UnexpectedFactKind {
653            id: fact_id,
654            role,
655            expected: expected
656                .iter()
657                .map(|kind| format!("{kind:?}"))
658                .collect::<Vec<_>>()
659                .join("|"),
660            actual: fact.kind,
661        });
662    }
663    Ok(fact)
664}
665
666fn push_unique_fact(facts: &mut Vec<FactId>, fact_id: FactId) {
667    if !facts.contains(&fact_id) {
668        facts.push(fact_id);
669    }
670}
671
672/// Analysis fact/finding validation failure.
673#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
674pub enum AnalysisFactError {
675    /// A fact id was zero.
676    #[error("invalid fact id {id:?}. Fix: assign non-zero stable fact ids before analysis.")]
677    InvalidFactId {
678        /// Invalid id.
679        id: FactId,
680    },
681    /// A fact id appeared more than once.
682    #[error("duplicate fact id {id:?}. Fix: deduplicate facts before GPU columnar packing.")]
683    DuplicateFactId {
684        /// Duplicate id.
685        id: FactId,
686    },
687    /// A source span had reversed byte order.
688    #[error(
689        "{context} span has start_byte {start_byte} after end_byte {end_byte}. Fix: normalize parser spans before analysis."
690    )]
691    InvalidSpan {
692        /// Span context.
693        context: String,
694        /// Start byte.
695        start_byte: u32,
696        /// End byte.
697        end_byte: u32,
698    },
699    /// Confidence exceeded 10000 basis points.
700    #[error(
701        "fact {id:?} confidence {confidence_bps} exceeds 10000. Fix: store confidence in basis points."
702    )]
703    InvalidConfidence {
704        /// Fact id.
705        id: FactId,
706        /// Invalid confidence.
707        confidence_bps: u16,
708    },
709    /// Inferred fact had no explanation.
710    #[error("fact {id:?} is inferred but has no reason. Fix: record why the fact is trusted.")]
711    MissingInferenceReason {
712        /// Fact id.
713        id: FactId,
714    },
715    /// Fact listed itself as provenance.
716    #[error("fact {id:?} lists itself as provenance. Fix: remove cyclic fact derivation.")]
717    SelfProvenance {
718        /// Fact id.
719        id: FactId,
720    },
721    /// Payload key was blank.
722    #[error("fact {id:?} has a blank payload key. Fix: normalize payload keys before packing.")]
723    InvalidPayloadKey {
724        /// Fact id.
725        id: FactId,
726    },
727    /// A provenance parent id was missing.
728    #[error(
729        "fact {id:?} references missing provenance parent {parent:?}. Fix: emit parent facts before derived facts."
730    )]
731    MissingProvenanceParent {
732        /// Derived fact id.
733        id: FactId,
734        /// Missing parent id.
735        parent: FactId,
736    },
737    /// A finding identity field was blank.
738    #[error("finding field `{field}` is blank. Fix: findings must be fact-backed and replayable.")]
739    InvalidFindingIdentity {
740        /// Invalid field name.
741        field: &'static str,
742    },
743    /// Finding confidence exceeded 10000 basis points.
744    #[error(
745        "finding `{finding_id}` confidence {confidence_bps} exceeds 10000. Fix: store confidence in basis points."
746    )]
747    InvalidFindingConfidence {
748        /// Finding id.
749        finding_id: String,
750        /// Invalid confidence.
751        confidence_bps: u16,
752    },
753    /// Finding referenced no facts.
754    #[error("finding `{finding_id}` references no facts. Fix: do not emit LLM-only findings.")]
755    FindingHasNoFacts {
756        /// Finding id.
757        finding_id: String,
758    },
759    /// Finding had no proof path.
760    #[error(
761        "finding `{finding_id}` has no proof path. Fix: include source-to-sink/auth path steps."
762    )]
763    FindingHasNoProofPath {
764        /// Finding id.
765        finding_id: String,
766    },
767    /// Finding had no primitive soundness evidence.
768    #[error(
769        "finding `{finding_id}` has no primitive soundness evidence. Fix: attach the query primitive ids and soundness tags before reporting."
770    )]
771    FindingHasNoSoundnessEvidence {
772        /// Finding id.
773        finding_id: String,
774    },
775    /// Finding primitive evidence violated its precision contract.
776    #[error(
777        "finding `{finding_id}` soundness evidence violates its precision contract: {violation:?}."
778    )]
779    FindingSoundnessViolation {
780        /// Finding id.
781        finding_id: String,
782        /// Canonical dynamic soundness violation.
783        violation: DynamicSoundnessViolation,
784    },
785    /// Finding declared a composed soundness marker inconsistent with evidence.
786    #[error(
787        "finding `{finding_id}` declares soundness {declared:?} but primitive evidence computes {computed:?}. Fix: recompute soundness from primitive evidence."
788    )]
789    FindingSoundnessMismatch {
790        /// Finding id.
791        finding_id: String,
792        /// Declared soundness.
793        declared: Soundness,
794        /// Computed soundness.
795        computed: Soundness,
796    },
797    /// Finding referenced an absent fact.
798    #[error(
799        "finding `{finding_id}` references missing fact {fact_id:?}. Fix: include all proof facts in the fact table."
800    )]
801    FindingReferencesMissingFact {
802        /// Finding id.
803        finding_id: String,
804        /// Missing fact id.
805        fact_id: FactId,
806    },
807    /// Proof role was blank.
808    #[error("proof step for fact {fact_id:?} has a blank role. Fix: name each proof step role.")]
809    InvalidProofRole {
810        /// Fact id.
811        fact_id: FactId,
812    },
813    /// A fact was present but had the wrong kind for its query role.
814    #[error(
815        "fact {id:?} has kind {actual:?} for role `{role}`, expected {expected}. Fix: normalize analysis facts before query proof emission."
816    )]
817    UnexpectedFactKind {
818        /// Fact id.
819        id: FactId,
820        /// Query role.
821        role: &'static str,
822        /// Expected kind list.
823        expected: String,
824        /// Actual fact kind.
825        actual: FactKind,
826    },
827}
828
829fn payload_digest(payload: &BTreeMap<String, String>, reason: &str) -> [u8; 32] {
830    let mut hasher = blake3::Hasher::new();
831    hash_field(&mut hasher, b"format", b"vyre-analysis-payload-v1");
832    for (key, value) in payload {
833        hash_field(&mut hasher, b"key", key.as_bytes());
834        hash_field(&mut hasher, b"value", value.as_bytes());
835    }
836    hash_field(&mut hasher, b"reason", reason.as_bytes());
837    *hasher.finalize().as_bytes()
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    fn span(offset: u32) -> AnalysisSourceSpan {
845        AnalysisSourceSpan::byte_range(7, offset, offset + 4)
846    }
847
848    fn fact(id: u64, kind: FactKind, subject: u64) -> AnalysisFact {
849        AnalysisFact::exact(FactId(id), kind, span(id as u32), subject)
850    }
851
852    fn table() -> AnalysisFactTable {
853        let mut source = fact(1, FactKind::Source, 10);
854        source
855            .payload
856            .insert("name".to_string(), "req.user".to_string());
857        let mut edge = fact(2, FactKind::Dataflow, 10);
858        edge.object = Some(20);
859        edge.provenance.push(FactId(1));
860        let mut sink = fact(3, FactKind::Sink, 20);
861        sink.payload
862            .insert("kind".to_string(), "sql.query".to_string());
863        AnalysisFactTable::new(vec![sink, edge, source])
864    }
865
866    #[test]
867    fn fact_table_to_columnar_sorts_by_fact_id_and_preserves_provenance_offsets() {
868        let columns = table()
869            .to_columnar()
870            .expect("Fix: canonical fact table should validate and pack");
871
872        assert_eq!(columns.ids, vec![1, 2, 3]);
873        assert_eq!(
874            columns.kinds,
875            vec![
876                FactKind::Source.tag(),
877                FactKind::Dataflow.tag(),
878                FactKind::Sink.tag()
879            ]
880        );
881        assert_eq!(columns.file_ids, vec![7, 7, 7]);
882        assert_eq!(columns.subjects, vec![10, 10, 20]);
883        assert_eq!(columns.objects, vec![0, 20, 0]);
884        assert_eq!(columns.provenance_offsets, vec![0, 0, 1, 1]);
885        assert_eq!(columns.provenance_ids, vec![1]);
886    }
887
888    #[test]
889    fn fact_table_rejects_duplicate_ids() {
890        let error = AnalysisFactTable::new(vec![
891            fact(1, FactKind::Source, 1),
892            fact(1, FactKind::Sink, 2),
893        ])
894        .validate()
895        .expect_err("Fix: duplicate fact ids must be rejected");
896
897        assert_eq!(error, AnalysisFactError::DuplicateFactId { id: FactId(1) });
898    }
899
900    #[test]
901    fn fact_table_rejects_missing_provenance_parent() {
902        let mut derived = fact(2, FactKind::Dataflow, 10);
903        derived.provenance.push(FactId(99));
904
905        let error = AnalysisFactTable::new(vec![fact(1, FactKind::Source, 10), derived])
906            .validate()
907            .expect_err("Fix: missing provenance parents must be rejected");
908
909        assert_eq!(
910            error,
911            AnalysisFactError::MissingProvenanceParent {
912                id: FactId(2),
913                parent: FactId(99),
914            }
915        );
916    }
917
918    #[test]
919    fn fact_table_rejects_inferred_fact_without_reason() {
920        let mut inferred = fact(4, FactKind::Auth, 40);
921        inferred.confidence_bps = 7500;
922        inferred.reason.clear();
923
924        let error = AnalysisFactTable::new(vec![inferred])
925            .validate()
926            .expect_err("Fix: inferred facts need a reason");
927
928        assert_eq!(
929            error,
930            AnalysisFactError::MissingInferenceReason { id: FactId(4) }
931        );
932    }
933
934    #[test]
935    fn finding_proof_bundle_validates_fact_backing_and_proof_path() {
936        let fact_table = table();
937        let bundle = FindingProofBundle {
938            finding_id: "finding.sql.source-to-sink.1".to_string(),
939            query_id: "vyre-libs::security::flows_to_with_sanitizer".to_string(),
940            backend_id: "cpu-ref".to_string(),
941            evidence_digest: "evidence:abc123".to_string(),
942            precision_contract: PrecisionContract::ZeroFalsePositive,
943            soundness: Soundness::Exact,
944            primitive_soundness: vec![DynamicPrimitiveSoundness::new(
945                "vyre-libs::security::sanitizer_dominates",
946                Soundness::Exact,
947            )],
948            fact_ids: vec![FactId(1), FactId(2), FactId(3)],
949            proof_path: vec![
950                FindingProofStep::new(FactId(1), span(1), "source"),
951                FindingProofStep::new(FactId(2), span(2), "dataflow-edge"),
952                FindingProofStep::new(FactId(3), span(3), "sink"),
953            ],
954            confidence_bps: 9800,
955            reason: "source reaches sql sink without sanitizer dominance".to_string(),
956        };
957
958        bundle
959            .validate_against(&fact_table)
960            .expect("Fix: fact-backed proof bundle should validate");
961    }
962
963    #[test]
964    fn finding_proof_bundle_rejects_llm_only_finding_without_facts() {
965        let fact_table = table();
966        let bundle = FindingProofBundle {
967            finding_id: "finding.llm-only".to_string(),
968            query_id: "manual".to_string(),
969            backend_id: "cpu-ref".to_string(),
970            evidence_digest: "evidence:abc123".to_string(),
971            precision_contract: PrecisionContract::ZeroFalsePositive,
972            soundness: Soundness::Exact,
973            primitive_soundness: vec![DynamicPrimitiveSoundness::new("manual", Soundness::Exact)],
974            fact_ids: Vec::new(),
975            proof_path: vec![FindingProofStep::new(FactId(1), span(1), "source")],
976            confidence_bps: 5000,
977            reason: "model guessed from code text".to_string(),
978        };
979
980        let error = bundle
981            .validate_against(&fact_table)
982            .expect_err("Fix: factless findings must be rejected");
983
984        assert_eq!(
985            error,
986            AnalysisFactError::FindingHasNoFacts {
987                finding_id: "finding.llm-only".to_string(),
988            }
989        );
990    }
991
992    #[test]
993    fn finding_proof_bundle_rejects_missing_fact_reference() {
994        let fact_table = table();
995        let bundle = FindingProofBundle {
996            finding_id: "finding.missing-fact".to_string(),
997            query_id: "vyre-libs::security::flows_to".to_string(),
998            backend_id: "cpu-ref".to_string(),
999            evidence_digest: "evidence:abc123".to_string(),
1000            precision_contract: PrecisionContract::ZeroFalsePositive,
1001            soundness: Soundness::Exact,
1002            primitive_soundness: vec![DynamicPrimitiveSoundness::new(
1003                "vyre-libs::security::sanitizer_dominates",
1004                Soundness::Exact,
1005            )],
1006            fact_ids: vec![FactId(1), FactId(42)],
1007            proof_path: vec![FindingProofStep::new(FactId(1), span(1), "source")],
1008            confidence_bps: 9000,
1009            reason: "source reaches sink".to_string(),
1010        };
1011
1012        let error = bundle
1013            .validate_against(&fact_table)
1014            .expect_err("Fix: findings must not reference absent facts");
1015
1016        assert_eq!(
1017            error,
1018            AnalysisFactError::FindingReferencesMissingFact {
1019                finding_id: "finding.missing-fact".to_string(),
1020                fact_id: FactId(42),
1021            }
1022        );
1023    }
1024
1025    #[test]
1026    fn finding_proof_bundle_rejects_zero_false_positive_unfiltered_mayover() {
1027        let fact_table = table();
1028        let bundle = FindingProofBundle {
1029            finding_id: "finding.unfiltered-mayover".to_string(),
1030            query_id: "vyre-libs::security::flows_to".to_string(),
1031            backend_id: "cpu-ref".to_string(),
1032            evidence_digest: "evidence:abc123".to_string(),
1033            precision_contract: PrecisionContract::ZeroFalsePositive,
1034            soundness: Soundness::MayOver,
1035            primitive_soundness: vec![DynamicPrimitiveSoundness::new(
1036                "vyre-libs::security::flows_to",
1037                Soundness::MayOver,
1038            )],
1039            fact_ids: vec![FactId(1), FactId(2), FactId(3)],
1040            proof_path: vec![
1041                FindingProofStep::new(FactId(1), span(1), "source"),
1042                FindingProofStep::new(FactId(2), span(2), "dataflow-edge"),
1043                FindingProofStep::new(FactId(3), span(3), "sink"),
1044            ],
1045            confidence_bps: 9000,
1046            reason: "unfiltered over-approximate flow should not ship as zero-FP".to_string(),
1047        };
1048
1049        let error = bundle
1050            .validate_against(&fact_table)
1051            .expect_err("Fix: unfiltered MayOver must not validate as zero false positive");
1052
1053        match error {
1054            AnalysisFactError::FindingSoundnessViolation {
1055                finding_id,
1056                violation,
1057            } => {
1058                assert_eq!(finding_id, "finding.unfiltered-mayover");
1059                assert_eq!(violation.op_id, "vyre-libs::security::flows_to");
1060                assert_eq!(violation.soundness, Soundness::MayOver);
1061                assert_eq!(violation.contract, PrecisionContract::ZeroFalsePositive);
1062            }
1063            other => panic!("unexpected soundness validation error: {other:?}"),
1064        }
1065    }
1066
1067    #[test]
1068    fn finding_proof_bundle_rejects_declared_soundness_mismatch() {
1069        let fact_table = table();
1070        let bundle = FindingProofBundle {
1071            finding_id: "finding.soundness-mismatch".to_string(),
1072            query_id: "vyre-libs::security::flows_to_with_sanitizer".to_string(),
1073            backend_id: "cpu-ref".to_string(),
1074            evidence_digest: "evidence:abc123".to_string(),
1075            precision_contract: PrecisionContract::ZeroFalsePositive,
1076            soundness: Soundness::Exact,
1077            primitive_soundness: vec![DynamicPrimitiveSoundness::new(
1078                "vyre-libs::security::flows_to_with_sanitizer",
1079                Soundness::MayOver,
1080            )
1081            .with_sanitizer_filter()],
1082            fact_ids: vec![FactId(1), FactId(2), FactId(3)],
1083            proof_path: vec![
1084                FindingProofStep::new(FactId(1), span(1), "source"),
1085                FindingProofStep::new(FactId(2), span(2), "dataflow-edge"),
1086                FindingProofStep::new(FactId(3), span(3), "sink"),
1087            ],
1088            confidence_bps: 9000,
1089            reason: "declared exact despite MayOver primitive evidence".to_string(),
1090        };
1091
1092        let error = bundle
1093            .validate_against(&fact_table)
1094            .expect_err("Fix: declared soundness must match primitive evidence join");
1095
1096        assert_eq!(
1097            error,
1098            AnalysisFactError::FindingSoundnessMismatch {
1099                finding_id: "finding.soundness-mismatch".to_string(),
1100                declared: Soundness::Exact,
1101                computed: Soundness::MayOver,
1102            }
1103        );
1104    }
1105}