1use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7use sqlx::{QueryBuilder, Row, SqlitePool};
8
9use crate::store::StoreError;
10
11pub use nyx_agent_types::finding::FindingRecord;
12
13pub const PROMOTION_RATE_LAPLACE_PRIOR: f64 = 5.0;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum FindingStatus {
22 Open,
23 Verified,
24 Quarantine,
25 Closed,
26}
27
28impl FindingStatus {
29 pub fn as_str(self) -> &'static str {
30 match self {
31 FindingStatus::Open => "Open",
32 FindingStatus::Verified => "Verified",
33 FindingStatus::Quarantine => "Quarantine",
34 FindingStatus::Closed => "Closed",
35 }
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum FindingOrigin {
41 Static,
42 Ai,
43 AiExploration,
49 Manual,
50}
51
52impl FindingOrigin {
53 pub fn as_str(self) -> &'static str {
54 match self {
55 FindingOrigin::Static => "Static",
56 FindingOrigin::Ai => "AI",
57 FindingOrigin::AiExploration => "AiExploration",
58 FindingOrigin::Manual => "Manual",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64pub enum TriageState {
65 Open,
66 Wontfix,
67 Dupe,
68 Accepted,
69}
70
71impl TriageState {
72 pub fn as_str(self) -> &'static str {
73 match self {
74 TriageState::Open => "Open",
75 TriageState::Wontfix => "Wontfix",
76 TriageState::Dupe => "Dupe",
77 TriageState::Accepted => "Accepted",
78 }
79 }
80}
81
82pub fn finding_id_hash(repo: &str, path: &str, line: Option<i64>, cap: &str, rule: &str) -> String {
91 let mut h = blake3::Hasher::new();
92 h.update(repo.as_bytes());
93 h.update(b"\0");
94 h.update(path.as_bytes());
95 h.update(b"\0");
96 h.update(&line.unwrap_or(-1).to_le_bytes());
97 h.update(b"\0");
98 h.update(cap.as_bytes());
99 h.update(b"\0");
100 h.update(rule.as_bytes());
101 let digest = h.finalize();
102 let bytes = digest.as_bytes();
103 let mut out = String::with_capacity(16);
104 for b in &bytes[..8] {
105 out.push_str(&format!("{b:02x}"));
106 }
107 out
108}
109
110#[derive(Debug, Default, Clone, Copy)]
114pub struct FindingFilter<'a> {
115 pub project_id: Option<&'a str>,
116 pub repo: Option<&'a str>,
117 pub run_id: Option<&'a str>,
118 pub cap: Option<&'a str>,
119 pub origin: Option<&'a str>,
120 pub status: Option<&'a str>,
121 pub severity: Option<&'a str>,
122 pub triage_state: Option<&'a str>,
123 pub chain_id: Option<&'a str>,
124 pub include_quarantine: bool,
128 pub limit: Option<i64>,
132}
133
134fn row_to_finding(row: sqlx::sqlite::SqliteRow) -> FindingRecord {
135 FindingRecord {
136 id: row.get("id"),
137 run_id: row.get("run_id"),
138 repo: row.get("repo"),
139 path: row.get("path"),
140 line: row.get("line"),
141 cap: row.get("cap"),
142 rule: row.get("rule"),
143 severity: row.get("severity"),
144 status: row.get("status"),
145 finding_origin: row.get("finding_origin"),
146 first_seen: row.get("first_seen"),
147 last_seen: row.get("last_seen"),
148 superseded_by: row.get("superseded_by"),
149 triage_state: row.get("triage_state"),
150 triage_assigned_to: row.get("triage_assigned_to"),
151 verdict_blob: row.get("verdict_blob"),
152 repro_path: row.get("repro_path"),
153 attack_provenance: row.get("attack_provenance"),
154 prompt_version: row.get("prompt_version"),
155 chain_id: row.get("chain_id"),
156 spec_id: row.get("spec_id"),
157 }
158}
159
160pub struct FindingStore<'a> {
161 pool: &'a SqlitePool,
162}
163
164impl<'a> FindingStore<'a> {
165 pub fn new(pool: &'a SqlitePool) -> Self {
166 Self { pool }
167 }
168
169 pub async fn upsert(&self, f: &FindingRecord) -> Result<(), StoreError> {
170 let mut tx = self.pool.begin().await?;
171 sqlx::query!(
172 r#"
173 INSERT INTO findings (
174 id, run_id, repo, path, line, cap, rule, severity, status,
175 finding_origin, first_seen, last_seen, superseded_by,
176 triage_state, triage_assigned_to, verdict_blob, repro_path,
177 attack_provenance, prompt_version, chain_id
178 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
179 ON CONFLICT(id) DO UPDATE SET
180 run_id = excluded.run_id,
181 severity = excluded.severity,
182 status = excluded.status,
183 finding_origin = excluded.finding_origin,
184 last_seen = excluded.last_seen,
185 superseded_by = excluded.superseded_by,
186 triage_state = excluded.triage_state,
187 triage_assigned_to = excluded.triage_assigned_to,
188 verdict_blob = excluded.verdict_blob,
189 repro_path = excluded.repro_path,
190 attack_provenance = excluded.attack_provenance,
191 prompt_version = excluded.prompt_version,
192 chain_id = excluded.chain_id
193 "#,
194 f.id,
195 f.run_id,
196 f.repo,
197 f.path,
198 f.line,
199 f.cap,
200 f.rule,
201 f.severity,
202 f.status,
203 f.finding_origin,
204 f.first_seen,
205 f.last_seen,
206 f.superseded_by,
207 f.triage_state,
208 f.triage_assigned_to,
209 f.verdict_blob,
210 f.repro_path,
211 f.attack_provenance,
212 f.prompt_version,
213 f.chain_id,
214 )
215 .execute(&mut *tx)
216 .await?;
217 sqlx::query(
222 "INSERT INTO run_findings (run_id, finding_id, status) \
223 VALUES (?, ?, ?) \
224 ON CONFLICT(run_id, finding_id) DO UPDATE SET status = excluded.status",
225 )
226 .bind(&f.run_id)
227 .bind(&f.id)
228 .bind(&f.status)
229 .execute(&mut *tx)
230 .await?;
231 tx.commit().await?;
232 Ok(())
233 }
234
235 pub async fn list_run_membership(
239 &self,
240 run_id: &str,
241 ) -> Result<Vec<(String, String)>, StoreError> {
242 let rows = sqlx::query_as::<_, (String, String)>(
243 "SELECT finding_id, status FROM run_findings WHERE run_id = ?",
244 )
245 .bind(run_id)
246 .fetch_all(self.pool)
247 .await?;
248 Ok(rows)
249 }
250
251 pub async fn get(&self, id: &str) -> Result<Option<FindingRecord>, StoreError> {
252 let row = sqlx::query_as!(
253 FindingRecord,
254 r#"
255 SELECT id AS "id!", run_id AS "run_id!", repo AS "repo!",
256 path AS "path!", line,
257 cap AS "cap!", rule AS "rule!", severity AS "severity!",
258 status AS "status!", finding_origin AS "finding_origin!",
259 first_seen AS "first_seen!: i64",
260 last_seen AS "last_seen!: i64",
261 superseded_by, triage_state AS "triage_state!",
262 triage_assigned_to, verdict_blob, repro_path,
263 attack_provenance, prompt_version, chain_id, spec_id
264 FROM findings WHERE id = ?
265 "#,
266 id
267 )
268 .fetch_optional(self.pool)
269 .await?;
270 Ok(row)
271 }
272
273 pub async fn list_active_for_repo(&self, repo: &str) -> Result<Vec<FindingRecord>, StoreError> {
276 let rows = sqlx::query_as!(
277 FindingRecord,
278 r#"
279 SELECT id AS "id!", run_id AS "run_id!", repo AS "repo!",
280 path AS "path!", line,
281 cap AS "cap!", rule AS "rule!", severity AS "severity!",
282 status AS "status!", finding_origin AS "finding_origin!",
283 first_seen AS "first_seen!: i64",
284 last_seen AS "last_seen!: i64",
285 superseded_by, triage_state AS "triage_state!",
286 triage_assigned_to, verdict_blob, repro_path,
287 attack_provenance, prompt_version, chain_id, spec_id
288 FROM findings
289 WHERE repo = ? AND status != 'Quarantine'
290 ORDER BY last_seen DESC
291 "#,
292 repo
293 )
294 .fetch_all(self.pool)
295 .await?;
296 Ok(rows)
297 }
298
299 pub async fn list_filtered(
306 &self,
307 filter: &FindingFilter<'_>,
308 ) -> Result<Vec<FindingRecord>, StoreError> {
309 let mut qb: QueryBuilder<sqlx::Sqlite> = QueryBuilder::new(
310 "SELECT id, run_id, repo, path, line, cap, rule, severity, status, \
311 finding_origin, first_seen, last_seen, superseded_by, triage_state, \
312 triage_assigned_to, verdict_blob, repro_path, attack_provenance, \
313 prompt_version, chain_id, spec_id FROM findings",
314 );
315 let mut needs_where = true;
316 let mut push_clause = |qb: &mut QueryBuilder<sqlx::Sqlite>| {
317 if needs_where {
318 qb.push(" WHERE ");
319 needs_where = false;
320 } else {
321 qb.push(" AND ");
322 }
323 };
324 if !filter.include_quarantine {
325 push_clause(&mut qb);
326 qb.push("status != 'Quarantine'");
327 }
328 if let Some(project_id) = filter.project_id {
329 push_clause(&mut qb);
330 qb.push("run_id IN (SELECT id FROM runs WHERE project_id = ")
331 .push_bind(project_id.to_string())
332 .push(")");
333 }
334 if let Some(repo) = filter.repo {
335 push_clause(&mut qb);
336 qb.push("repo = ").push_bind(repo.to_string());
337 }
338 if let Some(run_id) = filter.run_id {
339 push_clause(&mut qb);
340 qb.push("run_id = ").push_bind(run_id.to_string());
341 }
342 if let Some(cap) = filter.cap {
343 push_clause(&mut qb);
344 qb.push("cap = ").push_bind(cap.to_string());
345 }
346 if let Some(origin) = filter.origin {
347 push_clause(&mut qb);
348 qb.push("finding_origin = ").push_bind(origin.to_string());
349 }
350 if let Some(status) = filter.status {
351 push_clause(&mut qb);
352 qb.push("status = ").push_bind(status.to_string());
353 }
354 if let Some(severity) = filter.severity {
355 push_clause(&mut qb);
356 qb.push("severity = ").push_bind(severity.to_string());
357 }
358 if let Some(triage) = filter.triage_state {
359 push_clause(&mut qb);
360 qb.push("triage_state = ").push_bind(triage.to_string());
361 }
362 if let Some(chain_id) = filter.chain_id {
363 push_clause(&mut qb);
364 qb.push("chain_id = ").push_bind(chain_id.to_string());
365 }
366 qb.push(" ORDER BY last_seen DESC");
367 if let Some(limit) = filter.limit {
368 qb.push(" LIMIT ").push_bind(limit);
369 }
370 let rows = qb.build().fetch_all(self.pool).await?;
371 let out = rows.into_iter().map(row_to_finding).collect();
372 Ok(out)
373 }
374
375 pub async fn list_by_run(&self, run_id: &str) -> Result<Vec<FindingRecord>, StoreError> {
376 let rows = sqlx::query_as!(
377 FindingRecord,
378 r#"
379 SELECT id AS "id!", run_id AS "run_id!", repo AS "repo!",
380 path AS "path!", line,
381 cap AS "cap!", rule AS "rule!", severity AS "severity!",
382 status AS "status!", finding_origin AS "finding_origin!",
383 first_seen AS "first_seen!: i64",
384 last_seen AS "last_seen!: i64",
385 superseded_by, triage_state AS "triage_state!",
386 triage_assigned_to, verdict_blob, repro_path,
387 attack_provenance, prompt_version, chain_id, spec_id
388 FROM findings WHERE run_id = ? ORDER BY last_seen DESC
389 "#,
390 run_id
391 )
392 .fetch_all(self.pool)
393 .await?;
394 Ok(rows)
395 }
396
397 pub async fn set_triage(
398 &self,
399 id: &str,
400 state: &str,
401 assignee: Option<&str>,
402 ) -> Result<(), StoreError> {
403 sqlx::query!(
404 "UPDATE findings SET triage_state = ?, triage_assigned_to = ? WHERE id = ?",
405 state,
406 assignee,
407 id
408 )
409 .execute(self.pool)
410 .await?;
411 Ok(())
412 }
413
414 pub async fn set_chain(&self, id: &str, chain_id: &str) -> Result<(), StoreError> {
415 sqlx::query!("UPDATE findings SET chain_id = ? WHERE id = ?", chain_id, id)
416 .execute(self.pool)
417 .await?;
418 Ok(())
419 }
420
421 pub async fn set_spec(
428 &self,
429 id: &str,
430 spec_id: &str,
431 attack_provenance: &str,
432 prompt_version: &str,
433 ) -> Result<(), StoreError> {
434 sqlx::query(
435 "UPDATE findings SET spec_id = ?, attack_provenance = ?, prompt_version = ? \
436 WHERE id = ?",
437 )
438 .bind(spec_id)
439 .bind(attack_provenance)
440 .bind(prompt_version)
441 .bind(id)
442 .execute(self.pool)
443 .await?;
444 Ok(())
445 }
446
447 pub async fn set_attack_provenance(
455 &self,
456 id: &str,
457 attack_provenance: &str,
458 prompt_version: &str,
459 ) -> Result<(), StoreError> {
460 sqlx::query("UPDATE findings SET attack_provenance = ?, prompt_version = ? WHERE id = ?")
461 .bind(attack_provenance)
462 .bind(prompt_version)
463 .bind(id)
464 .execute(self.pool)
465 .await?;
466 Ok(())
467 }
468
469 pub async fn per_path_promotion_rate(
478 &self,
479 repo: &str,
480 ) -> Result<HashMap<String, f64>, StoreError> {
481 let rows = sqlx::query(
482 "SELECT path, \
483 SUM(CASE WHEN status IN ('Open','Verified') THEN 1 ELSE 0 END) AS promotions, \
484 COUNT(*) AS total \
485 FROM findings \
486 WHERE repo = ? AND attack_provenance IN ('AiExploration','LlmSynthesised') \
487 GROUP BY path",
488 )
489 .bind(repo)
490 .fetch_all(self.pool)
491 .await?;
492 let mut out = HashMap::with_capacity(rows.len());
493 for row in rows {
494 let path: String = row.get("path");
495 let promotions: i64 = row.get("promotions");
496 let total: i64 = row.get("total");
497 let rate = (promotions as f64) / (total as f64 + PROMOTION_RATE_LAPLACE_PRIOR);
498 out.insert(path, rate.clamp(0.0, 1.0));
499 }
500 Ok(out)
501 }
502
503 pub async fn set_verify_result(
508 &self,
509 id: &str,
510 status: &str,
511 verdict_blob: &str,
512 attack_provenance: &str,
513 ) -> Result<(), StoreError> {
514 sqlx::query(
515 "UPDATE findings SET status = ?, verdict_blob = ?, attack_provenance = ? \
516 WHERE id = ?",
517 )
518 .bind(status)
519 .bind(verdict_blob)
520 .bind(attack_provenance)
521 .bind(id)
522 .execute(self.pool)
523 .await?;
524 Ok(())
525 }
526
527 pub async fn supersede(&self, id: &str, by_id: &str) -> Result<(), StoreError> {
528 sqlx::query!("UPDATE findings SET superseded_by = ? WHERE id = ?", by_id, id)
529 .execute(self.pool)
530 .await?;
531 Ok(())
532 }
533
534 pub async fn delete(&self, id: &str) -> Result<u64, StoreError> {
535 let res = sqlx::query!("DELETE FROM findings WHERE id = ?", id).execute(self.pool).await?;
536 Ok(res.rows_affected())
537 }
538
539 pub async fn manual_promote(
547 &self,
548 id: &str,
549 new_status: &str,
550 verdict_blob: &str,
551 ) -> Result<(), StoreError> {
552 sqlx::query(
553 "UPDATE findings SET status = ?, verdict_blob = ?, attack_provenance = 'ManualPromote' \
554 WHERE id = ?",
555 )
556 .bind(new_status)
557 .bind(verdict_blob)
558 .bind(id)
559 .execute(self.pool)
560 .await?;
561 Ok(())
562 }
563
564 pub async fn manual_dismiss(&self, id: &str, verdict_blob: &str) -> Result<(), StoreError> {
571 sqlx::query(
572 "UPDATE findings SET status = 'Closed', verdict_blob = ?, \
573 attack_provenance = 'ManualDismiss' WHERE id = ?",
574 )
575 .bind(verdict_blob)
576 .bind(id)
577 .execute(self.pool)
578 .await?;
579 Ok(())
580 }
581
582 pub async fn quarantine(&self, id: &str, reason_json: &str) -> Result<u64, StoreError> {
591 let res =
592 sqlx::query("UPDATE findings SET status = 'Quarantine', verdict_blob = ? WHERE id = ?")
593 .bind(reason_json)
594 .bind(id)
595 .execute(self.pool)
596 .await?;
597 Ok(res.rows_affected())
598 }
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604 use crate::store::testutil::{
605 fresh_store, sample_chain, sample_finding, sample_repo, sample_repo_for_project, sample_run,
606 };
607
608 async fn seed(s: &crate::store::Store) {
609 s.repos().upsert(&sample_repo("repo-1")).await.expect("repo");
610 s.runs().insert(&sample_run("run-1")).await.expect("run");
611 }
612
613 #[tokio::test]
614 async fn stable_hash_converges_on_same_inputs() {
615 let a = finding_id_hash("r", "p/x.rs", Some(10), "sqli", "rule-1");
616 let b = finding_id_hash("r", "p/x.rs", Some(10), "sqli", "rule-1");
617 assert_eq!(a, b);
618 let c = finding_id_hash("r", "p/x.rs", Some(11), "sqli", "rule-1");
619 assert_ne!(a, c);
620 }
621
622 #[test]
623 fn stable_hash_is_16_hex_chars() {
624 let h = finding_id_hash("r", "p", Some(1), "c", "rule");
625 assert_eq!(h.len(), 16, "finding id hash truncated to 16 hex chars");
626 assert!(
627 h.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
628 "must be lowercase hex: {h}"
629 );
630 }
631
632 #[test]
633 fn stable_hash_distinguishes_each_field() {
634 let base = finding_id_hash("repo", "path", Some(7), "cap", "rule");
635 assert_ne!(base, finding_id_hash("REPO", "path", Some(7), "cap", "rule"));
636 assert_ne!(base, finding_id_hash("repo", "PATH", Some(7), "cap", "rule"));
637 assert_ne!(base, finding_id_hash("repo", "path", Some(8), "cap", "rule"));
638 assert_ne!(base, finding_id_hash("repo", "path", None, "cap", "rule"));
639 assert_ne!(base, finding_id_hash("repo", "path", Some(7), "CAP", "rule"));
640 assert_ne!(base, finding_id_hash("repo", "path", Some(7), "cap", "RULE"));
641 }
642
643 #[tokio::test]
644 async fn upsert_then_get_roundtrips() {
645 let (_tmp, s) = fresh_store().await;
646 seed(&s).await;
647 let f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
648 s.findings().upsert(&f).await.expect("insert");
649 let got = s.findings().get(&f.id).await.expect("get").expect("row");
650 assert_eq!(got, f);
651 }
652
653 #[tokio::test]
654 async fn upsert_updates_existing_row() {
655 let (_tmp, s) = fresh_store().await;
656 seed(&s).await;
657 let mut f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
658 s.findings().upsert(&f).await.expect("first");
659 f.severity = "Critical".to_string();
660 f.last_seen = 9_000;
661 s.findings().upsert(&f).await.expect("second");
662 let got = s.findings().get(&f.id).await.expect("get").expect("row");
663 assert_eq!(got.severity, "Critical");
664 assert_eq!(got.last_seen, 9_000);
665 assert_eq!(got.first_seen, 3_000);
667 }
668
669 #[tokio::test]
670 async fn list_active_excludes_quarantine() {
671 let (_tmp, s) = fresh_store().await;
672 seed(&s).await;
673 let mut quarantined = sample_finding("run-1", "repo-1", "src/q.rs", "rule-q");
674 quarantined.status = "Quarantine".to_string();
675 let open = sample_finding("run-1", "repo-1", "src/o.rs", "rule-o");
676 s.findings().upsert(&quarantined).await.expect("q");
677 s.findings().upsert(&open).await.expect("o");
678 let active = s.findings().list_active_for_repo("repo-1").await.expect("list");
679 let ids: Vec<_> = active.into_iter().map(|f| f.id).collect();
680 assert!(ids.contains(&open.id));
681 assert!(!ids.contains(&quarantined.id), "Quarantine rows must not appear in active list");
682 }
683
684 #[tokio::test]
685 async fn list_by_run_returns_only_matching_run() {
686 let (_tmp, s) = fresh_store().await;
687 seed(&s).await;
688 s.runs().insert(&sample_run("run-2")).await.expect("run-2");
689 let a = sample_finding("run-1", "repo-1", "src/a.rs", "ra");
690 let b = sample_finding("run-2", "repo-1", "src/b.rs", "rb");
691 s.findings().upsert(&a).await.expect("a");
692 s.findings().upsert(&b).await.expect("b");
693 let r1 = s.findings().list_by_run("run-1").await.expect("list");
694 assert_eq!(r1.len(), 1);
695 assert_eq!(r1[0].id, a.id);
696 }
697
698 #[tokio::test]
699 async fn set_triage_persists() {
700 let (_tmp, s) = fresh_store().await;
701 seed(&s).await;
702 let f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
703 s.findings().upsert(&f).await.expect("insert");
704 s.findings().set_triage(&f.id, "Wontfix", Some("alice")).await.expect("triage");
705 let got = s.findings().get(&f.id).await.expect("get").expect("row");
706 assert_eq!(got.triage_state, "Wontfix");
707 assert_eq!(got.triage_assigned_to.as_deref(), Some("alice"));
708 }
709
710 #[tokio::test]
711 async fn supersede_sets_pointer_and_clears_on_delete() {
712 let (_tmp, s) = fresh_store().await;
713 seed(&s).await;
714 let old = sample_finding("run-1", "repo-1", "src/o.rs", "rule-o");
715 let new = sample_finding("run-1", "repo-1", "src/n.rs", "rule-n");
716 s.findings().upsert(&old).await.expect("old");
717 s.findings().upsert(&new).await.expect("new");
718 s.findings().supersede(&old.id, &new.id).await.expect("supersede");
719 let got = s.findings().get(&old.id).await.expect("get").expect("row");
720 assert_eq!(got.superseded_by.as_deref(), Some(new.id.as_str()));
721
722 s.findings().delete(&new.id).await.expect("del new");
723 let got = s.findings().get(&old.id).await.expect("get").expect("row");
724 assert!(
725 got.superseded_by.is_none(),
726 "FK SET NULL should clear superseded_by when target deleted"
727 );
728 }
729
730 #[tokio::test]
731 async fn set_chain_links_finding_and_set_null_on_chain_delete() {
732 let (_tmp, s) = fresh_store().await;
733 seed(&s).await;
734 let f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
735 s.findings().upsert(&f).await.expect("finding");
736 let chain = sample_chain("chain-1", "run-1", &[&f.id]);
737 s.chains().insert(&chain).await.expect("chain");
738 s.findings().set_chain(&f.id, "chain-1").await.expect("link");
739 let got = s.findings().get(&f.id).await.expect("get").expect("row");
740 assert_eq!(got.chain_id.as_deref(), Some("chain-1"));
741
742 sqlx::query!("DELETE FROM chains WHERE id = ?", "chain-1")
743 .execute(s.pool())
744 .await
745 .expect("del chain");
746 let got = s.findings().get(&f.id).await.expect("get").expect("row");
747 assert!(got.chain_id.is_none(), "expected SET NULL on chain delete");
748 }
749
750 #[tokio::test]
751 async fn prompt_version_roundtrips() {
752 let (_tmp, s) = fresh_store().await;
753 seed(&s).await;
754 let mut f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
755 f.prompt_version = Some("prompts/finding/v17".to_string());
756 s.findings().upsert(&f).await.expect("insert");
757 let got = s.findings().get(&f.id).await.expect("get").expect("row");
758 assert_eq!(got.prompt_version.as_deref(), Some("prompts/finding/v17"));
759 }
760
761 #[tokio::test]
762 async fn spec_id_surfaces_on_every_read_path() {
763 let (_tmp, s) = fresh_store().await;
769 seed(&s).await;
770 let f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
771 s.findings().upsert(&f).await.expect("finding");
772 let spec = crate::store::HarnessSpecRecord {
773 id: "spec-1".to_string(),
774 cap: "SQL_QUERY".to_string(),
775 lang: "python".to_string(),
776 spec_blob: r#"{"schema_version":1,"cap":"SQL_QUERY"}"#.to_string(),
777 attack_provenance: Some("LlmSynthesised".to_string()),
778 prompt_version: Some("phase15.spec_derivation.v1".to_string()),
779 created_at: 4_000,
780 };
781 s.harness_specs()
782 .insert_with_finding_spec_link(
783 &spec,
784 &f.id,
785 "LlmSynthesised",
786 "phase15.spec_derivation.v1",
787 )
788 .await
789 .expect("dual write");
790
791 let got = s.findings().get(&f.id).await.expect("get").expect("row");
792 assert_eq!(got.spec_id.as_deref(), Some("spec-1"));
793
794 let active = s.findings().list_active_for_repo("repo-1").await.expect("active");
795 assert_eq!(
796 active.iter().find(|r| r.id == f.id).and_then(|r| r.spec_id.as_deref()),
797 Some("spec-1")
798 );
799
800 let by_run = s.findings().list_by_run("run-1").await.expect("by run");
801 assert_eq!(
802 by_run.iter().find(|r| r.id == f.id).and_then(|r| r.spec_id.as_deref()),
803 Some("spec-1")
804 );
805
806 let filtered = s
807 .findings()
808 .list_filtered(&FindingFilter { repo: Some("repo-1"), ..Default::default() })
809 .await
810 .expect("filtered");
811 assert_eq!(
812 filtered.iter().find(|r| r.id == f.id).and_then(|r| r.spec_id.as_deref()),
813 Some("spec-1")
814 );
815 }
816
817 #[tokio::test]
818 async fn upsert_does_not_clobber_existing_spec_id() {
819 let (_tmp, s) = fresh_store().await;
824 seed(&s).await;
825 let mut f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
826 s.findings().upsert(&f).await.expect("first insert");
827 let spec = crate::store::HarnessSpecRecord {
828 id: "spec-keep".to_string(),
829 cap: "SQL_QUERY".to_string(),
830 lang: "python".to_string(),
831 spec_blob: "{}".to_string(),
832 attack_provenance: None,
833 prompt_version: None,
834 created_at: 4_000,
835 };
836 s.harness_specs()
837 .insert_with_finding_spec_link(&spec, &f.id, "LlmSynthesised", "v1")
838 .await
839 .expect("dual write");
840 f.severity = "Critical".to_string();
841 f.last_seen = 9_000;
842 s.findings().upsert(&f).await.expect("re-upsert");
843 let got = s.findings().get(&f.id).await.expect("get").expect("row");
844 assert_eq!(got.spec_id.as_deref(), Some("spec-keep"));
845 assert_eq!(got.severity, "Critical");
846 }
847
848 #[tokio::test]
849 async fn list_filtered_combines_predicates() {
850 let (_tmp, s) = fresh_store().await;
851 seed(&s).await;
852 s.repos().upsert(&sample_repo("repo-2")).await.expect("repo-2");
853 s.runs().insert(&sample_run("run-2")).await.expect("run-2");
854 let mut a = sample_finding("run-1", "repo-1", "src/a.rs", "rule-a");
855 a.severity = "High".to_string();
856 a.finding_origin = "Static".to_string();
857 let mut b = sample_finding("run-1", "repo-1", "src/b.rs", "rule-b");
858 b.severity = "Low".to_string();
859 b.finding_origin = "AI".to_string();
860 let mut c = sample_finding("run-2", "repo-2", "src/c.rs", "rule-c");
861 c.severity = "High".to_string();
862 c.cap = "cmdi".to_string();
863 s.findings().upsert(&a).await.expect("a");
864 s.findings().upsert(&b).await.expect("b");
865 s.findings().upsert(&c).await.expect("c");
866
867 let all = s.findings().list_filtered(&FindingFilter::default()).await.expect("all");
868 assert_eq!(all.len(), 3);
869
870 let high = s
871 .findings()
872 .list_filtered(&FindingFilter { severity: Some("High"), ..Default::default() })
873 .await
874 .expect("sev");
875 let ids: Vec<_> = high.into_iter().map(|f| f.id).collect();
876 assert!(ids.contains(&a.id));
877 assert!(ids.contains(&c.id));
878 assert!(!ids.contains(&b.id));
879
880 let by_cap_and_run = s
881 .findings()
882 .list_filtered(&FindingFilter {
883 run_id: Some("run-2"),
884 cap: Some("cmdi"),
885 ..Default::default()
886 })
887 .await
888 .expect("cap+run");
889 assert_eq!(by_cap_and_run.len(), 1);
890 assert_eq!(by_cap_and_run[0].id, c.id);
891
892 let by_origin = s
893 .findings()
894 .list_filtered(&FindingFilter { origin: Some("AI"), ..Default::default() })
895 .await
896 .expect("origin");
897 assert_eq!(by_origin.len(), 1);
898 assert_eq!(by_origin[0].id, b.id);
899 }
900
901 #[tokio::test]
902 async fn list_filtered_scopes_by_project() {
903 let (_tmp, s) = fresh_store().await;
904 s.repos().upsert(&sample_repo("repo-1")).await.expect("repo-1");
905 s.projects()
906 .create("project-2", "project-2", None, None, None, 1_000)
907 .await
908 .expect("project-2");
909 s.repos().upsert(&sample_repo_for_project("repo-2", "project-2")).await.expect("repo-2");
910
911 let mut run_1 = sample_run("run-1");
912 run_1.project_id = Some(crate::store::project::DEFAULT_PROJECT_ID.to_string());
913 s.runs().insert(&run_1).await.expect("run-1");
914 let mut run_2 = sample_run("run-2");
915 run_2.project_id = Some("project-2".to_string());
916 s.runs().insert(&run_2).await.expect("run-2");
917
918 let a = sample_finding("run-1", "repo-1", "src/a.rs", "rule-a");
919 let b = sample_finding("run-2", "repo-2", "src/b.rs", "rule-b");
920 s.findings().upsert(&a).await.expect("a");
921 s.findings().upsert(&b).await.expect("b");
922
923 let got = s
924 .findings()
925 .list_filtered(&FindingFilter {
926 project_id: Some(crate::store::project::DEFAULT_PROJECT_ID),
927 ..Default::default()
928 })
929 .await
930 .expect("project filter");
931 assert_eq!(got.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(), vec![a.id.as_str()]);
932 }
933
934 #[tokio::test]
935 async fn list_filtered_excludes_quarantine_unless_opted_in() {
936 let (_tmp, s) = fresh_store().await;
937 seed(&s).await;
938 let open = sample_finding("run-1", "repo-1", "src/o.rs", "rule-o");
939 let mut quarantined = sample_finding("run-1", "repo-1", "src/q.rs", "rule-q");
940 quarantined.status = "Quarantine".to_string();
941 s.findings().upsert(&open).await.expect("o");
942 s.findings().upsert(&quarantined).await.expect("q");
943
944 let active = s.findings().list_filtered(&FindingFilter::default()).await.expect("active");
945 assert_eq!(active.len(), 1);
946 assert_eq!(active[0].id, open.id);
947
948 let everything = s
949 .findings()
950 .list_filtered(&FindingFilter { include_quarantine: true, ..Default::default() })
951 .await
952 .expect("everything");
953 assert_eq!(everything.len(), 2);
954 }
955
956 #[tokio::test]
957 async fn quarantine_flips_status_and_records_reason() {
958 let (_tmp, s) = fresh_store().await;
959 seed(&s).await;
960 let f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
961 s.findings().upsert(&f).await.expect("insert");
962 let n = s
963 .findings()
964 .quarantine(&f.id, "{\"reason\":\"payload synthesis failed twice\"}")
965 .await
966 .expect("quarantine");
967 assert_eq!(n, 1);
968 let got = s.findings().get(&f.id).await.expect("get").expect("row");
969 assert_eq!(got.status, "Quarantine");
970 assert!(got
971 .verdict_blob
972 .as_deref()
973 .unwrap_or_default()
974 .contains("payload synthesis failed twice"));
975 }
976
977 #[tokio::test]
978 async fn fk_required_run_id_rejects_unknown() {
979 let (_tmp, s) = fresh_store().await;
980 s.repos().upsert(&sample_repo("repo-1")).await.expect("repo");
982 let f = sample_finding("ghost", "repo-1", "src/a.rs", "rule-1");
983 let err = s.findings().upsert(&f).await.expect_err("must fail");
984 let msg = format!("{err}");
985 assert!(msg.to_lowercase().contains("foreign key"), "got: {msg}");
986 }
987
988 #[tokio::test]
989 async fn manual_promote_stamps_provenance_and_status() {
990 let (_tmp, s) = fresh_store().await;
991 seed(&s).await;
992 let mut f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
993 f.status = "Quarantine".to_string();
994 f.attack_provenance = Some("PayloadSynthesis".to_string());
995 f.verdict_blob = Some(r#"{"kind":"PayloadSynthFailed"}"#.to_string());
996 s.findings().upsert(&f).await.expect("insert");
997
998 s.findings()
999 .manual_promote(&f.id, "Open", r#"{"kind":"ManualPromote","from":"quarantine"}"#)
1000 .await
1001 .expect("promote");
1002 let got = s.findings().get(&f.id).await.expect("get").expect("row");
1003 assert_eq!(got.status, "Open");
1004 assert_eq!(got.attack_provenance.as_deref(), Some("ManualPromote"));
1005 assert_eq!(
1006 got.verdict_blob.as_deref(),
1007 Some(r#"{"kind":"ManualPromote","from":"quarantine"}"#),
1008 );
1009 }
1010
1011 #[tokio::test]
1012 async fn upsert_dual_writes_run_findings_row() {
1013 let (_tmp, s) = fresh_store().await;
1014 seed(&s).await;
1015 let f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
1016 s.findings().upsert(&f).await.expect("insert");
1017 let mem = s.findings().list_run_membership("run-1").await.expect("membership");
1018 assert_eq!(mem.len(), 1);
1019 assert_eq!(mem[0].0, f.id);
1020 assert_eq!(mem[0].1, "Open");
1021 }
1022
1023 #[tokio::test]
1024 async fn upsert_updates_run_findings_status_on_status_change() {
1025 let (_tmp, s) = fresh_store().await;
1026 seed(&s).await;
1027 let mut f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
1028 s.findings().upsert(&f).await.expect("insert");
1029 f.status = "Verified".to_string();
1030 s.findings().upsert(&f).await.expect("re-upsert");
1031 let mem = s.findings().list_run_membership("run-1").await.expect("membership");
1032 assert_eq!(mem.len(), 1);
1033 assert_eq!(mem[0].1, "Verified");
1034 }
1035
1036 #[tokio::test]
1037 async fn list_run_membership_scopes_to_run_id() {
1038 let (_tmp, s) = fresh_store().await;
1039 seed(&s).await;
1040 s.runs().insert(&sample_run("run-2")).await.expect("run-2");
1041 let f1 = sample_finding("run-1", "repo-1", "src/a.rs", "rule-a");
1042 let f2 = sample_finding("run-2", "repo-1", "src/b.rs", "rule-b");
1043 s.findings().upsert(&f1).await.expect("f1");
1044 s.findings().upsert(&f2).await.expect("f2");
1045
1046 let mem1 = s.findings().list_run_membership("run-1").await.expect("mem1");
1047 let ids1: Vec<&str> = mem1.iter().map(|(id, _)| id.as_str()).collect();
1048 assert_eq!(ids1.len(), 1);
1049 assert!(ids1.contains(&f1.id.as_str()));
1050
1051 let mem2 = s.findings().list_run_membership("run-2").await.expect("mem2");
1052 let ids2: Vec<&str> = mem2.iter().map(|(id, _)| id.as_str()).collect();
1053 assert_eq!(ids2.len(), 1);
1054 assert!(ids2.contains(&f2.id.as_str()));
1055 }
1056
1057 #[tokio::test]
1058 async fn run_findings_cascade_clears_on_run_delete() {
1059 let (_tmp, s) = fresh_store().await;
1060 seed(&s).await;
1061 let f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
1062 s.findings().upsert(&f).await.expect("insert");
1063 s.runs().delete("run-1").await.expect("delete run");
1064 let mem = s.findings().list_run_membership("run-1").await.expect("membership");
1065 assert!(mem.is_empty(), "FK cascade should empty run_findings on run delete");
1066 }
1067
1068 #[tokio::test]
1069 async fn per_path_promotion_rate_groups_ai_rows_by_path_and_applies_prior() {
1070 let (_tmp, s) = fresh_store().await;
1071 seed(&s).await;
1072 let mut a = sample_finding("run-1", "repo-1", "hot.py", "rule-a");
1076 a.attack_provenance = Some("LlmSynthesised".to_string());
1077 a.status = "Open".to_string();
1078 s.findings().upsert(&a).await.expect("a");
1079
1080 for i in 0..5 {
1082 let mut b = sample_finding("run-1", "repo-1", "warm.py", &format!("rule-b{i}"));
1083 b.attack_provenance = Some("AiExploration".to_string());
1084 b.status = "Verified".to_string();
1085 s.findings().upsert(&b).await.expect("b");
1086 }
1087
1088 for i in 0..2 {
1090 let mut c = sample_finding("run-1", "repo-1", "cold.py", &format!("rule-c{i}"));
1091 c.attack_provenance = Some("LlmSynthesised".to_string());
1092 c.status = "Quarantine".to_string();
1093 s.findings().upsert(&c).await.expect("c");
1094 }
1095
1096 let d = sample_finding("run-1", "repo-1", "static.py", "rule-d");
1098 s.findings().upsert(&d).await.expect("d");
1099
1100 let rates = s.findings().per_path_promotion_rate("repo-1").await.expect("rates");
1101 let a_rate = rates.get("hot.py").copied().unwrap_or(0.0);
1102 let b_rate = rates.get("warm.py").copied().unwrap_or(0.0);
1103 let c_rate = rates.get("cold.py").copied().unwrap_or(0.0);
1104 assert!((a_rate - 1.0 / 6.0).abs() < 1e-9, "hot.py rate: {a_rate}");
1105 assert!((b_rate - 0.5).abs() < 1e-9, "warm.py rate: {b_rate}");
1106 assert!(c_rate.abs() < 1e-9, "cold.py rate: {c_rate}");
1107 assert!(!rates.contains_key("static.py"), "static-pass rows must be excluded");
1108 assert!(b_rate > a_rate && a_rate > c_rate, "ordering must reflect promotion signal");
1109 }
1110
1111 #[tokio::test]
1112 async fn per_path_promotion_rate_scopes_to_repo() {
1113 let (_tmp, s) = fresh_store().await;
1114 seed(&s).await;
1115 s.repos().upsert(&sample_repo("repo-2")).await.expect("repo-2");
1116 let mut a = sample_finding("run-1", "repo-1", "shared.py", "rule-a");
1117 a.attack_provenance = Some("LlmSynthesised".to_string());
1118 a.status = "Open".to_string();
1119 s.findings().upsert(&a).await.expect("a");
1120 let mut b = sample_finding("run-1", "repo-2", "shared.py", "rule-b");
1121 b.attack_provenance = Some("LlmSynthesised".to_string());
1122 b.status = "Quarantine".to_string();
1123 s.findings().upsert(&b).await.expect("b");
1124 let r1 = s.findings().per_path_promotion_rate("repo-1").await.expect("r1");
1125 let r2 = s.findings().per_path_promotion_rate("repo-2").await.expect("r2");
1126 assert!(r1.get("shared.py").copied().unwrap_or(0.0) > 0.0);
1127 assert_eq!(r2.get("shared.py").copied().unwrap_or(0.0), 0.0);
1128 }
1129
1130 #[tokio::test]
1131 async fn manual_dismiss_stamps_provenance_and_closes_row() {
1132 let (_tmp, s) = fresh_store().await;
1133 seed(&s).await;
1134 let mut f = sample_finding("run-1", "repo-1", "src/a.rs", "rule-1");
1135 f.status = "Quarantine".to_string();
1136 f.attack_provenance = Some("SpecDerivation".to_string());
1137 s.findings().upsert(&f).await.expect("insert");
1138
1139 s.findings().manual_dismiss(&f.id, r#"{"kind":"ManualDismiss"}"#).await.expect("dismiss");
1140 let got = s.findings().get(&f.id).await.expect("get").expect("row");
1141 assert_eq!(got.status, "Closed");
1142 assert_eq!(got.attack_provenance.as_deref(), Some("ManualDismiss"));
1143 assert_eq!(got.verdict_blob.as_deref(), Some(r#"{"kind":"ManualDismiss"}"#));
1144 }
1145}