1use 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#[derive(
24 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
25)]
26pub struct FactId(pub u64);
27
28impl FactId {
29 #[must_use]
31 pub const fn is_valid(self) -> bool {
32 self.0 != 0
33 }
34}
35
36#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
38pub struct AnalysisSourceSpan {
39 pub file_id: u32,
41 pub start_byte: u32,
43 pub end_byte: u32,
45 pub start_line: u32,
47 pub start_column: u32,
49 pub end_line: u32,
51 pub end_column: u32,
53}
54
55impl AnalysisSourceSpan {
56 #[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 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
88pub enum FactKind {
89 Node,
91 Edge,
93 Symbol,
95 Call,
97 Dataflow,
99 Control,
101 Auth,
103 Sanitizer,
105 Sink,
107 Source,
109 Type,
111 Lifetime,
113 Concurrency,
115 Provenance,
117}
118
119impl FactKind {
120 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
144pub struct AnalysisFact {
145 pub id: FactId,
147 pub kind: FactKind,
149 pub span: AnalysisSourceSpan,
151 pub subject: u64,
153 pub object: Option<u64>,
155 pub payload: BTreeMap<String, String>,
157 pub provenance: Vec<FactId>,
159 pub confidence_bps: u16,
161 pub reason: String,
163}
164
165impl AnalysisFact {
166 #[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 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 #[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#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
247pub struct AnalysisFactTable {
248 pub facts: Vec<AnalysisFact>,
250}
251
252impl AnalysisFactTable {
253 #[must_use]
255 pub fn new(facts: Vec<AnalysisFact>) -> Self {
256 Self { facts }
257 }
258
259 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 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 #[must_use]
320 pub fn contains(&self, id: FactId) -> bool {
321 self.facts.iter().any(|fact| fact.id == id)
322 }
323
324 #[must_use]
326 pub fn get(&self, id: FactId) -> Option<&AnalysisFact> {
327 self.facts.iter().find(|fact| fact.id == id)
328 }
329}
330
331#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
333pub struct AnalysisFactColumns {
334 pub ids: Vec<u64>,
336 pub kinds: Vec<u16>,
338 pub file_ids: Vec<u32>,
340 pub start_bytes: Vec<u32>,
342 pub end_bytes: Vec<u32>,
344 pub subjects: Vec<u64>,
346 pub objects: Vec<u64>,
348 pub confidence_bps: Vec<u16>,
350 pub payload_digests: Vec<[u8; 32]>,
352 pub provenance_offsets: Vec<u32>,
354 pub provenance_ids: Vec<u64>,
356}
357
358#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
360pub struct FindingProofStep {
361 pub fact_id: FactId,
363 pub span: AnalysisSourceSpan,
365 pub role: String,
367}
368
369impl FindingProofStep {
370 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
398pub struct FindingProofBundle {
399 pub finding_id: String,
401 pub query_id: String,
403 pub backend_id: String,
405 pub evidence_digest: String,
407 pub precision_contract: PrecisionContract,
409 pub soundness: Soundness,
411 pub primitive_soundness: Vec<DynamicPrimitiveSoundness>,
413 pub fact_ids: Vec<FactId>,
415 pub proof_path: Vec<FindingProofStep>,
417 pub confidence_bps: u16,
419 pub reason: String,
421}
422
423#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
425pub struct SourceToSinkFindingRequest {
426 pub finding_id: String,
428 pub query_id: String,
430 pub backend_id: String,
432 pub evidence_digest: String,
434 pub precision_contract: PrecisionContract,
436 pub source_fact_id: FactId,
438 pub sink_fact_id: FactId,
440 pub path_fact_ids: Vec<FactId>,
442 pub sanitizer_fact_ids: Vec<FactId>,
444 pub query_hit: u32,
446 pub confidence_bps: u16,
448 pub reason: String,
450}
451
452impl FindingProofBundle {
453 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
538pub 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#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
674pub enum AnalysisFactError {
675 #[error("invalid fact id {id:?}. Fix: assign non-zero stable fact ids before analysis.")]
677 InvalidFactId {
678 id: FactId,
680 },
681 #[error("duplicate fact id {id:?}. Fix: deduplicate facts before GPU columnar packing.")]
683 DuplicateFactId {
684 id: FactId,
686 },
687 #[error(
689 "{context} span has start_byte {start_byte} after end_byte {end_byte}. Fix: normalize parser spans before analysis."
690 )]
691 InvalidSpan {
692 context: String,
694 start_byte: u32,
696 end_byte: u32,
698 },
699 #[error(
701 "fact {id:?} confidence {confidence_bps} exceeds 10000. Fix: store confidence in basis points."
702 )]
703 InvalidConfidence {
704 id: FactId,
706 confidence_bps: u16,
708 },
709 #[error("fact {id:?} is inferred but has no reason. Fix: record why the fact is trusted.")]
711 MissingInferenceReason {
712 id: FactId,
714 },
715 #[error("fact {id:?} lists itself as provenance. Fix: remove cyclic fact derivation.")]
717 SelfProvenance {
718 id: FactId,
720 },
721 #[error("fact {id:?} has a blank payload key. Fix: normalize payload keys before packing.")]
723 InvalidPayloadKey {
724 id: FactId,
726 },
727 #[error(
729 "fact {id:?} references missing provenance parent {parent:?}. Fix: emit parent facts before derived facts."
730 )]
731 MissingProvenanceParent {
732 id: FactId,
734 parent: FactId,
736 },
737 #[error("finding field `{field}` is blank. Fix: findings must be fact-backed and replayable.")]
739 InvalidFindingIdentity {
740 field: &'static str,
742 },
743 #[error(
745 "finding `{finding_id}` confidence {confidence_bps} exceeds 10000. Fix: store confidence in basis points."
746 )]
747 InvalidFindingConfidence {
748 finding_id: String,
750 confidence_bps: u16,
752 },
753 #[error("finding `{finding_id}` references no facts. Fix: do not emit LLM-only findings.")]
755 FindingHasNoFacts {
756 finding_id: String,
758 },
759 #[error(
761 "finding `{finding_id}` has no proof path. Fix: include source-to-sink/auth path steps."
762 )]
763 FindingHasNoProofPath {
764 finding_id: String,
766 },
767 #[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: String,
774 },
775 #[error(
777 "finding `{finding_id}` soundness evidence violates its precision contract: {violation:?}."
778 )]
779 FindingSoundnessViolation {
780 finding_id: String,
782 violation: DynamicSoundnessViolation,
784 },
785 #[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: String,
792 declared: Soundness,
794 computed: Soundness,
796 },
797 #[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: String,
804 fact_id: FactId,
806 },
807 #[error("proof step for fact {fact_id:?} has a blank role. Fix: name each proof step role.")]
809 InvalidProofRole {
810 fact_id: FactId,
812 },
813 #[error(
815 "fact {id:?} has kind {actual:?} for role `{role}`, expected {expected}. Fix: normalize analysis facts before query proof emission."
816 )]
817 UnexpectedFactKind {
818 id: FactId,
820 role: &'static str,
822 expected: String,
824 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}