Skip to main content

remem/user_context/
claims.rs

1use anyhow::{anyhow, bail, Context, Result};
2use rusqlite::{params, Connection, OptionalExtension};
3use serde::Serialize;
4use sha2::{Digest, Sha256};
5
6pub const DEFAULT_USER_KEY: &str = "user:default";
7pub const DEFAULT_OWNER_SCOPE: &str = "user";
8pub const DEFAULT_OWNER_KEY: &str = "user:default";
9
10const DEFAULT_SOURCE_KIND: &str = "manual";
11const DEFAULT_SOURCE_REFS_JSON: &str = r#"[{"kind":"manual_cli","command":"user remember"}]"#;
12pub(crate) const PREFERENCE_BACKFILL_SOURCE_KIND: &str = "preference_backfill";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum UserContextClaimType {
16    Identity,
17    Role,
18    Preference,
19    Skill,
20    Goal,
21    Project,
22    Relationship,
23    Constraint,
24    Activity,
25}
26
27impl UserContextClaimType {
28    pub fn db_value(self) -> &'static str {
29        match self {
30            Self::Identity => "identity",
31            Self::Role => "role",
32            Self::Preference => "preference",
33            Self::Skill => "skill",
34            Self::Goal => "goal",
35            Self::Project => "project",
36            Self::Relationship => "relationship",
37            Self::Constraint => "constraint",
38            Self::Activity => "activity",
39        }
40    }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum UserContextSensitivity {
45    Normal,
46    Personal,
47    Sensitive,
48    Restricted,
49}
50
51impl UserContextSensitivity {
52    pub fn db_value(self) -> &'static str {
53        match self {
54            Self::Normal => "normal",
55            Self::Personal => "personal",
56            Self::Sensitive => "sensitive",
57            Self::Restricted => "restricted",
58        }
59    }
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct UserContextClaim {
64    pub id: i64,
65    pub user_key: String,
66    pub owner_scope: String,
67    pub owner_key: String,
68    pub claim_type: String,
69    pub claim_key: String,
70    pub claim_text: String,
71    pub confidence: f64,
72    pub sensitivity: String,
73    pub source_kind: String,
74    pub source_refs_json: String,
75    pub status: String,
76    pub valid_from_epoch: Option<i64>,
77    pub valid_to_epoch: Option<i64>,
78    pub last_confirmed_at_epoch: Option<i64>,
79    pub supersedes_claim_id: Option<i64>,
80    pub created_at_epoch: i64,
81    pub updated_at_epoch: i64,
82}
83
84#[derive(Debug, Clone)]
85pub struct ManualClaimRequest<'a> {
86    pub text: &'a str,
87    pub owner_scope: Option<&'a str>,
88    pub owner_key: Option<&'a str>,
89    pub claim_type: UserContextClaimType,
90    pub claim_key: Option<&'a str>,
91    pub confidence: f64,
92    pub sensitivity: UserContextSensitivity,
93    pub valid_from_epoch: Option<i64>,
94    pub valid_to_epoch: Option<i64>,
95}
96
97#[derive(Debug, Clone)]
98pub(crate) struct PreferenceBackfillClaimRequest<'a> {
99    pub memory_id: i64,
100    pub text: &'a str,
101}
102
103#[derive(Debug, Clone)]
104pub struct ClaimListRequest<'a> {
105    pub owner_scope: Option<&'a str>,
106    pub owner_key: Option<&'a str>,
107    pub include_inactive: bool,
108    pub limit: i64,
109}
110
111#[derive(Debug, Clone)]
112pub struct ClaimEditRequest<'a> {
113    pub text: &'a str,
114    pub claim_type: Option<UserContextClaimType>,
115    pub claim_key: Option<&'a str>,
116    pub sensitivity: Option<UserContextSensitivity>,
117    pub valid_from_epoch: Option<i64>,
118    pub valid_to_epoch: Option<i64>,
119}
120
121#[derive(Debug, Clone, Serialize)]
122pub struct ClaimEditResult {
123    pub previous: UserContextClaim,
124    pub current: UserContextClaim,
125}
126
127pub fn create_manual_claim(
128    conn: &Connection,
129    req: &ManualClaimRequest<'_>,
130) -> Result<UserContextClaim> {
131    let text = normalize_required("claim text", req.text)?;
132    validate_validity(req.valid_from_epoch, req.valid_to_epoch)?;
133    let (owner_scope, owner_key) = normalized_owner(req.owner_scope, req.owner_key)?;
134    let claim_type = req.claim_type.db_value();
135    validate_confidence(req.confidence)?;
136    let claim_key = normalized_claim_key(req.claim_key, claim_type, text);
137    let now = chrono::Utc::now().timestamp();
138    conn.execute(
139        "INSERT INTO user_context_claims
140         (user_key, owner_scope, owner_key, claim_type, claim_key, claim_text,
141          confidence, sensitivity, source_kind, source_refs_json, status,
142          valid_from_epoch, valid_to_epoch, last_confirmed_at_epoch,
143          supersedes_claim_id, created_at_epoch, updated_at_epoch)
144         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active',
145                 ?11, ?12, ?13, NULL, ?14, ?14)",
146        params![
147            DEFAULT_USER_KEY,
148            owner_scope,
149            owner_key,
150            claim_type,
151            claim_key,
152            text,
153            req.confidence,
154            req.sensitivity.db_value(),
155            DEFAULT_SOURCE_KIND,
156            DEFAULT_SOURCE_REFS_JSON,
157            req.valid_from_epoch,
158            req.valid_to_epoch,
159            now,
160            now,
161        ],
162    )
163    .context("insert manual user-context claim")?;
164    load_claim(conn, conn.last_insert_rowid())
165}
166
167pub(crate) fn preference_claim_key(text: &str) -> Result<String> {
168    let text = normalize_required("claim text", text)?;
169    Ok(normalized_claim_key(
170        None,
171        UserContextClaimType::Preference.db_value(),
172        text,
173    ))
174}
175
176pub(crate) fn active_preference_backfill_memory_source_exists_sql(memory_alias: &str) -> String {
177    let memory_alias = memory_alias.trim();
178    let memory_id_expr = if memory_alias.is_empty() {
179        "id".to_string()
180    } else {
181        format!("{memory_alias}.id")
182    };
183    format!(
184        "EXISTS (
185             SELECT 1
186             FROM user_context_claims preference_backfill_claim
187             WHERE preference_backfill_claim.status = 'active'
188               AND preference_backfill_claim.source_kind = '{PREFERENCE_BACKFILL_SOURCE_KIND}'
189               AND EXISTS (
190                 SELECT 1
191                 FROM json_each(
192                     CASE
193                         WHEN json_valid(preference_backfill_claim.source_refs_json)
194                         THEN preference_backfill_claim.source_refs_json
195                         ELSE '[]'
196                     END
197                 ) preference_backfill_ref
198                 WHERE json_extract(preference_backfill_ref.value, '$.kind') = 'memory'
199                   AND json_extract(preference_backfill_ref.value, '$.id') = {memory_id_expr}
200               )
201         )"
202    )
203}
204
205pub(crate) fn active_preference_backfill_covers_user_preference_memory(
206    conn: &Connection,
207    memory_id: i64,
208) -> Result<bool> {
209    let source_exists = active_preference_backfill_memory_source_exists_sql("memories");
210    let sql = format!(
211        "SELECT COUNT(*)
212         FROM memories
213         WHERE id = ?1
214           AND memory_type = 'preference'
215           AND owner_scope = ?2
216           AND owner_key = ?3
217           AND {source_exists}"
218    );
219    let count: i64 = conn.query_row(
220        &sql,
221        params![memory_id, DEFAULT_OWNER_SCOPE, DEFAULT_OWNER_KEY],
222        |row| row.get(0),
223    )?;
224    Ok(count > 0)
225}
226
227pub(crate) fn create_preference_backfill_claim(
228    conn: &Connection,
229    req: &PreferenceBackfillClaimRequest<'_>,
230) -> Result<UserContextClaim> {
231    let text = normalize_required("claim text", req.text)?;
232    if req.memory_id <= 0 {
233        bail!("source memory id must be positive");
234    }
235    let claim_type = UserContextClaimType::Preference.db_value();
236    let claim_key = normalized_claim_key(None, claim_type, text);
237    let source_refs_json = serde_json::json!([
238        {
239            "kind": "memory",
240            "id": req.memory_id
241        }
242    ])
243    .to_string();
244    let now = chrono::Utc::now().timestamp();
245    conn.execute(
246        "INSERT INTO user_context_claims
247         (user_key, owner_scope, owner_key, claim_type, claim_key, claim_text,
248          confidence, sensitivity, source_kind, source_refs_json, status,
249          valid_from_epoch, valid_to_epoch, last_confirmed_at_epoch,
250          supersedes_claim_id, created_at_epoch, updated_at_epoch)
251         VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1.0, 'normal', ?7, ?8, 'active',
252                 NULL, NULL, ?9, NULL, ?9, ?9)",
253        params![
254            DEFAULT_USER_KEY,
255            DEFAULT_OWNER_SCOPE,
256            DEFAULT_OWNER_KEY,
257            claim_type,
258            claim_key,
259            text,
260            PREFERENCE_BACKFILL_SOURCE_KIND,
261            source_refs_json,
262            now,
263        ],
264    )
265    .context("insert preference backfill user-context claim")?;
266    load_claim(conn, conn.last_insert_rowid())
267}
268
269pub fn list_claims(conn: &Connection, req: &ClaimListRequest<'_>) -> Result<Vec<UserContextClaim>> {
270    let mut conditions = Vec::new();
271    let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
272    let mut idx = 1;
273
274    if !req.include_inactive {
275        conditions.push("status = ?1".to_string());
276        values.push(Box::new("active".to_string()));
277        conditions.push("sensitivity <> ?2".to_string());
278        values.push(Box::new("restricted".to_string()));
279        let now = chrono::Utc::now().timestamp();
280        conditions.push("(valid_from_epoch IS NULL OR valid_from_epoch <= ?3)".to_string());
281        values.push(Box::new(now));
282        conditions.push("(valid_to_epoch IS NULL OR valid_to_epoch > ?4)".to_string());
283        values.push(Box::new(now));
284        conditions.push(crate::memory::suppression::user_claim_policy_filter_sql(
285            "user_context_claims",
286        ));
287        idx = 5;
288    }
289
290    if let Some(owner_scope) = normalized_optional(req.owner_scope) {
291        validate_owner_scope(owner_scope)?;
292        conditions.push(format!("owner_scope = ?{idx}"));
293        values.push(Box::new(owner_scope.to_string()));
294        idx += 1;
295    }
296
297    if let Some(owner_key) = normalized_optional(req.owner_key) {
298        conditions.push(format!("owner_key = ?{idx}"));
299        values.push(Box::new(owner_key.to_string()));
300        idx += 1;
301    }
302
303    let mut sql = "SELECT id, user_key, owner_scope, owner_key, claim_type, claim_key,
304                          claim_text, confidence, sensitivity, source_kind,
305                          source_refs_json, status, valid_from_epoch,
306                          valid_to_epoch, last_confirmed_at_epoch,
307                          supersedes_claim_id, created_at_epoch, updated_at_epoch
308                   FROM user_context_claims"
309        .to_string();
310    if !conditions.is_empty() {
311        sql.push_str(" WHERE ");
312        sql.push_str(&conditions.join(" AND "));
313    }
314    sql.push_str(&format!(
315        " ORDER BY updated_at_epoch DESC, id DESC LIMIT ?{idx}"
316    ));
317    values.push(Box::new(req.limit.clamp(1, 500)));
318
319    let mut stmt = conn.prepare(&sql)?;
320    let refs = crate::db::to_sql_refs(&values);
321    let rows = stmt.query_map(refs.as_slice(), claim_from_row)?;
322    crate::db::query::collect_rows(rows)
323}
324
325pub fn load_claim(conn: &Connection, id: i64) -> Result<UserContextClaim> {
326    conn.query_row(
327        "SELECT id, user_key, owner_scope, owner_key, claim_type, claim_key,
328                claim_text, confidence, sensitivity, source_kind,
329                source_refs_json, status, valid_from_epoch, valid_to_epoch,
330                last_confirmed_at_epoch, supersedes_claim_id,
331                created_at_epoch, updated_at_epoch
332         FROM user_context_claims
333         WHERE id = ?1",
334        [id],
335        claim_from_row,
336    )
337    .optional()?
338    .ok_or_else(|| anyhow!("user-context claim {id} not found"))
339}
340
341pub fn edit_claim(
342    conn: &Connection,
343    id: i64,
344    req: &ClaimEditRequest<'_>,
345) -> Result<ClaimEditResult> {
346    let text = normalize_required("claim text", req.text)?;
347    validate_validity(req.valid_from_epoch, req.valid_to_epoch)?;
348    let tx = conn.unchecked_transaction()?;
349    let previous = load_claim(&tx, id)?;
350    if previous.status != "active" {
351        bail!(
352            "only active user-context claims can be edited; claim {id} is {}",
353            previous.status
354        );
355    }
356    let now = chrono::Utc::now().timestamp();
357    let claim_type = req
358        .claim_type
359        .map(UserContextClaimType::db_value)
360        .unwrap_or(previous.claim_type.as_str());
361    let claim_key = normalized_claim_key(req.claim_key, claim_type, text);
362    let sensitivity = req
363        .sensitivity
364        .map(UserContextSensitivity::db_value)
365        .unwrap_or(previous.sensitivity.as_str());
366    let valid_from = req.valid_from_epoch.or(previous.valid_from_epoch);
367    let valid_to = req.valid_to_epoch.or(previous.valid_to_epoch);
368    validate_validity(valid_from, valid_to)?;
369
370    tx.execute(
371        "UPDATE user_context_claims
372         SET status = 'superseded', updated_at_epoch = ?1
373         WHERE id = ?2",
374        params![now, id],
375    )?;
376    tx.execute(
377        "INSERT INTO user_context_claims
378         (user_key, owner_scope, owner_key, claim_type, claim_key, claim_text,
379          confidence, sensitivity, source_kind, source_refs_json, status,
380          valid_from_epoch, valid_to_epoch, last_confirmed_at_epoch,
381          supersedes_claim_id, created_at_epoch, updated_at_epoch)
382         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active',
383                 ?11, ?12, ?13, ?14, ?15, ?15)",
384        params![
385            previous.user_key,
386            previous.owner_scope,
387            previous.owner_key,
388            claim_type,
389            claim_key,
390            text,
391            previous.confidence,
392            sensitivity,
393            previous.source_kind,
394            previous.source_refs_json,
395            valid_from,
396            valid_to,
397            now,
398            previous.id,
399            now,
400        ],
401    )?;
402    let current = load_claim(&tx, tx.last_insert_rowid())?;
403    tx.commit()?;
404    Ok(ClaimEditResult { previous, current })
405}
406
407pub fn suppress_claim(conn: &Connection, id: i64) -> Result<UserContextClaim> {
408    let claim = load_claim(conn, id)?;
409    if claim.status == "suppressed" {
410        return Ok(claim);
411    }
412    if claim.status != "active" {
413        bail!(
414            "only active user-context claims can be suppressed; claim {id} is {}",
415            claim.status
416        );
417    }
418    transition_status(conn, id, "suppressed")
419}
420
421pub fn unsuppress_claim(conn: &Connection, id: i64) -> Result<UserContextClaim> {
422    let claim = load_claim(conn, id)?;
423    if claim.status != "suppressed" {
424        bail!(
425            "only suppressed user-context claims can be unsuppressed; claim {id} is {}",
426            claim.status
427        );
428    }
429    transition_status(conn, id, "active")
430}
431
432pub fn delete_claim(conn: &Connection, id: i64) -> Result<UserContextClaim> {
433    transition_status(conn, id, "deleted")
434}
435
436fn transition_status(conn: &Connection, id: i64, status: &str) -> Result<UserContextClaim> {
437    let updated = conn.execute(
438        "UPDATE user_context_claims
439         SET status = ?1, updated_at_epoch = ?2
440         WHERE id = ?3",
441        params![status, chrono::Utc::now().timestamp(), id],
442    )?;
443    if updated != 1 {
444        bail!("failed to update user-context claim {id}");
445    }
446    load_claim(conn, id)
447}
448
449fn claim_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserContextClaim> {
450    Ok(UserContextClaim {
451        id: row.get(0)?,
452        user_key: row.get(1)?,
453        owner_scope: row.get(2)?,
454        owner_key: row.get(3)?,
455        claim_type: row.get(4)?,
456        claim_key: row.get(5)?,
457        claim_text: row.get(6)?,
458        confidence: row.get(7)?,
459        sensitivity: row.get(8)?,
460        source_kind: row.get(9)?,
461        source_refs_json: row.get(10)?,
462        status: row.get(11)?,
463        valid_from_epoch: row.get(12)?,
464        valid_to_epoch: row.get(13)?,
465        last_confirmed_at_epoch: row.get(14)?,
466        supersedes_claim_id: row.get(15)?,
467        created_at_epoch: row.get(16)?,
468        updated_at_epoch: row.get(17)?,
469    })
470}
471
472fn normalized_owner<'a>(
473    owner_scope: Option<&'a str>,
474    owner_key: Option<&'a str>,
475) -> Result<(&'a str, &'a str)> {
476    let owner_scope = normalized_optional(owner_scope).unwrap_or(DEFAULT_OWNER_SCOPE);
477    validate_owner_scope(owner_scope)?;
478    let owner_key = normalized_optional(owner_key);
479    match (owner_scope, owner_key) {
480        ("user", None) => Ok((owner_scope, DEFAULT_OWNER_KEY)),
481        ("user", Some(owner_key)) => Ok((owner_scope, owner_key)),
482        (_, Some(owner_key)) => Ok((owner_scope, owner_key)),
483        _ => bail!("--owner-key is required when --scope is not user"),
484    }
485}
486
487fn validate_owner_scope(owner_scope: &str) -> Result<()> {
488    if matches!(owner_scope, "user" | "workspace" | "repo" | "session") {
489        return Ok(());
490    }
491    bail!("unsupported user-context owner scope: {owner_scope}");
492}
493
494fn normalized_claim_key(provided: Option<&str>, claim_type: &str, text: &str) -> String {
495    if let Some(value) = normalized_optional(provided) {
496        return value.to_string();
497    }
498    let mut hasher = Sha256::new();
499    hasher.update(claim_type.as_bytes());
500    hasher.update(b"\0");
501    hasher.update(text.as_bytes());
502    let digest = hasher.finalize();
503    format!(
504        "{claim_type}:manual:{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
505        digest[0], digest[1], digest[2], digest[3], digest[4], digest[5], digest[6], digest[7]
506    )
507}
508
509fn normalize_required<'a>(label: &str, value: &'a str) -> Result<&'a str> {
510    let value = value.trim();
511    if value.is_empty() {
512        bail!("{label} cannot be empty");
513    }
514    Ok(value)
515}
516
517fn normalized_optional(value: Option<&str>) -> Option<&str> {
518    value.map(str::trim).filter(|value| !value.is_empty())
519}
520
521fn validate_confidence(confidence: f64) -> Result<()> {
522    if (0.0..=1.0).contains(&confidence) {
523        return Ok(());
524    }
525    bail!("confidence must be between 0.0 and 1.0");
526}
527
528fn validate_validity(valid_from_epoch: Option<i64>, valid_to_epoch: Option<i64>) -> Result<()> {
529    if let (Some(from), Some(to)) = (valid_from_epoch, valid_to_epoch) {
530        if to <= from {
531            bail!("valid_to_epoch must be greater than valid_from_epoch");
532        }
533    }
534    Ok(())
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use rusqlite::Connection;
541
542    fn migrated_conn() -> Result<Connection> {
543        let conn = Connection::open_in_memory()?;
544        crate::migrate::run_migrations(&conn)?;
545        Ok(conn)
546    }
547
548    #[test]
549    fn manual_claim_defaults_to_active_user_scope_with_source_metadata() -> Result<()> {
550        let conn = migrated_conn()?;
551        let claim = create_manual_claim(
552            &conn,
553            &ManualClaimRequest {
554                text: "Prefer concise architecture review",
555                owner_scope: None,
556                owner_key: None,
557                claim_type: UserContextClaimType::Preference,
558                claim_key: None,
559                confidence: 1.0,
560                sensitivity: UserContextSensitivity::Normal,
561                valid_from_epoch: None,
562                valid_to_epoch: None,
563            },
564        )?;
565
566        assert_eq!(claim.user_key, DEFAULT_USER_KEY);
567        assert_eq!(claim.owner_scope, DEFAULT_OWNER_SCOPE);
568        assert_eq!(claim.owner_key, DEFAULT_OWNER_KEY);
569        assert_eq!(claim.source_kind, "manual");
570        assert_eq!(claim.status, "active");
571        assert!(claim.source_refs_json.contains("manual_cli"));
572        assert!(claim.claim_key.starts_with("preference:manual:"));
573        Ok(())
574    }
575
576    #[test]
577    fn non_user_scope_requires_owner_key() -> Result<()> {
578        let conn = migrated_conn()?;
579        let err = create_manual_claim(
580            &conn,
581            &ManualClaimRequest {
582                text: "Repo scoped preference",
583                owner_scope: Some("repo"),
584                owner_key: None,
585                claim_type: UserContextClaimType::Preference,
586                claim_key: None,
587                confidence: 1.0,
588                sensitivity: UserContextSensitivity::Normal,
589                valid_from_epoch: None,
590                valid_to_epoch: None,
591            },
592        )
593        .expect_err("repo scope requires owner-key");
594        assert!(err.to_string().contains("--owner-key is required"));
595        Ok(())
596    }
597
598    #[test]
599    fn default_list_excludes_suppressed_deleted_expired_future_and_restricted() -> Result<()> {
600        let conn = migrated_conn()?;
601        let active =
602            create_manual_claim(&conn, &request("active", UserContextSensitivity::Normal))?;
603        let suppressed = create_manual_claim(
604            &conn,
605            &request("suppressed", UserContextSensitivity::Normal),
606        )?;
607        suppress_claim(&conn, suppressed.id)?;
608        let deleted =
609            create_manual_claim(&conn, &request("deleted", UserContextSensitivity::Normal))?;
610        delete_claim(&conn, deleted.id)?;
611        create_manual_claim(
612            &conn,
613            &ManualClaimRequest {
614                text: "expired",
615                valid_to_epoch: Some(1),
616                ..request("expired", UserContextSensitivity::Normal)
617            },
618        )?;
619        create_manual_claim(
620            &conn,
621            &ManualClaimRequest {
622                text: "future",
623                valid_from_epoch: Some(chrono::Utc::now().timestamp() + 86_400),
624                ..request("future", UserContextSensitivity::Normal)
625            },
626        )?;
627        create_manual_claim(
628            &conn,
629            &request("restricted", UserContextSensitivity::Restricted),
630        )?;
631        let pattern_suppressed = create_manual_claim(
632            &conn,
633            &request("contains secret phrase", UserContextSensitivity::Normal),
634        )?;
635        crate::memory::suppression::create_suppression(
636            &conn,
637            &crate::memory::suppression::SuppressRequest {
638                target: crate::memory::suppression::parse_target("pattern:secret")?,
639                reason: Some("too noisy"),
640                actor: Some("test"),
641            },
642        )?;
643
644        let visible = list_claims(
645            &conn,
646            &ClaimListRequest {
647                owner_scope: None,
648                owner_key: None,
649                include_inactive: false,
650                limit: 100,
651            },
652        )?;
653        assert_eq!(visible.len(), 1);
654        assert_eq!(visible[0].id, active.id);
655
656        let all = list_claims(
657            &conn,
658            &ClaimListRequest {
659                owner_scope: None,
660                owner_key: None,
661                include_inactive: true,
662                limit: 100,
663            },
664        )?;
665        assert!(all.iter().any(|claim| claim.id == pattern_suppressed.id));
666        assert_eq!(all.len(), 7);
667        Ok(())
668    }
669
670    #[test]
671    fn suppress_and_unsuppress_only_cross_active_suppressed_boundary() -> Result<()> {
672        let conn = migrated_conn()?;
673        let active = create_manual_claim(
674            &conn,
675            &request("governance boundary", UserContextSensitivity::Normal),
676        )?;
677
678        assert_eq!(suppress_claim(&conn, active.id)?.status, "suppressed");
679        assert_eq!(suppress_claim(&conn, active.id)?.status, "suppressed");
680        let err = edit_claim(
681            &conn,
682            active.id,
683            &ClaimEditRequest {
684                text: "restored through edit",
685                claim_type: None,
686                claim_key: None,
687                sensitivity: None,
688                valid_from_epoch: None,
689                valid_to_epoch: None,
690            },
691        )
692        .expect_err("suppressed claims must not be restored through edit");
693        assert!(err
694            .to_string()
695            .contains("only active user-context claims can be edited"));
696        assert_eq!(unsuppress_claim(&conn, active.id)?.status, "active");
697
698        let deleted = delete_claim(&conn, active.id)?;
699        assert_eq!(deleted.status, "deleted");
700        let err = unsuppress_claim(&conn, active.id)
701            .expect_err("deleted claims must not be restored through unsuppress");
702        assert!(err
703            .to_string()
704            .contains("only suppressed user-context claims can be unsuppressed"));
705        Ok(())
706    }
707
708    #[test]
709    fn edit_supersedes_old_claim_and_inserts_new_active_claim() -> Result<()> {
710        let conn = migrated_conn()?;
711        let original =
712            create_manual_claim(&conn, &request("old text", UserContextSensitivity::Normal))?;
713
714        let edited = edit_claim(
715            &conn,
716            original.id,
717            &ClaimEditRequest {
718                text: "new text",
719                claim_type: Some(UserContextClaimType::Goal),
720                claim_key: Some("goal:remem"),
721                sensitivity: Some(UserContextSensitivity::Personal),
722                valid_from_epoch: None,
723                valid_to_epoch: None,
724            },
725        )?;
726
727        assert_eq!(edited.previous.id, original.id);
728        assert_eq!(load_claim(&conn, original.id)?.status, "superseded");
729        assert_eq!(edited.current.supersedes_claim_id, Some(original.id));
730        assert_eq!(edited.current.claim_text, "new text");
731        assert_eq!(edited.current.claim_type, "goal");
732        assert_eq!(edited.current.claim_key, "goal:remem");
733        assert_eq!(edited.current.sensitivity, "personal");
734        assert_eq!(edited.current.status, "active");
735        Ok(())
736    }
737
738    fn request(text: &str, sensitivity: UserContextSensitivity) -> ManualClaimRequest<'_> {
739        ManualClaimRequest {
740            text,
741            owner_scope: None,
742            owner_key: None,
743            claim_type: UserContextClaimType::Preference,
744            claim_key: None,
745            confidence: 1.0,
746            sensitivity,
747            valid_from_epoch: None,
748            valid_to_epoch: None,
749        }
750    }
751}