1use crate::error::{Result, UserError};
8use crate::provider_platform::{list_provider_summaries, ProviderRegistry, ProviderStability};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::Path;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15pub const PROVIDER_EVIDENCE_SCHEMA_VERSION: u32 = 1;
17
18pub const SUPPORTED_EVIDENCE_MAX_AGE_SECS: u64 = 30 * 24 * 60 * 60; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum SupportTier {
26 Supported,
28 Experimental,
30 ExplicitOnly,
32}
33
34impl SupportTier {
35 pub fn as_str(self) -> &'static str {
36 match self {
37 Self::Supported => "supported",
38 Self::Experimental => "experimental",
39 Self::ExplicitOnly => "explicit_only",
40 }
41 }
42
43 pub fn parse(s: &str) -> Result<Self> {
44 match s.trim().to_ascii_lowercase().as_str() {
45 "supported" | "stable" => Ok(Self::Supported),
46 "experimental" => Ok(Self::Experimental),
47 "explicit_only" | "explicit-only" | "explicit" => Ok(Self::ExplicitOnly),
48 other => Err(UserError::InvalidConfig {
49 reason: format!(
50 "unknown support tier '{other}' (use supported|experimental|explicit_only)"
51 ),
52 }
53 .into()),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum EvidenceOperation {
62 Stt,
63 Tts,
64}
65
66impl EvidenceOperation {
67 pub fn as_str(self) -> &'static str {
68 match self {
69 Self::Stt => "stt",
70 Self::Tts => "tts",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
77#[serde(rename_all = "snake_case")]
78pub enum EvidenceFailureCategory {
79 #[default]
80 None,
81 Auth,
82 RateLimit,
83 Quota,
84 Network,
85 ModelUnavailable,
86 ProtocolDrift,
87 AccountGuardrail,
88 InvalidPayload,
89 Timeout,
90 Other,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct ProviderEvidenceRecord {
96 pub schema_version: u32,
97 pub provider_id: String,
98 pub operation: EvidenceOperation,
99 pub model_id: String,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub voice_alias: Option<String>,
103 pub support_tier: SupportTier,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub aurum_commit: Option<String>,
107 pub aurum_version: String,
108 pub protocol_contract: String,
110 pub executed_at_unix: u64,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub workflow_run_id: Option<String>,
114 pub auth_ok: bool,
115 pub passed: bool,
116 #[serde(default)]
117 pub failure_category: EvidenceFailureCategory,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub latency_ms: Option<u64>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub encoded_bytes: Option<u64>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub decoded_bytes: Option<u64>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub result_units: Option<u64>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub sample_rate_hz: Option<u32>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub backend_kind: Option<String>,
131 #[serde(default)]
132 pub timestamps_reliable: bool,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub capability_snapshot_digest: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub expires_at_unix: Option<u64>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub notes: Option<String>,
140}
141
142impl ProviderEvidenceRecord {
143 pub fn validate_schema(&self) -> Result<()> {
144 if self.schema_version != PROVIDER_EVIDENCE_SCHEMA_VERSION {
145 return Err(UserError::Other {
146 message: format!(
147 "unsupported provider evidence schema_version {} (expected {PROVIDER_EVIDENCE_SCHEMA_VERSION})",
148 self.schema_version
149 ),
150 }
151 .into());
152 }
153 if self.provider_id.trim().is_empty() || self.model_id.trim().is_empty() {
154 return Err(UserError::Other {
155 message: "provider evidence requires non-empty provider_id and model_id".into(),
156 }
157 .into());
158 }
159 if self.executed_at_unix == 0 {
160 return Err(UserError::Other {
161 message: "provider evidence requires executed_at_unix".into(),
162 }
163 .into());
164 }
165 if let Some(ref n) = self.notes {
167 for bad in ["sk-", "Bearer ", "BEGIN_", "transcript=", "pcm="] {
168 if n.contains(bad) {
169 return Err(UserError::Other {
170 message: format!(
171 "provider evidence notes contain forbidden fragment {bad:?}"
172 ),
173 }
174 .into());
175 }
176 }
177 }
178 Ok(())
179 }
180
181 pub fn route_key(&self) -> String {
182 format!(
183 "{}:{}:{}:{}",
184 self.provider_id,
185 self.operation.as_str(),
186 self.model_id,
187 self.voice_alias.as_deref().unwrap_or("-")
188 )
189 }
190
191 pub fn is_fresh(&self, now_unix: u64) -> bool {
193 if let Some(exp) = self.expires_at_unix {
194 return now_unix <= exp;
195 }
196 now_unix.saturating_sub(self.executed_at_unix) <= SUPPORTED_EVIDENCE_MAX_AGE_SECS
197 }
198
199 pub fn qualifies_as_supported(&self, now_unix: u64) -> bool {
201 matches!(self.support_tier, SupportTier::Supported)
202 && self.passed
203 && self.auth_ok
204 && self.is_fresh(now_unix)
205 }
206
207 pub fn load(path: &Path) -> Result<Self> {
208 let data = fs::read_to_string(path).map_err(|e| UserError::Other {
209 message: format!("read provider evidence {}: {e}", path.display()),
210 })?;
211 if data.len() > 256 * 1024 {
212 return Err(UserError::Other {
213 message: "provider evidence file exceeds 256 KiB bound".into(),
214 }
215 .into());
216 }
217 let rec: Self = serde_json::from_str(&data).map_err(|e| UserError::Other {
218 message: format!("parse provider evidence: {e}"),
219 })?;
220 rec.validate_schema()?;
221 Ok(rec)
222 }
223
224 pub fn to_json_pretty(&self) -> Result<String> {
225 serde_json::to_string_pretty(self).map_err(|e| {
226 UserError::Other {
227 message: format!("serialize provider evidence: {e}"),
228 }
229 .into()
230 })
231 }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
236pub struct SupportedRouteClaim {
237 pub provider_id: String,
238 pub operation: EvidenceOperation,
239 pub model_id: String,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub voice_alias: Option<String>,
242 #[serde(default = "default_true")]
244 pub required_for_release: bool,
245}
246
247fn default_true() -> bool {
248 true
249}
250
251impl SupportedRouteClaim {
252 pub fn route_key(&self) -> String {
253 format!(
254 "{}:{}:{}:{}",
255 self.provider_id,
256 self.operation.as_str(),
257 self.model_id,
258 self.voice_alias.as_deref().unwrap_or("-")
259 )
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
265pub struct ProviderEvidenceIndex {
266 pub schema_version: u32,
267 pub aurum_version: String,
268 pub supported_claims: Vec<SupportedRouteClaim>,
270 #[serde(default)]
272 pub experimental_routes: Vec<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub notes: Option<String>,
275}
276
277impl ProviderEvidenceIndex {
278 pub fn load(path: &Path) -> Result<Self> {
279 let data = fs::read_to_string(path).map_err(|e| UserError::Other {
280 message: format!("read evidence index {}: {e}", path.display()),
281 })?;
282 let idx: Self = serde_json::from_str(&data).map_err(|e| UserError::Other {
283 message: format!("parse evidence index: {e}"),
284 })?;
285 if idx.schema_version != PROVIDER_EVIDENCE_SCHEMA_VERSION {
286 return Err(UserError::Other {
287 message: format!(
288 "unsupported evidence index schema_version {}",
289 idx.schema_version
290 ),
291 }
292 .into());
293 }
294 Ok(idx)
295 }
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
300pub struct EvidenceGateFinding {
301 pub severity: String,
302 pub route: String,
303 pub code: String,
304 pub message: String,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309pub struct EvidenceGateReport {
310 pub passed: bool,
311 pub now_unix: u64,
312 pub findings: Vec<EvidenceGateFinding>,
313}
314
315pub fn load_evidence_dir(dir: &Path) -> Result<Vec<ProviderEvidenceRecord>> {
317 if !dir.is_dir() {
318 return Err(UserError::Other {
319 message: format!("evidence directory missing: {}", dir.display()),
320 }
321 .into());
322 }
323 let mut out = Vec::new();
324 for ent in fs::read_dir(dir).map_err(|e| UserError::Other {
325 message: format!("read evidence dir: {e}"),
326 })? {
327 let ent = ent.map_err(|e| UserError::Other {
328 message: format!("read evidence entry: {e}"),
329 })?;
330 let path = ent.path();
331 if path.extension().and_then(|e| e.to_str()) != Some("json") {
332 continue;
333 }
334 if path.file_name().and_then(|n| n.to_str()) == Some("index.json") {
335 continue;
336 }
337 out.push(ProviderEvidenceRecord::load(&path)?);
338 }
339 Ok(out)
340}
341
342fn now_unix() -> u64 {
343 SystemTime::now()
344 .duration_since(UNIX_EPOCH)
345 .map(|d| d.as_secs())
346 .unwrap_or(0)
347}
348
349pub fn evaluate_supported_evidence_gate(
353 index: &ProviderEvidenceIndex,
354 records: &[ProviderEvidenceRecord],
355 now: Option<u64>,
356) -> EvidenceGateReport {
357 let now_unix = now.unwrap_or_else(now_unix);
358 let mut by_route: BTreeMap<String, Vec<&ProviderEvidenceRecord>> = BTreeMap::new();
359 for r in records {
360 by_route.entry(r.route_key()).or_default().push(r);
361 }
362
363 let mut findings = Vec::new();
364 for claim in &index.supported_claims {
365 if !claim.required_for_release {
366 continue;
367 }
368 if claim.provider_id == "local" {
370 findings.push(EvidenceGateFinding {
371 severity: "pass".into(),
372 route: claim.route_key(),
373 code: "local_supported".into(),
374 message: "local route does not require protected network evidence".into(),
375 });
376 continue;
377 }
378
379 let key = claim.route_key();
380 let Some(list) = by_route.get(&key) else {
381 findings.push(EvidenceGateFinding {
382 severity: "fail".into(),
383 route: key,
384 code: "missing_evidence".into(),
385 message: "no evidence record for supported claim; demote, restore, or remove"
386 .into(),
387 });
388 continue;
389 };
390
391 let best = list
392 .iter()
393 .filter(|r| r.passed && r.auth_ok)
394 .max_by_key(|r| r.executed_at_unix);
395 match best {
396 None => findings.push(EvidenceGateFinding {
397 severity: "fail".into(),
398 route: key,
399 code: "no_passing_evidence".into(),
400 message: "evidence exists but no passing/auth_ok record".into(),
401 }),
402 Some(r) if !r.is_fresh(now_unix) => findings.push(EvidenceGateFinding {
403 severity: "fail".into(),
404 route: key,
405 code: "stale_evidence".into(),
406 message: format!(
407 "latest passing evidence is older than {} days (executed_at_unix={})",
408 SUPPORTED_EVIDENCE_MAX_AGE_SECS / 86400,
409 r.executed_at_unix
410 ),
411 }),
412 Some(r) if !matches!(r.support_tier, SupportTier::Supported) => {
413 findings.push(EvidenceGateFinding {
414 severity: "fail".into(),
415 route: key,
416 code: "tier_mismatch".into(),
417 message: format!(
418 "claim is supported but evidence tier is {}",
419 r.support_tier.as_str()
420 ),
421 });
422 }
423 Some(_) => findings.push(EvidenceGateFinding {
424 severity: "pass".into(),
425 route: key,
426 code: "ok".into(),
427 message: "fresh passing evidence present".into(),
428 }),
429 }
430 }
431
432 let passed = findings.iter().all(|f| f.severity != "fail");
433 EvidenceGateReport {
434 passed,
435 now_unix,
436 findings,
437 }
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
442pub struct CatalogueDriftReport {
443 pub provider_id: String,
444 pub missing_from_discovery: Vec<String>,
445 pub unexpected_in_discovery: Vec<String>,
446}
447
448pub fn detect_catalogue_drift(
450 provider_id: &str,
451 reviewed: &[String],
452 discovered: &[String],
453) -> CatalogueDriftReport {
454 let rev: std::collections::BTreeSet<_> = reviewed.iter().cloned().collect();
455 let disc: std::collections::BTreeSet<_> = discovered.iter().cloned().collect();
456 CatalogueDriftReport {
457 provider_id: provider_id.into(),
458 missing_from_discovery: rev.difference(&disc).cloned().collect(),
459 unexpected_in_discovery: disc.difference(&rev).cloned().collect(),
460 }
461}
462
463pub fn tier_from_registry_stability(s: ProviderStability) -> SupportTier {
465 match s {
466 ProviderStability::Stable => SupportTier::Supported,
467 ProviderStability::Experimental => SupportTier::Experimental,
468 ProviderStability::TestOnly => SupportTier::ExplicitOnly,
469 }
470}
471
472pub fn effective_provider_tier(
474 provider_id: &str,
475 registry_stability: ProviderStability,
476 evidence: &[ProviderEvidenceRecord],
477 now_unix: u64,
478) -> SupportTier {
479 if provider_id == "local" {
480 return SupportTier::Supported;
481 }
482 let base = tier_from_registry_stability(registry_stability);
483 if !matches!(base, SupportTier::Supported) {
484 return base;
485 }
486 let has_fresh = evidence
489 .iter()
490 .any(|r| r.provider_id == provider_id && r.qualifies_as_supported(now_unix));
491 if has_fresh {
492 SupportTier::Supported
493 } else {
494 SupportTier::Experimental
495 }
496}
497
498pub fn provider_tier_matrix(
500 registry: &ProviderRegistry,
501 evidence: &[ProviderEvidenceRecord],
502 now_unix: u64,
503) -> Vec<(String, SupportTier, bool, bool)> {
504 list_provider_summaries(registry)
505 .into_iter()
506 .map(|s| {
507 let tier = effective_provider_tier(&s.id, s.stability, evidence, now_unix);
508 (s.id, tier, s.stt, s.tts)
509 })
510 .collect()
511}
512
513pub fn local_stt_evidence(now_unix: u64) -> ProviderEvidenceRecord {
515 ProviderEvidenceRecord {
516 schema_version: PROVIDER_EVIDENCE_SCHEMA_VERSION,
517 provider_id: "local".into(),
518 operation: EvidenceOperation::Stt,
519 model_id: "base".into(),
520 voice_alias: None,
521 support_tier: SupportTier::Supported,
522 aurum_commit: None,
523 aurum_version: env!("CARGO_PKG_VERSION").into(),
524 protocol_contract: "local_whisper_v1".into(),
525 executed_at_unix: now_unix,
526 workflow_run_id: Some("offline-ci".into()),
527 auth_ok: true,
528 passed: true,
529 failure_category: EvidenceFailureCategory::None,
530 latency_ms: None,
531 encoded_bytes: None,
532 decoded_bytes: None,
533 result_units: Some(1),
534 sample_rate_hz: Some(16_000),
535 backend_kind: Some("asr".into()),
536 timestamps_reliable: true,
537 capability_snapshot_digest: None,
538 expires_at_unix: Some(now_unix + SUPPORTED_EVIDENCE_MAX_AGE_SECS),
539 notes: Some("local STT — no network evidence required".into()),
540 }
541}
542
543pub fn local_tts_evidence(now_unix: u64) -> ProviderEvidenceRecord {
544 ProviderEvidenceRecord {
545 schema_version: PROVIDER_EVIDENCE_SCHEMA_VERSION,
546 provider_id: "local".into(),
547 operation: EvidenceOperation::Tts,
548 model_id: "kitten-nano-int8".into(),
549 voice_alias: Some("Luna".into()),
550 support_tier: SupportTier::Supported,
551 aurum_commit: None,
552 aurum_version: env!("CARGO_PKG_VERSION").into(),
553 protocol_contract: "local_kitten_v1".into(),
554 executed_at_unix: now_unix,
555 workflow_run_id: Some("offline-ci".into()),
556 auth_ok: true,
557 passed: true,
558 failure_category: EvidenceFailureCategory::None,
559 latency_ms: None,
560 encoded_bytes: None,
561 decoded_bytes: None,
562 result_units: Some(1),
563 sample_rate_hz: Some(24_000),
564 backend_kind: Some("local".into()),
565 timestamps_reliable: false,
566 capability_snapshot_digest: None,
567 expires_at_unix: Some(now_unix + SUPPORTED_EVIDENCE_MAX_AGE_SECS),
568 notes: Some("local TTS — no network evidence required".into()),
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 fn claim(provider: &str, op: EvidenceOperation, model: &str) -> SupportedRouteClaim {
577 SupportedRouteClaim {
578 provider_id: provider.into(),
579 operation: op,
580 model_id: model.into(),
581 voice_alias: None,
582 required_for_release: true,
583 }
584 }
585
586 #[test]
587 fn local_gate_passes_without_files() {
588 let idx = ProviderEvidenceIndex {
589 schema_version: 1,
590 aurum_version: "0.0.22".into(),
591 supported_claims: vec![claim("local", EvidenceOperation::Stt, "base")],
592 experimental_routes: vec![],
593 notes: None,
594 };
595 let rep = evaluate_supported_evidence_gate(&idx, &[], None);
596 assert!(rep.passed, "{:?}", rep.findings);
597 }
598
599 #[test]
600 fn missing_remote_evidence_fails() {
601 let idx = ProviderEvidenceIndex {
602 schema_version: 1,
603 aurum_version: "0.0.22".into(),
604 supported_claims: vec![claim("openai", EvidenceOperation::Stt, "whisper-1")],
605 experimental_routes: vec![],
606 notes: None,
607 };
608 let rep = evaluate_supported_evidence_gate(&idx, &[], Some(1_700_000_000));
609 assert!(!rep.passed);
610 assert!(rep.findings.iter().any(|f| f.code == "missing_evidence"));
611 }
612
613 #[test]
614 fn stale_evidence_fails() {
615 let now = 2_000_000_000u64;
616 let mut rec = local_stt_evidence(now);
617 rec.provider_id = "openai".into();
618 rec.model_id = "whisper-1".into();
619 rec.protocol_contract = "openai_stt_v1".into();
620 rec.executed_at_unix = now - SUPPORTED_EVIDENCE_MAX_AGE_SECS - 10;
621 rec.expires_at_unix = None;
622 let idx = ProviderEvidenceIndex {
623 schema_version: 1,
624 aurum_version: "0.0.22".into(),
625 supported_claims: vec![claim("openai", EvidenceOperation::Stt, "whisper-1")],
626 experimental_routes: vec![],
627 notes: None,
628 };
629 let rep = evaluate_supported_evidence_gate(&idx, &[rec], Some(now));
630 assert!(!rep.passed);
631 assert!(rep.findings.iter().any(|f| f.code == "stale_evidence"));
632 }
633
634 #[test]
635 fn fresh_passing_remote_ok() {
636 let now = 2_000_000_000u64;
637 let mut rec = local_stt_evidence(now);
638 rec.provider_id = "openai".into();
639 rec.model_id = "whisper-1".into();
640 rec.protocol_contract = "openai_stt_v1".into();
641 let idx = ProviderEvidenceIndex {
642 schema_version: 1,
643 aurum_version: "0.0.22".into(),
644 supported_claims: vec![claim("openai", EvidenceOperation::Stt, "whisper-1")],
645 experimental_routes: vec![],
646 notes: None,
647 };
648 let rep = evaluate_supported_evidence_gate(&idx, &[rec], Some(now));
649 assert!(rep.passed, "{:?}", rep.findings);
650 }
651
652 #[test]
653 fn catalogue_drift_detects_removed_model() {
654 let d = detect_catalogue_drift(
655 "openai",
656 &["whisper-1".into(), "gone-model".into()],
657 &["whisper-1".into(), "new-model".into()],
658 );
659 assert_eq!(d.missing_from_discovery, vec!["gone-model".to_string()]);
660 assert_eq!(d.unexpected_in_discovery, vec!["new-model".to_string()]);
661 }
662
663 #[test]
664 fn effective_tier_demotes_without_evidence() {
665 let now = 2_000_000_000u64;
666 let t = effective_provider_tier("openai", ProviderStability::Stable, &[], now);
667 assert_eq!(t, SupportTier::Experimental);
668 let rec = {
669 let mut r = local_stt_evidence(now);
670 r.provider_id = "openai".into();
671 r.model_id = "whisper-1".into();
672 r
673 };
674 let t2 = effective_provider_tier("openai", ProviderStability::Stable, &[rec], now);
675 assert_eq!(t2, SupportTier::Supported);
676 }
677
678 #[test]
679 fn privacy_rejects_secret_notes() {
680 let mut r = local_stt_evidence(1);
681 r.notes = Some("sk-abc".into());
682 assert!(r.validate_schema().is_err());
683 }
684}