Skip to main content

remem/memory/
suppression.rs

1use std::collections::HashSet;
2
3use anyhow::{anyhow, bail, Context, Result};
4use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
5use serde::Serialize;
6
7const ACTIVE_STATUS: &str = "active";
8const DEFAULT_ACTOR: &str = "cli";
9const DEFAULT_REASON: &str = "manual suppression";
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12pub struct SuppressionTarget {
13    pub kind: String,
14    pub id: Option<i64>,
15    pub value: Option<String>,
16}
17
18impl SuppressionTarget {
19    pub fn label(&self) -> String {
20        match (self.id, self.value.as_deref()) {
21            (Some(id), _) => format!("{}:{id}", self.kind),
22            (None, Some(value)) => format!("{}:{value}", self.kind),
23            (None, None) => self.kind.clone(),
24        }
25    }
26}
27
28#[derive(Debug, Clone, Serialize)]
29pub struct SuppressionRecord {
30    pub id: i64,
31    pub owner_scope: Option<String>,
32    pub owner_key: Option<String>,
33    pub target_kind: String,
34    pub target_id: Option<i64>,
35    pub target_value: Option<String>,
36    pub reason: String,
37    pub actor: String,
38    pub status: String,
39    pub created_at_epoch: i64,
40    pub updated_at_epoch: i64,
41}
42
43#[derive(Debug, Clone, Serialize)]
44pub struct FeedbackRecord {
45    pub id: i64,
46    pub target_kind: String,
47    pub target_id: Option<i64>,
48    pub target_value: Option<String>,
49    pub feedback: String,
50    pub source: String,
51    pub context_injection_item_id: Option<i64>,
52    pub session_id: Option<String>,
53    pub project: Option<String>,
54    pub reason: Option<String>,
55    pub created_at_epoch: i64,
56}
57
58#[derive(Debug, Clone)]
59pub struct SuppressRequest<'a> {
60    pub target: SuppressionTarget,
61    pub reason: Option<&'a str>,
62    pub actor: Option<&'a str>,
63}
64
65#[derive(Debug, Clone)]
66pub struct FeedbackRequest<'a> {
67    pub target: SuppressionTarget,
68    pub feedback: &'a str,
69    pub source: Option<&'a str>,
70    pub context_injection_item_id: Option<i64>,
71    pub session_id: Option<&'a str>,
72    pub project: Option<&'a str>,
73    pub reason: Option<&'a str>,
74}
75
76pub fn parse_target(raw: &str) -> Result<SuppressionTarget> {
77    let input = raw.trim();
78    if input.is_empty() {
79        bail!("suppression target cannot be empty");
80    }
81    if let Ok(id) = input.parse::<i64>() {
82        if id <= 0 {
83            bail!("memory target id must be positive");
84        }
85        return Ok(SuppressionTarget {
86            kind: "memory".to_string(),
87            id: Some(id),
88            value: None,
89        });
90    }
91
92    let Some((kind_raw, value_raw)) = input.split_once(':') else {
93        return Ok(SuppressionTarget {
94            kind: "topic_key".to_string(),
95            id: None,
96            value: Some(input.to_string()),
97        });
98    };
99    let kind = normalize_kind(kind_raw)?;
100    let value = value_raw.trim();
101    if value.is_empty() {
102        bail!("suppression target value cannot be empty");
103    }
104
105    if id_target_kind(&kind) {
106        let id = value
107            .parse::<i64>()
108            .with_context(|| format!("{kind} target requires an integer id"))?;
109        if id <= 0 {
110            bail!("{kind} target id must be positive");
111        }
112        return Ok(SuppressionTarget {
113            kind,
114            id: Some(id),
115            value: None,
116        });
117    }
118
119    Ok(SuppressionTarget {
120        kind,
121        id: None,
122        value: Some(value.to_string()),
123    })
124}
125
126pub fn memory_policy_filter_sql(alias: &str) -> String {
127    format!(
128        "NOT EXISTS (
129             SELECT 1
130             FROM memory_suppressions ms
131             WHERE ms.status = 'active'
132               AND (
133                    (ms.target_kind = 'memory' AND ms.target_id = {alias}.id)
134                 OR (ms.target_kind = 'topic_key'
135                     AND ms.target_value IS NOT NULL
136                     AND {alias}.topic_key = ms.target_value)
137                 OR (ms.target_kind = 'entity'
138                     AND ms.target_value IS NOT NULL
139                     AND EXISTS (
140                         SELECT 1
141                         FROM memory_entities ms_me
142                         JOIN entities ms_e ON ms_e.id = ms_me.entity_id
143                         WHERE ms_me.memory_id = {alias}.id
144                           AND lower(ms_e.canonical_name) = lower(ms.target_value)
145                     ))
146                 OR (ms.target_kind = 'pattern'
147                     AND ms.target_value IS NOT NULL
148                     AND (
149                         instr(lower({alias}.title), lower(ms.target_value)) > 0
150                      OR instr(lower({alias}.content), lower(ms.target_value)) > 0
151                     ))
152               )
153         )"
154    )
155}
156
157pub fn user_claim_policy_filter_sql(alias: &str) -> String {
158    format!(
159        "NOT EXISTS (
160             SELECT 1
161             FROM memory_suppressions ms
162             WHERE ms.status = 'active'
163               AND (
164                    (ms.target_kind = 'user_claim' AND ms.target_id = {alias}.id)
165                 OR (ms.target_kind = 'pattern'
166                     AND ms.target_value IS NOT NULL
167                     AND (
168                         instr(lower({alias}.claim_text), lower(ms.target_value)) > 0
169                      OR instr(lower({alias}.claim_key), lower(ms.target_value)) > 0
170                     ))
171               )
172         )"
173    )
174}
175
176pub fn user_claim_is_policy_suppressed(conn: &Connection, claim_id: i64) -> Result<bool> {
177    let sql = format!(
178        "SELECT NOT ({}) FROM user_context_claims WHERE id = ?1",
179        user_claim_policy_filter_sql("user_context_claims")
180    );
181    conn.query_row(&sql, [claim_id], |row| row.get::<_, bool>(0))
182        .optional()?
183        .context("user-context claim disappeared during suppression check")
184}
185
186pub fn active_suppressed_memory_ids(conn: &Connection, ids: &[i64]) -> Result<HashSet<i64>> {
187    if ids.is_empty() {
188        return Ok(HashSet::new());
189    }
190    let placeholders = (1..=ids.len())
191        .map(|idx| format!("?{idx}"))
192        .collect::<Vec<_>>()
193        .join(", ");
194    let sql = format!(
195        "SELECT m.id
196         FROM memories m
197         WHERE m.id IN ({placeholders})
198           AND NOT ({})",
199        memory_policy_filter_sql("m")
200    );
201    let mut stmt = conn.prepare(&sql)?;
202    let rows = stmt.query_map(params_from_iter(ids.iter()), |row| row.get::<_, i64>(0))?;
203    let suppressed = crate::db::query::collect_rows(rows)?;
204    Ok(suppressed.into_iter().collect())
205}
206
207pub fn has_active_suppressions(conn: &Connection) -> Result<bool> {
208    let count: i64 = conn.query_row(
209        "SELECT COUNT(*) FROM memory_suppressions WHERE status = 'active'",
210        [],
211        |row| row.get(0),
212    )?;
213    Ok(count > 0)
214}
215
216pub fn create_suppression(
217    conn: &Connection,
218    req: &SuppressRequest<'_>,
219) -> Result<SuppressionRecord> {
220    validate_target(&req.target)?;
221    let reason = normalize_text(req.reason, DEFAULT_REASON)?;
222    let actor = normalize_text(req.actor, DEFAULT_ACTOR)?;
223    if let Some(existing) = load_active_suppression_for_target(conn, &req.target)? {
224        return Ok(existing);
225    }
226    let now = chrono::Utc::now().timestamp();
227    let tx = conn.unchecked_transaction()?;
228    tx.execute(
229        "INSERT INTO memory_suppressions
230         (owner_scope, owner_key, target_kind, target_id, target_value, reason, actor,
231          status, created_at_epoch, updated_at_epoch)
232         VALUES (NULL, NULL, ?1, ?2, ?3, ?4, ?5, 'active', ?6, ?6)",
233        params![
234            req.target.kind,
235            req.target.id,
236            req.target.value,
237            reason,
238            actor,
239            now
240        ],
241    )
242    .context("insert memory suppression")?;
243    crate::memory::preference::compilation::enqueue_for_suppression_targets(
244        &tx,
245        std::slice::from_ref(&req.target),
246    )?;
247    let record = load_suppression(&tx, tx.last_insert_rowid())?;
248    tx.commit()?;
249    Ok(record)
250}
251
252pub fn revoke_suppression_arg(
253    conn: &Connection,
254    arg: &str,
255    reason: Option<&str>,
256    actor: Option<&str>,
257) -> Result<Vec<SuppressionRecord>> {
258    let actor = normalize_text(actor, DEFAULT_ACTOR)?;
259    let reason = normalize_text(reason, "manual unsuppression")?;
260    if let Ok(id) = arg.trim().parse::<i64>() {
261        if let Some(record) = load_suppression_optional(conn, id)? {
262            if record.status != ACTIVE_STATUS {
263                bail!("suppression {id} is already {}", record.status);
264            }
265            return revoke_suppression_ids(conn, &[id], &reason, &actor);
266        }
267    }
268    let target = parse_target(arg)?;
269    let active = active_suppressions_for_target(conn, &target)?;
270    if active.is_empty() {
271        bail!("no active suppression found for {}", target.label());
272    }
273    let ids = active.iter().map(|record| record.id).collect::<Vec<_>>();
274    revoke_suppression_ids(conn, &ids, &reason, &actor)
275}
276
277pub fn record_feedback(conn: &Connection, req: &FeedbackRequest<'_>) -> Result<FeedbackRecord> {
278    validate_target(&req.target)?;
279    let feedback = normalize_feedback(req.feedback)?;
280    let source = normalize_text(req.source, DEFAULT_ACTOR)?;
281    let reason = optional_trimmed(req.reason);
282    let session_id = optional_trimmed(req.session_id);
283    let project = optional_trimmed(req.project);
284    let now = chrono::Utc::now().timestamp();
285    conn.execute(
286        "INSERT INTO memory_feedback
287         (target_kind, target_id, target_value, feedback, source,
288          context_injection_item_id, session_id, project, reason, created_at_epoch)
289         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
290        params![
291            req.target.kind,
292            req.target.id,
293            req.target.value,
294            feedback,
295            source,
296            req.context_injection_item_id,
297            session_id,
298            project,
299            reason,
300            now,
301        ],
302    )
303    .context("insert memory feedback")?;
304    load_feedback(conn, conn.last_insert_rowid())
305}
306
307pub fn list_suppressions(
308    conn: &Connection,
309    include_inactive: bool,
310) -> Result<Vec<SuppressionRecord>> {
311    let sql = if include_inactive {
312        "SELECT id, owner_scope, owner_key, target_kind, target_id, target_value,
313                reason, actor, status, created_at_epoch, updated_at_epoch
314         FROM memory_suppressions
315         ORDER BY updated_at_epoch DESC, id DESC"
316    } else {
317        "SELECT id, owner_scope, owner_key, target_kind, target_id, target_value,
318                reason, actor, status, created_at_epoch, updated_at_epoch
319         FROM memory_suppressions
320         WHERE status = 'active'
321         ORDER BY updated_at_epoch DESC, id DESC"
322    };
323    let mut stmt = conn.prepare(sql)?;
324    let rows = stmt.query_map([], suppression_from_row)?;
325    crate::db::query::collect_rows(rows)
326}
327
328pub fn active_suppressions_for_memory(
329    conn: &Connection,
330    memory_id: i64,
331) -> Result<Vec<SuppressionRecord>> {
332    let mut stmt = conn.prepare(
333        "SELECT ms.id, ms.owner_scope, ms.owner_key, ms.target_kind, ms.target_id,
334                ms.target_value, ms.reason, ms.actor, ms.status,
335                ms.created_at_epoch, ms.updated_at_epoch
336         FROM memory_suppressions ms
337         JOIN memories m ON m.id = ?1
338         WHERE ms.status = 'active'
339           AND (
340                (ms.target_kind = 'memory' AND ms.target_id = m.id)
341             OR (ms.target_kind = 'topic_key'
342                 AND ms.target_value IS NOT NULL
343                 AND m.topic_key = ms.target_value)
344             OR (ms.target_kind = 'entity'
345                 AND ms.target_value IS NOT NULL
346                 AND EXISTS (
347                     SELECT 1
348                     FROM memory_entities ms_me
349                     JOIN entities ms_e ON ms_e.id = ms_me.entity_id
350                     WHERE ms_me.memory_id = m.id
351                       AND lower(ms_e.canonical_name) = lower(ms.target_value)
352                 ))
353             OR (ms.target_kind = 'pattern'
354                 AND ms.target_value IS NOT NULL
355                 AND (
356                     instr(lower(m.title), lower(ms.target_value)) > 0
357                  OR instr(lower(m.content), lower(ms.target_value)) > 0
358                 ))
359           )
360         ORDER BY ms.updated_at_epoch DESC, ms.id DESC",
361    )?;
362    let rows = stmt.query_map([memory_id], suppression_from_row)?;
363    crate::db::query::collect_rows(rows)
364}
365
366fn revoke_suppression_ids(
367    conn: &Connection,
368    ids: &[i64],
369    reason: &str,
370    actor: &str,
371) -> Result<Vec<SuppressionRecord>> {
372    let now = chrono::Utc::now().timestamp();
373    let tx = conn.unchecked_transaction()?;
374    let active = ids
375        .iter()
376        .map(|id| load_suppression(&tx, *id))
377        .collect::<Result<Vec<_>>>()?;
378    for id in ids {
379        let updated = tx.execute(
380            "UPDATE memory_suppressions
381             SET status = 'revoked',
382                 reason = ?1,
383                 actor = ?2,
384                 updated_at_epoch = ?3
385             WHERE id = ?4 AND status = 'active'",
386            params![reason, actor, now, id],
387        )?;
388        if updated != 1 {
389            bail!("suppression {id} was not active during revocation");
390        }
391    }
392    let targets = active
393        .into_iter()
394        .map(|record| SuppressionTarget {
395            kind: record.target_kind,
396            id: record.target_id,
397            value: record.target_value,
398        })
399        .collect::<Vec<_>>();
400    crate::memory::preference::compilation::enqueue_for_suppression_targets(&tx, &targets)?;
401    let mut revoked = Vec::new();
402    for id in ids {
403        revoked.push(load_suppression(&tx, *id)?);
404    }
405    tx.commit()?;
406    Ok(revoked)
407}
408
409fn load_active_suppression_for_target(
410    conn: &Connection,
411    target: &SuppressionTarget,
412) -> Result<Option<SuppressionRecord>> {
413    let mut active = active_suppressions_for_target(conn, target)?;
414    Ok(active.pop())
415}
416
417fn active_suppressions_for_target(
418    conn: &Connection,
419    target: &SuppressionTarget,
420) -> Result<Vec<SuppressionRecord>> {
421    let mut stmt = conn.prepare(
422        "SELECT id, owner_scope, owner_key, target_kind, target_id, target_value,
423                reason, actor, status, created_at_epoch, updated_at_epoch
424         FROM memory_suppressions
425         WHERE status = 'active'
426           AND target_kind = ?1
427           AND (
428                (target_id IS NOT NULL AND target_id = ?2)
429             OR (target_value IS NOT NULL AND target_value = ?3)
430           )
431         ORDER BY updated_at_epoch DESC, id DESC",
432    )?;
433    let rows = stmt.query_map(
434        params![target.kind, target.id, target.value],
435        suppression_from_row,
436    )?;
437    crate::db::query::collect_rows(rows)
438}
439
440fn load_suppression(conn: &Connection, id: i64) -> Result<SuppressionRecord> {
441    load_suppression_optional(conn, id)?.ok_or_else(|| anyhow!("suppression {id} not found"))
442}
443
444fn load_suppression_optional(conn: &Connection, id: i64) -> Result<Option<SuppressionRecord>> {
445    conn.query_row(
446        "SELECT id, owner_scope, owner_key, target_kind, target_id, target_value,
447                reason, actor, status, created_at_epoch, updated_at_epoch
448         FROM memory_suppressions
449         WHERE id = ?1",
450        [id],
451        suppression_from_row,
452    )
453    .optional()
454    .map_err(Into::into)
455}
456
457fn load_feedback(conn: &Connection, id: i64) -> Result<FeedbackRecord> {
458    conn.query_row(
459        "SELECT id, target_kind, target_id, target_value, feedback, source,
460                context_injection_item_id, session_id, project, reason, created_at_epoch
461         FROM memory_feedback
462         WHERE id = ?1",
463        [id],
464        feedback_from_row,
465    )
466    .optional()?
467    .ok_or_else(|| anyhow!("feedback {id} not found"))
468}
469
470fn suppression_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SuppressionRecord> {
471    Ok(SuppressionRecord {
472        id: row.get(0)?,
473        owner_scope: row.get(1)?,
474        owner_key: row.get(2)?,
475        target_kind: row.get(3)?,
476        target_id: row.get(4)?,
477        target_value: row.get(5)?,
478        reason: row.get(6)?,
479        actor: row.get(7)?,
480        status: row.get(8)?,
481        created_at_epoch: row.get(9)?,
482        updated_at_epoch: row.get(10)?,
483    })
484}
485
486fn feedback_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<FeedbackRecord> {
487    Ok(FeedbackRecord {
488        id: row.get(0)?,
489        target_kind: row.get(1)?,
490        target_id: row.get(2)?,
491        target_value: row.get(3)?,
492        feedback: row.get(4)?,
493        source: row.get(5)?,
494        context_injection_item_id: row.get(6)?,
495        session_id: row.get(7)?,
496        project: row.get(8)?,
497        reason: row.get(9)?,
498        created_at_epoch: row.get(10)?,
499    })
500}
501
502fn validate_target(target: &SuppressionTarget) -> Result<()> {
503    normalize_kind(&target.kind)?;
504    match target.kind.as_str() {
505        "memory" | "user_claim" | "user_candidate" => {
506            if target.id.is_none() {
507                bail!("{} suppression target requires an id", target.kind);
508            }
509        }
510        "topic_key" | "entity" | "pattern" => {
511            if target
512                .value
513                .as_deref()
514                .is_none_or(|value| value.trim().is_empty())
515            {
516                bail!("{} suppression target requires a value", target.kind);
517            }
518        }
519        "summary" => {
520            if target.id.is_none() && target.value.as_deref().is_none_or(str::is_empty) {
521                bail!("summary suppression target requires an id or value");
522            }
523        }
524        _ => unreachable!("normalize_kind accepted an unknown target kind"),
525    }
526    Ok(())
527}
528
529fn normalize_kind(raw: &str) -> Result<String> {
530    let normalized = raw.trim().replace('-', "_");
531    let kind = match normalized.as_str() {
532        "memory" | "mem" => "memory",
533        "claim" | "user_claim" | "user_context_claim" => "user_claim",
534        "candidate" | "user_candidate" => "user_candidate",
535        "topic" | "topic_key" => "topic_key",
536        "entity" => "entity",
537        "pattern" => "pattern",
538        "summary" | "summary_line" => "summary",
539        _ => bail!("unsupported suppression target kind: {raw}"),
540    };
541    Ok(kind.to_string())
542}
543
544fn id_target_kind(kind: &str) -> bool {
545    matches!(kind, "memory" | "user_claim" | "user_candidate")
546}
547
548fn normalize_feedback(raw: &str) -> Result<&'static str> {
549    match raw.trim().replace('-', "_").as_str() {
550        "relevant" => Ok("relevant"),
551        "not_relevant" => Ok("not_relevant"),
552        "harmful" => Ok("harmful"),
553        "stale" => Ok("stale"),
554        "too_noisy" => Ok("too_noisy"),
555        _ => bail!("unsupported feedback value: {raw}"),
556    }
557}
558
559fn normalize_text<'a>(value: Option<&'a str>, default: &'a str) -> Result<String> {
560    let normalized = value.unwrap_or(default).trim();
561    if normalized.is_empty() {
562        bail!("value cannot be empty");
563    }
564    Ok(normalized.to_string())
565}
566
567fn optional_trimmed(value: Option<&str>) -> Option<String> {
568    value
569        .map(str::trim)
570        .filter(|value| !value.is_empty())
571        .map(str::to_string)
572}
573
574#[cfg(test)]
575mod tests {
576    use anyhow::Result;
577    use rusqlite::{params, Connection};
578
579    use super::*;
580    use crate::db::test_support::ScopedTestDataDir;
581
582    #[test]
583    fn parse_target_accepts_memory_claim_and_text_keys() -> Result<()> {
584        assert_eq!(
585            parse_target("42")?,
586            SuppressionTarget {
587                kind: "memory".to_string(),
588                id: Some(42),
589                value: None,
590            }
591        );
592        assert_eq!(
593            parse_target("claim:7")?,
594            SuppressionTarget {
595                kind: "user_claim".to_string(),
596                id: Some(7),
597                value: None,
598            }
599        );
600        assert_eq!(
601            parse_target("topic:rust")?,
602            SuppressionTarget {
603                kind: "topic_key".to_string(),
604                id: None,
605                value: Some("rust".to_string()),
606            }
607        );
608        assert_eq!(
609            parse_target("rust")?,
610            SuppressionTarget {
611                kind: "topic_key".to_string(),
612                id: None,
613                value: Some("rust".to_string()),
614            }
615        );
616        Ok(())
617    }
618
619    #[test]
620    fn suppression_records_and_revokes_policy_without_deleting_memory() -> Result<()> {
621        let conn = Connection::open_in_memory()?;
622        crate::migrate::run_migrations(&conn)?;
623        conn.execute(
624            "INSERT INTO memories
625             (id, project, topic_key, title, content, memory_type, created_at_epoch, updated_at_epoch, status)
626             VALUES (1, '/repo', 'topic-a', 'Suppressed', 'body', 'decision', 10, 10, 'active')",
627            [],
628        )?;
629        let target = parse_target("memory:1")?;
630        let record = create_suppression(
631            &conn,
632            &SuppressRequest {
633                target: target.clone(),
634                reason: Some("stale"),
635                actor: Some("test"),
636            },
637        )?;
638        assert_eq!(record.status, "active");
639        assert_eq!(active_suppressed_memory_ids(&conn, &[1])?.len(), 1);
640        let still_exists: i64 =
641            conn.query_row("SELECT COUNT(*) FROM memories WHERE id = 1", [], |row| {
642                row.get(0)
643            })?;
644        assert_eq!(still_exists, 1);
645
646        let revoked = revoke_suppression_arg(&conn, &record.id.to_string(), None, None)?;
647        assert_eq!(revoked[0].status, "revoked");
648        assert!(active_suppressed_memory_ids(&conn, &[1])?.is_empty());
649        Ok(())
650    }
651
652    #[test]
653    fn preference_suppression_and_revocation_enqueue_fresh_compiles() -> Result<()> {
654        let _dir = ScopedTestDataDir::new("preference-suppression-compile");
655        crate::runtime_config::init_config()?;
656        crate::runtime_config::set_config_value("rule_compilation.enabled", "true")?;
657        let conn = crate::db::open_db()?;
658        conn.execute(
659            "INSERT INTO memories
660             (id, project, topic_key, title, content, memory_type, created_at_epoch,
661              updated_at_epoch, status, scope)
662             VALUES (1, '/repo', 'package-manager', 'Preference', 'Use bun, not npm',
663                     'preference', 10, 10, 'active', 'project')",
664            [],
665        )?;
666        conn.execute(
667            "INSERT INTO memory_preference_reinforcements
668             (memory_id, reinforcement_count, last_reinforced_at_epoch,
669              created_at_epoch, updated_at_epoch, machine_checkable)
670             VALUES (1, 3, 10, 10, 10, 1)",
671            [],
672        )?;
673
674        let record = create_suppression(
675            &conn,
676            &SuppressRequest {
677                target: parse_target("memory:1")?,
678                reason: Some("test suppression"),
679                actor: Some("test"),
680            },
681        )?;
682        let first_job: i64 = conn.query_row(
683            "SELECT id FROM jobs
684             WHERE job_type = 'compile_rules' AND project = '/repo' AND state = 'pending'",
685            [],
686            |row| row.get(0),
687        )?;
688        conn.execute(
689            "UPDATE jobs SET state = 'processing' WHERE id = ?1",
690            params![first_job],
691        )?;
692
693        revoke_suppression_arg(&conn, &record.id.to_string(), None, None)?;
694
695        let states: (i64, i64) = conn.query_row(
696            "SELECT SUM(state = 'processing'), SUM(state = 'pending')
697             FROM jobs WHERE job_type = 'compile_rules' AND project = '/repo'",
698            [],
699            |row| Ok((row.get(0)?, row.get(1)?)),
700        )?;
701        assert_eq!(states, (1, 1));
702        Ok(())
703    }
704
705    #[test]
706    fn entity_and_pattern_suppressions_match_memory_policy_filter() -> Result<()> {
707        let conn = Connection::open_in_memory()?;
708        crate::migrate::run_migrations(&conn)?;
709        conn.execute(
710            "INSERT INTO memories
711             (id, project, title, content, memory_type, created_at_epoch, updated_at_epoch, status)
712             VALUES (1, '/repo', 'Graphiti note', 'entity body', 'decision', 10, 10, 'active'),
713                    (2, '/repo', 'Other', 'contains private phrase', 'decision', 11, 11, 'active')",
714            [],
715        )?;
716        conn.execute(
717            "INSERT OR IGNORE INTO entities(id, canonical_name, entity_type, created_at_epoch)
718             VALUES (1, 'Graphiti', 'tool', 10)",
719            [],
720        )?;
721        conn.execute(
722            "INSERT OR IGNORE INTO memory_entities(memory_id, entity_id)
723             VALUES (1, 1)",
724            [],
725        )?;
726        create_suppression(
727            &conn,
728            &SuppressRequest {
729                target: parse_target("entity:graphiti")?,
730                reason: None,
731                actor: None,
732            },
733        )?;
734        create_suppression(
735            &conn,
736            &SuppressRequest {
737                target: parse_target("pattern:private phrase")?,
738                reason: None,
739                actor: None,
740            },
741        )?;
742        let rows: Vec<i64> = {
743            let sql = format!(
744                "SELECT m.id FROM memories m WHERE {} ORDER BY m.id",
745                memory_policy_filter_sql("m")
746            );
747            let mut stmt = conn.prepare(&sql)?;
748            let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?;
749            crate::db::query::collect_rows(rows)?
750        };
751        assert!(rows.is_empty());
752        Ok(())
753    }
754
755    #[test]
756    fn feedback_records_event_without_mutating_target() -> Result<()> {
757        let conn = Connection::open_in_memory()?;
758        crate::migrate::run_migrations(&conn)?;
759        conn.execute(
760            "INSERT INTO memories
761             (id, project, title, content, memory_type, created_at_epoch, updated_at_epoch, status)
762             VALUES (1, '/repo', 'Feedback target', 'body', 'decision', 10, 10, 'active')",
763            [],
764        )?;
765        let feedback = record_feedback(
766            &conn,
767            &FeedbackRequest {
768                target: parse_target("memory:1")?,
769                feedback: "not-relevant",
770                source: Some("test"),
771                context_injection_item_id: None,
772                session_id: Some("s1"),
773                project: Some("/repo"),
774                reason: Some("wrong task"),
775            },
776        )?;
777        assert_eq!(feedback.feedback, "not_relevant");
778        let status: String = conn.query_row(
779            "SELECT status FROM memories WHERE id = ?1",
780            params![1],
781            |row| row.get(0),
782        )?;
783        assert_eq!(status, "active");
784        Ok(())
785    }
786}