Skip to main content

chio_kernel/
weights_binding.rs

1//! Kernel-side model-card binding refusal.
2//!
3//! When `policy.weights_card_required` is `required` or
4//! `required_with_pin`, the kernel refuses to bind a provider unless a
5//! signed model card matches the loaded weights AND the requested
6//! capability set is admissible under the card. This module is the
7//! refusal-decision surface; the policy load path lives in `chio-policy`
8//! and the cosign bundle verify path lives in `chio-weights`.
9//!
10//! # Refusal contract
11//!
12//! Provider bind verifies, in order:
13//!
14//! 1. The provider's loaded `weights_hash` (lowercase hex sha-256 of the
15//!    canonical weights blob) byte-equals the card's `weights_hash`.
16//!    Mismatch surfaces as
17//!    [`WeightsBindingError::CardMismatch`] / `urn:chio:error:weights:card-mismatch`.
18//! 2. The requested capability scope set is a subset of the card's
19//!    `allowed_capability_set`. Any scope not in the set surfaces as
20//!    [`WeightsBindingError::ScopeNotSubset`] / `urn:chio:error:weights:scope-not-subset`.
21//! 3. No requested tool intersects the card's `banned_tools`. Banned tools
22//!    reject at provider bind, not at first call. Surfaces as
23//!    [`WeightsBindingError::ToolBanned`] / `urn:chio:error:weights:tool-banned`.
24//!
25//! All gates fail-closed: the first failing gate short-circuits.
26//!
27//! # Async discipline
28//!
29//! The verifier surface takes no `&mut self`.
30//! [`evaluate_weights_binding`] is a free function over `&ModelCard` so
31//! it composes cleanly with the async kernel without locking.
32
33use std::fmt;
34
35use chio_weights::card::{ModelCard, StringSet};
36use chio_weights::error::WeightsError;
37
38/// The kernel-side bind-time decision surface error. Wraps
39/// [`chio_weights::error::WeightsError`] so consumers get a single result
40/// type to pattern-match on.
41pub type WeightsBindingError = WeightsError;
42
43/// Inputs the kernel collects at provider bind. The provider supplies its
44/// loaded `weights_hash` and the operator supplies the requested
45/// capability set. Banned-tool intersection is computed against the same
46/// `requested_scopes` because the provider matrix unifies tool
47/// identifiers into the scope namespace.
48#[derive(Debug, Clone)]
49pub struct WeightsBindingRequest<'a> {
50    /// Lowercase hex sha-256 of the provider's loaded weights blob.
51    /// The provider computes this; the card binding refusal verifies
52    /// byte-equality against the card's `weights_hash`.
53    pub loaded_weights_hash: &'a str,
54    /// Capability scope set the operator wants to grant against the
55    /// bound provider. Subset containment against the card's
56    /// `allowed_capability_set` is enforced.
57    pub requested_scopes: &'a StringSet,
58    /// Tool identifiers the operator wants to permit. Intersection with
59    /// the card's `banned_tools` is enforced. May overlap or equal
60    /// `requested_scopes` depending on the deployment model.
61    pub requested_tools: &'a StringSet,
62}
63
64/// Evaluate the kernel binding refusal contract.
65///
66/// Returns `Ok(())` only when ALL three gates pass:
67///
68/// 1. `card.weights_hash == request.loaded_weights_hash`,
69/// 2. `card.allowed_capability_set.covers(request.requested_scopes)`,
70/// 3. `card.banned_tools.intersects(request.requested_tools)` is false.
71///
72/// Fail-closed: any failing gate returns the matching
73/// [`WeightsBindingError`]. The first failing gate short-circuits;
74/// deployments needing distinct telemetry per gate read the
75/// [`WeightsError::urn`].
76pub fn evaluate_weights_binding(
77    card: &ModelCard,
78    request: &WeightsBindingRequest<'_>,
79) -> Result<(), WeightsBindingError> {
80    // Gate 1: weights_hash byte-equality. Lowercase hex on both sides
81    // (the card schema enforces lowercase at validate; the provider
82    // normalises before passing in).
83    if card.weights_hash != request.loaded_weights_hash {
84        return Err(WeightsError::CardMismatch {
85            expected: card.weights_hash.clone(),
86            found: request.loaded_weights_hash.to_string(),
87        });
88    }
89
90    // Gate 2: scope subset containment. We surface the FIRST scope that
91    // fails so the error message is actionable.
92    for scope in request.requested_scopes.iter() {
93        if !card.allowed_capability_set.contains(scope) {
94            return Err(WeightsError::ScopeNotSubset {
95                scope: scope.to_string(),
96            });
97        }
98    }
99
100    // Gate 3: banned-tools intersection. Surface the FIRST overlap.
101    for tool in request.requested_tools.iter() {
102        if card.banned_tools.contains(tool) {
103            return Err(WeightsError::ToolBanned {
104                tool: tool.to_string(),
105            });
106        }
107    }
108
109    Ok(())
110}
111
112/// Evaluate binding after the provider-side loaded-weight digest has been
113/// recomputed.
114///
115/// Hosted or protocol adapters that cannot expose loaded model bytes pass an
116/// error here. The kernel maps that unavailable state to a schema-level
117/// refusal and never falls back to caller-supplied hashes.
118pub fn evaluate_weights_binding_with_loaded_hash<H, E>(
119    card: &ModelCard,
120    loaded_weights_hash: Result<H, E>,
121    requested_scopes: &StringSet,
122    requested_tools: &StringSet,
123) -> Result<(), WeightsBindingError>
124where
125    H: AsRef<str>,
126    E: fmt::Display,
127{
128    let loaded_weights_hash = loaded_weights_hash.map_err(|error| {
129        WeightsError::SchemaRejected(format!("loaded weights unavailable: {error}"))
130    })?;
131    let request = WeightsBindingRequest {
132        loaded_weights_hash: loaded_weights_hash.as_ref(),
133        requested_scopes,
134        requested_tools,
135    };
136    evaluate_weights_binding(card, &request)
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use chrono::{TimeZone, Utc};
143
144    fn fixed_issued_at() -> chrono::DateTime<Utc> {
145        match Utc.with_ymd_and_hms(2026, 4, 30, 12, 0, 0) {
146            chrono::LocalResult::Single(t) => t,
147            _ => panic!("fixed_issued_at fixture must construct"),
148        }
149    }
150
151    fn good_card() -> ModelCard {
152        let issued = fixed_issued_at();
153        match ModelCard::new(
154            "0000000000000000000000000000000000000000000000000000000000000001",
155            StringSet::new(["tool:read", "tool:write"]),
156            StringSet::new(["tool:exec"]),
157            "public-internet",
158            "https://example.com/issuer",
159            issued,
160            issued + chrono::Duration::days(30),
161        ) {
162            Ok(c) => c,
163            Err(e) => panic!("good_card must construct: {e}"),
164        }
165    }
166
167    #[test]
168    fn binding_succeeds_when_all_gates_pass() {
169        let card = good_card();
170        let scopes = StringSet::new(["tool:read"]);
171        let tools = StringSet::new(["tool:read"]);
172        let req = WeightsBindingRequest {
173            loaded_weights_hash: "0000000000000000000000000000000000000000000000000000000000000001",
174            requested_scopes: &scopes,
175            requested_tools: &tools,
176        };
177        assert!(evaluate_weights_binding(&card, &req).is_ok());
178    }
179
180    #[test]
181    fn rejects_card_mismatch() {
182        let card = good_card();
183        let scopes = StringSet::new(["tool:read"]);
184        let tools = StringSet::new(["tool:read"]);
185        let req = WeightsBindingRequest {
186            loaded_weights_hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
187            requested_scopes: &scopes,
188            requested_tools: &tools,
189        };
190        let err = match evaluate_weights_binding(&card, &req) {
191            Err(e) => e,
192            Ok(()) => panic!("must reject"),
193        };
194        assert_eq!(err.urn(), "urn:chio:error:weights:card-mismatch");
195        assert!(matches!(err, WeightsError::CardMismatch { .. }));
196    }
197
198    #[test]
199    fn rejects_scope_not_subset() {
200        let card = good_card();
201        let scopes = StringSet::new(["tool:read", "tool:admin"]);
202        let tools = StringSet::default();
203        let req = WeightsBindingRequest {
204            loaded_weights_hash: "0000000000000000000000000000000000000000000000000000000000000001",
205            requested_scopes: &scopes,
206            requested_tools: &tools,
207        };
208        let err = match evaluate_weights_binding(&card, &req) {
209            Err(e) => e,
210            Ok(()) => panic!("must reject"),
211        };
212        assert_eq!(err.urn(), "urn:chio:error:weights:scope-not-subset");
213        match err {
214            WeightsError::ScopeNotSubset { scope } => {
215                assert_eq!(scope, "tool:admin");
216            }
217            other => panic!("unexpected error: {other:?}"),
218        }
219    }
220
221    #[test]
222    fn rejects_banned_tool() {
223        let card = good_card();
224        let scopes = StringSet::new(["tool:read"]);
225        let tools = StringSet::new(["tool:read", "tool:exec"]);
226        let req = WeightsBindingRequest {
227            loaded_weights_hash: "0000000000000000000000000000000000000000000000000000000000000001",
228            requested_scopes: &scopes,
229            requested_tools: &tools,
230        };
231        let err = match evaluate_weights_binding(&card, &req) {
232            Err(e) => e,
233            Ok(()) => panic!("must reject"),
234        };
235        assert_eq!(err.urn(), "urn:chio:error:weights:tool-banned");
236        match err {
237            WeightsError::ToolBanned { tool } => assert_eq!(tool, "tool:exec"),
238            other => panic!("unexpected error: {other:?}"),
239        }
240    }
241
242    #[test]
243    fn gate_order_is_card_mismatch_first() {
244        // Multiple gates fail simultaneously; the surface returns the
245        // FIRST gate in declared order. This is part of the contract:
246        // operators can rely on stable error codes for the first
247        // misconfiguration they see.
248        let card = good_card();
249        let scopes = StringSet::new(["tool:admin"]);
250        let tools = StringSet::new(["tool:exec"]);
251        let req = WeightsBindingRequest {
252            loaded_weights_hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
253            requested_scopes: &scopes,
254            requested_tools: &tools,
255        };
256        let err = match evaluate_weights_binding(&card, &req) {
257            Err(e) => e,
258            Ok(()) => panic!("must reject"),
259        };
260        assert!(matches!(err, WeightsError::CardMismatch { .. }));
261    }
262
263    #[test]
264    fn empty_scopes_and_tools_pass_when_hash_matches() {
265        let card = good_card();
266        let scopes = StringSet::default();
267        let tools = StringSet::default();
268        let req = WeightsBindingRequest {
269            loaded_weights_hash: "0000000000000000000000000000000000000000000000000000000000000001",
270            requested_scopes: &scopes,
271            requested_tools: &tools,
272        };
273        assert!(evaluate_weights_binding(&card, &req).is_ok());
274    }
275}