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}