1use 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
21pub 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 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#[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 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
354pub 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
386pub trait PromotionAuthorityVerifier: promotion_verifier_sealed::Sealed {
389 fn verify(
390 &self,
391 request: &PromotionAuthorityRequest,
392 ) -> Result<AuthenticatedPromotionAuthority, PromotionAuthorityError>;
393}
394
395#[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#[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}