Skip to main content

nyx_agent_core/store/
finding.rs

1//! `findings` table - the main aggregated finding store with stable hash
2//! IDs so re-running a scan over the same code converges on the same row.
3
4use 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
13/// Smoothing prior baked into [`FindingStore::per_path_promotion_rate`].
14/// A path with only one observation must not get the full rate boost a
15/// path with 50 observations does; this denominator floor dampens
16/// low-cardinality rows. Picked at 5 because nyx-side AI passes
17/// typically observe a path 1-3 times in one run.
18pub 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    /// AI-discovered candidate finding that the verifier promoted
44    /// from `candidate_findings.Pending` to a real `findings` row.
45    /// Distinct from the bare `Ai` variant because the originating
46    /// signal is the agent's source-code exploration, not a
47    /// static-pass diag the agent later annotated.
48    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
82/// Stable hash for a finding row. Repeating a scan over the same
83/// `(repo, path, line, cap, rule)` tuple converges on the same id.
84///
85/// BLAKE3 over a NUL-delimited tuple, truncated to the first 16 hex
86/// characters (64 bits of state). The truncation is deliberate: UI rows
87/// quote the id and 64 hex characters wrap badly. 16 hex chars gives
88/// 2^32 expected pairs before a collision becomes more likely than not,
89/// which is well above the row counts a single deployment scans.
90pub 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/// Filter accepted by [`FindingStore::list_filtered`]. Borrows from the
111/// caller so the API can hand its query parameters straight through
112/// without cloning.
113#[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    /// When `false` (default) rows with `status = 'Quarantine'` are
125    /// excluded. The default findings list view leaves this off;
126    /// the Quarantine page passes `true`.
127    pub include_quarantine: bool,
128    /// Optional row cap. `None` means "no LIMIT" - the UI is expected to
129    /// stay below ~10k rows per page so a cap is informative, not a
130    /// safety net.
131    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        // Mirror the observation onto `run_findings` so the
218        // FindingDiffStatus classifier can compare current-run
219        // membership against prior runs. Runtime-checked SQL so the
220        // `.sqlx/` cache does not grow for a one-line dual-write.
221        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    /// All `(finding_id, status)` pairs observed during `run_id`.
236    /// Backs the `FindingDiffStatus` classifier in the API layer.
237    /// Returns an empty vec when the run never observed any finding.
238    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    /// List findings for `repo` filtered by the UI's standard
274    /// `(cap, status, origin)` triple. Quarantine rows are excluded.
275    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    /// Composite filter used by the findings browser. Every
300    /// field is optional; combining them ANDs in SQLite, and an empty
301    /// filter returns every active row (i.e. status != Quarantine
302    /// unless [`FindingFilter::include_quarantine`] is set). Ordering
303    /// matches [`FindingStore::list_active_for_repo`] / [`FindingStore::list_by_run`]: most-recent
304    /// `last_seen` first.
305    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    /// Stamp `id` with the SpecDerivation result. Sets the `spec_id`
422    /// back-link plus the `attack_provenance` / `prompt_version`
423    /// columns so the findings detail view can render "AI synthesised
424    /// the harness spec for this row" without an extra join. Runtime-
425    /// checked SQL to keep the `.sqlx/` cache from growing for a
426    /// helper that only runs once per finding per scan.
427    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    /// Stamp `id` with the supplied `attack_provenance` + `prompt_version`
448    /// pair without touching status / verdict_blob. Used after
449    /// PayloadSynthesis insert so the finding's detail view can render
450    /// "AI synthesised the payload for this row" without joining
451    /// through the `payloads` table. Runtime-checked SQL to keep the
452    /// `.sqlx/` cache from growing for a helper called once per
453    /// finding per scan.
454    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    /// Per-path AI-finding promotion rate for `repo`, in [0.0, 1.0].
470    /// Numerator: AI-originated findings on the path whose final
471    /// status is `Open` (verifier-confirmed) or `Verified` (operator-
472    /// promoted). Denominator: total AI-originated findings on the
473    /// path, plus a Laplace prior so a path with one observation does
474    /// not score the same as a path with fifty. Backs the file
475    /// priority heuristic in the Novel discovery walker: paths that
476    /// historically converted are more worth burning AI budget on.
477    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    /// Stamp the verifier outcome on `id`: flips `status` (Verified
504    /// for Confirmed, Closed for NotConfirmed, untouched for Errored,
505    /// where the caller passes the row's existing status) and
506    /// overwrites `verdict_blob` + `attack_provenance`.
507    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    /// Operator-driven promote: flip `id` to `new_status`, write the
540    /// supplied JSON `verdict_blob`, and stamp
541    /// `attack_provenance = 'ManualPromote'` so the audit trail
542    /// distinguishes operator overrides from verifier-confirmed rows.
543    /// Mirror image of the candidate-side `promote_candidate_to_finding`
544    /// path: both writers now produce a typed `ManualPromote` blob
545    /// instead of reusing `set_verify_result`.
546    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    /// Operator-driven dismissal: flip `id` to `'Closed'`, write the
565    /// supplied JSON `verdict_blob`, and stamp
566    /// `attack_provenance = 'ManualDismiss'`. Used for the quarantine
567    /// dismiss flow, where reusing `set_verify_result` would silently
568    /// inherit the prior verifier's provenance and obscure the
569    /// operator's intent.
570    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    /// Flip `id` to `status = 'Quarantine'`, stamping `verdict_blob`
583    /// with the supplied JSON reason. PayloadSynthesis falls back
584    /// here when both synthesis attempts fail to parse;
585    /// SpecDerivation and NovelFindingsDiscovery reuse the same
586    /// shape so the quarantine page can surface a uniform reason
587    /// field. Runtime-checked SQL to avoid bloating the `.sqlx/` cache
588    /// with a one-off operator-facing helper; the parameter is bound,
589    /// so injection is not a concern.
590    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        // first_seen is NOT overwritten on conflict.
666        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        // SpecDerivation writes `findings.spec_id` via
764        // `HarnessSpecStore::insert_with_finding_spec_link`. The reads
765        // exposed by `FindingStore::get` / `list_active_for_repo` /
766        // `list_by_run` / `list_filtered` must all project the column
767        // so a UI back-link can render without joining `harness_specs`.
768        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        // A re-scan upserts a FindingRecord constructed by the static
820        // pass with `spec_id: None`. The existing AI-side back-link on
821        // the row must survive the conflict-update so the UI does not
822        // lose the SpecDerivation pointer between scans.
823        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        // intentionally do NOT insert run "ghost"
981        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        // Path A: 1 AI-promoted (Open) row. With Laplace prior 5 the
1073        // rate floats around 1/(1+5)=0.167: non-zero, but well below
1074        // the full-confidence ceiling.
1075        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        // Path B: 5 AI-promoted rows. Rate -> 5/(5+5) = 0.5.
1081        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        // Path C: 2 AI-rejected (Quarantine) rows. Rate -> 0/(2+5) = 0.
1089        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        // Static-pass row on path D must NOT count (no attack_provenance).
1097        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}