Skip to main content

provider_core/
catalog.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//! Provider-native catalog normalization and immutable last-known-good state.
4
5use router_protocol::CatalogSourceKind;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeSet, HashSet};
9use thiserror::Error;
10
11pub const CATALOG_SCHEMA_VERSION: &str = "1.0.0";
12pub const PROMOTION_AUTHORITY_SCHEMA_VERSION: &str = "1.0.0";
13pub const REFRESH_BUCKET_SECONDS: u64 = 6 * 60 * 60;
14pub const STALE_WARNING_SECONDS: u64 = 18 * 60 * 60;
15pub const SNAPSHOT_EXPIRY_SECONDS: u64 = 24 * 60 * 60;
16const MAX_CATALOG_MODELS: usize = 512;
17const MAX_MODEL_MODALITIES: usize = 8;
18const MAX_REASONING_MODES: usize = 8;
19const MAX_ROLE_ALIASES: usize = 8;
20
21/// Provider adapters normalize their native discovery response through this
22/// interface. It has no transport or inference capability.
23pub trait ProviderCatalogDiscovery {
24    type NativeResponse;
25
26    fn source(&self) -> CatalogSourceKind;
27    fn normalize(
28        &self,
29        response: &Self::NativeResponse,
30        observed_at_epoch_seconds: u64,
31    ) -> Result<Vec<NormalizedModelSpec>, CatalogError>;
32}
33
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct NormalizedModelSpec {
37    pub provider_model_id: String,
38    pub context_tokens: u64,
39    pub modalities: Vec<String>,
40    pub tool_support: bool,
41    pub reasoning_modes: Vec<String>,
42    pub pricing_provenance: String,
43    pub availability: String,
44    pub observed_at_epoch_seconds: u64,
45}
46
47impl NormalizedModelSpec {
48    pub fn fingerprint(&self) -> Result<String, CatalogError> {
49        validate_spec(self)?;
50        serde_json::to_vec(self)
51            .map(|bytes| sha256_hex(&bytes))
52            .map_err(|_| CatalogError::Malformed)
53    }
54}
55
56#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct CatalogSnapshot {
59    schema_version: String,
60    snapshot_id: String,
61    provider_id: String,
62    catalog_version: String,
63    source: CatalogSourceKind,
64    source_receipt: String,
65    created_at_epoch_seconds: u64,
66    expires_at_epoch_seconds: u64,
67    content_sha256: String,
68    models: Vec<NormalizedModelSpec>,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum CatalogFreshness {
74    Fresh,
75    Warning,
76    Expired,
77    InvalidClock,
78}
79
80impl CatalogSnapshot {
81    #[allow(clippy::too_many_arguments)]
82    pub fn accept(
83        snapshot_id: impl Into<String>,
84        provider_id: impl Into<String>,
85        catalog_version: impl Into<String>,
86        source: CatalogSourceKind,
87        source_receipt: impl Into<String>,
88        created_at_epoch_seconds: u64,
89        mut models: Vec<NormalizedModelSpec>,
90    ) -> Result<Self, CatalogError> {
91        let snapshot_id = snapshot_id.into();
92        let provider_id = provider_id.into();
93        let catalog_version = catalog_version.into();
94        let source_receipt = source_receipt.into();
95        if !valid_id(&snapshot_id)
96            || !valid_id(&provider_id)
97            || !valid_id(&catalog_version)
98            || !valid_typed_digest_ref(&source_receipt, &["receipt", "manifest"])
99            || models.is_empty()
100            || models.len() > MAX_CATALOG_MODELS
101        {
102            return Err(CatalogError::Malformed);
103        }
104        let mut ids = HashSet::new();
105        for model in &models {
106            validate_spec(model)?;
107            if model.observed_at_epoch_seconds > created_at_epoch_seconds
108                || !ids.insert(model.provider_model_id.as_str())
109            {
110                return Err(CatalogError::Contradictory);
111            }
112        }
113        models.sort_by(|left, right| left.provider_model_id.cmp(&right.provider_model_id));
114        let canonical = serde_json::to_vec(&models).map_err(|_| CatalogError::Malformed)?;
115        Ok(Self {
116            schema_version: CATALOG_SCHEMA_VERSION.to_owned(),
117            snapshot_id,
118            provider_id,
119            catalog_version,
120            source,
121            source_receipt,
122            created_at_epoch_seconds,
123            expires_at_epoch_seconds: created_at_epoch_seconds
124                .saturating_add(SNAPSHOT_EXPIRY_SECONDS),
125            content_sha256: sha256_hex(&canonical),
126            models,
127        })
128    }
129
130    pub fn freshness(&self, now_epoch_seconds: u64) -> CatalogFreshness {
131        if self.validate_integrity().is_err() {
132            return CatalogFreshness::Expired;
133        }
134        if now_epoch_seconds < self.created_at_epoch_seconds {
135            return CatalogFreshness::InvalidClock;
136        }
137        let age = now_epoch_seconds - self.created_at_epoch_seconds;
138        if age >= SNAPSHOT_EXPIRY_SECONDS {
139            CatalogFreshness::Expired
140        } else if age >= STALE_WARNING_SECONDS {
141            CatalogFreshness::Warning
142        } else {
143            CatalogFreshness::Fresh
144        }
145    }
146
147    pub fn content_sha256(&self) -> &str {
148        &self.content_sha256
149    }
150
151    pub fn catalog_version(&self) -> &str {
152        &self.catalog_version
153    }
154
155    pub fn provider_id(&self) -> &str {
156        &self.provider_id
157    }
158
159    pub fn model(&self, provider_model_id: &str) -> Option<&NormalizedModelSpec> {
160        self.models
161            .iter()
162            .find(|model| model.provider_model_id == provider_model_id)
163    }
164
165    pub fn models(&self) -> &[NormalizedModelSpec] {
166        &self.models
167    }
168
169    /// Builds a deterministic promotion artifact containing only the exact
170    /// configured models that completed qualification. The observed catalog
171    /// remains unchanged in the LKG; this derived snapshot is a bounded,
172    /// integrity-checked authority input with the same source/version/time.
173    pub fn promotion_subset(&self, provider_model_ids: &[String]) -> Result<Self, CatalogError> {
174        self.validate_integrity()?;
175        if provider_model_ids.is_empty() || provider_model_ids.len() > MAX_CATALOG_MODELS {
176            return Err(CatalogError::Malformed);
177        }
178        let wanted = provider_model_ids.iter().collect::<HashSet<_>>();
179        if wanted.len() != provider_model_ids.len() {
180            return Err(CatalogError::Contradictory);
181        }
182        let models = self
183            .models
184            .iter()
185            .filter(|model| wanted.contains(&model.provider_model_id))
186            .cloned()
187            .collect::<Vec<_>>();
188        if models.len() != wanted.len() {
189            return Err(CatalogError::Contradictory);
190        }
191        Self::accept(
192            format!("{}.qualified", self.snapshot_id),
193            self.provider_id.clone(),
194            self.catalog_version.clone(),
195            self.source,
196            self.source_receipt.clone(),
197            self.created_at_epoch_seconds,
198            models,
199        )
200    }
201
202    pub fn validate_integrity(&self) -> Result<(), CatalogError> {
203        if self.schema_version != CATALOG_SCHEMA_VERSION
204            || !valid_id(&self.snapshot_id)
205            || !valid_id(&self.provider_id)
206            || !valid_id(&self.catalog_version)
207            || !valid_typed_digest_ref(&self.source_receipt, &["receipt", "manifest"])
208            || self.models.is_empty()
209            || self.models.len() > MAX_CATALOG_MODELS
210            || self.expires_at_epoch_seconds
211                != self
212                    .created_at_epoch_seconds
213                    .saturating_add(SNAPSHOT_EXPIRY_SECONDS)
214        {
215            return Err(CatalogError::Malformed);
216        }
217        let mut ids = HashSet::with_capacity(self.models.len());
218        for model in &self.models {
219            validate_spec(model)?;
220            if model.observed_at_epoch_seconds > self.created_at_epoch_seconds
221                || !ids.insert(model.provider_model_id.as_str())
222            {
223                return Err(CatalogError::Contradictory);
224            }
225        }
226        let mut canonical = self.models.clone();
227        canonical.sort_by(|left, right| left.provider_model_id.cmp(&right.provider_model_id));
228        let encoded = serde_json::to_vec(&canonical).map_err(|_| CatalogError::Malformed)?;
229        if self.content_sha256 != sha256_hex(&encoded) {
230            return Err(CatalogError::Contradictory);
231        }
232        Ok(())
233    }
234}
235
236#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum QualificationState {
239    Discovered,
240    Quarantined,
241    Qualifying,
242    Qualified,
243    Rejected,
244    Retired,
245}
246
247#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct QualificationEvidence {
250    pub evidence_ref: String,
251    pub model_sha256: String,
252    pub catalog_version: String,
253    pub catalog_sha256: String,
254    pub policy_version: String,
255    pub policy_sha256: String,
256    pub conformance_sha256: String,
257    pub operator_promotion_receipt: String,
258    pub role_aliases: Vec<String>,
259    pub fixture_only: bool,
260}
261
262#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct ModelQualification {
265    pub provider_model_id: String,
266    pub model_fingerprint: String,
267    pub catalog_version: String,
268    pub state: QualificationState,
269    pub evidence: Option<QualificationEvidence>,
270}
271
272#[derive(Clone, Debug, PartialEq, Eq)]
273pub struct EligibilityBinding<'a> {
274    pub policy_version: &'a str,
275    pub policy_sha256: &'a str,
276    pub conformance_sha256: &'a str,
277    pub operator_promotion_receipt: &'a str,
278}
279
280/// Canonical authority input for one exact catalog-promotion mutation.
281///
282/// This type deliberately does not choose a signature/MAC algorithm or trust-
283/// anchor representation. Those remain an explicit product/Security decision.
284/// A future verifier must authenticate these exact canonical bytes before an
285/// apply path may consume the resulting private proof token.
286#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
287#[serde(deny_unknown_fields)]
288pub struct PromotionMutationBinding {
289    pub schema_version: String,
290    pub promotion_id: String,
291    pub provider_id: String,
292    pub phase_id: String,
293    pub account_ref: String,
294    pub catalog_ref: String,
295    pub provider_model_id: String,
296    pub model_sha256: String,
297    pub catalog_version: String,
298    pub catalog_sha256: String,
299    pub policy_version: String,
300    pub policy_sha256: String,
301    pub conformance_sha256: String,
302    pub configuration_authority_id: String,
303    pub configuration_authority_sha256: String,
304    pub expected_state_generation: u64,
305    pub state_preimage_sha256: String,
306    pub state_postimage_sha256: String,
307    pub rollback_snapshot_sha256: String,
308    pub authorization_nonce: String,
309    pub authorized_at_epoch_seconds: u64,
310    pub rollback_not_after_epoch_seconds: u64,
311}
312
313impl PromotionMutationBinding {
314    /// Hashes the strict, field-ordered Serde representation used as the future
315    /// verifier input. Any generation, preimage, postimage, authority, catalog,
316    /// model, policy, conformance, nonce, or rollback substitution changes it.
317    pub fn binding_sha256(&self) -> Result<String, PromotionAuthorityError> {
318        self.validate()?;
319        serde_json::to_vec(self)
320            .map(|canonical| sha256_hex(&canonical))
321            .map_err(|_| PromotionAuthorityError::MalformedBinding)
322    }
323
324    fn validate(&self) -> Result<(), PromotionAuthorityError> {
325        if self.schema_version != PROMOTION_AUTHORITY_SCHEMA_VERSION
326            || !valid_id(&self.promotion_id)
327            || !valid_id(&self.provider_id)
328            || !valid_id(&self.phase_id)
329            || !valid_id(&self.account_ref)
330            || !valid_id(&self.catalog_ref)
331            || !valid_model_id(&self.provider_model_id)
332            || !valid_sha256(&self.model_sha256)
333            || !valid_id(&self.catalog_version)
334            || !valid_sha256(&self.catalog_sha256)
335            || !valid_id(&self.policy_version)
336            || !valid_sha256(&self.policy_sha256)
337            || !valid_sha256(&self.conformance_sha256)
338            || !valid_id(&self.configuration_authority_id)
339            || !valid_sha256(&self.configuration_authority_sha256)
340            || !valid_sha256(&self.state_preimage_sha256)
341            || !valid_sha256(&self.state_postimage_sha256)
342            || !valid_sha256(&self.rollback_snapshot_sha256)
343            || self.state_preimage_sha256 == self.state_postimage_sha256
344            || self.rollback_snapshot_sha256 != self.state_preimage_sha256
345            || !valid_id(&self.authorization_nonce)
346            || self.rollback_not_after_epoch_seconds <= self.authorized_at_epoch_seconds
347        {
348            return Err(PromotionAuthorityError::MalformedBinding);
349        }
350        Ok(())
351    }
352}
353
354/// Algorithm-neutral inputs for the separately decided promotion verifier.
355/// Evidence bytes are intentionally opaque and are never formatted or logged.
356pub struct PromotionAuthorityRequest {
357    pub binding: PromotionMutationBinding,
358    pub operator_authority_id: String,
359    pub conformance_authority_id: String,
360    pub operator_evidence: Vec<u8>,
361    pub conformance_evidence: Vec<u8>,
362    pub fixture_only: bool,
363}
364
365impl PromotionAuthorityRequest {
366    fn validate(&self) -> Result<(), PromotionAuthorityError> {
367        const MAX_AUTHORITY_EVIDENCE_BYTES: usize = 64 * 1024;
368        self.binding.validate()?;
369        if !valid_id(&self.operator_authority_id)
370            || !valid_id(&self.conformance_authority_id)
371            || self.operator_evidence.is_empty()
372            || self.operator_evidence.len() > MAX_AUTHORITY_EVIDENCE_BYTES
373            || self.conformance_evidence.is_empty()
374            || self.conformance_evidence.len() > MAX_AUTHORITY_EVIDENCE_BYTES
375        {
376            return Err(PromotionAuthorityError::MalformedEvidence);
377        }
378        Ok(())
379    }
380}
381
382mod promotion_verifier_sealed {
383    pub trait Sealed {}
384}
385
386/// Sealed until the product owner and Security choose the production
387/// algorithm, trust-anchor format/lifecycle, and replay-store contract.
388pub trait PromotionAuthorityVerifier: promotion_verifier_sealed::Sealed {
389    fn verify(
390        &self,
391        request: &PromotionAuthorityRequest,
392    ) -> Result<AuthenticatedPromotionAuthority, PromotionAuthorityError>;
393}
394
395/// The only production verifier available while M-01's trust-root decision is
396/// open. It makes accidental apply fail closed rather than treating a digest
397/// reference or caller boolean as authentication.
398#[derive(Clone, Copy, Debug, Default)]
399pub struct UnconfiguredPromotionAuthorityVerifier;
400
401impl promotion_verifier_sealed::Sealed for UnconfiguredPromotionAuthorityVerifier {}
402
403impl PromotionAuthorityVerifier for UnconfiguredPromotionAuthorityVerifier {
404    fn verify(
405        &self,
406        _request: &PromotionAuthorityRequest,
407    ) -> Result<AuthenticatedPromotionAuthority, PromotionAuthorityError> {
408        Err(PromotionAuthorityError::TrustRootDecisionUnavailable)
409    }
410}
411
412/// Non-cloneable proof that both configured authorities authenticated one exact
413/// mutation binding. Only verifier implementations in this module can mint it.
414#[derive(Debug, PartialEq, Eq)]
415#[must_use]
416pub struct AuthenticatedPromotionAuthority {
417    binding_sha256: String,
418    operator_authority_id: String,
419    conformance_authority_id: String,
420}
421
422impl AuthenticatedPromotionAuthority {
423    pub fn binding_sha256(&self) -> &str {
424        &self.binding_sha256
425    }
426
427    pub fn operator_authority_id(&self) -> &str {
428        &self.operator_authority_id
429    }
430
431    pub fn conformance_authority_id(&self) -> &str {
432        &self.conformance_authority_id
433    }
434
435    #[cfg(test)]
436    fn from_verified(request: &PromotionAuthorityRequest) -> Result<Self, PromotionAuthorityError> {
437        Ok(Self {
438            binding_sha256: request.binding.binding_sha256()?,
439            operator_authority_id: request.operator_authority_id.clone(),
440            conformance_authority_id: request.conformance_authority_id.clone(),
441        })
442    }
443}
444
445pub fn authenticate_promotion(
446    verifier: &impl PromotionAuthorityVerifier,
447    request: &PromotionAuthorityRequest,
448) -> Result<AuthenticatedPromotionAuthority, PromotionAuthorityError> {
449    request.validate()?;
450    if request.fixture_only {
451        return Err(PromotionAuthorityError::FixtureEvidenceForbidden);
452    }
453    let expected_binding = request.binding.binding_sha256()?;
454    let authenticated = verifier.verify(request)?;
455    if authenticated.binding_sha256 != expected_binding
456        || authenticated.operator_authority_id != request.operator_authority_id
457        || authenticated.conformance_authority_id != request.conformance_authority_id
458    {
459        return Err(PromotionAuthorityError::BindingMismatch);
460    }
461    Ok(authenticated)
462}
463
464#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
465pub enum PromotionAuthorityError {
466    #[error("promotion authority binding is malformed")]
467    MalformedBinding,
468    #[error("promotion authority evidence is malformed")]
469    MalformedEvidence,
470    #[error("fixture evidence cannot authorize promotion")]
471    FixtureEvidenceForbidden,
472    #[error("promotion trust-root decision is unavailable")]
473    TrustRootDecisionUnavailable,
474    #[error("promotion authority authentication failed")]
475    AuthenticationFailed,
476    #[error("authenticated promotion binding changed")]
477    BindingMismatch,
478}
479
480impl ModelQualification {
481    pub fn discovered(
482        provider_model_id: impl Into<String>,
483        model_fingerprint: impl Into<String>,
484        catalog_version: impl Into<String>,
485    ) -> Result<Self, CatalogError> {
486        let value = Self {
487            provider_model_id: provider_model_id.into(),
488            model_fingerprint: model_fingerprint.into(),
489            catalog_version: catalog_version.into(),
490            state: QualificationState::Discovered,
491            evidence: None,
492        };
493        if !valid_model_id(&value.provider_model_id)
494            || !valid_sha256(&value.model_fingerprint)
495            || !valid_id(&value.catalog_version)
496        {
497            return Err(CatalogError::Malformed);
498        }
499        Ok(value)
500    }
501
502    pub fn quarantine(mut self) -> Result<Self, CatalogError> {
503        self.transition(
504            QualificationState::Discovered,
505            QualificationState::Quarantined,
506        )?;
507        Ok(self)
508    }
509
510    pub fn begin_qualification(mut self) -> Result<Self, CatalogError> {
511        self.transition(
512            QualificationState::Quarantined,
513            QualificationState::Qualifying,
514        )?;
515        Ok(self)
516    }
517
518    pub fn qualify(mut self, evidence: QualificationEvidence) -> Result<Self, CatalogError> {
519        if self.state != QualificationState::Qualifying
520            || evidence.model_sha256 != self.model_fingerprint
521            || evidence.catalog_version != self.catalog_version
522            || !valid_qualification_evidence(&evidence)
523        {
524            return Err(CatalogError::InvalidTransition);
525        }
526        self.state = QualificationState::Qualified;
527        self.evidence = Some(evidence);
528        Ok(self)
529    }
530
531    pub fn reject(mut self) -> Result<Self, CatalogError> {
532        if !matches!(
533            self.state,
534            QualificationState::Quarantined | QualificationState::Qualifying
535        ) {
536            return Err(CatalogError::InvalidTransition);
537        }
538        self.state = QualificationState::Rejected;
539        Ok(self)
540    }
541
542    pub fn retire(mut self) -> Result<Self, CatalogError> {
543        if self.state != QualificationState::Qualified {
544            return Err(CatalogError::InvalidTransition);
545        }
546        self.state = QualificationState::Retired;
547        Ok(self)
548    }
549
550    pub fn automatic_eligible(
551        &self,
552        snapshot: &CatalogSnapshot,
553        now_epoch_seconds: u64,
554        binding: &EligibilityBinding<'_>,
555        operator_approved: bool,
556    ) -> bool {
557        snapshot.validate_integrity().is_ok()
558            && valid_typed_digest_ref(binding.operator_promotion_receipt, &["approval", "receipt"])
559            && valid_model_id(&self.provider_model_id)
560            && valid_sha256(&self.model_fingerprint)
561            && valid_id(&self.catalog_version)
562            && self.state == QualificationState::Qualified
563            && operator_approved
564            && matches!(
565                snapshot.freshness(now_epoch_seconds),
566                CatalogFreshness::Fresh | CatalogFreshness::Warning
567            )
568            && snapshot.catalog_version() == self.catalog_version
569            && self.evidence.as_ref().is_some_and(|evidence| {
570                valid_qualification_evidence(evidence)
571                    && !evidence.fixture_only
572                    && evidence.model_sha256 == self.model_fingerprint
573                    && evidence.catalog_version == self.catalog_version
574                    && evidence.catalog_sha256 == snapshot.content_sha256()
575                    && evidence.policy_version == binding.policy_version
576                    && evidence.policy_sha256 == binding.policy_sha256
577                    && evidence.conformance_sha256 == binding.conformance_sha256
578                    && evidence.operator_promotion_receipt == binding.operator_promotion_receipt
579            })
580            && snapshot
581                .model(&self.provider_model_id)
582                .is_some_and(|model| {
583                    model.fingerprint().ok().as_deref() == Some(self.model_fingerprint.as_str())
584                })
585    }
586
587    fn transition(
588        &mut self,
589        expected: QualificationState,
590        next: QualificationState,
591    ) -> Result<(), CatalogError> {
592        if self.state != expected {
593            return Err(CatalogError::InvalidTransition);
594        }
595        self.state = next;
596        Ok(())
597    }
598}
599
600#[derive(Clone, Copy, Debug, PartialEq, Eq)]
601pub enum RefreshReason {
602    StartupCatchUp,
603    UtcBucket,
604}
605
606#[derive(Clone, Copy, Debug, PartialEq, Eq)]
607pub enum RefreshDirective {
608    Due { bucket: u64, reason: RefreshReason },
609    Wait { until_epoch_seconds: u64 },
610}
611
612#[derive(Clone, Copy, Debug, PartialEq, Eq)]
613pub struct RefreshScheduler {
614    jitter_seconds: u64,
615}
616
617impl RefreshScheduler {
618    pub fn new(jitter_seconds: u64) -> Result<Self, CatalogError> {
619        if jitter_seconds >= REFRESH_BUCKET_SECONDS {
620            return Err(CatalogError::InvalidSchedule);
621        }
622        Ok(Self { jitter_seconds })
623    }
624
625    pub fn directive(
626        self,
627        now_epoch_seconds: u64,
628        last_success_epoch_seconds: Option<u64>,
629    ) -> RefreshDirective {
630        let bucket = now_epoch_seconds / REFRESH_BUCKET_SECONDS;
631        let scheduled = bucket
632            .saturating_mul(REFRESH_BUCKET_SECONDS)
633            .saturating_add(self.jitter_seconds);
634        let Some(last_success) = last_success_epoch_seconds else {
635            return RefreshDirective::Due {
636                bucket,
637                reason: RefreshReason::StartupCatchUp,
638            };
639        };
640        if last_success / REFRESH_BUCKET_SECONDS < bucket && now_epoch_seconds >= scheduled {
641            RefreshDirective::Due {
642                bucket,
643                reason: RefreshReason::UtcBucket,
644            }
645        } else {
646            RefreshDirective::Wait {
647                until_epoch_seconds: if now_epoch_seconds < scheduled {
648                    scheduled
649                } else {
650                    bucket
651                        .saturating_add(1)
652                        .saturating_mul(REFRESH_BUCKET_SECONDS)
653                        .saturating_add(self.jitter_seconds)
654                },
655            }
656        }
657    }
658}
659
660#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
661pub enum CatalogError {
662    #[error("catalog record is malformed")]
663    Malformed,
664    #[error("catalog contains contradictory records")]
665    Contradictory,
666    #[error("qualification transition is invalid")]
667    InvalidTransition,
668    #[error("refresh schedule is invalid")]
669    InvalidSchedule,
670}
671
672fn validate_spec(spec: &NormalizedModelSpec) -> Result<(), CatalogError> {
673    if !valid_model_id(&spec.provider_model_id)
674        || spec.context_tokens == 0
675        || spec.modalities.is_empty()
676        || spec.modalities.len() > MAX_MODEL_MODALITIES
677        || spec.modalities.iter().any(|item| !valid_id(item))
678        || spec.modalities.iter().collect::<BTreeSet<_>>().len() != spec.modalities.len()
679        || spec.reasoning_modes.len() > MAX_REASONING_MODES
680        || spec.reasoning_modes.iter().any(|item| !valid_id(item))
681        || spec.reasoning_modes.iter().collect::<BTreeSet<_>>().len() != spec.reasoning_modes.len()
682        || !valid_id(&spec.pricing_provenance)
683        || !valid_id(&spec.availability)
684    {
685        return Err(CatalogError::Malformed);
686    }
687    Ok(())
688}
689
690fn valid_qualification_evidence(evidence: &QualificationEvidence) -> bool {
691    valid_sha256(&evidence.model_sha256)
692        && valid_id(&evidence.catalog_version)
693        && valid_sha256(&evidence.catalog_sha256)
694        && valid_id(&evidence.policy_version)
695        && valid_sha256(&evidence.policy_sha256)
696        && valid_sha256(&evidence.conformance_sha256)
697        && valid_typed_digest_ref(
698            &evidence.operator_promotion_receipt,
699            &["approval", "receipt"],
700        )
701        && valid_typed_digest_ref(
702            &evidence.evidence_ref,
703            &["evaluation", "manifest", "receipt"],
704        )
705        && !evidence.role_aliases.is_empty()
706        && evidence.role_aliases.len() <= MAX_ROLE_ALIASES
707        && evidence.role_aliases.iter().all(|role| valid_id(role))
708        && evidence.role_aliases.iter().collect::<BTreeSet<_>>().len()
709            == evidence.role_aliases.len()
710}
711
712fn valid_id(value: &str) -> bool {
713    !value.is_empty()
714        && value.len() <= 128
715        && value
716            .bytes()
717            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
718}
719
720fn valid_model_id(value: &str) -> bool {
721    !value.is_empty()
722        && value.len() <= 256
723        && !value.starts_with('/')
724        && !value.ends_with('/')
725        && !value.contains("..")
726        && !value.contains("//")
727        && value.bytes().all(|byte| {
728            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/')
729        })
730}
731
732fn valid_sha256(value: &str) -> bool {
733    value.len() == 64
734        && value
735            .bytes()
736            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
737}
738
739fn valid_typed_digest_ref(value: &str, allowed_kinds: &[&str]) -> bool {
740    let mut parts = value.split(':');
741    let kind = parts.next();
742    let algorithm = parts.next();
743    let digest = parts.next();
744    parts.next().is_none()
745        && kind.is_some_and(|kind| allowed_kinds.contains(&kind))
746        && algorithm == Some("sha256")
747        && digest.is_some_and(valid_sha256)
748}
749
750fn sha256_hex(bytes: &[u8]) -> String {
751    let digest = Sha256::digest(bytes);
752    digest.iter().map(|byte| format!("{byte:02x}")).collect()
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    struct DeterministicTestVerifier;
760
761    impl promotion_verifier_sealed::Sealed for DeterministicTestVerifier {}
762
763    impl DeterministicTestVerifier {
764        fn evidence(label: &[u8], binding_sha256: &str) -> Vec<u8> {
765            let mut hasher = Sha256::new();
766            hasher.update(label);
767            hasher.update(binding_sha256.as_bytes());
768            hasher.finalize().to_vec()
769        }
770
771        fn request(&self, binding: PromotionMutationBinding) -> PromotionAuthorityRequest {
772            let binding_sha256 = binding.binding_sha256().expect("fixture binding");
773            PromotionAuthorityRequest {
774                binding,
775                operator_authority_id: "operator.test-anchor".to_owned(),
776                conformance_authority_id: "conformance.test-anchor".to_owned(),
777                operator_evidence: Self::evidence(b"operator-test-anchor", &binding_sha256),
778                conformance_evidence: Self::evidence(b"conformance-test-anchor", &binding_sha256),
779                fixture_only: false,
780            }
781        }
782    }
783
784    impl PromotionAuthorityVerifier for DeterministicTestVerifier {
785        fn verify(
786            &self,
787            request: &PromotionAuthorityRequest,
788        ) -> Result<AuthenticatedPromotionAuthority, PromotionAuthorityError> {
789            let binding_sha256 = request.binding.binding_sha256()?;
790            let operator = Self::evidence(b"operator-test-anchor", &binding_sha256);
791            let conformance = Self::evidence(b"conformance-test-anchor", &binding_sha256);
792            if request.operator_evidence != operator
793                || request.conformance_evidence != conformance
794                || request.operator_authority_id != "operator.test-anchor"
795                || request.conformance_authority_id != "conformance.test-anchor"
796            {
797                return Err(PromotionAuthorityError::AuthenticationFailed);
798            }
799            AuthenticatedPromotionAuthority::from_verified(request)
800        }
801    }
802
803    fn digest_ref(kind: &str, digit: char) -> String {
804        format!("{kind}:sha256:{}", digit.to_string().repeat(64))
805    }
806
807    fn model(id: &str, observed: u64) -> NormalizedModelSpec {
808        NormalizedModelSpec {
809            provider_model_id: id.to_owned(),
810            context_tokens: 32_768,
811            modalities: vec!["text".to_owned()],
812            tool_support: true,
813            reasoning_modes: vec!["medium".to_owned()],
814            pricing_provenance: "fixture".to_owned(),
815            availability: "available".to_owned(),
816            observed_at_epoch_seconds: observed,
817        }
818    }
819
820    #[test]
821    fn snapshot_is_versioned_hashed_and_expires_at_exact_boundaries() {
822        let snapshot = CatalogSnapshot::accept(
823            "snapshot.fixture.1",
824            "openai",
825            "catalog.1",
826            CatalogSourceKind::OpenaiHarnessCatalog,
827            digest_ref("receipt", '1'),
828            100,
829            vec![model("gpt-5.6-terra", 100)],
830        )
831        .expect("valid snapshot");
832        assert!(valid_sha256(snapshot.content_sha256()));
833        assert_eq!(
834            snapshot.freshness(100 + STALE_WARNING_SECONDS - 1),
835            CatalogFreshness::Fresh
836        );
837        assert_eq!(
838            snapshot.freshness(100 + STALE_WARNING_SECONDS),
839            CatalogFreshness::Warning
840        );
841        assert_eq!(
842            snapshot.freshness(100 + SNAPSHOT_EXPIRY_SECONDS),
843            CatalogFreshness::Expired
844        );
845        assert_eq!(snapshot.freshness(99), CatalogFreshness::InvalidClock);
846    }
847
848    #[test]
849    fn new_models_are_ineligible_until_exact_qualification_and_approval() {
850        let spec = model("gpt-5.6-luna", 10);
851        let fingerprint = spec.fingerprint().expect("fingerprint");
852        let snapshot = CatalogSnapshot::accept(
853            "snapshot.fixture.2",
854            "openai",
855            "catalog.2",
856            CatalogSourceKind::OpenaiHarnessCatalog,
857            digest_ref("receipt", '2'),
858            10,
859            vec![spec],
860        )
861        .expect("snapshot");
862        let discovered =
863            ModelQualification::discovered("gpt-5.6-luna", fingerprint.clone(), "catalog.2")
864                .expect("discovered");
865        let policy_sha256 = "1".repeat(64);
866        let conformance_sha256 = "2".repeat(64);
867        let operator_receipt = digest_ref("approval", '3');
868        let binding = EligibilityBinding {
869            policy_version: "policy.1",
870            policy_sha256: &policy_sha256,
871            conformance_sha256: &conformance_sha256,
872            operator_promotion_receipt: &operator_receipt,
873        };
874        assert!(!discovered.automatic_eligible(&snapshot, 10, &binding, true));
875        let qualified = discovered
876            .quarantine()
877            .expect("quarantine")
878            .begin_qualification()
879            .expect("qualification")
880            .qualify(QualificationEvidence {
881                evidence_ref: digest_ref("evaluation", '4'),
882                model_sha256: fingerprint,
883                catalog_version: "catalog.2".to_owned(),
884                catalog_sha256: snapshot.content_sha256().to_owned(),
885                policy_version: "policy.1".to_owned(),
886                policy_sha256: "1".repeat(64),
887                conformance_sha256: "2".repeat(64),
888                operator_promotion_receipt: digest_ref("approval", '3'),
889                role_aliases: vec!["routine".to_owned()],
890                fixture_only: false,
891            })
892            .expect("qualified");
893        assert!(!qualified.automatic_eligible(&snapshot, 10, &binding, false));
894        assert!(qualified.automatic_eligible(&snapshot, 10, &binding, true));
895        let mut fixture_qualification = qualified.clone();
896        fixture_qualification
897            .evidence
898            .as_mut()
899            .expect("evidence")
900            .fixture_only = true;
901        assert!(!fixture_qualification.automatic_eligible(&snapshot, 10, &binding, true));
902        assert!(!qualified.automatic_eligible(
903            &snapshot,
904            10 + SNAPSHOT_EXPIRY_SECONDS,
905            &binding,
906            true
907        ));
908    }
909
910    #[test]
911    fn namespaced_model_ids_remain_eligible_after_exact_qualification() {
912        let spec = model("vendor/gpt-role", 10);
913        let fingerprint = spec.fingerprint().expect("fingerprint");
914        let snapshot = CatalogSnapshot::accept(
915            "snapshot.fixture.namespaced",
916            "openrouter",
917            "catalog.namespaced",
918            CatalogSourceKind::OpenrouterCatalog,
919            digest_ref("receipt", '5'),
920            10,
921            vec![spec],
922        )
923        .expect("namespaced snapshot");
924        let policy_sha256 = "6".repeat(64);
925        let conformance_sha256 = "7".repeat(64);
926        let operator_receipt = digest_ref("approval", '8');
927        let qualification = ModelQualification::discovered(
928            "vendor/gpt-role",
929            fingerprint.clone(),
930            "catalog.namespaced",
931        )
932        .expect("discovered namespaced model")
933        .quarantine()
934        .expect("quarantined")
935        .begin_qualification()
936        .expect("qualifying")
937        .qualify(QualificationEvidence {
938            evidence_ref: digest_ref("evaluation", '9'),
939            model_sha256: fingerprint,
940            catalog_version: "catalog.namespaced".to_owned(),
941            catalog_sha256: snapshot.content_sha256().to_owned(),
942            policy_version: "policy.namespaced".to_owned(),
943            policy_sha256: policy_sha256.clone(),
944            conformance_sha256: conformance_sha256.clone(),
945            operator_promotion_receipt: operator_receipt.clone(),
946            role_aliases: vec!["standard".to_owned()],
947            fixture_only: false,
948        })
949        .expect("qualified namespaced model");
950        let binding = EligibilityBinding {
951            policy_version: "policy.namespaced",
952            policy_sha256: &policy_sha256,
953            conformance_sha256: &conformance_sha256,
954            operator_promotion_receipt: &operator_receipt,
955        };
956
957        assert!(qualification.automatic_eligible(&snapshot, 10, &binding, true));
958    }
959
960    #[test]
961    fn contradictory_and_invalid_transitions_fail_closed() {
962        assert_eq!(
963            CatalogSnapshot::accept(
964                "snapshot.fixture.3",
965                "openai",
966                "catalog.3",
967                CatalogSourceKind::OpenaiHarnessCatalog,
968                digest_ref("receipt", '3'),
969                10,
970                vec![model("duplicate", 10), model("duplicate", 10)],
971            ),
972            Err(CatalogError::Contradictory)
973        );
974        let discovered = ModelQualification::discovered("gpt-5.6-sol", "0".repeat(64), "catalog.3")
975            .expect("discovered");
976        assert_eq!(
977            discovered.clone().begin_qualification(),
978            Err(CatalogError::InvalidTransition)
979        );
980        assert_eq!(discovered.retire(), Err(CatalogError::InvalidTransition));
981    }
982
983    #[test]
984    fn deserialized_catalog_tampering_and_unhashed_receipts_fail_closed() {
985        assert_eq!(
986            CatalogSnapshot::accept(
987                "snapshot.fixture.4",
988                "openai",
989                "catalog.4",
990                CatalogSourceKind::OpenaiHarnessCatalog,
991                "receipt:raw-path/unsafe",
992                10,
993                vec![model("gpt-5.6-terra", 10)],
994            ),
995            Err(CatalogError::Malformed)
996        );
997        let mut snapshot = CatalogSnapshot::accept(
998            "snapshot.fixture.4",
999            "openai",
1000            "catalog.4",
1001            CatalogSourceKind::OpenaiHarnessCatalog,
1002            digest_ref("receipt", '4'),
1003            10,
1004            vec![model("gpt-5.6-terra", 10)],
1005        )
1006        .expect("snapshot");
1007        snapshot.models[0].context_tokens = 1;
1008        assert_eq!(
1009            snapshot.validate_integrity(),
1010            Err(CatalogError::Contradictory)
1011        );
1012        assert_eq!(snapshot.freshness(10), CatalogFreshness::Expired);
1013    }
1014
1015    #[test]
1016    fn scheduler_uses_six_hour_utc_buckets_jitter_and_startup_catchup() {
1017        let scheduler = RefreshScheduler::new(300).expect("schedule");
1018        assert_eq!(
1019            scheduler.directive(25, None),
1020            RefreshDirective::Due {
1021                bucket: 0,
1022                reason: RefreshReason::StartupCatchUp
1023            }
1024        );
1025        assert_eq!(
1026            scheduler.directive(REFRESH_BUCKET_SECONDS, Some(100)),
1027            RefreshDirective::Wait {
1028                until_epoch_seconds: REFRESH_BUCKET_SECONDS + 300
1029            }
1030        );
1031        assert_eq!(
1032            scheduler.directive(REFRESH_BUCKET_SECONDS + 300, Some(100)),
1033            RefreshDirective::Due {
1034                bucket: 1,
1035                reason: RefreshReason::UtcBucket
1036            }
1037        );
1038    }
1039
1040    fn promotion_binding() -> PromotionMutationBinding {
1041        PromotionMutationBinding {
1042            schema_version: PROMOTION_AUTHORITY_SCHEMA_VERSION.to_owned(),
1043            promotion_id: "promotion.catalog.1".to_owned(),
1044            provider_id: "openai".to_owned(),
1045            phase_id: "openai_subscription".to_owned(),
1046            account_ref: "account:openai".to_owned(),
1047            catalog_ref: "catalog:v2:openai-subscription".to_owned(),
1048            provider_model_id: "gpt-5.6-terra".to_owned(),
1049            model_sha256: "1".repeat(64),
1050            catalog_version: "catalog.1".to_owned(),
1051            catalog_sha256: "2".repeat(64),
1052            policy_version: "policy.1".to_owned(),
1053            policy_sha256: "3".repeat(64),
1054            conformance_sha256: "4".repeat(64),
1055            configuration_authority_id: "config-authority.local".to_owned(),
1056            configuration_authority_sha256: "5".repeat(64),
1057            expected_state_generation: 9,
1058            state_preimage_sha256: "6".repeat(64),
1059            state_postimage_sha256: "7".repeat(64),
1060            rollback_snapshot_sha256: "6".repeat(64),
1061            authorization_nonce: "nonce.catalog.1".to_owned(),
1062            authorized_at_epoch_seconds: 100,
1063            rollback_not_after_epoch_seconds: 200,
1064        }
1065    }
1066
1067    #[test]
1068    fn promotion_authority_binding_is_canonical_and_tamper_evident() {
1069        let binding = promotion_binding();
1070        let digest = binding.binding_sha256().expect("valid exact binding");
1071        assert!(valid_sha256(&digest));
1072        assert_eq!(
1073            digest,
1074            binding.binding_sha256().expect("deterministic digest")
1075        );
1076
1077        let mut changed_generation = binding.clone();
1078        changed_generation.expected_state_generation += 1;
1079        assert_ne!(
1080            digest,
1081            changed_generation
1082                .binding_sha256()
1083                .expect("changed generation remains structurally valid")
1084        );
1085        let mut changed_postimage = binding.clone();
1086        changed_postimage.state_postimage_sha256 = "8".repeat(64);
1087        assert_ne!(
1088            digest,
1089            changed_postimage
1090                .binding_sha256()
1091                .expect("changed postimage remains structurally valid")
1092        );
1093    }
1094
1095    #[test]
1096    fn promotion_authority_is_fail_closed_without_a_decided_trust_root() {
1097        let request = PromotionAuthorityRequest {
1098            binding: promotion_binding(),
1099            operator_authority_id: "operator.local".to_owned(),
1100            conformance_authority_id: "conformance.local".to_owned(),
1101            operator_evidence: b"opaque-operator-evidence".to_vec(),
1102            conformance_evidence: b"opaque-conformance-evidence".to_vec(),
1103            fixture_only: false,
1104        };
1105        assert_eq!(
1106            authenticate_promotion(&UnconfiguredPromotionAuthorityVerifier, &request),
1107            Err(PromotionAuthorityError::TrustRootDecisionUnavailable)
1108        );
1109    }
1110
1111    #[test]
1112    fn deterministic_test_verifier_rejects_fixture_replay_and_binding_substitution() {
1113        let verifier = DeterministicTestVerifier;
1114        let mut request = verifier.request(promotion_binding());
1115        request.fixture_only = true;
1116        assert_eq!(
1117            authenticate_promotion(&verifier, &request),
1118            Err(PromotionAuthorityError::FixtureEvidenceForbidden)
1119        );
1120
1121        request.fixture_only = false;
1122        let authenticated = authenticate_promotion(&verifier, &request)
1123            .expect("unit-only configured-anchor fixture authenticates exact binding");
1124        assert_eq!(
1125            authenticated.binding_sha256(),
1126            request.binding.binding_sha256().expect("binding digest")
1127        );
1128
1129        let mut substituted = request;
1130        substituted.binding.state_preimage_sha256 = "9".repeat(64);
1131        substituted.binding.rollback_snapshot_sha256 = "9".repeat(64);
1132        assert_eq!(
1133            authenticate_promotion(&verifier, &substituted),
1134            Err(PromotionAuthorityError::AuthenticationFailed)
1135        );
1136    }
1137}