Skip to main content

chio_weights/
card.rs

1//! Model card schema and `ModelCard` type.
2//!
3//! A [`ModelCard`] is the signed declaration that binds a model's loaded
4//! weights to an allowed capability set, banned tools, and a training-data
5//! class. The on-the-wire shape is locked under
6//! `spec/schemas/model-card.v1.json` and the canonical-JSON encoding rules
7//! match `chio-custody-hw`'s `PasskeyCapability`:
8//!
9//! - object keys sorted by UTF-16 code units (RFC 8785 / JCS),
10//! - sets serialised as sorted unique JSON arrays,
11//! - timestamps as RFC 3339 UTC,
12//! - no inter-token whitespace.
13//!
14//! Two implementations (Rust, TypeScript / Python / Go) producing the same
15//! card must produce byte-identical canonical bytes; cosign signatures are
16//! taken over those bytes.
17//!
18//! # Trust contract
19//!
20//! - `weights_hash` is a lowercase 64-character hexadecimal SHA-256 digest.
21//!   The kernel binding refusal recomputes the digest from the loaded
22//!   weights blob and rejects fail-closed on mismatch.
23//! - `allowed_capability_set` is the upper bound on the kernel's grantable
24//!   scope set under this card. Provider bind verifies subset containment
25//!   for the requested capability set.
26//! - `banned_tools` rejects at provider bind, not at first call.
27//! - `expires_at` rejects fail-closed once exceeded.
28
29use std::collections::BTreeSet;
30
31use chrono::{DateTime, Utc};
32use serde::{de, Deserialize, Deserializer, Serialize};
33use sha2::{Digest, Sha256};
34
35use crate::error::WeightsError;
36
37/// Schema version for v1 model cards. The `card_version` field on
38/// [`ModelCard`] MUST be this literal string.
39pub const CARD_VERSION_V1: &str = "1";
40
41/// Sorted unique-string set of capability scopes or tool identifiers.
42///
43/// Encoded as a JSON array in canonical (sorted) order. Set semantics are
44/// enforced at construction time so the canonical-JSON encoding is
45/// deterministic across implementations.
46#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
47#[serde(transparent)]
48pub struct StringSet(BTreeSet<String>);
49
50impl<'de> Deserialize<'de> for StringSet {
51    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
52    where
53        D: Deserializer<'de>,
54    {
55        let items = Vec::<String>::deserialize(deserializer)?;
56        let mut set = BTreeSet::new();
57        for item in items {
58            if item.trim().is_empty() {
59                return Err(de::Error::custom(
60                    "string set entries must be non-empty strings",
61                ));
62            }
63            if item.trim() != item {
64                return Err(de::Error::custom(
65                    "string set entries must not contain surrounding whitespace",
66                ));
67            }
68            if set.contains(&item) {
69                return Err(de::Error::custom(format!(
70                    "duplicate string set entry {item:?}"
71                )));
72            }
73            set.insert(item);
74        }
75        Ok(Self(set))
76    }
77}
78
79impl StringSet {
80    /// Build a [`StringSet`] from any iterable. Duplicates are deduplicated;
81    /// final order is lexicographic per `BTreeSet`.
82    pub fn new<I, S>(items: I) -> Self
83    where
84        I: IntoIterator<Item = S>,
85        S: Into<String>,
86    {
87        Self(items.into_iter().map(Into::into).collect())
88    }
89
90    /// True if every item in `subset` is also in `self`.
91    #[must_use]
92    pub fn covers(&self, subset: &StringSet) -> bool {
93        subset.0.is_subset(&self.0)
94    }
95
96    /// True if `self` and `other` share at least one element.
97    #[must_use]
98    pub fn intersects(&self, other: &StringSet) -> bool {
99        self.0.intersection(&other.0).next().is_some()
100    }
101
102    /// True if `self` contains the given item.
103    #[must_use]
104    pub fn contains(&self, item: &str) -> bool {
105        self.0.contains(item)
106    }
107
108    /// Iterate the items in canonical (lexicographic) order.
109    pub fn iter(&self) -> impl Iterator<Item = &str> {
110        self.0.iter().map(String::as_str)
111    }
112
113    /// Number of distinct items.
114    #[must_use]
115    pub fn len(&self) -> usize {
116        self.0.len()
117    }
118
119    /// True if no items are present.
120    #[must_use]
121    pub fn is_empty(&self) -> bool {
122        self.0.is_empty()
123    }
124
125    /// Borrow the underlying `BTreeSet` (canonical iteration order).
126    #[must_use]
127    pub fn as_set(&self) -> &BTreeSet<String> {
128        &self.0
129    }
130
131    fn validate_entries(&self, field: &'static str) -> Result<(), WeightsError> {
132        for item in &self.0 {
133            if item.trim().is_empty() {
134                return Err(WeightsError::SchemaRejected(format!(
135                    "{field} entries must be non-empty strings"
136                )));
137            }
138            if item.trim() != item {
139                return Err(WeightsError::SchemaRejected(format!(
140                    "{field} entries must not contain surrounding whitespace"
141                )));
142            }
143        }
144        Ok(())
145    }
146}
147
148/// Signed declaration binding a model's loaded weights to a permitted
149/// capability set, banned tools, and a training-data class.
150///
151/// Schema locked at `spec/schemas/model-card.v1.json`. Canonical-JSON
152/// encoding (RFC 8785 / JCS) is the on-the-wire byte form that cosign
153/// signatures are taken over; the serde field declaration order is
154/// therefore irrelevant to the hashed bytes.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct ModelCard {
158    /// Schema version. MUST be [`CARD_VERSION_V1`] for v1 cards; any other
159    /// value rejects at validation.
160    pub card_version: String,
161    /// Lowercase 64-character hexadecimal SHA-256 digest of the canonical
162    /// weights blob.
163    pub weights_hash: String,
164    /// Capability scopes the kernel is permitted to grant against a
165    /// provider bound under this card.
166    pub allowed_capability_set: StringSet,
167    /// Tool identifiers the kernel MUST NOT route to under this card.
168    pub banned_tools: StringSet,
169    /// Coarse-grained training-data classification (free-form).
170    pub training_data_class: String,
171    /// Logical issuer identity (URI / OIDC subject / SAN).
172    pub issuer: String,
173    /// RFC 3339 UTC timestamp at which the issuer minted this card.
174    pub issued_at: DateTime<Utc>,
175    /// RFC 3339 UTC timestamp at which this card expires.
176    pub expires_at: DateTime<Utc>,
177}
178
179impl ModelCard {
180    /// Build a v1 model card. Performs structural validation; returns a
181    /// [`WeightsError`] if any required invariant is violated.
182    ///
183    /// Validated invariants:
184    ///
185    /// - `weights_hash` is a 64-character lowercase hexadecimal string.
186    /// - `training_data_class` is non-empty.
187    /// - `issuer` is non-empty.
188    /// - `expires_at >= issued_at`.
189    pub fn new(
190        weights_hash: impl Into<String>,
191        allowed_capability_set: StringSet,
192        banned_tools: StringSet,
193        training_data_class: impl Into<String>,
194        issuer: impl Into<String>,
195        issued_at: DateTime<Utc>,
196        expires_at: DateTime<Utc>,
197    ) -> Result<Self, WeightsError> {
198        let card = Self {
199            card_version: CARD_VERSION_V1.to_string(),
200            weights_hash: weights_hash.into(),
201            allowed_capability_set,
202            banned_tools,
203            training_data_class: training_data_class.into(),
204            issuer: issuer.into(),
205            issued_at,
206            expires_at,
207        };
208        card.validate()?;
209        Ok(card)
210    }
211
212    /// Re-run structural validation. Called by [`Self::new`] and exposed for
213    /// post-deserialisation use.
214    pub fn validate(&self) -> Result<(), WeightsError> {
215        if self.card_version != CARD_VERSION_V1 {
216            return Err(WeightsError::SchemaRejected(format!(
217                "card_version must be {CARD_VERSION_V1:?}, got {:?}",
218                self.card_version
219            )));
220        }
221        if !is_lowercase_sha256_hex(&self.weights_hash) {
222            return Err(WeightsError::SchemaRejected(format!(
223                "weights_hash must be 64 lowercase hex chars, got {:?}",
224                self.weights_hash
225            )));
226        }
227        self.allowed_capability_set
228            .validate_entries("allowed_capability_set")?;
229        self.banned_tools.validate_entries("banned_tools")?;
230        validate_required_text_field(&self.training_data_class, "training_data_class")?;
231        validate_required_text_field(&self.issuer, "issuer")?;
232        if self.expires_at < self.issued_at {
233            return Err(WeightsError::SchemaRejected(format!(
234                "expires_at ({}) precedes issued_at ({})",
235                self.expires_at, self.issued_at,
236            )));
237        }
238        Ok(())
239    }
240
241    /// Verifier-side liveness check.
242    pub fn require_live(&self, now: DateTime<Utc>) -> Result<(), WeightsError> {
243        if now < self.expires_at {
244            Ok(())
245        } else {
246            Err(WeightsError::Expired {
247                expires_at: self.expires_at,
248                now,
249            })
250        }
251    }
252
253    /// Encode to canonical JSON bytes (RFC 8785 / JCS). Deterministic across
254    /// any compliant implementation.
255    pub fn to_canonical_json(&self) -> Result<Vec<u8>, WeightsError> {
256        chio_core_types::canonical::canonical_json_bytes(self)
257            .map_err(|err| WeightsError::Encoding(format!("canonical-json encode: {err}")))
258    }
259
260    /// Decode from canonical JSON bytes. Round-trip with
261    /// [`Self::to_canonical_json`]. Validates after deserialisation.
262    pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, WeightsError> {
263        let card: Self = serde_json::from_slice(bytes)
264            .map_err(|err| WeightsError::Encoding(format!("canonical-json decode: {err}")))?;
265        card.validate()?;
266        let canonical = card.to_canonical_json()?;
267        if canonical.as_slice() != bytes {
268            return Err(WeightsError::Encoding(
269                "model card bytes are not RFC 8785 canonical JSON".to_string(),
270            ));
271        }
272        Ok(card)
273    }
274}
275
276/// Compute the lowercase hex SHA-256 of a byte slice. Used by the kernel
277/// binding refusal path to recompute `weights_hash` from a loaded
278/// weights blob.
279#[must_use]
280pub fn weights_hash_of(bytes: &[u8]) -> String {
281    let digest = Sha256::digest(bytes);
282    hex::encode(digest)
283}
284
285#[inline]
286fn is_lowercase_hex_byte(b: u8) -> bool {
287    matches!(b, b'0'..=b'9' | b'a'..=b'f')
288}
289
290#[inline]
291fn is_lowercase_sha256_hex(value: &str) -> bool {
292    value.len() == 64 && value.bytes().all(is_lowercase_hex_byte)
293}
294
295fn validate_required_text_field(value: &str, field: &'static str) -> Result<(), WeightsError> {
296    if value.trim().is_empty() {
297        return Err(WeightsError::MissingField(field));
298    }
299    if value.trim() != value {
300        return Err(WeightsError::SchemaRejected(format!(
301            "{field} must not contain surrounding whitespace"
302        )));
303    }
304    Ok(())
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use chrono::TimeZone;
311
312    fn fixed_issued_at() -> DateTime<Utc> {
313        match Utc.with_ymd_and_hms(2026, 4, 30, 12, 0, 0) {
314            chrono::LocalResult::Single(t) => t,
315            _ => panic!("fixed_issued_at fixture must construct"),
316        }
317    }
318
319    fn good_card() -> ModelCard {
320        let issued = fixed_issued_at();
321        let expires = issued + chrono::Duration::days(30);
322        match ModelCard::new(
323            "0000000000000000000000000000000000000000000000000000000000000001",
324            StringSet::new(["tool:read", "tool:write"]),
325            StringSet::new(["tool:exec"]),
326            "public-internet",
327            "https://example.com/issuer",
328            issued,
329            expires,
330        ) {
331            Ok(card) => card,
332            Err(e) => panic!("good_card must construct: {e}"),
333        }
334    }
335
336    fn good_card_value() -> serde_json::Value {
337        serde_json::json!({
338            "allowed_capability_set": ["tool:read", "tool:write"],
339            "banned_tools": ["tool:exec"],
340            "card_version": CARD_VERSION_V1,
341            "expires_at": "2026-05-30T12:00:00Z",
342            "issued_at": "2026-04-30T12:00:00Z",
343            "issuer": "https://example.com/issuer",
344            "training_data_class": "public-internet",
345            "weights_hash": "0000000000000000000000000000000000000000000000000000000000000001",
346        })
347    }
348
349    fn value_to_bytes(value: &serde_json::Value) -> Vec<u8> {
350        match serde_json::to_vec(value) {
351            Ok(bytes) => bytes,
352            Err(e) => panic!("serialize fixture value: {e}"),
353        }
354    }
355
356    #[test]
357    fn lowercase_sha256_hex_helper_accepts_only_exact_lowercase_digest() {
358        assert!(is_lowercase_sha256_hex(
359            "0000000000000000000000000000000000000000000000000000000000000001"
360        ));
361        assert!(!is_lowercase_sha256_hex(
362            "ABCDEF0000000000000000000000000000000000000000000000000000000001"
363        ));
364        assert!(!is_lowercase_sha256_hex("abcdef"));
365        assert!(!is_lowercase_sha256_hex(
366            "000000000000000000000000000000000000000000000000000000000000000g"
367        ));
368    }
369
370    #[test]
371    fn validate_rejects_uppercase_weights_hash() {
372        let issued = fixed_issued_at();
373        let res = ModelCard::new(
374            "ABCDEF0000000000000000000000000000000000000000000000000000000001",
375            StringSet::new(["tool:read"]),
376            StringSet::default(),
377            "public-internet",
378            "https://example.com/issuer",
379            issued,
380            issued + chrono::Duration::days(1),
381        );
382        assert!(matches!(res, Err(WeightsError::SchemaRejected(_))));
383    }
384
385    #[test]
386    fn validate_rejects_short_weights_hash() {
387        let issued = fixed_issued_at();
388        let res = ModelCard::new(
389            "abcdef",
390            StringSet::default(),
391            StringSet::default(),
392            "public-internet",
393            "https://example.com/issuer",
394            issued,
395            issued + chrono::Duration::days(1),
396        );
397        assert!(matches!(res, Err(WeightsError::SchemaRejected(_))));
398    }
399
400    #[test]
401    fn validate_rejects_empty_training_data_class() {
402        let issued = fixed_issued_at();
403        let res = ModelCard::new(
404            "0000000000000000000000000000000000000000000000000000000000000001",
405            StringSet::default(),
406            StringSet::default(),
407            "",
408            "https://example.com/issuer",
409            issued,
410            issued + chrono::Duration::days(1),
411        );
412        assert!(matches!(res, Err(WeightsError::MissingField(_))));
413    }
414
415    #[test]
416    fn validate_rejects_blank_or_padded_required_text_fields() {
417        let issued = fixed_issued_at();
418        let blank_training = ModelCard::new(
419            "0000000000000000000000000000000000000000000000000000000000000001",
420            StringSet::default(),
421            StringSet::default(),
422            " ",
423            "https://example.com/issuer",
424            issued,
425            issued + chrono::Duration::days(1),
426        );
427        assert!(matches!(
428            blank_training,
429            Err(WeightsError::MissingField("training_data_class"))
430        ));
431
432        let padded_issuer = ModelCard::new(
433            "0000000000000000000000000000000000000000000000000000000000000001",
434            StringSet::default(),
435            StringSet::default(),
436            "public-internet",
437            " https://example.com/issuer",
438            issued,
439            issued + chrono::Duration::days(1),
440        );
441        assert!(matches!(
442            padded_issuer,
443            Err(WeightsError::SchemaRejected(message)) if message.contains("issuer")
444        ));
445    }
446
447    #[test]
448    fn validate_rejects_expires_before_issued() {
449        let issued = fixed_issued_at();
450        let res = ModelCard::new(
451            "0000000000000000000000000000000000000000000000000000000000000001",
452            StringSet::default(),
453            StringSet::default(),
454            "public-internet",
455            "https://example.com/issuer",
456            issued,
457            issued - chrono::Duration::seconds(1),
458        );
459        assert!(matches!(res, Err(WeightsError::SchemaRejected(_))));
460    }
461
462    #[test]
463    fn from_canonical_json_rejects_unknown_fields() {
464        let mut value = good_card_value();
465        match value {
466            serde_json::Value::Object(ref mut map) => {
467                map.insert(
468                    "unexpected".to_string(),
469                    serde_json::Value::String("field".to_string()),
470                );
471            }
472            _ => panic!("fixture must be a JSON object"),
473        }
474        let bytes = value_to_bytes(&value);
475        let res = ModelCard::from_canonical_json(&bytes);
476        assert!(matches!(res, Err(WeightsError::Encoding(_))));
477    }
478
479    #[test]
480    fn from_canonical_json_rejects_duplicate_set_entries() {
481        let mut value = good_card_value();
482        match value {
483            serde_json::Value::Object(ref mut map) => {
484                map.insert(
485                    "allowed_capability_set".to_string(),
486                    serde_json::json!(["tool:read", "tool:read"]),
487                );
488            }
489            _ => panic!("fixture must be a JSON object"),
490        }
491        let bytes = value_to_bytes(&value);
492        let res = ModelCard::from_canonical_json(&bytes);
493        assert!(matches!(res, Err(WeightsError::Encoding(_))));
494    }
495
496    #[test]
497    fn from_canonical_json_rejects_empty_set_entries() {
498        let mut value = good_card_value();
499        match value {
500            serde_json::Value::Object(ref mut map) => {
501                map.insert(
502                    "banned_tools".to_string(),
503                    serde_json::json!(["tool:exec", ""]),
504                );
505            }
506            _ => panic!("fixture must be a JSON object"),
507        }
508        let bytes = value_to_bytes(&value);
509        let res = ModelCard::from_canonical_json(&bytes);
510        assert!(matches!(res, Err(WeightsError::Encoding(_))));
511    }
512
513    #[test]
514    fn new_rejects_empty_set_entries() {
515        let issued = fixed_issued_at();
516        let res = ModelCard::new(
517            "0000000000000000000000000000000000000000000000000000000000000001",
518            StringSet::new([""]),
519            StringSet::default(),
520            "public-internet",
521            "https://example.com/issuer",
522            issued,
523            issued + chrono::Duration::days(1),
524        );
525        assert!(matches!(res, Err(WeightsError::SchemaRejected(_))));
526    }
527
528    #[test]
529    fn new_rejects_blank_or_padded_set_entries() {
530        let issued = fixed_issued_at();
531        let blank = ModelCard::new(
532            "0000000000000000000000000000000000000000000000000000000000000001",
533            StringSet::new([" "]),
534            StringSet::default(),
535            "public-internet",
536            "https://example.com/issuer",
537            issued,
538            issued + chrono::Duration::days(1),
539        );
540        assert!(matches!(
541            blank,
542            Err(WeightsError::SchemaRejected(message)) if message.contains("allowed_capability_set")
543        ));
544
545        let padded = ModelCard::new(
546            "0000000000000000000000000000000000000000000000000000000000000001",
547            StringSet::default(),
548            StringSet::new([" tool:exec"]),
549            "public-internet",
550            "https://example.com/issuer",
551            issued,
552            issued + chrono::Duration::days(1),
553        );
554        assert!(matches!(
555            padded,
556            Err(WeightsError::SchemaRejected(message)) if message.contains("banned_tools")
557        ));
558    }
559
560    #[test]
561    fn require_live_rejects_expired_card() {
562        let card = good_card();
563        let later = card.expires_at + chrono::Duration::seconds(1);
564        let res = card.require_live(later);
565        assert!(matches!(res, Err(WeightsError::Expired { .. })));
566    }
567
568    #[test]
569    fn canonical_round_trip_is_byte_stable() {
570        let card = good_card();
571        let bytes = match card.to_canonical_json() {
572            Ok(b) => b,
573            Err(e) => panic!("encode must succeed: {e}"),
574        };
575        let again = match ModelCard::from_canonical_json(&bytes) {
576            Ok(c) => c,
577            Err(e) => panic!("decode must succeed: {e}"),
578        };
579        let bytes2 = match again.to_canonical_json() {
580            Ok(b) => b,
581            Err(e) => panic!("re-encode must succeed: {e}"),
582        };
583        assert_eq!(bytes, bytes2, "canonical-json bytes must round-trip stably");
584    }
585
586    #[test]
587    fn from_canonical_json_rejects_noncanonical_whitespace() {
588        let card = good_card();
589        let pretty = match serde_json::to_vec_pretty(&card) {
590            Ok(bytes) => bytes,
591            Err(e) => panic!("pretty encode must succeed: {e}"),
592        };
593        let res = ModelCard::from_canonical_json(&pretty);
594        assert!(
595            matches!(res, Err(WeightsError::Encoding(_))),
596            "signed model cards must be presented as RFC 8785 canonical bytes"
597        );
598    }
599
600    #[test]
601    fn weights_hash_of_matches_known_vector() {
602        // sha256(b"") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
603        assert_eq!(
604            weights_hash_of(b""),
605            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
606        );
607    }
608
609    #[test]
610    fn allowed_capability_set_covers_subset() {
611        let card = good_card();
612        let req = StringSet::new(["tool:read"]);
613        assert!(card.allowed_capability_set.covers(&req));
614        let bad = StringSet::new(["tool:read", "tool:admin"]);
615        assert!(!card.allowed_capability_set.covers(&bad));
616    }
617
618    #[test]
619    fn banned_tools_intersects_detects_overlap() {
620        let card = good_card();
621        let req = StringSet::new(["tool:read", "tool:exec"]);
622        assert!(card.banned_tools.intersects(&req));
623        let benign = StringSet::new(["tool:read"]);
624        assert!(!card.banned_tools.intersects(&benign));
625    }
626}