Skip to main content

aurum_core/provider_platform/
evidence.rs

1//! Provider support-tier evidence, freshness, and demotion policy (JOE-2223).
2//!
3//! `supported` is an operational claim: reviewed factory + mocks + **fresh**
4//! protected inference evidence (≤30 days). Mocks alone never promote a remote
5//! route. Evidence never retains payloads, keys, or private voice IDs.
6
7use 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
15/// Evidence record schema version.
16pub const PROVIDER_EVIDENCE_SCHEMA_VERSION: u32 = 1;
17
18/// Maximum age (seconds) of a passing protected smoke for a `supported` route.
19pub const SUPPORTED_EVIDENCE_MAX_AGE_SECS: u64 = 30 * 24 * 60 * 60; // 30 days
20
21/// Product support tier (code/docs/CLI). Distinct from registry
22/// [`ProviderStability`] which describes implementation maturity.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum SupportTier {
26    /// Meets full entry rules including fresh protected evidence.
27    Supported,
28    /// Implemented + mocks; evidence missing/stale/limited. Never a default.
29    Experimental,
30    /// Requires deliberate selection; hidden from normal recommendation flows.
31    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/// Operation covered by an evidence record.
59#[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/// Closed failure categories (no free-form vendor bodies).
76#[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/// Machine-readable provider evidence record (redacted).
94#[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    /// Reviewed voice alias (never private ElevenLabs IDs in public evidence).
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub voice_alias: Option<String>,
103    pub support_tier: SupportTier,
104    /// Full git commit when available.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub aurum_commit: Option<String>,
107    pub aurum_version: String,
108    /// Protocol/endpoint contract label (e.g. `openai_stt_v1`).
109    pub protocol_contract: String,
110    /// UTC unix seconds when the protected smoke executed.
111    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    /// Non-empty result without payload (text chars or audio samples count only).
125    #[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    /// Explicit expiry (unix). If absent, freshness uses max age from execution.
136    #[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        // Privacy: notes must not look like secrets/payloads.
166        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    /// Freshness relative to `now_unix` (typically wall clock).
192    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    /// A route may claim `supported` only with a fresh **passing** record.
200    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/// Reviewed claim that a route is product-supported (must be backed by evidence).
235#[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    /// When true, missing/stale evidence fails the release gate.
243    #[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/// Versioned index of claims + optional evidence directory.
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
265pub struct ProviderEvidenceIndex {
266    pub schema_version: u32,
267    pub aurum_version: String,
268    /// Routes that product code/docs currently claim as `supported`.
269    pub supported_claims: Vec<SupportedRouteClaim>,
270    /// Routes intentionally experimental (documentation only; do not block release).
271    #[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/// One gate finding.
299#[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/// Result of evaluating supported claims against evidence files.
308#[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
315/// Load all `*.json` evidence records under `dir` (non-recursive).
316pub 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
349/// Evaluate release readiness for supported remote routes.
350///
351/// Local routes never require protected network evidence.
352pub 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        // Local is always supported without remote smoke.
369        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/// Catalogue drift: reviewed model IDs that discovery no longer lists.
441#[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
448/// Compare a reviewed allowlist to a discovery set (never auto-trust discovery).
449pub 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
463/// Map registry stability to product support tier **before** evidence overlay.
464pub 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
472/// Effective tier for a provider after applying evidence (local stays supported).
473pub 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    // Registry says stable/supported — require at least one fresh passing evidence
487    // for any STT or TTS model route, else demote to experimental for gate purposes.
488    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
498/// Summarize builtin registry + evidence for documentation/release.
499pub 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
513/// Built-in local evidence record (always valid for CI / offline).
514pub 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}