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;