Skip to main content

chio_weights/
lineage.rs

1//! Lineage anchoring for published model cards.
2//!
3//! Publishing a [`crate::bundle::VerifiedModelCard`] to the public registry
4//! emits a lineage anchor proof; consumers verify the proof through the
5//! existing [`chio_lineage::anchor`] surface. This does not fork the
6//! anchor surface; it derives a frontier-shaped digest from a verified
7//! model card and packages it as a [`ModelCardLineageAnchor`] artifact whose
8//! SHA-256 digest format and signing-state plumbing match
9//! [`chio_lineage::anchor::AnchoredFrontier`] verbatim.
10//!
11//! # Trust contract
12//!
13//! Every public entry point that returns `Ok(_)` MUST mean:
14//!
15//! 1. the input was a [`crate::bundle::VerifiedModelCard`] produced by the
16//!    cosign-bundle helper (so the upstream attestation already verified),
17//!    and
18//! 2. the emitted anchor's digest covers the canonical-JSON bytes of the
19//!    model card AND the verified attestation's `(subject_digest_sha256,
20//!    certificate_identity, certificate_oidc_issuer, rekor_log_index,
21//!    rekor_inclusion_verified)` tuple.
22//!
23//! The digest is computed over a deterministically sorted JSON projection
24//! so two implementations producing the same `(card, attestation)` pair
25//! produce byte-identical anchor digests.
26//!
27//! # Soft-dep state
28//!
29//! `chio-lineage`'s [`SigningState`] enum records hybrid-signing presence.
30//! When the caller passes `Some(algorithm)` (i.e. a signer was named but
31//! hybrid signing has not produced a payload yet), the anchor records
32//! [`SigningState::UnsignedSignerStubbed`] so verifiers fail-closed; when
33//! the caller passes `None`, the anchor records
34//! [`SigningState::UnsignedSoftDepAbsent`]. Neither variant impersonates
35//! [`SigningState::Signed`]. These anchors never construct
36//! [`SigningState::Signed`], and imported signed anchors are rejected until
37//! a local verifier can authenticate the signature against a trusted key.
38
39use chio_lineage::anchor::{
40    is_lowercase_hex_signature_payload, CanonicalSource, FrontierDigest, SigningState,
41};
42use chrono::{DateTime, Utc};
43use serde::{Deserialize, Serialize};
44use sha2::{Digest, Sha256};
45
46use crate::bundle::VerifiedModelCard;
47use crate::error::WeightsError;
48
49/// Schema name pinning the lineage anchor projection used by model cards.
50/// Distinct from the lineage-graph frontier schema so consumers can route
51/// model-card anchors to the right verifier without parsing the body.
52pub const MODEL_CARD_ANCHOR_SCHEMA: &str = "chio.weights.lineage-anchor/v1";
53
54/// Lineage anchor proof for a published model card.
55///
56/// Produced by [`anchor_model_card`] and consumed by
57/// [`verify_model_card_anchor`] (and by external lineage-graph consumers
58/// through the existing `chio-lineage` surface). The shape mirrors
59/// [`chio_lineage::anchor::AnchoredFrontier`] so the public registry can
60/// emit one artifact format across both surfaces.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ModelCardLineageAnchor {
63    /// Pinned schema version. MUST equal [`MODEL_CARD_ANCHOR_SCHEMA`].
64    pub schema_version: String,
65    /// Lineage-graph schema this anchor is published alongside.
66    pub graph_schema: String,
67    /// Canonical-bytes source identifier reused from the lineage anchor
68    /// surface so consumers do not learn a second enum.
69    pub canonical_source: CanonicalSource,
70    /// Frontier-shaped digest: SHA-256 over the canonical projection bytes.
71    pub digest: FrontierDigest,
72    /// PQ-hybrid signing state placeholder. Mirrors the lineage anchor
73    /// soft-dep slot; hybrid signing populates this when wired.
74    pub signing: SigningState,
75    /// Lowercase hexadecimal SHA-256 of the canonical-JSON model card
76    /// bytes. Pinned in the artifact so consumers can correlate without
77    /// re-deriving the digest.
78    pub card_canonical_sha256: String,
79    /// Hex-encoded `subject_digest_sha256` from the upstream verified
80    /// attestation. Equals `card_canonical_sha256` when the cosign bundle
81    /// signed the model card's canonical bytes directly; recorded
82    /// separately so a future refactor that signs over a different
83    /// projection (for example, a higher-level envelope) is detectable
84    /// without reading the bundle.
85    pub attestation_subject_sha256: String,
86    /// Certificate identity SAN that satisfied the expected-identity regex.
87    pub certificate_identity: String,
88    /// OIDC issuer extracted from the Fulcio certificate.
89    pub certificate_oidc_issuer: String,
90    /// Rekor transparency log index. `0` if the bundle did not carry one.
91    pub rekor_log_index: u64,
92    /// `true` when the bundle's Rekor inclusion proof verified.
93    pub rekor_inclusion_verified: bool,
94    /// Card `weights_hash` lifted onto the anchor for receipt correlation.
95    pub weights_hash: String,
96    /// Card `issuer` lifted onto the anchor for receipt correlation.
97    pub card_issuer: String,
98    /// Card `expires_at` lifted so anchor consumers can reason about
99    /// liveness without re-parsing the card body.
100    pub card_expires_at: DateTime<Utc>,
101}
102
103impl ModelCardLineageAnchor {
104    /// Return `true` only when the artifact carries a locally verified
105    /// signature. No verifier is wired in yet, so every current state is
106    /// treated as unsigned for fail-closed consumers.
107    #[must_use]
108    pub fn is_signed(&self) -> bool {
109        false
110    }
111}
112
113/// Compute the SHA-256 digest of the canonical lineage projection for a
114/// model card and a verified attestation. The projection is a
115/// deterministically-keyed JSON object so two implementations producing
116/// the same inputs produce byte-identical bytes.
117///
118/// Exposed for tests and for external indexers that want to recompute the
119/// expected digest without depending on the entire `chio-weights` build.
120pub fn anchor_projection_bytes(
121    card_bytes: &[u8],
122    attestation: &chio_attest_verify::VerifiedAttestation,
123) -> Result<Vec<u8>, WeightsError> {
124    let card_sha = sha256_hex(card_bytes);
125    let subject_sha = hex::encode(attestation.subject_digest_sha256);
126    // Use serde_json's BTreeMap-equivalent: build a sorted Map by hand to
127    // preserve byte stability without depending on a canonical-JSON crate
128    // here. The keys below are listed in lexicographic order so the
129    // emitted JSON is canonical at the top level.
130    let payload = serde_json::json!({
131        "card_canonical_sha256": card_sha,
132        "certificate_identity": attestation.certificate_identity,
133        "certificate_oidc_issuer": attestation.certificate_oidc_issuer,
134        "rekor_inclusion_verified": attestation.rekor_inclusion_verified,
135        "rekor_log_index": attestation.rekor_log_index,
136        "schema_version": MODEL_CARD_ANCHOR_SCHEMA,
137        "subject_digest_sha256": subject_sha,
138    });
139    chio_core_types::canonical::canonical_json_bytes(&payload)
140        .map_err(|err| WeightsError::Encoding(format!("anchor projection encode: {err}")))
141}
142
143/// SHA-256 hex of a byte slice. Mirrors the lineage anchor surface so the
144/// digest representation is consistent across both anchor formats.
145fn sha256_hex(bytes: &[u8]) -> String {
146    let digest = Sha256::digest(bytes);
147    hex::encode(digest)
148}
149
150/// Build a [`ModelCardLineageAnchor`] from a verified model card.
151///
152/// `card_bytes` MUST be the canonical-JSON byte slice the cosign bundle
153/// was signed over (i.e. the same bytes passed to
154/// [`crate::bundle::verify_model_card_bundle`]). `signer_hint` records the
155/// PQ-hybrid signing state in the same shape used by
156/// [`chio_lineage::anchor::pin_frontier`]; pass `None` when hybrid
157/// signing is absent.
158///
159/// Fail-closed: rejects when `card_bytes` does not decode to the same
160/// model card carried by `verified.card`. This guards against a caller
161/// that accidentally hands the helper a stale byte slice.
162pub fn anchor_model_card(
163    verified: &VerifiedModelCard,
164    card_bytes: &[u8],
165    graph_schema: &str,
166    signer_hint: Option<&str>,
167) -> Result<ModelCardLineageAnchor, WeightsError> {
168    let decoded = crate::card::ModelCard::from_canonical_json(card_bytes)?;
169    if decoded != verified.card {
170        return Err(WeightsError::Encoding(
171            "card_bytes do not match verified.card; refusing to anchor stale bytes".to_string(),
172        ));
173    }
174
175    let projection = anchor_projection_bytes(card_bytes, &verified.attestation)?;
176    let digest_hex = sha256_hex(&projection);
177
178    // Fail-closed: this helper never has a real signature payload to record,
179    // so we MUST NOT construct `SigningState::Signed { signature_hex: "" }`
180    // here. Doing so would let a verifier matching `SigningState::Signed
181    // { .. }` treat the anchor as authenticated even though no hybrid
182    // signature exists. Until hybrid signing plumbs a real payload through
183    // this helper, a signer hint produces `UnsignedSignerStubbed` so verifiers
184    // see an explicit unsigned variant.
185    let signing = match signer_hint {
186        Some(algorithm) => SigningState::UnsignedSignerStubbed {
187            algorithm: algorithm.to_string(),
188        },
189        None => SigningState::UnsignedSoftDepAbsent,
190    };
191
192    Ok(ModelCardLineageAnchor {
193        schema_version: MODEL_CARD_ANCHOR_SCHEMA.to_string(),
194        graph_schema: graph_schema.to_string(),
195        canonical_source: CanonicalSource::EquivalenceShim,
196        digest: FrontierDigest {
197            algo: "sha256".to_string(),
198            hex: digest_hex,
199        },
200        signing,
201        card_canonical_sha256: sha256_hex(card_bytes),
202        attestation_subject_sha256: hex::encode(verified.attestation.subject_digest_sha256),
203        certificate_identity: verified.attestation.certificate_identity.clone(),
204        certificate_oidc_issuer: verified.attestation.certificate_oidc_issuer.clone(),
205        rekor_log_index: verified.attestation.rekor_log_index,
206        rekor_inclusion_verified: verified.attestation.rekor_inclusion_verified,
207        weights_hash: verified.card.weights_hash.clone(),
208        card_issuer: verified.card.issuer.clone(),
209        card_expires_at: verified.card.expires_at,
210    })
211}
212
213/// Recompute the anchor's digest from the given inputs and reject if it
214/// does not match `anchor.digest`. Consumers reading an anchor from the
215/// public registry call this to verify the proof against the original
216/// inputs without trusting the registry's stored digest.
217pub fn verify_model_card_anchor(
218    anchor: &ModelCardLineageAnchor,
219    card_bytes: &[u8],
220    attestation: &chio_attest_verify::VerifiedAttestation,
221) -> Result<(), WeightsError> {
222    if anchor.schema_version != MODEL_CARD_ANCHOR_SCHEMA {
223        return Err(WeightsError::SchemaRejected(format!(
224            "model card anchor schema_version must be {MODEL_CARD_ANCHOR_SCHEMA:?}, got {:?}",
225            anchor.schema_version
226        )));
227    }
228    if anchor.digest.algo != "sha256" {
229        return Err(WeightsError::SchemaRejected(format!(
230            "model card anchor digest algo must be \"sha256\", got {:?}",
231            anchor.digest.algo
232        )));
233    }
234    let expected_hex = sha256_hex(&anchor_projection_bytes(card_bytes, attestation)?);
235    if expected_hex != anchor.digest.hex {
236        return Err(WeightsError::BundleRejected(format!(
237            "model card anchor digest mismatch: expected {expected_hex}, got {}",
238            anchor.digest.hex
239        )));
240    }
241    let card_sha = sha256_hex(card_bytes);
242    if card_sha != anchor.card_canonical_sha256 {
243        return Err(WeightsError::BundleRejected(format!(
244            "model card anchor card_canonical_sha256 mismatch: expected {card_sha}, got {}",
245            anchor.card_canonical_sha256
246        )));
247    }
248    let expected_subject_sha = hex::encode(attestation.subject_digest_sha256);
249    if expected_subject_sha != anchor.attestation_subject_sha256 {
250        return Err(WeightsError::BundleRejected(format!(
251            "model card anchor attestation_subject_sha256 mismatch: expected {expected_subject_sha}, got {}",
252            anchor.attestation_subject_sha256
253        )));
254    }
255    if attestation.certificate_identity != anchor.certificate_identity {
256        return Err(WeightsError::BundleRejected(format!(
257            "model card anchor certificate_identity mismatch: expected {:?}, got {:?}",
258            attestation.certificate_identity, anchor.certificate_identity
259        )));
260    }
261    if attestation.certificate_oidc_issuer != anchor.certificate_oidc_issuer {
262        return Err(WeightsError::BundleRejected(format!(
263            "model card anchor certificate_oidc_issuer mismatch: expected {:?}, got {:?}",
264            attestation.certificate_oidc_issuer, anchor.certificate_oidc_issuer
265        )));
266    }
267    if attestation.rekor_log_index != anchor.rekor_log_index {
268        return Err(WeightsError::BundleRejected(format!(
269            "model card anchor rekor_log_index mismatch: expected {}, got {}",
270            attestation.rekor_log_index, anchor.rekor_log_index
271        )));
272    }
273    if attestation.rekor_inclusion_verified != anchor.rekor_inclusion_verified {
274        return Err(WeightsError::BundleRejected(format!(
275            "model card anchor rekor_inclusion_verified mismatch: expected {}, got {}",
276            attestation.rekor_inclusion_verified, anchor.rekor_inclusion_verified
277        )));
278    }
279
280    // Defense-in-depth: no local verifier is wired for lineage signature
281    // payloads yet. Reject every imported Signed state so arbitrary hex
282    // cannot be promoted into public trust evidence.
283    if let SigningState::Signed {
284        algorithm,
285        signature_hex,
286    } = &anchor.signing
287    {
288        if !is_lowercase_hex_signature_payload(signature_hex) {
289            return Err(WeightsError::BundleRejected(
290                "model card anchor signing state was Signed but signature_hex was empty or not lower-case hexadecimal"
291                    .to_string(),
292            ));
293        }
294        return Err(WeightsError::BundleRejected(format!(
295            "model card anchor signing algorithm {algorithm:?} is not verified by this build"
296        )));
297    }
298
299    let card = crate::card::ModelCard::from_canonical_json(card_bytes)?;
300    if card.weights_hash != anchor.weights_hash {
301        return Err(WeightsError::BundleRejected(format!(
302            "model card anchor weights_hash mismatch: expected {:?}, got {:?}",
303            card.weights_hash, anchor.weights_hash
304        )));
305    }
306    if card.issuer != anchor.card_issuer {
307        return Err(WeightsError::BundleRejected(format!(
308            "model card anchor card_issuer mismatch: expected {:?}, got {:?}",
309            card.issuer, anchor.card_issuer
310        )));
311    }
312    if card.expires_at != anchor.card_expires_at {
313        return Err(WeightsError::BundleRejected(format!(
314            "model card anchor card_expires_at mismatch: expected {}, got {}",
315            card.expires_at, anchor.card_expires_at
316        )));
317    }
318    Ok(())
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use std::time::SystemTime;
325
326    use chio_attest_verify::VerifiedAttestation;
327    use chrono::TimeZone;
328
329    use crate::card::{ModelCard, StringSet};
330
331    fn fixed_now() -> DateTime<Utc> {
332        match Utc.with_ymd_and_hms(2026, 4, 30, 12, 0, 0) {
333            chrono::LocalResult::Single(t) => t,
334            _ => panic!("fixed_now fixture must construct"),
335        }
336    }
337
338    fn good_card() -> ModelCard {
339        let now = fixed_now();
340        match ModelCard::new(
341            "0000000000000000000000000000000000000000000000000000000000000001",
342            StringSet::new(["tool:read"]),
343            StringSet::default(),
344            "public-internet",
345            "https://example.com/issuer",
346            now,
347            now + chrono::Duration::days(30),
348        ) {
349            Ok(c) => c,
350            Err(e) => panic!("good_card must construct: {e}"),
351        }
352    }
353
354    fn good_attestation(card_sha: [u8; 32]) -> VerifiedAttestation {
355        VerifiedAttestation {
356            subject_digest_sha256: card_sha,
357            certificate_identity: "https://example.com/issuer".into(),
358            certificate_oidc_issuer: "https://token.example.com".into(),
359            rekor_log_index: 42,
360            rekor_inclusion_verified: true,
361            signed_at: SystemTime::UNIX_EPOCH,
362        }
363    }
364
365    #[test]
366    fn anchor_round_trips_unsigned() {
367        let card = good_card();
368        let bytes = match card.to_canonical_json() {
369            Ok(b) => b,
370            Err(e) => panic!("encode: {e}"),
371        };
372        let mut digest = [0u8; 32];
373        digest.copy_from_slice(&Sha256::digest(&bytes));
374        let att = good_attestation(digest);
375        let verified = VerifiedModelCard {
376            card: card.clone(),
377            attestation: att.clone(),
378        };
379        let anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
380            Ok(a) => a,
381            Err(e) => panic!("anchor: {e}"),
382        };
383        assert_eq!(anchor.schema_version, MODEL_CARD_ANCHOR_SCHEMA);
384        assert!(matches!(
385            anchor.signing,
386            SigningState::UnsignedSoftDepAbsent
387        ));
388        match verify_model_card_anchor(&anchor, &bytes, &att) {
389            Ok(()) => {}
390            Err(e) => panic!("verify: {e}"),
391        }
392    }
393
394    #[test]
395    fn anchor_records_signer_hint() {
396        let card = good_card();
397        let bytes = match card.to_canonical_json() {
398            Ok(b) => b,
399            Err(e) => panic!("encode: {e}"),
400        };
401        let mut digest = [0u8; 32];
402        digest.copy_from_slice(&Sha256::digest(&bytes));
403        let att = good_attestation(digest);
404        let verified = VerifiedModelCard {
405            card,
406            attestation: att,
407        };
408        let anchor = match anchor_model_card(
409            &verified,
410            &bytes,
411            "chio.lineage.graph/v1",
412            Some("hybrid:ed25519+ml-dsa-65"),
413        ) {
414            Ok(a) => a,
415            Err(e) => panic!("anchor: {e}"),
416        };
417        // Signer hint is present but no signature payload was supplied, so
418        // the anchor MUST record `UnsignedSignerStubbed`. A naive `Signed`
419        // variant with empty `signature_hex` would let a verifier matching
420        // on the variant alone treat the artifact as authenticated.
421        assert!(matches!(
422            anchor.signing,
423            SigningState::UnsignedSignerStubbed { ref algorithm }
424                if algorithm == "hybrid:ed25519+ml-dsa-65"
425        ));
426        assert!(!anchor.is_signed());
427    }
428
429    #[test]
430    fn anchor_signed_state_with_malformed_payload_is_unsigned() {
431        let card = good_card();
432        let bytes = match card.to_canonical_json() {
433            Ok(b) => b,
434            Err(e) => panic!("encode: {e}"),
435        };
436        let mut digest = [0u8; 32];
437        digest.copy_from_slice(&Sha256::digest(&bytes));
438        let att = good_attestation(digest);
439        let verified = VerifiedModelCard {
440            card,
441            attestation: att,
442        };
443        let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
444            Ok(a) => a,
445            Err(e) => panic!("anchor: {e}"),
446        };
447
448        for signature_hex in ["DEADBEEF", "dead beef", " deadbeef", "zz", "f"] {
449            anchor.signing = SigningState::Signed {
450                algorithm: "hybrid:ed25519+ml-dsa-65".to_string(),
451                signature_hex: signature_hex.to_string(),
452            };
453            assert!(
454                !anchor.is_signed(),
455                "malformed signature payload {signature_hex:?} must be unsigned"
456            );
457        }
458    }
459
460    #[test]
461    fn anchor_rejects_stale_bytes() {
462        let card = good_card();
463        let bytes = match card.to_canonical_json() {
464            Ok(b) => b,
465            Err(e) => panic!("encode: {e}"),
466        };
467        // Stale bytes: a different card.
468        let now = fixed_now();
469        let other = match ModelCard::new(
470            "0000000000000000000000000000000000000000000000000000000000000002",
471            StringSet::default(),
472            StringSet::default(),
473            "public-internet",
474            "https://example.com/issuer",
475            now,
476            now + chrono::Duration::days(1),
477        ) {
478            Ok(c) => c,
479            Err(e) => panic!("other card: {e}"),
480        };
481        let other_bytes = match other.to_canonical_json() {
482            Ok(b) => b,
483            Err(e) => panic!("encode: {e}"),
484        };
485        let mut digest = [0u8; 32];
486        digest.copy_from_slice(&Sha256::digest(&bytes));
487        let att = good_attestation(digest);
488        let verified = VerifiedModelCard {
489            card,
490            attestation: att,
491        };
492        let res = anchor_model_card(&verified, &other_bytes, "chio.lineage.graph/v1", None);
493        assert!(matches!(res, Err(WeightsError::Encoding(_))));
494    }
495
496    #[test]
497    fn verify_rejects_tampered_digest() {
498        let card = good_card();
499        let bytes = match card.to_canonical_json() {
500            Ok(b) => b,
501            Err(e) => panic!("encode: {e}"),
502        };
503        let mut digest = [0u8; 32];
504        digest.copy_from_slice(&Sha256::digest(&bytes));
505        let att = good_attestation(digest);
506        let verified = VerifiedModelCard {
507            card,
508            attestation: att.clone(),
509        };
510        let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
511            Ok(a) => a,
512            Err(e) => panic!("anchor: {e}"),
513        };
514        anchor.digest.hex = "0".repeat(64);
515        let res = verify_model_card_anchor(&anchor, &bytes, &att);
516        assert!(matches!(res, Err(WeightsError::BundleRejected(_))));
517    }
518
519    #[test]
520    fn verify_rejects_tampered_attestation_metadata() {
521        let card = good_card();
522        let bytes = match card.to_canonical_json() {
523            Ok(b) => b,
524            Err(e) => panic!("encode: {e}"),
525        };
526        let mut digest = [0u8; 32];
527        digest.copy_from_slice(&Sha256::digest(&bytes));
528        let att = good_attestation(digest);
529        let verified = VerifiedModelCard {
530            card,
531            attestation: att.clone(),
532        };
533        let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
534            Ok(a) => a,
535            Err(e) => panic!("anchor: {e}"),
536        };
537        anchor.certificate_identity = "https://example.com/forged".to_string();
538        let res = verify_model_card_anchor(&anchor, &bytes, &att);
539        assert!(matches!(res, Err(WeightsError::BundleRejected(_))));
540    }
541
542    #[test]
543    fn verify_rejects_tampered_card_metadata() {
544        let card = good_card();
545        let bytes = match card.to_canonical_json() {
546            Ok(b) => b,
547            Err(e) => panic!("encode: {e}"),
548        };
549        let mut digest = [0u8; 32];
550        digest.copy_from_slice(&Sha256::digest(&bytes));
551        let att = good_attestation(digest);
552        let verified = VerifiedModelCard {
553            card,
554            attestation: att.clone(),
555        };
556        let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
557            Ok(a) => a,
558            Err(e) => panic!("anchor: {e}"),
559        };
560        anchor.card_issuer = "https://example.com/forged".to_string();
561        let res = verify_model_card_anchor(&anchor, &bytes, &att);
562        assert!(matches!(res, Err(WeightsError::BundleRejected(_))));
563    }
564
565    #[test]
566    fn verify_rejects_wrong_schema_version() {
567        let card = good_card();
568        let bytes = match card.to_canonical_json() {
569            Ok(b) => b,
570            Err(e) => panic!("encode: {e}"),
571        };
572        let mut digest = [0u8; 32];
573        digest.copy_from_slice(&Sha256::digest(&bytes));
574        let att = good_attestation(digest);
575        let verified = VerifiedModelCard {
576            card,
577            attestation: att.clone(),
578        };
579        let mut anchor = match anchor_model_card(&verified, &bytes, "chio.lineage.graph/v1", None) {
580            Ok(a) => a,
581            Err(e) => panic!("anchor: {e}"),
582        };
583        anchor.schema_version = "chio.weights.lineage-anchor/v999".to_string();
584        let res = verify_model_card_anchor(&anchor, &bytes, &att);
585        assert!(matches!(res, Err(WeightsError::SchemaRejected(_))));
586    }
587
588    #[test]
589    fn anchor_digest_is_deterministic_across_runs() {
590        let card = good_card();
591        let bytes = match card.to_canonical_json() {
592            Ok(b) => b,
593            Err(e) => panic!("encode: {e}"),
594        };
595        let mut digest = [0u8; 32];
596        digest.copy_from_slice(&Sha256::digest(&bytes));
597        let att = good_attestation(digest);
598        let a = match anchor_projection_bytes(&bytes, &att) {
599            Ok(bytes) => sha256_hex(&bytes),
600            Err(e) => panic!("projection A: {e}"),
601        };
602        let b = match anchor_projection_bytes(&bytes, &att) {
603            Ok(bytes) => sha256_hex(&bytes),
604            Err(e) => panic!("projection B: {e}"),
605        };
606        assert_eq!(a, b);
607    }
608}