Skip to main content

cookie_agent_models/manifests/
mod.rs

1//! In-memory model manifests, schema 1: the canonical, fingerprinted form of
2//! the current runtime's models that run bindings are frozen against. Nothing
3//! here is persisted.
4
5use std::{
6    cmp::Ordering,
7    collections::{BTreeMap, BTreeSet},
8    sync::Arc,
9};
10
11use cookie_agent_identity::ModelSnapshotRevision;
12use serde_json::Value;
13use sha2::{Digest as _, Sha256};
14use thiserror::Error;
15
16use crate::secure_store::SecureStoreError;
17
18pub use cookie_agent_protocol::{
19    CompiledSafeModelBlueprint, FrozenAuthParameterValue, FrozenCredentialBinding,
20    FrozenCredentialSource, FrozenModelBinding, FrozenProviderOptions, FrozenProviderSource,
21    FrozenRequestDefaults, FrozenResolvedRequestDefaults, FrozenSetupBinding,
22    FrozenVariantBlueprint, HeaderName, ModelSnapshotManifestSchemaVersion,
23    ModelSnapshotManifestV1, ModelSnapshotPayloadV1, NormalizedDecimal, SafeEndpointIdentity,
24    SafeStaticHeaderValue, Sha256Digest,
25};
26
27/// Hard per-manifest byte limit.
28pub const MODEL_SNAPSHOT_MAX_BYTES: u64 = 4 * 1024 * 1024;
29/// Hard direct matching-file limit.
30pub const MODEL_SNAPSHOT_MAX_FILES: usize = 4096;
31
32const MAX_IJSON_INTEGER: u64 = 9_007_199_254_740_991;
33const MAX_JSON_DEPTH: usize = 64;
34const MAX_JSON_ITEMS: usize = 1_000_000;
35
36/// Canonicalizes and validates a payload into the current in-memory manifest.
37/// Nothing is persisted: runs resolve their models from the live runtime, and
38/// events keep their bindings only as a record of what ran.
39pub fn build_manifest(
40    mut payload: ModelSnapshotPayloadV1,
41) -> Result<Arc<ModelSnapshotManifestV1>, ManifestError> {
42    normalize_payload(&mut payload)?;
43    validate_payload(&payload)?;
44    let canonical = canonical_payload_bytes(&payload)?;
45    if canonical.len() as u64 > MODEL_SNAPSHOT_MAX_BYTES {
46        return Err(ManifestError::InvalidModelSnapshotManifest);
47    }
48    let digest = sha256_hex(&canonical);
49    let revision = ModelSnapshotRevision::new(format!("sha256:{digest}"))
50        .map_err(|_| ManifestError::InvalidModelSnapshotManifest)?;
51    Ok(Arc::new(ModelSnapshotManifestV1 {
52        schema_version: ModelSnapshotManifestSchemaVersion::current(),
53        revision,
54        payload,
55    }))
56}
57
58/// Returns exact RFC-8785 JCS bytes for an integer-only payload.
59pub fn canonical_payload_bytes(payload: &ModelSnapshotPayloadV1) -> Result<Vec<u8>, ManifestError> {
60    let value =
61        serde_json::to_value(payload).map_err(|_| ManifestError::InvalidModelSnapshotManifest)?;
62    validate_json_value(&value, 0, &mut 0)?;
63    let mut output = Vec::new();
64    write_jcs(&value, &mut output)?;
65    Ok(output)
66}
67
68/// Computes the schema-1 blueprint fingerprint over every field except itself.
69pub fn blueprint_fingerprint(
70    blueprint: &CompiledSafeModelBlueprint,
71) -> Result<Sha256Digest, ManifestError> {
72    let mut value =
73        serde_json::to_value(blueprint).map_err(|_| ManifestError::InvalidModelSnapshotManifest)?;
74    value
75        .as_object_mut()
76        .ok_or(ManifestError::InvalidModelSnapshotManifest)?
77        .remove("blueprint_fingerprint");
78    validate_json_value(&value, 0, &mut 0)?;
79    let mut canonical = Vec::new();
80    write_jcs(&value, &mut canonical)?;
81    let mut hasher = Sha256::new();
82    hasher.update(b"cookie-agent/model-blueprint/v1\0");
83    hasher.update(canonical);
84    Sha256Digest::new(format!("{:x}", hasher.finalize()))
85        .map_err(|_| ManifestError::InvalidModelSnapshotManifest)
86}
87
88pub struct FrozenBehaviorRef<'a> {
89    pub descriptor: &'a oven_sdk::LanguageModelDescriptor,
90    pub defaults: &'a FrozenResolvedRequestDefaults,
91    pub options: &'a FrozenProviderOptions,
92    pub static_headers: &'a BTreeMap<HeaderName, SafeStaticHeaderValue>,
93    pub behavior_fingerprint: &'a Sha256Digest,
94    pub selection_fingerprint: &'a Sha256Digest,
95}
96
97#[must_use]
98pub fn selected_behavior<'a>(
99    blueprint: &'a CompiledSafeModelBlueprint,
100    selection: &cookie_agent_identity::ModelSelection,
101) -> Option<FrozenBehaviorRef<'a>> {
102    if selection.model != blueprint.selection.model {
103        return None;
104    }
105    match selection.variant.as_ref() {
106        None => Some(FrozenBehaviorRef {
107            descriptor: &blueprint.descriptor,
108            defaults: &blueprint.defaults,
109            options: &blueprint.options,
110            static_headers: &blueprint.static_headers,
111            behavior_fingerprint: &blueprint.behavior_fingerprint,
112            selection_fingerprint: &blueprint.selection_fingerprint,
113        }),
114        Some(id) => blueprint
115            .variants
116            .iter()
117            .find(|variant| &variant.id == id)
118            .map(|variant| FrozenBehaviorRef {
119                descriptor: &variant.descriptor,
120                defaults: &variant.defaults,
121                options: &variant.options,
122                static_headers: &variant.static_headers,
123                behavior_fingerprint: &variant.behavior_fingerprint,
124                selection_fingerprint: &variant.selection_fingerprint,
125            }),
126    }
127}
128
129pub fn behavior_fingerprint(
130    blueprint: &CompiledSafeModelBlueprint,
131    selection: &cookie_agent_identity::ModelSelection,
132) -> Result<Sha256Digest, ManifestError> {
133    let behavior = selected_behavior(blueprint, selection)
134        .ok_or(ManifestError::InvalidModelSnapshotManifest)?;
135    hash_canonical(
136        b"cookie-agent/model-behavior/v1\0",
137        &serde_json::json!({
138            "selection": selection,
139            "source": blueprint.source,
140            "config_override_fingerprint": blueprint.config_override_fingerprint,
141            "setup_binding": blueprint.setup_binding,
142            "credential_binding": blueprint.credential_binding,
143            "endpoint_identity": blueprint.endpoint_identity,
144            "provider_recipe": blueprint.provider_recipe,
145            "protocol_recipe": blueprint.protocol_recipe,
146            "setup_recipe": blueprint.setup_recipe,
147            "auth_method": blueprint.auth_method,
148            "compiler_version": blueprint.compiler_version,
149            "descriptor": behavior.descriptor,
150            "defaults": behavior.defaults,
151            "options": behavior.options,
152            "static_headers": behavior.static_headers,
153        }),
154    )
155}
156
157pub fn selection_fingerprint(
158    blueprint: &CompiledSafeModelBlueprint,
159    selection: &cookie_agent_identity::ModelSelection,
160) -> Result<Sha256Digest, ManifestError> {
161    let behavior = selected_behavior(blueprint, selection)
162        .ok_or(ManifestError::InvalidModelSnapshotManifest)?;
163    hash_canonical(
164        b"cookie-agent/model-selection/v1\0",
165        &serde_json::json!({
166            "selection": selection,
167            "descriptor": behavior.descriptor,
168            "defaults": behavior.defaults,
169            "options": behavior.options,
170            "behavior_fingerprint": behavior.behavior_fingerprint,
171        }),
172    )
173}
174
175pub fn frozen_binding(
176    manifest_revision: ModelSnapshotRevision,
177    blueprint: &CompiledSafeModelBlueprint,
178    selection: cookie_agent_identity::ModelSelection,
179) -> Result<FrozenModelBinding, ManifestError> {
180    let behavior = selected_behavior(blueprint, &selection)
181        .ok_or(ManifestError::InvalidModelSnapshotManifest)?;
182    let binding = FrozenModelBinding {
183        manifest_revision,
184        blueprint_fingerprint: blueprint.blueprint_fingerprint.clone(),
185        selection,
186        source: blueprint.source.clone(),
187        config_override_fingerprint: blueprint.config_override_fingerprint.clone(),
188        credential_binding: blueprint.credential_binding.clone(),
189        setup_binding: blueprint.setup_binding.clone(),
190        endpoint_identity: blueprint.endpoint_identity.clone(),
191        provider_recipe: blueprint.provider_recipe.clone(),
192        protocol_recipe: blueprint.protocol_recipe.clone(),
193        setup_recipe: blueprint.setup_recipe.clone(),
194        compiler_version: blueprint.compiler_version.clone(),
195        descriptor: behavior.descriptor.clone(),
196        defaults: behavior.defaults.clone(),
197        options: behavior.options.clone(),
198        static_headers: behavior.static_headers.clone(),
199        behavior_fingerprint: behavior.behavior_fingerprint.clone(),
200        selection_fingerprint: behavior.selection_fingerprint.clone(),
201    };
202    binding
203        .validate()
204        .map_err(|_| ManifestError::InvalidModelSnapshotManifest)?;
205    Ok(binding)
206}
207
208fn hash_canonical(domain: &[u8], value: &Value) -> Result<Sha256Digest, ManifestError> {
209    validate_json_value(value, 0, &mut 0)?;
210    let mut canonical = Vec::new();
211    write_jcs(value, &mut canonical)?;
212    let mut hasher = Sha256::new();
213    hasher.update(domain);
214    hasher.update(canonical);
215    Sha256Digest::new(format!("{:x}", hasher.finalize()))
216        .map_err(|_| ManifestError::InvalidModelSnapshotManifest)
217}
218
219pub(crate) fn canonical_state_fingerprint(
220    domain: &[u8],
221    value: &impl serde::Serialize,
222) -> Result<crate::Sha256Digest, ManifestError> {
223    let value =
224        serde_json::to_value(value).map_err(|_| ManifestError::InvalidModelSnapshotManifest)?;
225    let digest = hash_canonical(domain, &value)?;
226    crate::Sha256Digest::new(digest.as_str().to_owned())
227        .map_err(|_| ManifestError::InvalidModelSnapshotManifest)
228}
229
230fn normalize_payload(payload: &mut ModelSnapshotPayloadV1) -> Result<(), ManifestError> {
231    for blueprint in &mut payload.blueprints {
232        blueprint
233            .credential_binding
234            .fields
235            .sort_by(|left, right| left.as_str().cmp(right.as_str()));
236        blueprint.credential_binding.owned_headers.sort();
237        blueprint
238            .variants
239            .sort_by(|left, right| left.id.cmp(&right.id));
240        let base_selection = blueprint.selection.clone();
241        blueprint.behavior_fingerprint = behavior_fingerprint(blueprint, &base_selection)?;
242        blueprint.selection_fingerprint = selection_fingerprint(blueprint, &base_selection)?;
243        for index in 0..blueprint.variants.len() {
244            let selection = cookie_agent_identity::ModelSelection {
245                model: blueprint.selection.model.clone(),
246                variant: Some(blueprint.variants[index].id.clone()),
247            };
248            let behavior = behavior_fingerprint(blueprint, &selection)?;
249            blueprint.variants[index].behavior_fingerprint = behavior;
250            let selection_fingerprint = selection_fingerprint(blueprint, &selection)?;
251            blueprint.variants[index].selection_fingerprint = selection_fingerprint;
252        }
253        blueprint.blueprint_fingerprint = blueprint_fingerprint(blueprint)?;
254    }
255    payload.blueprints.sort_by(|left, right| {
256        left.selection
257            .model
258            .cmp(&right.selection.model)
259            .then_with(|| left.selection.variant.cmp(&right.selection.variant))
260    });
261    Ok(())
262}
263
264fn validate_payload(payload: &ModelSnapshotPayloadV1) -> Result<(), ManifestError> {
265    if payload.blueprints.len() > MODEL_SNAPSHOT_MAX_FILES {
266        return Err(ManifestError::InvalidModelSnapshotManifest);
267    }
268    let mut models = BTreeSet::new();
269    let mut fingerprints = BTreeSet::new();
270    for blueprint in &payload.blueprints {
271        if !models.insert(blueprint.selection.model.clone())
272            || !fingerprints.insert(blueprint.blueprint_fingerprint.as_str().to_owned())
273            || blueprint.credential_binding.fields.len() > 32
274            || blueprint
275                .credential_binding
276                .fields
277                .windows(2)
278                .any(|pair| pair[0] >= pair[1])
279            || blueprint.credential_binding.owned_headers.len() > 32
280            || blueprint
281                .credential_binding
282                .owned_headers
283                .windows(2)
284                .any(|pair| pair[0] >= pair[1])
285            || blueprint.selection.variant.is_some()
286            || blueprint.setup_recipe != blueprint.setup_binding.setup_recipe
287            || blueprint.auth_method != blueprint.credential_binding.auth_method
288            || matches!(
289                &blueprint.source,
290                FrozenProviderSource::Managed { provider_recipe, .. }
291                    if provider_recipe != &blueprint.provider_recipe
292            )
293            || blueprint.descriptor.identity.provider_id.as_str()
294                != blueprint.selection.model.provider_id().as_str()
295            || blueprint.descriptor.identity.model_id.as_str()
296                != blueprint.selection.model.model_id().as_str()
297            || blueprint.defaults.validate().is_err()
298            || blueprint.variants.len() > 256
299            || blueprint
300                .variants
301                .windows(2)
302                .any(|pair| pair[0].id >= pair[1].id)
303            || blueprint.variants.iter().any(|variant| {
304                variant.descriptor.identity != blueprint.descriptor.identity
305                    || variant.defaults.validate().is_err()
306            })
307            || behavior_fingerprint(blueprint, &blueprint.selection)?
308                != blueprint.behavior_fingerprint
309            || selection_fingerprint(blueprint, &blueprint.selection)?
310                != blueprint.selection_fingerprint
311            || blueprint.variants.iter().any(|variant| {
312                let selection = cookie_agent_identity::ModelSelection {
313                    model: blueprint.selection.model.clone(),
314                    variant: Some(variant.id.clone()),
315                };
316                behavior_fingerprint(blueprint, &selection)
317                    .map_or(true, |value| value != variant.behavior_fingerprint)
318                    || selection_fingerprint(blueprint, &selection)
319                        .map_or(true, |value| value != variant.selection_fingerprint)
320            })
321            || blueprint_fingerprint(blueprint)? != blueprint.blueprint_fingerprint
322        {
323            return Err(ManifestError::InvalidModelSnapshotManifest);
324        }
325    }
326    Ok(())
327}
328
329fn validate_json_value(
330    value: &Value,
331    depth: usize,
332    items: &mut usize,
333) -> Result<(), ManifestError> {
334    if depth > MAX_JSON_DEPTH {
335        return Err(ManifestError::InvalidModelSnapshotManifest);
336    }
337    *items = items
338        .checked_add(1)
339        .ok_or(ManifestError::InvalidModelSnapshotManifest)?;
340    if *items > MAX_JSON_ITEMS {
341        return Err(ManifestError::InvalidModelSnapshotManifest);
342    }
343    match value {
344        Value::Number(number) => {
345            let valid = number
346                .as_i64()
347                .is_some_and(|value| value.unsigned_abs() <= MAX_IJSON_INTEGER)
348                || number
349                    .as_u64()
350                    .is_some_and(|value| value <= MAX_IJSON_INTEGER);
351            if !valid {
352                return Err(ManifestError::InvalidModelSnapshotManifest);
353            }
354        }
355        Value::Array(values) => {
356            for value in values {
357                validate_json_value(value, depth + 1, items)?;
358            }
359        }
360        Value::Object(values) => {
361            for value in values.values() {
362                validate_json_value(value, depth + 1, items)?;
363            }
364        }
365        Value::String(_) => {}
366        Value::Null | Value::Bool(_) => {}
367    }
368    Ok(())
369}
370
371fn write_jcs(value: &Value, output: &mut Vec<u8>) -> Result<(), ManifestError> {
372    match value {
373        Value::Null => output.extend_from_slice(b"null"),
374        Value::Bool(true) => output.extend_from_slice(b"true"),
375        Value::Bool(false) => output.extend_from_slice(b"false"),
376        Value::Number(number) => output.extend_from_slice(number.to_string().as_bytes()),
377        Value::String(value) => output.extend_from_slice(
378            serde_json::to_string(value)
379                .map_err(|_| ManifestError::InvalidModelSnapshotManifest)?
380                .as_bytes(),
381        ),
382        Value::Array(values) => {
383            output.push(b'[');
384            for (index, value) in values.iter().enumerate() {
385                if index > 0 {
386                    output.push(b',');
387                }
388                write_jcs(value, output)?;
389            }
390            output.push(b']');
391        }
392        Value::Object(values) => {
393            output.push(b'{');
394            let mut entries = values.iter().collect::<Vec<_>>();
395            entries.sort_by(|(left, _), (right, _)| compare_utf16(left, right));
396            for (index, (key, value)) in entries.into_iter().enumerate() {
397                if index > 0 {
398                    output.push(b',');
399                }
400                output.extend_from_slice(
401                    serde_json::to_string(key)
402                        .map_err(|_| ManifestError::InvalidModelSnapshotManifest)?
403                        .as_bytes(),
404                );
405                output.push(b':');
406                write_jcs(value, output)?;
407            }
408            output.push(b'}');
409        }
410    }
411    Ok(())
412}
413
414fn compare_utf16(left: &str, right: &str) -> Ordering {
415    left.encode_utf16().cmp(right.encode_utf16())
416}
417
418fn sha256_hex(bytes: &[u8]) -> String {
419    format!("{:x}", Sha256::digest(bytes))
420}
421
422#[derive(Debug, Error)]
423pub enum ManifestError {
424    #[error("invalid_model_snapshot_manifest")]
425    InvalidModelSnapshotManifest,
426    #[error("model_snapshot_digest_mismatch")]
427    ModelSnapshotDigestMismatch,
428    #[error("missing_model_snapshot_manifest")]
429    MissingModelSnapshotManifest,
430    #[error("model snapshot storage failed")]
431    Storage(#[from] SecureStoreError),
432}