Skip to main content

clawft_kernel/model_manifest/
body.rs

1//! The `model` manifest body and operator attestation (ADR-101 section 3).
2//!
3//! Upstream rarely signs weights, so trust is an operator attestation over a
4//! hash manifest: the operator hashes the shards where they lie, signs the
5//! body with a key pinned in [`TrustAnchors`], and a shard whose hash later
6//! differs is refused. The envelope, signing domain and signature check are
7//! the ones signed workload packages use ([`crate::workload_pkg`]); only the
8//! body differs.
9
10use serde::{Deserialize, Serialize};
11use serde_json::json;
12
13use crate::workload_pkg::codec::is_lower_hex;
14use crate::workload_pkg::manifest::{valid_token, validate_rel_path};
15use crate::workload_pkg::verify::{AcceptedSigner, VerifyError, check_signatures};
16use crate::workload_pkg::{
17    FileRef, ManifestEnvelope, ManifestError, TrustAnchors, sign_envelope,
18};
19
20/// Workload kind string of a model manifest.
21pub const KIND_MODEL: &str = "model";
22/// Most shard files one model may list. One `model.present` capability
23/// carries every shard hash plus a model marker in a list attribute, which
24/// is bounded at 256 entries.
25pub const MAX_SHARDS: usize = 255;
26/// Prefix of the identity marker entry in the `model.present` `shards` list.
27pub const MODEL_MARKER_PREFIX: &str = "model:";
28
29/// Weight file layout.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum ModelFormat {
33    /// MLX quantisation directory (safetensors plus config).
34    Mlx,
35    /// One or more GGUF files.
36    Gguf,
37    /// Plain safetensors shards (an HF snapshot).
38    Safetensors,
39    /// Ollama blobs (GGUF content addressed by the Ollama store).
40    Ollama,
41}
42
43impl ModelFormat {
44    /// Wire name, also the `format.*` capability suffix.
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Self::Mlx => "mlx",
48            Self::Gguf => "gguf",
49            Self::Safetensors => "safetensors",
50            Self::Ollama => "ollama",
51        }
52    }
53}
54
55/// Where the weights came from. Provenance, never trust.
56#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct ModelSource {
59    /// Hugging Face repo id (`org/name`).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub hf_repo: Option<String>,
62    /// Hugging Face revision (commit hash or ref).
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub hf_revision: Option<String>,
65    /// Ollama tag (`name:tag`).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub ollama_tag: Option<String>,
68}
69
70impl ModelSource {
71    fn validate(&self) -> Result<(), ManifestError> {
72        if self.hf_repo.is_none() && self.ollama_tag.is_none() {
73            return Err(invalid("source needs an hf_repo or an ollama_tag"));
74        }
75        if self.hf_revision.is_some() && self.hf_repo.is_none() {
76            return Err(invalid("hf_revision needs an hf_repo"));
77        }
78        for s in [&self.hf_repo, &self.hf_revision, &self.ollama_tag]
79            .into_iter()
80            .flatten()
81        {
82            if s.is_empty() || s.len() > 256 || s.chars().any(|c| c.is_control() || c == ' ') {
83                return Err(invalid("source strings must be 1..256 printable chars"));
84            }
85        }
86        Ok(())
87    }
88}
89
90/// Body of a `kind = "model"` manifest.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct ModelPackageBody {
94    /// Model name (`Qwen3-Coder-Next-4bit`); the `model` parameter of
95    /// `perf.infer.tok_s`.
96    pub name: String,
97    /// Layout.
98    pub format: ModelFormat,
99    /// Weight shards, relative to the model root, sorted by path.
100    pub shards: Vec<FileRef>,
101    /// BLAKE3 of the tokenizer file, when the model has one.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub tokenizer_blake3: Option<String>,
104    /// Path of the tokenizer file relative to the model root; present
105    /// exactly when `tokenizer_blake3` is, so a node that receives only the
106    /// signed manifest knows which file to check.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub tokenizer_path: Option<String>,
109    /// BLAKE3 of the chat template (or the config that carries it).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub template_blake3: Option<String>,
112    /// Path of the template file relative to the model root; present
113    /// exactly when `template_blake3` is.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub template_path: Option<String>,
116    /// Provenance.
117    pub source: ModelSource,
118    /// The signer states the weights may be handed to other nodes. Absent
119    /// means false: weights are never shared unless explicitly opted in. It
120    /// is part of the signed statement.
121    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
122    pub redistributable: bool,
123}
124
125fn invalid(msg: impl Into<String>) -> ManifestError {
126    ManifestError::Invalid(msg.into())
127}
128
129impl ModelPackageBody {
130    /// Validate names, paths, hashes and bounds.
131    pub fn validate(&self) -> Result<(), ManifestError> {
132        if !valid_token(&self.name, 64) {
133            return Err(invalid(format!("bad model name {:?}", self.name)));
134        }
135        if self.shards.is_empty() || self.shards.len() > MAX_SHARDS {
136            return Err(invalid(format!("a model lists 1..={MAX_SHARDS} shards")));
137        }
138        for s in &self.shards {
139            validate_rel_path(&s.path)?;
140            if !is_lower_hex(&s.blake3, 64) {
141                return Err(invalid(format!("{}: blake3 must be 64 lower-case hex", s.path)));
142            }
143        }
144        if self.shards.windows(2).any(|w| w[0].path >= w[1].path) {
145            return Err(invalid("shards must be sorted by path with no duplicates"));
146        }
147        for h in [&self.tokenizer_blake3, &self.template_blake3].into_iter().flatten() {
148            if !is_lower_hex(h, 64) {
149                return Err(invalid("tokenizer/template hashes are 64 lower-case hex"));
150            }
151        }
152        for (hash, path) in [
153            (&self.tokenizer_blake3, &self.tokenizer_path),
154            (&self.template_blake3, &self.template_path),
155        ] {
156            match (hash, path) {
157                (None, None) => {}
158                (Some(_), Some(p)) => validate_rel_path(p)?,
159                _ => return Err(invalid("a tokenizer/template hash and path come together")),
160            }
161        }
162        self.source.validate()
163    }
164
165    /// Sum of shard sizes.
166    pub fn total_bytes(&self) -> u64 {
167        self.shards.iter().map(|s| s.size).fold(0, u64::saturating_add)
168    }
169
170    /// Shard hashes in manifest order.
171    pub fn shard_hashes(&self) -> impl Iterator<Item = &str> {
172        self.shards.iter().map(|s| s.blake3.as_str())
173    }
174}
175
176/// A model manifest that passed signature verification.
177#[derive(Debug, Clone)]
178pub struct VerifiedModel {
179    /// BLAKE3 of the signed statement (the model's identity).
180    pub package_id: String,
181    /// The envelope as read.
182    pub envelope: ManifestEnvelope,
183    /// Typed body.
184    pub body: ModelPackageBody,
185    /// Accepted signers (at least one).
186    pub signers: Vec<AcceptedSigner>,
187}
188
189/// Model manifest failure.
190#[derive(Debug, thiserror::Error)]
191pub enum ModelError {
192    /// Manifest shape.
193    #[error(transparent)]
194    Manifest(#[from] ManifestError),
195    /// Signature check.
196    #[error(transparent)]
197    Verify(#[from] VerifyError),
198    /// Filesystem failure.
199    #[error("{path}: {msg}")]
200    Io {
201        /// Path involved.
202        path: String,
203        /// Error text.
204        msg: String,
205    },
206    /// A file's bytes differ from the attested hash. The model is refused.
207    #[error("{path}: hash mismatch (expected {expected}, found {actual})")]
208    HashMismatch {
209        /// Relative path.
210        path: String,
211        /// Attested hash or size.
212        expected: String,
213        /// Found hash or size.
214        actual: String,
215    },
216    /// Another model already holds this name with a different manifest.
217    #[error("model name {0:?} is already adopted with a different manifest")]
218    NameConflict(String),
219    /// Nothing adopted under this id or name.
220    #[error("no adopted model {0:?}")]
221    Unknown(String),
222    /// The model cannot be used in its current state.
223    #[error("model {id} is not ready: {reason}")]
224    NotReady {
225        /// Package id.
226        id: String,
227        /// Why.
228        reason: String,
229    },
230    /// Registry persistence or input problem.
231    #[error("model registry: {0}")]
232    Registry(String),
233    /// Nothing recognisable as model weights under the path.
234    #[error("{0}")]
235    Scan(String),
236}
237
238/// Operator attestation: wrap `body` in a `model` envelope and sign it with
239/// the operator key. The attestation is only useful if `key_id`'s public key
240/// is pinned in the node's [`TrustAnchors`].
241pub fn attest(
242    body: &ModelPackageBody,
243    key: &ed25519_dalek::SigningKey,
244    key_id: &str,
245) -> Result<ManifestEnvelope, ModelError> {
246    body.validate()?;
247    let value = serde_json::to_value(body).map_err(|e| ManifestError::Parse(e.to_string()))?;
248    let mut env = ManifestEnvelope::new(KIND_MODEL, json!(value));
249    sign_envelope(&mut env, key, key_id)?;
250    Ok(env)
251}
252
253/// Verify a model envelope: kind, body, and at least one signature from a
254/// pinned signer. File contents are checked separately (see the registry),
255/// because weights are too large to read here.
256pub fn verify_model(
257    envelope: &ManifestEnvelope,
258    anchors: &TrustAnchors,
259) -> Result<VerifiedModel, ModelError> {
260    if envelope.kind != KIND_MODEL {
261        return Err(invalid(format!("kind {:?} is not a model", envelope.kind)).into());
262    }
263    let body: ModelPackageBody = serde_json::from_value(envelope.body.clone())
264        .map_err(|e| ManifestError::Parse(format!("model body: {e}")))?;
265    body.validate()?;
266    let signers = check_signatures(envelope, anchors)?;
267    if signers.is_empty() {
268        return Err(if envelope.signatures.is_empty() {
269            VerifyError::MissingSignature
270        } else {
271            VerifyError::UntrustedSigner {
272                key_ids: envelope
273                    .signatures
274                    .iter()
275                    .map(|s| s.key_id.as_str())
276                    .collect::<Vec<_>>()
277                    .join(","),
278            }
279        }
280        .into());
281    }
282    Ok(VerifiedModel {
283        package_id: envelope.package_id()?,
284        envelope: envelope.clone(),
285        body,
286        signers,
287    })
288}