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}