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