bamboo-domain 2026.8.13

Domain models and shared types for the Bamboo agent framework
Documentation
use serde_json::Value;

pub fn validate_schema(schema: &Value, value: &Value) -> Result<(), String> {
    validate_schema_shape(schema, "$")?;
    validate_at(schema, value, "$")
}

pub fn validate_schema_shape(schema: &Value, path: &str) -> Result<(), String> {
    let object = schema
        .as_object()
        .ok_or_else(|| format!("{path}: schema must be an object"))?;
    const SUPPORTED: &[&str] = &[
        "type",
        "enum",
        "required",
        "properties",
        "additionalProperties",
        "items",
        "minimum",
        "maximum",
        "x-bamboo-secret",
    ];
    for key in object.keys() {
        if !SUPPORTED.contains(&key.as_str()) {
            return Err(format!("{path}: unsupported schema keyword '{key}'"));
        }
    }
    if object
        .get("x-bamboo-secret")
        .is_some_and(|value| !value.is_boolean())
    {
        return Err(format!("{path}: x-bamboo-secret must be boolean"));
    }
    if let Some(kind) = object.get("type") {
        let valid_kind = |kind: &str| {
            matches!(
                kind,
                "null" | "boolean" | "object" | "array" | "number" | "integer" | "string"
            )
        };
        match kind {
            Value::String(kind) if valid_kind(kind) => {}
            Value::Array(kinds)
                if !kinds.is_empty()
                    && kinds
                        .iter()
                        .all(|kind| kind.as_str().is_some_and(valid_kind)) => {}
            _ => return Err(format!("{path}: invalid or unsupported type")),
        }
    }
    if object.get("enum").is_some_and(|value| !value.is_array()) {
        return Err(format!("{path}: enum must be an array"));
    }
    if let Some(required) = object.get("required") {
        if !required
            .as_array()
            .is_some_and(|values| values.iter().all(Value::is_string))
        {
            return Err(format!("{path}: required must be an array of strings"));
        }
    }
    if let Some(properties) = object.get("properties") {
        let properties = properties
            .as_object()
            .ok_or_else(|| format!("{path}: properties must be an object"))?;
        for (key, child) in properties {
            validate_schema_shape(child, &format!("{path}/properties/{key}"))?;
        }
    }
    if object
        .get("additionalProperties")
        .is_some_and(|value| !value.is_boolean())
    {
        return Err(format!("{path}: additionalProperties must be boolean"));
    }
    if let Some(items) = object.get("items") {
        validate_schema_shape(items, &format!("{path}/items"))?;
    }
    for keyword in ["minimum", "maximum"] {
        if object.get(keyword).is_some_and(|value| !value.is_number()) {
            return Err(format!("{path}: {keyword} must be numeric"));
        }
    }
    Ok(())
}

fn validate_at(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
    let object = schema
        .as_object()
        .ok_or_else(|| format!("{path}: schema must be an object"))?;
    if object.get("x-bamboo-secret") == Some(&Value::Bool(true)) {
        let handle = value
            .as_object()
            .filter(|handle| handle.len() == 1)
            .and_then(|handle| handle.get("$secret"))
            .and_then(Value::as_str)
            .filter(|handle| !handle.trim().is_empty());
        return handle
            .map(|_| ())
            .ok_or_else(|| format!("{path}: secret input must be a typed capability handle"));
    }
    if let Some(types) = object.get("type") {
        let valid = match types {
            Value::String(kind) => matches_type(kind, value),
            Value::Array(kinds) => kinds
                .iter()
                .filter_map(Value::as_str)
                .any(|kind| matches_type(kind, value)),
            _ => false,
        };
        if !valid {
            return Err(format!("{path}: value does not match schema type"));
        }
    }
    if let Some(values) = object.get("enum").and_then(Value::as_array) {
        if !values.contains(value) {
            return Err(format!("{path}: value is not in enum"));
        }
    }
    if let Some(required) = object.get("required").and_then(Value::as_array) {
        let value_object = value
            .as_object()
            .ok_or_else(|| format!("{path}: required applies to an object"))?;
        for key in required.iter().filter_map(Value::as_str) {
            if !value_object.contains_key(key) {
                return Err(format!("{path}: required property '{key}' is missing"));
            }
        }
    }
    if let Some(value_object) = value.as_object() {
        let properties = object.get("properties").and_then(Value::as_object);
        for (key, child) in value_object {
            if let Some(child_schema) = properties.and_then(|schemas| schemas.get(key)) {
                validate_at(child_schema, child, &format!("{path}/{key}"))?;
            } else if object.get("additionalProperties") == Some(&Value::Bool(false)) {
                return Err(format!(
                    "{path}: additional property '{key}' is not allowed"
                ));
            }
        }
    }
    if let (Some(items), Some(values)) = (object.get("items"), value.as_array()) {
        for (index, child) in values.iter().enumerate() {
            validate_at(items, child, &format!("{path}/{index}"))?;
        }
    }
    if let Some(minimum) = object.get("minimum").and_then(Value::as_f64) {
        if value.as_f64().is_none_or(|number| number < minimum) {
            return Err(format!("{path}: value is below minimum"));
        }
    }
    if let Some(maximum) = object.get("maximum").and_then(Value::as_f64) {
        if value.as_f64().is_none_or(|number| number > maximum) {
            return Err(format!("{path}: value is above maximum"));
        }
    }
    Ok(())
}

fn matches_type(kind: &str, value: &Value) -> bool {
    match kind {
        "null" => value.is_null(),
        "boolean" => value.is_boolean(),
        "object" => value.is_object(),
        "array" => value.is_array(),
        "number" => value.is_number(),
        "integer" => value.as_i64().is_some() || value.as_u64().is_some(),
        "string" => value.is_string(),
        _ => false,
    }
}