Skip to main content

remem/user_context/
candidates.rs

1use anyhow::{anyhow, bail, Context, Result};
2use rusqlite::{params, Connection, OptionalExtension};
3use serde::Serialize;
4
5use crate::runtime_config::AutoPromotePolicy;
6
7use super::claims::{
8    load_claim, UserContextClaim, UserContextClaimType, UserContextSensitivity, DEFAULT_OWNER_KEY,
9    DEFAULT_OWNER_SCOPE, DEFAULT_USER_KEY,
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum UserContextCandidateRisk {
14    Low,
15    Medium,
16    High,
17}
18
19impl UserContextCandidateRisk {
20    pub fn db_value(self) -> &'static str {
21        match self {
22            Self::Low => "low",
23            Self::Medium => "medium",
24            Self::High => "high",
25        }
26    }
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct UserContextCandidate {
31    pub id: i64,
32    pub user_key: String,
33    pub owner_scope: String,
34    pub owner_key: String,
35    pub source_project: Option<String>,
36    pub host: Option<String>,
37    pub session_id: Option<String>,
38    pub claim_type: String,
39    pub claim_key: Option<String>,
40    pub claim_text: String,
41    pub confidence: f64,
42    pub sensitivity: String,
43    pub risk_class: String,
44    pub source_kind: String,
45    pub source_refs_json: String,
46    pub source_preview: Option<String>,
47    pub review_status: String,
48    pub auto_promote_block_reason: Option<String>,
49    pub review_note: Option<String>,
50    pub result_claim_id: Option<i64>,
51    pub created_at_epoch: i64,
52    pub updated_at_epoch: i64,
53}
54
55#[derive(Debug, Clone)]
56pub struct CandidateCreateRequest<'a> {
57    pub text: &'a str,
58    pub owner_scope: Option<&'a str>,
59    pub owner_key: Option<&'a str>,
60    pub source_project: Option<&'a str>,
61    pub host: Option<&'a str>,
62    pub session_id: Option<&'a str>,
63    pub claim_type: UserContextClaimType,
64    pub claim_key: Option<&'a str>,
65    pub confidence: f64,
66    pub sensitivity: UserContextSensitivity,
67    pub risk_class: UserContextCandidateRisk,
68    pub source_kind: &'a str,
69    pub source_refs_json: &'a str,
70    pub source_preview: Option<&'a str>,
71    pub auto_promote: bool,
72    pub auto_promote_block_reason: Option<&'a str>,
73}
74
75#[derive(Debug, Clone)]
76pub struct CandidateListRequest<'a> {
77    pub review_status: Option<&'a str>,
78    pub include_resolved: bool,
79    pub limit: i64,
80}
81
82#[derive(Debug, Clone)]
83pub struct CandidateEditRequest<'a> {
84    pub text: &'a str,
85    pub claim_type: Option<UserContextClaimType>,
86    pub claim_key: Option<&'a str>,
87    pub sensitivity: Option<UserContextSensitivity>,
88    pub review_note: Option<&'a str>,
89}
90
91#[derive(Debug, Clone, Serialize)]
92pub struct CandidateApplyResult {
93    pub candidate: UserContextCandidate,
94    pub claim: Option<UserContextClaim>,
95    pub action: String,
96}
97
98pub fn create_candidate(
99    conn: &Connection,
100    req: &CandidateCreateRequest<'_>,
101) -> Result<CandidateApplyResult> {
102    create_candidate_with_policy(conn, req, &AutoPromotePolicy::relaxed_default())
103}
104
105pub(crate) fn create_candidate_with_policy(
106    conn: &Connection,
107    req: &CandidateCreateRequest<'_>,
108    policy: &AutoPromotePolicy,
109) -> Result<CandidateApplyResult> {
110    let text = normalize_required("candidate text", req.text)?;
111    validate_confidence(req.confidence)?;
112    validate_source_refs(req.source_refs_json)?;
113    let (owner_scope, owner_key) = normalized_owner(req.owner_scope, req.owner_key)?;
114    let claim_type = req.claim_type.db_value();
115    let claim_key = normalized_optional(req.claim_key);
116    let source_kind = normalize_required("source kind", req.source_kind)?;
117    let source_project = normalized_optional(req.source_project);
118    let host = normalized_optional(req.host);
119    let session_id = normalized_optional(req.session_id);
120    let source_preview = normalized_optional(req.source_preview);
121    if let Some(reason) =
122        crate::user_context::non_retention::block_reason(text, source_preview, source_kind)
123    {
124        bail!("user-context candidate blocked by non-retention policy: {reason}");
125    }
126    let now = chrono::Utc::now().timestamp();
127    let allowed = auto_promote_allowed(req, source_kind, policy);
128    let block_reason = if allowed {
129        None
130    } else {
131        Some(
132            normalized_optional(req.auto_promote_block_reason)
133                .unwrap_or_else(|| auto_promote_block_reason(req, source_kind, policy))
134                .to_string(),
135        )
136    };
137
138    let tx = conn.unchecked_transaction()?;
139    tx.execute(
140        "INSERT INTO user_context_candidates
141         (user_key, owner_scope, owner_key, source_project, host, session_id,
142          claim_type, claim_key, claim_text, confidence, sensitivity, risk_class,
143          source_kind, source_refs_json, source_preview, review_status,
144          auto_promote_block_reason, review_note, result_claim_id,
145          created_at_epoch, updated_at_epoch)
146         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
147                 ?13, ?14, ?15, 'pending_review', ?16, NULL, NULL, ?17, ?17)",
148        params![
149            DEFAULT_USER_KEY,
150            owner_scope,
151            owner_key,
152            source_project,
153            host,
154            session_id,
155            claim_type,
156            claim_key,
157            text,
158            req.confidence,
159            req.sensitivity.db_value(),
160            req.risk_class.db_value(),
161            source_kind,
162            req.source_refs_json,
163            source_preview,
164            block_reason,
165            now,
166        ],
167    )
168    .context("insert user-context candidate")?;
169    let id = tx.last_insert_rowid();
170    let result = if allowed {
171        apply_candidate_tx(&tx, id, None, "auto_promoted")?
172    } else {
173        CandidateApplyResult {
174            candidate: load_candidate_tx(&tx, id)?,
175            claim: None,
176            action: "pending_review".to_string(),
177        }
178    };
179    tx.commit()?;
180    Ok(result)
181}
182
183pub fn list_candidates(
184    conn: &Connection,
185    req: &CandidateListRequest<'_>,
186) -> Result<Vec<UserContextCandidate>> {
187    let mut conditions = Vec::new();
188    let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
189    let mut idx = 1;
190    if let Some(status) = normalized_optional(req.review_status) {
191        validate_review_status(status)?;
192        conditions.push(format!("review_status = ?{idx}"));
193        values.push(Box::new(status.to_string()));
194        idx += 1;
195    } else if !req.include_resolved {
196        conditions.push("review_status IN ('pending_review', 'deferred')".to_string());
197    }
198    let mut sql = candidate_select_sql().to_string();
199    if !conditions.is_empty() {
200        sql.push_str(" WHERE ");
201        sql.push_str(&conditions.join(" AND "));
202    }
203    sql.push_str(&format!(
204        " ORDER BY updated_at_epoch DESC, id DESC LIMIT ?{idx}"
205    ));
206    values.push(Box::new(req.limit.clamp(1, 500)));
207    let mut stmt = conn.prepare(&sql)?;
208    let refs = crate::db::to_sql_refs(&values);
209    let rows = stmt.query_map(refs.as_slice(), candidate_from_row)?;
210    crate::db::query::collect_rows(rows)
211}
212
213pub fn load_candidate(conn: &Connection, id: i64) -> Result<UserContextCandidate> {
214    load_candidate_tx(conn, id)
215}
216
217pub fn approve_candidate(conn: &Connection, id: i64) -> Result<CandidateApplyResult> {
218    let tx = conn.unchecked_transaction()?;
219    let result = apply_candidate_tx(&tx, id, None, "approved")?;
220    tx.commit()?;
221    Ok(result)
222}
223
224pub fn edit_candidate(
225    conn: &Connection,
226    id: i64,
227    req: &CandidateEditRequest<'_>,
228) -> Result<CandidateApplyResult> {
229    let tx = conn.unchecked_transaction()?;
230    let result = apply_candidate_tx(&tx, id, Some(req), "edited")?;
231    tx.commit()?;
232    Ok(result)
233}
234
235pub fn reject_candidate(
236    conn: &Connection,
237    id: i64,
238    review_note: Option<&str>,
239) -> Result<UserContextCandidate> {
240    transition_candidate(conn, id, "rejected", review_note)
241}
242
243pub fn suppress_candidate(
244    conn: &Connection,
245    id: i64,
246    review_note: Option<&str>,
247) -> Result<UserContextCandidate> {
248    transition_candidate(conn, id, "suppressed", review_note)
249}
250
251fn apply_candidate_tx(
252    conn: &Connection,
253    id: i64,
254    edit: Option<&CandidateEditRequest<'_>>,
255    final_status: &str,
256) -> Result<CandidateApplyResult> {
257    let mut candidate = load_candidate_tx(conn, id)?;
258    ensure_reviewable(&candidate)?;
259    let claim_type = edit
260        .and_then(|edit| edit.claim_type)
261        .map(UserContextClaimType::db_value)
262        .unwrap_or(candidate.claim_type.as_str())
263        .to_string();
264    let text = edit
265        .map(|edit| normalize_required("candidate text", edit.text))
266        .transpose()?
267        .unwrap_or(candidate.claim_text.as_str())
268        .to_string();
269    let sensitivity = edit
270        .and_then(|edit| edit.sensitivity)
271        .map(UserContextSensitivity::db_value)
272        .unwrap_or(candidate.sensitivity.as_str())
273        .to_string();
274    let claim_key = edit
275        .and_then(|edit| normalized_optional(edit.claim_key))
276        .map(str::to_string)
277        .or_else(|| candidate.claim_key.clone())
278        .ok_or_else(|| {
279            anyhow!(
280                "claim_key is required before applying user-context candidate {}",
281                candidate.id
282            )
283        })?;
284    let note = edit
285        .and_then(|edit| normalized_optional(edit.review_note))
286        .map(str::to_string);
287    if let Some(reason) = crate::user_context::non_retention::block_reason(
288        &text,
289        candidate.source_preview.as_deref(),
290        &candidate.source_kind,
291    ) {
292        bail!("user-context candidate blocked by non-retention policy: {reason}");
293    }
294    let now = chrono::Utc::now().timestamp();
295    if edit.is_some() {
296        let updated = conn.execute(
297            "UPDATE user_context_candidates
298             SET claim_type = ?1,
299                 claim_key = ?2,
300                 claim_text = ?3,
301                 sensitivity = ?4,
302                 review_note = ?5,
303                 updated_at_epoch = ?6
304             WHERE id = ?7
305               AND review_status IN ('pending_review', 'deferred')",
306            params![&claim_type, &claim_key, &text, &sensitivity, note, now, id],
307        )?;
308        if updated != 1 {
309            bail!("user-context candidate {id} is no longer reviewable");
310        }
311        candidate = load_candidate_tx(conn, id)?;
312    }
313    let active = active_claims_for_key(
314        conn,
315        &candidate.owner_scope,
316        &candidate.owner_key,
317        &claim_type,
318        &claim_key,
319    )?;
320    if final_status == "auto_promoted" && active_claim_key_conflict(&active, &text, &sensitivity) {
321        block_candidate_auto_promote(
322            conn,
323            id,
324            "claim_key_conflict_requires_review",
325            note.as_deref(),
326            now,
327        )?;
328        return Ok(CandidateApplyResult {
329            candidate: load_candidate_tx(conn, id)?,
330            claim: None,
331            action: "pending_review".to_string(),
332        });
333    }
334    if let Some(existing) = active
335        .iter()
336        .find(|claim| claim.claim_text == text && claim.sensitivity == sensitivity)
337    {
338        supersede_other_active_claims(conn, &active, existing.id, now)?;
339        update_candidate_after_apply(
340            conn,
341            id,
342            final_status,
343            existing.id,
344            note.as_deref()
345                .or(Some("noop: existing active claim matches candidate")),
346            now,
347        )?;
348        return Ok(CandidateApplyResult {
349            candidate: load_candidate_tx(conn, id)?,
350            claim: Some(existing.clone()),
351            action: "noop_existing_claim".to_string(),
352        });
353    }
354    for claim in &active {
355        conn.execute(
356            "UPDATE user_context_claims
357             SET status = 'superseded', updated_at_epoch = ?1
358             WHERE id = ?2",
359            params![now, claim.id],
360        )?;
361    }
362    let supersedes_claim_id = active.first().map(|claim| claim.id);
363    let source_refs_json = claim_source_refs_json(&candidate)?;
364    conn.execute(
365        "INSERT INTO user_context_claims
366         (user_key, owner_scope, owner_key, claim_type, claim_key, claim_text,
367          confidence, sensitivity, source_kind, source_refs_json, status,
368          valid_from_epoch, valid_to_epoch, last_confirmed_at_epoch,
369          supersedes_claim_id, created_at_epoch, updated_at_epoch)
370         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'user_context_candidate',
371                 ?9, 'active', NULL, NULL, ?10, ?11, ?10, ?10)",
372        params![
373            candidate.user_key,
374            candidate.owner_scope,
375            candidate.owner_key,
376            &claim_type,
377            &claim_key,
378            &text,
379            candidate.confidence,
380            &sensitivity,
381            source_refs_json,
382            now,
383            supersedes_claim_id,
384        ],
385    )?;
386    let claim = load_claim(conn, conn.last_insert_rowid())?;
387    update_candidate_after_apply(conn, id, final_status, claim.id, note.as_deref(), now)?;
388    Ok(CandidateApplyResult {
389        candidate: load_candidate_tx(conn, id)?,
390        claim: Some(claim),
391        action: if supersedes_claim_id.is_some() {
392            "superseded_existing_claim".to_string()
393        } else {
394            "created_claim".to_string()
395        },
396    })
397}
398
399fn transition_candidate(
400    conn: &Connection,
401    id: i64,
402    review_status: &str,
403    review_note: Option<&str>,
404) -> Result<UserContextCandidate> {
405    let candidate = load_candidate(conn, id)?;
406    ensure_reviewable(&candidate)?;
407    let note = normalized_optional(review_note);
408    let updated = conn.execute(
409        "UPDATE user_context_candidates
410         SET review_status = ?1, review_note = ?2, updated_at_epoch = ?3
411         WHERE id = ?4
412           AND review_status IN ('pending_review', 'deferred')",
413        params![review_status, note, chrono::Utc::now().timestamp(), id],
414    )?;
415    if updated != 1 {
416        bail!("user-context candidate {id} is no longer reviewable");
417    }
418    load_candidate(conn, id)
419}
420
421fn active_claims_for_key(
422    conn: &Connection,
423    owner_scope: &str,
424    owner_key: &str,
425    claim_type: &str,
426    claim_key: &str,
427) -> Result<Vec<UserContextClaim>> {
428    let mut stmt = conn.prepare(
429        "SELECT id, user_key, owner_scope, owner_key, claim_type, claim_key,
430                claim_text, confidence, sensitivity, source_kind,
431                source_refs_json, status, valid_from_epoch, valid_to_epoch,
432                last_confirmed_at_epoch, supersedes_claim_id,
433                created_at_epoch, updated_at_epoch
434         FROM user_context_claims
435         WHERE owner_scope = ?1
436           AND owner_key = ?2
437           AND claim_type = ?3
438           AND claim_key = ?4
439           AND status = 'active'
440         ORDER BY updated_at_epoch DESC, id DESC",
441    )?;
442    let rows = stmt.query_map(
443        params![owner_scope, owner_key, claim_type, claim_key],
444        |row| {
445            Ok(UserContextClaim {
446                id: row.get(0)?,
447                user_key: row.get(1)?,
448                owner_scope: row.get(2)?,
449                owner_key: row.get(3)?,
450                claim_type: row.get(4)?,
451                claim_key: row.get(5)?,
452                claim_text: row.get(6)?,
453                confidence: row.get(7)?,
454                sensitivity: row.get(8)?,
455                source_kind: row.get(9)?,
456                source_refs_json: row.get(10)?,
457                status: row.get(11)?,
458                valid_from_epoch: row.get(12)?,
459                valid_to_epoch: row.get(13)?,
460                last_confirmed_at_epoch: row.get(14)?,
461                supersedes_claim_id: row.get(15)?,
462                created_at_epoch: row.get(16)?,
463                updated_at_epoch: row.get(17)?,
464            })
465        },
466    )?;
467    crate::db::query::collect_rows(rows)
468}
469
470fn supersede_other_active_claims(
471    conn: &Connection,
472    active: &[UserContextClaim],
473    keep_id: i64,
474    now: i64,
475) -> Result<()> {
476    for claim in active.iter().filter(|claim| claim.id != keep_id) {
477        conn.execute(
478            "UPDATE user_context_claims
479             SET status = 'superseded', updated_at_epoch = ?1
480             WHERE id = ?2",
481            params![now, claim.id],
482        )?;
483    }
484    Ok(())
485}
486
487fn block_candidate_auto_promote(
488    conn: &Connection,
489    id: i64,
490    reason: &str,
491    review_note: Option<&str>,
492    now: i64,
493) -> Result<()> {
494    let updated = conn.execute(
495        "UPDATE user_context_candidates
496         SET auto_promote_block_reason = ?1,
497             review_note = ?2,
498             updated_at_epoch = ?3
499         WHERE id = ?4
500           AND review_status IN ('pending_review', 'deferred')",
501        params![reason, review_note, now, id],
502    )?;
503    if updated != 1 {
504        bail!("user-context candidate {id} is no longer reviewable");
505    }
506    Ok(())
507}
508
509fn update_candidate_after_apply(
510    conn: &Connection,
511    id: i64,
512    review_status: &str,
513    claim_id: i64,
514    review_note: Option<&str>,
515    now: i64,
516) -> Result<()> {
517    let updated = conn.execute(
518        "UPDATE user_context_candidates
519         SET review_status = ?1, result_claim_id = ?2, review_note = ?3,
520             updated_at_epoch = ?4
521         WHERE id = ?5
522           AND review_status IN ('pending_review', 'deferred')",
523        params![review_status, claim_id, review_note, now, id],
524    )?;
525    if updated != 1 {
526        bail!("user-context candidate {id} is no longer reviewable");
527    }
528    Ok(())
529}
530
531fn ensure_reviewable(candidate: &UserContextCandidate) -> Result<()> {
532    if matches!(
533        candidate.review_status.as_str(),
534        "pending_review" | "deferred"
535    ) {
536        return Ok(());
537    }
538    bail!(
539        "only pending_review or deferred user-context candidates can be reviewed; candidate {} is {}",
540        candidate.id,
541        candidate.review_status
542    );
543}
544
545fn load_candidate_tx(conn: &Connection, id: i64) -> Result<UserContextCandidate> {
546    conn.query_row(
547        &format!("{} WHERE id = ?1", candidate_select_sql()),
548        [id],
549        candidate_from_row,
550    )
551    .optional()?
552    .ok_or_else(|| anyhow!("user-context candidate {id} not found"))
553}
554
555fn candidate_select_sql() -> &'static str {
556    "SELECT id, user_key, owner_scope, owner_key, source_project, host,
557            session_id, claim_type, claim_key, claim_text, confidence,
558            sensitivity, risk_class, source_kind, source_refs_json,
559            source_preview, review_status, auto_promote_block_reason,
560            review_note, result_claim_id, created_at_epoch, updated_at_epoch
561     FROM user_context_candidates"
562}
563
564fn candidate_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserContextCandidate> {
565    Ok(UserContextCandidate {
566        id: row.get(0)?,
567        user_key: row.get(1)?,
568        owner_scope: row.get(2)?,
569        owner_key: row.get(3)?,
570        source_project: row.get(4)?,
571        host: row.get(5)?,
572        session_id: row.get(6)?,
573        claim_type: row.get(7)?,
574        claim_key: row.get(8)?,
575        claim_text: row.get(9)?,
576        confidence: row.get(10)?,
577        sensitivity: row.get(11)?,
578        risk_class: row.get(12)?,
579        source_kind: row.get(13)?,
580        source_refs_json: row.get(14)?,
581        source_preview: row.get(15)?,
582        review_status: row.get(16)?,
583        auto_promote_block_reason: row.get(17)?,
584        review_note: row.get(18)?,
585        result_claim_id: row.get(19)?,
586        created_at_epoch: row.get(20)?,
587        updated_at_epoch: row.get(21)?,
588    })
589}
590
591fn active_claim_key_conflict(active: &[UserContextClaim], text: &str, sensitivity: &str) -> bool {
592    active
593        .iter()
594        .any(|claim| claim.claim_text != text || claim.sensitivity != sensitivity)
595}
596
597fn auto_promote_allowed(
598    req: &CandidateCreateRequest<'_>,
599    source_kind: &str,
600    policy: &AutoPromotePolicy,
601) -> bool {
602    req.auto_promote
603        && req.risk_class == UserContextCandidateRisk::Low
604        && req.sensitivity == UserContextSensitivity::Normal
605        && req.confidence >= policy.min_confidence
606        && policy.allows_source_kind(source_kind)
607        && normalized_optional(req.claim_key).is_some()
608}
609
610fn auto_promote_block_reason(
611    req: &CandidateCreateRequest<'_>,
612    source_kind: &str,
613    policy: &AutoPromotePolicy,
614) -> &'static str {
615    if !req.auto_promote {
616        return "requires_review";
617    }
618    if source_kind == "third_party_statement" {
619        return "third_party_requires_review";
620    }
621    if req.risk_class != UserContextCandidateRisk::Low {
622        return "risk_requires_review";
623    }
624    if req.sensitivity != UserContextSensitivity::Normal {
625        return "sensitivity_requires_review";
626    }
627    if req.confidence < policy.min_confidence {
628        return "low_confidence";
629    }
630    if !policy.allows_source_kind(source_kind) {
631        return "source_requires_review";
632    }
633    if normalized_optional(req.claim_key).is_none() {
634        return "missing_claim_key";
635    }
636    "requires_review"
637}
638
639fn claim_source_refs_json(candidate: &UserContextCandidate) -> Result<String> {
640    let source_refs: serde_json::Value = serde_json::from_str(&candidate.source_refs_json)?;
641    serde_json::to_string(&serde_json::json!([{
642        "kind": "user_context_candidate",
643        "candidate_id": candidate.id,
644        "source_kind": candidate.source_kind,
645        "source_refs": source_refs,
646    }]))
647    .context("encode candidate claim source refs")
648}
649
650fn validate_review_status(status: &str) -> Result<()> {
651    if matches!(
652        status,
653        "pending_review"
654            | "auto_promoted"
655            | "approved"
656            | "edited"
657            | "rejected"
658            | "suppressed"
659            | "deferred"
660    ) {
661        return Ok(());
662    }
663    bail!("unsupported user-context candidate review status: {status}");
664}
665
666fn normalized_owner<'a>(
667    owner_scope: Option<&'a str>,
668    owner_key: Option<&'a str>,
669) -> Result<(&'a str, &'a str)> {
670    let owner_scope = normalized_optional(owner_scope).unwrap_or(DEFAULT_OWNER_SCOPE);
671    validate_owner_scope(owner_scope)?;
672    let owner_key = normalized_optional(owner_key);
673    match (owner_scope, owner_key) {
674        ("user", None) => Ok((owner_scope, DEFAULT_OWNER_KEY)),
675        ("user", Some(owner_key)) => Ok((owner_scope, owner_key)),
676        (_, Some(owner_key)) => Ok((owner_scope, owner_key)),
677        _ => bail!("owner_key is required when owner_scope is not user"),
678    }
679}
680
681fn validate_owner_scope(owner_scope: &str) -> Result<()> {
682    if matches!(owner_scope, "user" | "workspace" | "repo" | "session") {
683        return Ok(());
684    }
685    bail!("unsupported user-context owner scope: {owner_scope}");
686}
687
688fn normalize_required<'a>(label: &str, value: &'a str) -> Result<&'a str> {
689    let value = value.trim();
690    if value.is_empty() {
691        bail!("{label} cannot be empty");
692    }
693    Ok(value)
694}
695
696fn normalized_optional(value: Option<&str>) -> Option<&str> {
697    value.map(str::trim).filter(|value| !value.is_empty())
698}
699
700fn validate_confidence(confidence: f64) -> Result<()> {
701    if (0.0..=1.0).contains(&confidence) {
702        return Ok(());
703    }
704    bail!("confidence must be between 0.0 and 1.0");
705}
706
707fn validate_source_refs(source_refs_json: &str) -> Result<()> {
708    let value: serde_json::Value =
709        serde_json::from_str(source_refs_json).context("parse candidate source refs")?;
710    if !value.is_array() {
711        bail!("candidate source refs must be a JSON array");
712    }
713    if value.as_array().is_some_and(Vec::is_empty) {
714        bail!("candidate source refs must not be empty");
715    }
716    Ok(())
717}
718
719#[cfg(test)]
720mod tests;