Skip to main content

vv_agent/checkpoint/
canonical.rs

1use super::*;
2
3pub fn event_payload_digest(event: &Value) -> CheckpointResult<String> {
4    if !event.is_object() {
5        return Err(CheckpointError::new(
6            "event_payload_invalid",
7            "event payload must be an object",
8        ));
9    }
10    sha256_canonical(event, "event payload")
11}
12
13pub fn model_request_digest(request: &Value) -> CheckpointResult<String> {
14    operation_request_digest(OperationKind::Model, request)
15}
16
17pub fn tool_request_digest(
18    tool_call_id: &str,
19    tool_name: &str,
20    arguments: &Value,
21    idempotency_key: &str,
22) -> CheckpointResult<String> {
23    require_non_empty(tool_call_id, "tool_call_id")?;
24    require_non_empty(tool_name, "tool_name")?;
25    require_non_empty(idempotency_key, "idempotency_key")?;
26    if !arguments.is_object() {
27        return Err(CheckpointError::new(
28            "operation_request_invalid",
29            "tool arguments must be an object",
30        ));
31    }
32    operation_request_digest(
33        OperationKind::Tool,
34        &serde_json::json!({
35            "schema_version": OPERATION_REQUEST_SCHEMA,
36            "kind": "tool",
37            "request": {
38            "tool_call_id": tool_call_id,
39            "tool_name": tool_name,
40            "arguments": arguments,
41            "idempotency_key": idempotency_key,
42            },
43        }),
44    )
45}
46
47pub fn operation_request_digest(kind: OperationKind, request: &Value) -> CheckpointResult<String> {
48    let object = request.as_object().ok_or_else(|| {
49        CheckpointError::new(
50            "operation_request_invalid",
51            "operation request must be an object",
52        )
53    })?;
54    let expected = ["schema_version", "kind", "request"];
55    if object.len() != expected.len() || expected.iter().any(|field| !object.contains_key(*field)) {
56        return Err(CheckpointError::new(
57            "operation_request_invalid",
58            "operation request has missing or unknown fields",
59        ));
60    }
61    if object.get("schema_version").and_then(Value::as_str) != Some(OPERATION_REQUEST_SCHEMA) {
62        return Err(CheckpointError::new(
63            "operation_request_schema_unsupported",
64            "operation request schema is unsupported",
65        ));
66    }
67    let actual_kind = object.get("kind").and_then(Value::as_str).ok_or_else(|| {
68        CheckpointError::new(
69            "operation_request_invalid",
70            "operation request kind is invalid",
71        )
72    })?;
73    let expected_kind = match kind {
74        OperationKind::Model => "model",
75        OperationKind::Tool => "tool",
76    };
77    if actual_kind != expected_kind {
78        return Err(CheckpointError::new(
79            "operation_request_invalid",
80            "operation request kind does not match the journal operation",
81        ));
82    }
83    let request_payload = object
84        .get("request")
85        .and_then(Value::as_object)
86        .ok_or_else(|| {
87            CheckpointError::new(
88                "operation_request_invalid",
89                "operation request payload must be an object",
90            )
91        })?;
92    let required: &[&str] = match kind {
93        OperationKind::Model => &[
94            "model",
95            "messages",
96            "settings",
97            "tools",
98            "output_schema",
99            "idempotency_key",
100        ],
101        OperationKind::Tool => &["tool_call_id", "tool_name", "arguments", "idempotency_key"],
102    };
103    if request_payload.len() != required.len()
104        || required
105            .iter()
106            .any(|field| !request_payload.contains_key(*field))
107    {
108        return Err(CheckpointError::new(
109            "operation_request_invalid",
110            "operation request payload has missing or unknown fields",
111        ));
112    }
113    validate_i_json(request, "operation request")
114        .map_err(|error| CheckpointError::new("operation_request_not_i_json", error.message))?;
115    sha256_canonical(request, "operation request")
116}
117
118pub fn canonical_json_bytes(value: &Value, field_name: &str) -> CheckpointResult<Vec<u8>> {
119    validate_i_json(value, field_name)?;
120    serde_json_canonicalizer::to_vec(value).map_err(|error| {
121        CheckpointError::new(
122            "checkpoint_canonicalization_invalid",
123            format!("{field_name} cannot be canonicalized: {error}"),
124        )
125    })
126}
127
128pub fn run_definition_digest(definition: &Value) -> CheckpointResult<String> {
129    validate_run_definition(definition)?;
130    sha256_canonical(definition, "run_definition")
131}
132
133pub fn canonical_run_definition_bytes(definition: &Value) -> CheckpointResult<Vec<u8>> {
134    validate_run_definition(definition)?;
135    canonical_json_bytes(definition, "run_definition")
136}
137
138pub fn validate_run_definition(definition: &Value) -> CheckpointResult<()> {
139    validate_i_json(definition, "run_definition")?;
140    let object = definition.as_object().ok_or_else(|| {
141        CheckpointError::new(
142            "checkpoint_definition_invalid",
143            "run_definition must be an object",
144        )
145    })?;
146    let required = [
147        "schema_version",
148        "agent",
149        "root_input",
150        "compiled_prompt",
151        "initial_messages",
152        "initial_shared_state",
153        "run_metadata",
154        "context_ref",
155        "model",
156        "credential_slots",
157        "runtime_controls",
158        "tools",
159        "tool_policy",
160        "checkpoint_policy",
161        "budget_limits",
162        "output_schema",
163        "workspace_ref",
164        "session_ref",
165        "extensions",
166        "capability_refs",
167    ];
168    let required_set = required.iter().copied().collect::<BTreeSet<_>>();
169    if object
170        .keys()
171        .any(|key| !required_set.contains(key.as_str()))
172        || required.iter().any(|key| !object.contains_key(*key))
173    {
174        return Err(CheckpointError::new(
175            "checkpoint_definition_invalid",
176            "run_definition has missing or unknown top-level fields",
177        ));
178    }
179    if object.get("schema_version").and_then(Value::as_str) != Some(RUN_DEFINITION_SCHEMA) {
180        return Err(CheckpointError::new(
181            "checkpoint_definition_schema_unsupported",
182            "run_definition schema_version is unsupported",
183        ));
184    }
185    require_object(object.get("agent"), "run_definition.agent")?;
186    require_string(object.get("root_input"), "run_definition.root_input")?;
187    require_string(
188        object.get("compiled_prompt"),
189        "run_definition.compiled_prompt",
190    )?;
191    require_array(
192        object.get("initial_messages"),
193        "run_definition.initial_messages",
194    )?;
195    require_object(
196        object.get("initial_shared_state"),
197        "run_definition.initial_shared_state",
198    )?;
199    require_object(object.get("run_metadata"), "run_definition.run_metadata")?;
200    require_object(object.get("model"), "run_definition.model")?;
201    require_object(
202        object.get("runtime_controls"),
203        "run_definition.runtime_controls",
204    )?;
205    require_array(object.get("tools"), "run_definition.tools")?;
206    require_object(object.get("tool_policy"), "run_definition.tool_policy")?;
207    require_object(
208        object.get("checkpoint_policy"),
209        "run_definition.checkpoint_policy",
210    )?;
211    require_object(
212        object.get("capability_refs"),
213        "run_definition.capability_refs",
214    )?;
215
216    validate_optional_object_or_null(object.get("context_ref"), "run_definition.context_ref")?;
217    validate_optional_object_or_null(object.get("budget_limits"), "run_definition.budget_limits")?;
218    validate_optional_object_or_null(object.get("output_schema"), "run_definition.output_schema")?;
219    validate_optional_object_or_null(object.get("workspace_ref"), "run_definition.workspace_ref")?;
220    validate_optional_object_or_null(object.get("session_ref"), "run_definition.session_ref")?;
221
222    let slots = require_array(
223        object.get("credential_slots"),
224        "run_definition.credential_slots",
225    )?;
226    validate_credential_slots(definition, slots)?;
227    validate_capability_refs(
228        object.get("capability_refs"),
229        "run_definition.capability_refs",
230    )?;
231    validate_extensions(object.get("extensions"), "run_definition.extensions")?;
232    validate_tool_policy(object.get("tool_policy"))?;
233    validate_checkpoint_policy(object.get("checkpoint_policy"))?;
234    validate_header_names(object.get("model"))?;
235    Ok(())
236}
237
238/// Normalize field-specific sets in a definition, lower-case provider header
239/// names, redact declared credential slots, and validate the result.
240pub fn normalize_run_definition(
241    definition: &Value,
242    credential_slots: &[String],
243) -> CheckpointResult<Value> {
244    let mut normalized = definition.clone();
245    let object = normalized.as_object_mut().ok_or_else(|| {
246        CheckpointError::new(
247            "checkpoint_definition_invalid",
248            "run_definition must be an object",
249        )
250    })?;
251    let slots = credential_slots
252        .iter()
253        .cloned()
254        .map(Value::String)
255        .collect::<Vec<_>>();
256    object.insert("credential_slots".to_string(), Value::Array(slots));
257    normalize_headers(object.get_mut("model"))?;
258    normalize_tool_policy(object.get_mut("tool_policy"))?;
259    normalize_extensions(object.get_mut("extensions"))?;
260    let normalized = redact_run_definition(&Value::Object(object.clone()), credential_slots)?;
261    validate_run_definition(&normalized)?;
262    Ok(normalized)
263}
264
265pub fn redact_run_definition(
266    definition: &Value,
267    credential_slots: &[String],
268) -> CheckpointResult<Value> {
269    let mut redacted = definition.clone();
270    let mut previous: Option<&str> = None;
271    for slot in credential_slots {
272        if let Some(previous_slot) = previous {
273            if utf16_cmp(previous_slot, slot) != Ordering::Less {
274                return Err(CheckpointError::new(
275                    "checkpoint_credential_slots_invalid",
276                    "credential slots must be sorted and unique",
277                ));
278            }
279        }
280        validate_pointer(slot)?;
281        set_json_pointer(
282            &mut redacted,
283            slot,
284            Value::String(CREDENTIAL_REDACTED.to_string()),
285        )?;
286        previous = Some(slot);
287    }
288    Ok(redacted)
289}
290
291pub fn resolve_json_pointer<'a>(value: &'a Value, pointer: &str) -> CheckpointResult<&'a Value> {
292    let tokens = pointer_tokens(pointer)?;
293    let mut current = value;
294    for token in tokens {
295        current = match current {
296            Value::Object(object) => object.get(&token).ok_or_else(|| {
297                CheckpointError::new(
298                    "checkpoint_credential_slot_unresolved",
299                    format!("JSON pointer {pointer} does not resolve"),
300                )
301            })?,
302            Value::Array(array) => {
303                let index = token.parse::<usize>().map_err(|_| {
304                    CheckpointError::new(
305                        "checkpoint_credential_slot_unresolved",
306                        format!("JSON pointer {pointer} has an invalid array index"),
307                    )
308                })?;
309                array.get(index).ok_or_else(|| {
310                    CheckpointError::new(
311                        "checkpoint_credential_slot_unresolved",
312                        format!("JSON pointer {pointer} does not resolve"),
313                    )
314                })?
315            }
316            _ => {
317                return Err(CheckpointError::new(
318                    "checkpoint_credential_slot_unresolved",
319                    format!("JSON pointer {pointer} does not resolve"),
320                ));
321            }
322        };
323    }
324    Ok(current)
325}
326
327pub fn set_json_pointer(
328    value: &mut Value,
329    pointer: &str,
330    replacement: Value,
331) -> CheckpointResult<()> {
332    let tokens = pointer_tokens(pointer)?;
333    if tokens.is_empty() {
334        *value = replacement;
335        return Ok(());
336    }
337    let mut current = value;
338    for token in &tokens[..tokens.len() - 1] {
339        current = match current {
340            Value::Object(object) => object.get_mut(token).ok_or_else(|| {
341                CheckpointError::new(
342                    "checkpoint_credential_slot_unresolved",
343                    format!("JSON pointer {pointer} does not resolve"),
344                )
345            })?,
346            Value::Array(array) => {
347                let index = token.parse::<usize>().map_err(|_| {
348                    CheckpointError::new(
349                        "checkpoint_credential_slot_unresolved",
350                        format!("JSON pointer {pointer} has an invalid array index"),
351                    )
352                })?;
353                array.get_mut(index).ok_or_else(|| {
354                    CheckpointError::new(
355                        "checkpoint_credential_slot_unresolved",
356                        format!("JSON pointer {pointer} does not resolve"),
357                    )
358                })?
359            }
360            _ => {
361                return Err(CheckpointError::new(
362                    "checkpoint_credential_slot_unresolved",
363                    format!("JSON pointer {pointer} does not resolve"),
364                ));
365            }
366        };
367    }
368    let last = tokens.last().expect("non-empty pointer tokens");
369    match current {
370        Value::Object(object) => {
371            if !object.contains_key(last) {
372                return Err(CheckpointError::new(
373                    "checkpoint_credential_slot_unresolved",
374                    format!("JSON pointer {pointer} does not resolve"),
375                ));
376            }
377            object.insert(last.clone(), replacement);
378        }
379        Value::Array(array) => {
380            let index = last.parse::<usize>().map_err(|_| {
381                CheckpointError::new(
382                    "checkpoint_credential_slot_unresolved",
383                    format!("JSON pointer {pointer} has an invalid array index"),
384                )
385            })?;
386            let Some(item) = array.get_mut(index) else {
387                return Err(CheckpointError::new(
388                    "checkpoint_credential_slot_unresolved",
389                    format!("JSON pointer {pointer} does not resolve"),
390                ));
391            };
392            *item = replacement;
393        }
394        _ => {
395            return Err(CheckpointError::new(
396                "checkpoint_credential_slot_unresolved",
397                format!("JSON pointer {pointer} does not resolve"),
398            ));
399        }
400    }
401    Ok(())
402}
403
404pub fn validate_extension_namespace(namespace: &str) -> CheckpointResult<()> {
405    if namespace.is_empty() || !namespace.is_ascii() {
406        return Err(CheckpointError::new(
407            "checkpoint_extension_namespace_invalid",
408            "extension namespace must be non-empty ASCII",
409        ));
410    }
411    if namespace.len() > MAX_EXTENSION_NAMESPACE_BYTES {
412        return Err(CheckpointError::new(
413            "checkpoint_extension_namespace_invalid",
414            format!("extension namespace exceeds {MAX_EXTENSION_NAMESPACE_BYTES} bytes"),
415        ));
416    }
417    let Some(first) = namespace.as_bytes().first().copied() else {
418        unreachable!("empty namespace handled above");
419    };
420    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
421        return Err(CheckpointError::new(
422            "checkpoint_extension_namespace_invalid",
423            "extension namespace must begin with a lowercase letter or digit",
424        ));
425    }
426    if !namespace.contains('.')
427        || namespace.bytes().any(|byte| {
428            !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte))
429        })
430    {
431        return Err(CheckpointError::new(
432            "checkpoint_extension_namespace_invalid",
433            "extension namespace does not match the reverse-DNS grammar",
434        ));
435    }
436    Ok(())
437}
438
439pub fn validate_sha256(value: &str, field_name: &str) -> CheckpointResult<()> {
440    if value.len() != 64
441        || value
442            .bytes()
443            .any(|byte| !byte.is_ascii_hexdigit() || byte.is_ascii_uppercase())
444    {
445        return Err(CheckpointError::new(
446            "checkpoint_digest_invalid",
447            format!("{field_name} must be a lowercase SHA-256 hex digest"),
448        ));
449    }
450    Ok(())
451}
452
453pub fn validate_checkpoint_key(key: &str) -> CheckpointResult<()> {
454    if key.trim().is_empty() || key.len() > MAX_CHECKPOINT_KEY_BYTES {
455        return Err(CheckpointError::new(
456            "checkpoint_key_invalid",
457            format!(
458                "checkpoint key must be non-empty and at most {MAX_CHECKPOINT_KEY_BYTES} UTF-8 bytes"
459            ),
460        ));
461    }
462    Ok(())
463}
464
465fn sha256_canonical(value: &Value, field_name: &str) -> CheckpointResult<String> {
466    let bytes = canonical_json_bytes(value, field_name)?;
467    Ok(format!("{:x}", Sha256::digest(bytes)))
468}
469
470pub(super) fn validate_i_json(value: &Value, field_name: &str) -> CheckpointResult<()> {
471    match value {
472        Value::Null | Value::Bool(_) | Value::String(_) => Ok(()),
473        Value::Number(number) => validate_number(number, field_name),
474        Value::Array(items) => items
475            .iter()
476            .enumerate()
477            .try_for_each(|(index, item)| validate_i_json(item, &format!("{field_name}[{index}]"))),
478        Value::Object(object) => object
479            .iter()
480            .try_for_each(|(key, item)| validate_i_json(item, &format!("{field_name}.{key}"))),
481    }
482}
483
484fn validate_number(number: &Number, field_name: &str) -> CheckpointResult<()> {
485    if let Some(value) = number.as_u64() {
486        if value > MAX_WIRE_INTEGER {
487            return Err(CheckpointError::new(
488                "checkpoint_definition_not_i_json",
489                format!("{field_name} is outside the JSON-safe integer range"),
490            ));
491        }
492    } else if let Some(value) = number.as_i64() {
493        if value.unsigned_abs() > MAX_WIRE_INTEGER {
494            return Err(CheckpointError::new(
495                "checkpoint_definition_not_i_json",
496                format!("{field_name} is outside the JSON-safe integer range"),
497            ));
498        }
499    } else if number.as_f64().is_none_or(|value| !value.is_finite()) {
500        return Err(CheckpointError::new(
501            "checkpoint_definition_not_i_json",
502            format!("{field_name} is not a finite JSON number"),
503        ));
504    }
505    Ok(())
506}
507
508pub(super) fn validate_capability_ref(
509    reference: &CapabilityRef,
510    field_name: &str,
511) -> CheckpointResult<()> {
512    if reference.id.trim().is_empty() || reference.version.trim().is_empty() {
513        return Err(CheckpointError::new(
514            "checkpoint_capability_ref_invalid",
515            format!("{field_name} requires non-empty id and version"),
516        ));
517    }
518    Ok(())
519}
520
521pub(super) fn validate_capability_slot(slot: &str) -> CheckpointResult<()> {
522    if slot.is_empty()
523        || !slot.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
524        || slot.bytes().any(|byte| {
525            !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_.:-".contains(&byte))
526        })
527    {
528        return Err(CheckpointError::new(
529            "checkpoint_capability_ref_invalid",
530            format!("invalid capability reference slot {slot}"),
531        ));
532    }
533    Ok(())
534}
535
536fn validate_capability_refs(value: Option<&Value>, field_name: &str) -> CheckpointResult<()> {
537    let object = require_object(value, field_name)?;
538    for (slot, reference) in object {
539        validate_capability_slot(slot)?;
540        let reference_object = require_object(Some(reference), &format!("{field_name}.{slot}"))?;
541        if reference_object.len() != 2
542            || !reference_object.contains_key("id")
543            || !reference_object.contains_key("version")
544        {
545            return Err(CheckpointError::new(
546                "checkpoint_capability_ref_invalid",
547                format!("{field_name}.{slot} must contain exactly id and version"),
548            ));
549        }
550        require_string(reference_object.get("id"), "capability reference id")?;
551        require_string(
552            reference_object.get("version"),
553            "capability reference version",
554        )?;
555    }
556    Ok(())
557}
558
559fn validate_extensions(value: Option<&Value>, field_name: &str) -> CheckpointResult<()> {
560    let extensions = require_array(value, field_name)?;
561    let mut previous: Option<&str> = None;
562    for extension in extensions {
563        let object = require_object(Some(extension), "run_definition extension")?;
564        let namespace = require_string(object.get("namespace"), "extension namespace")?;
565        validate_extension_namespace(namespace)?;
566        require_string(object.get("version"), "extension version")?;
567        if !object.get("required").is_some_and(Value::is_boolean) {
568            return Err(CheckpointError::new(
569                "checkpoint_definition_invalid",
570                "extension required must be boolean",
571            ));
572        }
573        if let Some(previous) = previous {
574            if previous >= namespace {
575                return Err(CheckpointError::new(
576                    "checkpoint_definition_invalid",
577                    "extensions must be sorted and unique by namespace",
578                ));
579            }
580        }
581        previous = Some(namespace);
582    }
583    Ok(())
584}
585
586fn validate_tool_policy(value: Option<&Value>) -> CheckpointResult<()> {
587    let object = require_object(value, "run_definition.tool_policy")?;
588    if let Some(allowed) = object.get("allowed_tools") {
589        if !allowed.is_null() {
590            validate_sorted_unique_strings(Some(allowed), "tool_policy.allowed_tools")?;
591        }
592    }
593    validate_sorted_unique_strings(
594        object.get("disallowed_tools"),
595        "tool_policy.disallowed_tools",
596    )?;
597    if let Some(predicate) = object.get("predicate_ref") {
598        if !predicate.is_null() {
599            validate_ref_value(predicate, "tool_policy.predicate_ref")?;
600        }
601    }
602    Ok(())
603}
604
605fn validate_checkpoint_policy(value: Option<&Value>) -> CheckpointResult<()> {
606    let object = require_object(value, "run_definition.checkpoint_policy")?;
607    for key in ["ambiguous_model_policy", "ambiguous_tool_policy"] {
608        require_string(object.get(key), &format!("checkpoint_policy.{key}"))?;
609    }
610    let max = object.get("max_extension_state_bytes").ok_or_else(|| {
611        CheckpointError::new(
612            "checkpoint_definition_invalid",
613            "checkpoint_policy.max_extension_state_bytes is required",
614        )
615    })?;
616    let Some(max) = max.as_u64() else {
617        return Err(CheckpointError::new(
618            "checkpoint_definition_not_i_json",
619            "max_extension_state_bytes must be a safe integer",
620        ));
621    };
622    if max > MAX_WIRE_INTEGER {
623        return Err(CheckpointError::new(
624            "checkpoint_definition_not_i_json",
625            "max_extension_state_bytes exceeds the safe integer range",
626        ));
627    }
628    Ok(())
629}
630
631fn validate_header_names(model: Option<&Value>) -> CheckpointResult<()> {
632    let Some(model) = model.and_then(Value::as_object) else {
633        return Ok(());
634    };
635    let Some(settings) = model.get("settings").and_then(Value::as_object) else {
636        return Ok(());
637    };
638    let Some(headers) = settings.get("extra_headers").and_then(Value::as_object) else {
639        return Ok(());
640    };
641    let mut normalized = BTreeSet::new();
642    for name in headers.keys() {
643        let lower = name.to_ascii_lowercase();
644        if !normalized.insert(lower) {
645            return Err(CheckpointError::new(
646                "checkpoint_definition_header_collision",
647                "header names collide after ASCII lowercasing",
648            ));
649        }
650    }
651    Ok(())
652}
653
654fn validate_credential_slots(definition: &Value, slots: &[Value]) -> CheckpointResult<()> {
655    let mut previous: Option<&str> = None;
656    for slot in slots {
657        let slot = slot.as_str().ok_or_else(|| {
658            CheckpointError::new(
659                "checkpoint_credential_slots_invalid",
660                "credential slots must be strings",
661            )
662        })?;
663        if let Some(previous) = previous {
664            if utf16_cmp(previous, slot) != Ordering::Less {
665                return Err(CheckpointError::new(
666                    "checkpoint_credential_slots_invalid",
667                    "credential slots must be sorted and unique",
668                ));
669            }
670        }
671        validate_pointer(slot)?;
672        let resolved = resolve_json_pointer(definition, slot)?;
673        if resolved.as_str() != Some(CREDENTIAL_REDACTED) {
674            return Err(CheckpointError::new(
675                "checkpoint_credential_value_not_redacted",
676                format!("credential slot {slot} is not redacted"),
677            ));
678        }
679        previous = Some(slot);
680    }
681    Ok(())
682}
683
684fn validate_sorted_unique_strings(value: Option<&Value>, field_name: &str) -> CheckpointResult<()> {
685    let values = require_array(value, field_name)?;
686    let mut previous: Option<&str> = None;
687    for value in values {
688        let string = require_string(Some(value), field_name)?;
689        if let Some(previous) = previous {
690            if utf16_cmp(previous, string) != Ordering::Less {
691                return Err(CheckpointError::new(
692                    "checkpoint_definition_invalid",
693                    format!("{field_name} must be sorted and unique"),
694                ));
695            }
696        }
697        previous = Some(string);
698    }
699    Ok(())
700}
701
702fn validate_ref_value(value: &Value, field_name: &str) -> CheckpointResult<()> {
703    let object = require_object(Some(value), field_name)?;
704    if object.len() != 2 || !object.contains_key("id") || !object.contains_key("version") {
705        return Err(CheckpointError::new(
706            "checkpoint_capability_ref_invalid",
707            format!("{field_name} must contain exactly id and version"),
708        ));
709    }
710    require_string(object.get("id"), &format!("{field_name}.id"))?;
711    require_string(object.get("version"), &format!("{field_name}.version"))?;
712    Ok(())
713}
714
715pub(super) fn validate_pointer(pointer: &str) -> CheckpointResult<()> {
716    pointer_tokens(pointer).map(|_| ())
717}
718
719fn pointer_tokens(pointer: &str) -> CheckpointResult<Vec<String>> {
720    if pointer.is_empty() {
721        return Ok(Vec::new());
722    }
723    if !pointer.starts_with('/') {
724        return Err(CheckpointError::new(
725            "checkpoint_credential_slots_invalid",
726            "JSON pointer must be empty or start with '/'",
727        ));
728    }
729    pointer
730        .split('/')
731        .skip(1)
732        .map(|raw| {
733            let mut token = String::with_capacity(raw.len());
734            let bytes = raw.as_bytes();
735            let mut index = 0;
736            while index < bytes.len() {
737                if bytes[index] != b'~' {
738                    let character = raw[index..].chars().next().expect("valid UTF-8");
739                    token.push(character);
740                    index += character.len_utf8();
741                    continue;
742                }
743                if index + 1 >= bytes.len() {
744                    return Err(CheckpointError::new(
745                        "checkpoint_credential_slots_invalid",
746                        "JSON pointer contains an invalid escape",
747                    ));
748                }
749                match bytes[index + 1] {
750                    b'0' => token.push('~'),
751                    b'1' => token.push('/'),
752                    _ => {
753                        return Err(CheckpointError::new(
754                            "checkpoint_credential_slots_invalid",
755                            "JSON pointer contains an invalid escape",
756                        ));
757                    }
758                }
759                index += 2;
760            }
761            Ok(token)
762        })
763        .collect()
764}
765
766fn normalize_headers(model: Option<&mut Value>) -> CheckpointResult<()> {
767    let Some(model) = model.and_then(Value::as_object_mut) else {
768        return Ok(());
769    };
770    let Some(settings) = model.get_mut("settings").and_then(Value::as_object_mut) else {
771        return Ok(());
772    };
773    let Some(headers) = settings
774        .get_mut("extra_headers")
775        .and_then(Value::as_object_mut)
776    else {
777        return Ok(());
778    };
779    let mut normalized = Map::new();
780    for (name, value) in std::mem::take(headers) {
781        let lower = name.to_ascii_lowercase();
782        if normalized.insert(lower, value).is_some() {
783            return Err(CheckpointError::new(
784                "checkpoint_definition_header_collision",
785                "header names collide after ASCII lowercasing",
786            ));
787        }
788    }
789    *headers = normalized;
790    Ok(())
791}
792
793fn normalize_tool_policy(policy: Option<&mut Value>) -> CheckpointResult<()> {
794    let Some(policy) = policy.and_then(Value::as_object_mut) else {
795        return Ok(());
796    };
797    for key in ["allowed_tools", "disallowed_tools"] {
798        let Some(values) = policy.get_mut(key).and_then(Value::as_array_mut) else {
799            continue;
800        };
801        let mut strings = values
802            .iter()
803            .map(|value| {
804                value.as_str().map(str::to_string).ok_or_else(|| {
805                    CheckpointError::new(
806                        "checkpoint_definition_invalid",
807                        format!("tool policy {key} must contain strings"),
808                    )
809                })
810            })
811            .collect::<CheckpointResult<Vec<_>>>()?;
812        strings.sort_by(|left, right| utf16_cmp(left, right));
813        strings.dedup();
814        *values = strings.into_iter().map(Value::String).collect();
815    }
816    Ok(())
817}
818
819fn normalize_extensions(extensions: Option<&mut Value>) -> CheckpointResult<()> {
820    let Some(extensions) = extensions.and_then(Value::as_array_mut) else {
821        return Ok(());
822    };
823    extensions.sort_by(|left, right| {
824        left.get("namespace")
825            .and_then(Value::as_str)
826            .cmp(&right.get("namespace").and_then(Value::as_str))
827    });
828    Ok(())
829}
830
831pub(super) fn require_non_empty(value: &str, field_name: &str) -> CheckpointResult<()> {
832    if value.trim().is_empty() {
833        return Err(CheckpointError::new(
834            "checkpoint_value_invalid",
835            format!("{field_name} must be non-empty"),
836        ));
837    }
838    Ok(())
839}
840
841pub(super) fn require_positive(value: u64, field_name: &str) -> CheckpointResult<()> {
842    if value == 0 || value > MAX_WIRE_INTEGER {
843        return Err(CheckpointError::new(
844            "checkpoint_integer_invalid",
845            format!("{field_name} must be between 1 and {MAX_WIRE_INTEGER}"),
846        ));
847    }
848    Ok(())
849}
850
851fn require_string<'a>(value: Option<&'a Value>, field_name: &str) -> CheckpointResult<&'a str> {
852    value.and_then(Value::as_str).ok_or_else(|| {
853        CheckpointError::new(
854            "checkpoint_definition_invalid",
855            format!("{field_name} must be a string"),
856        )
857    })
858}
859
860fn require_object<'a>(
861    value: Option<&'a Value>,
862    field_name: &str,
863) -> CheckpointResult<&'a Map<String, Value>> {
864    value.and_then(Value::as_object).ok_or_else(|| {
865        CheckpointError::new(
866            "checkpoint_definition_invalid",
867            format!("{field_name} must be an object"),
868        )
869    })
870}
871
872fn require_array<'a>(
873    value: Option<&'a Value>,
874    field_name: &str,
875) -> CheckpointResult<&'a Vec<Value>> {
876    value.and_then(Value::as_array).ok_or_else(|| {
877        CheckpointError::new(
878            "checkpoint_definition_invalid",
879            format!("{field_name} must be an array"),
880        )
881    })
882}
883
884fn validate_optional_object_or_null(
885    value: Option<&Value>,
886    field_name: &str,
887) -> CheckpointResult<()> {
888    if value.is_some_and(|value| !value.is_object() && !value.is_null()) {
889        return Err(CheckpointError::new(
890            "checkpoint_definition_invalid",
891            format!("{field_name} must be an object or null"),
892        ));
893    }
894    Ok(())
895}
896
897pub(super) fn utf16_cmp(left: &str, right: &str) -> Ordering {
898    left.encode_utf16().cmp(right.encode_utf16())
899}