behavior-contracts 0.2.3

Language-neutral IR runtime core (expression evaluation, template rendering, execution plan, canonical serialization) shared across DSL implementations. Passes the dsl-contracts conformance vectors byte-for-byte.
Documentation
//! guard — Portability Guard (`assert_portable`).
//!
//! DSL-agnostic IR portability invariant: a portable IR must not contain
//! non-serializable values. In Rust the runtime works over `serde_json::Value`
//! (already JSON-serializable) plus the native [`crate::value::Value`], so the
//! only structurally non-portable case is a non-finite float (NaN/±Inf) which
//! cannot be represented in JSON. `assert_portable` walks a value and rejects it.

use crate::expr::FORBIDDEN_OBJECT_KEY;
use crate::value::Value;
use serde_json::Value as J;

#[derive(Debug, Clone)]
pub struct PortabilityError {
    pub path: String,
    pub message: String,
}

impl std::fmt::Display for PortabilityError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Portability Guard at {}: {}", self.path, self.message)
    }
}
impl std::error::Error for PortabilityError {}

/// Assert that a runtime [`Value`] is portable (JSON-serializable). The only
/// non-portable scalar in the Rust value model is a non-finite float.
pub fn assert_portable(value: &Value) -> Result<(), PortabilityError> {
    assert_portable_at(value, "$")
}

fn assert_portable_at(value: &Value, path: &str) -> Result<(), PortabilityError> {
    match value {
        Value::Float(f) if !f.is_finite() => Err(PortabilityError {
            path: path.to_string(),
            message: format!("non-finite float {f} cannot be placed in portable IR"),
        }),
        Value::Arr(a) => {
            for (i, v) in a.iter().enumerate() {
                assert_portable_at(v, &format!("{path}[{i}]"))?;
            }
            Ok(())
        }
        Value::Obj(o) => {
            for (k, v) in o {
                assert_portable_at(v, &format!("{path}.{k}"))?;
            }
            Ok(())
        }
        _ => Ok(()),
    }
}

/// The closed set of Expression-IR operators allowed in a portable IR (expression-ir.md §3).
pub const PORTABLE_EXPR_OPERATORS: &[&str] = &[
    "int", "float", "ref", "refOpt", "obj", "arr", "add", "sub", "mul", "neg", "div", "mod",
    "concat", "eq", "ne", "lt", "le", "gt", "ge", "and", "or", "not", "coalesce", "cond", "len",
];

fn perr<T>(path: &str, message: impl Into<String>) -> Result<T, PortabilityError> {
    Err(PortabilityError {
        path: path.to_string(),
        message: message.into(),
    })
}

/// The closed set of scalar types in the portable type notation
/// (scp-ir-architecture.md §5.2, bc#44 B0). Compound types are the single-key
/// objects `{opt|arr|obj: ...}`.
pub const PORTABLE_SCALAR_TYPES: &[&str] = &["string", "int", "float", "bool", "null"];

/// Validate a portable type-notation node (§5.2) with the same fail-closed
/// strength as the Expression IR guard. Closed set: a scalar-type string
/// ([`PORTABLE_SCALAR_TYPES`], no `"any"` escape hatch); `{opt: T}` / `{arr: T}`
/// (single-key object, value a type `T`); `{obj: {field: T, ...}}` (single-key
/// object, value a field->type map with the `__proto__` own key forbidden).
fn assert_portable_type_notation(node: &J, path: &str) -> Result<(), PortabilityError> {
    match node {
        J::String(s) => {
            if !PORTABLE_SCALAR_TYPES.contains(&s.as_str()) {
                return perr(path, format!("unknown scalar type '{s}' (fail-closed)"));
            }
            Ok(())
        }
        J::Object(o) => {
            if o.len() != 1 {
                return perr(
                    path,
                    format!(
                        "compound type must be a single-key object (opt|arr|obj), got {} keys (fail-closed)",
                        o.len()
                    ),
                );
            }
            let (kind, arg) = o.iter().next().unwrap();
            match kind.as_str() {
                "opt" | "arr" => assert_portable_type_notation(arg, &format!("{path}.{kind}")),
                "obj" => {
                    let Some(fields) = arg.as_object() else {
                        return perr(
                            &format!("{path}.obj"),
                            "{obj: ...} type expects a field->type object (fail-closed)".to_string(),
                        );
                    };
                    for (k, v) in fields {
                        if k == FORBIDDEN_OBJECT_KEY {
                            return perr(
                                &format!("{path}.obj"),
                                format!("object type key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
                            );
                        }
                        assert_portable_type_notation(v, &format!("{path}.obj.{k}"))?;
                    }
                    Ok(())
                }
                other => perr(
                    path,
                    format!("unknown compound type kind '{other}' (opt|arr|obj) (fail-closed)"),
                ),
            }
        }
        _ => perr(
            path,
            "type notation must be a scalar-type string or a single-key {opt|arr|obj} object (fail-closed)".to_string(),
        ),
    }
}

/// Portability Guard for a component-graph IR (scp-ir-architecture.md §5), operating
/// on the raw `serde_json::Value` IR (P0-2). Enforces, fail-closed:
///   1. `component` (componentRef / map) is a string catalog reference only.
///   2. every Expression-IR node in ports / output / map.over / cond uses only the
///      known operator closed set ([`PORTABLE_EXPR_OPERATORS`]).
///   3. any type annotation (`outType` / `outputType`, bc#44 B0) matches the
///      portable type-notation closed set (§5.2). Annotations are additive: an IR
///      without them validates identically to before B0.
pub fn assert_portable_component_graph(ir: &J) -> Result<(), PortabilityError> {
    assert_portable_cg_at(ir, "$")
}

fn assert_portable_cg_at(ir: &J, path: &str) -> Result<(), PortabilityError> {
    let components = ir
        .get("components")
        .and_then(|c| c.as_array())
        .ok_or_else(|| PortabilityError {
            path: format!("{path}.components"),
            message: "IR.components must be an array".into(),
        })?;
    for (ci, c) in components.iter().enumerate() {
        assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
    }
    Ok(())
}

fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
    if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
        return perr(&format!("{path}.name"), "component.name must be a string");
    }
    let body = c
        .get("body")
        .and_then(|b| b.as_array())
        .ok_or_else(|| PortabilityError {
            path: format!("{path}.body"),
            message: "component.body must be an array".into(),
        })?;
    for (ni, n) in body.iter().enumerate() {
        assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
    }
    assert_portable_expr(
        c.get("output").unwrap_or(&J::Null),
        &format!("{path}.output"),
    )?;
    // Portable type notation (bc#44 B0): `outputType` is additive/optional.
    if let Some(ot) = c.get("outputType") {
        assert_portable_type_notation(ot, &format!("{path}.outputType"))?;
    }
    Ok(())
}

fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
    assert_portable_body_node_kind(n, path)?;
    // Portable type notation (bc#44 B0): a body node's `outType` is additive/optional
    // (validated for every node kind).
    if let Some(ot) = n.get("outType") {
        assert_portable_type_notation(ot, &format!("{path}.outType"))?;
    }
    Ok(())
}

fn assert_portable_body_node_kind(n: &J, path: &str) -> Result<(), PortabilityError> {
    if let Some(m) = n.get("map") {
        require_string_component(m.get("component"), &format!("{path}.map.component"))?;
        assert_portable_expr(
            m.get("over").unwrap_or(&J::Null),
            &format!("{path}.map.over"),
        )?;
        assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
        // v2 vocabulary: `when` is an Expression IR node (unknown operators are
        // rejected fail-closed); `into` must be a string key; `batched` a bool.
        if let Some(w) = m.get("when") {
            assert_portable_expr(w, &format!("{path}.map.when"))?;
        }
        if let Some(into) = m.get("into") {
            if !into.is_string() {
                return perr(&format!("{path}.map.into"), "'into' must be a string key");
            }
        }
        if let Some(b) = m.get("batched") {
            if !b.is_boolean() {
                return perr(
                    &format!("{path}.map.batched"),
                    "'batched' must be a boolean",
                );
            }
        }
        Ok(())
    } else if let Some(co) = n.get("cond") {
        assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
        assert_portable_expr(
            co.get("then").unwrap_or(&J::Null),
            &format!("{path}.cond.then"),
        )?;
        assert_portable_expr(
            co.get("else").unwrap_or(&J::Null),
            &format!("{path}.cond.else"),
        )
    } else if n.get("component").is_some() {
        require_string_component(n.get("component"), &format!("{path}.component"))?;
        assert_portable_ports(n.get("ports"), &format!("{path}.ports"))
    } else {
        perr(path, "unknown body node kind (not componentRef/map/cond)")
    }
}

fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
    match v {
        Some(J::String(_)) => Ok(()),
        _ => perr(path, "'component' must be a string catalog reference"),
    }
}

fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
    let obj = ports
        .and_then(|p| p.as_object())
        .ok_or_else(|| PortabilityError {
            path: path.to_string(),
            message: "ports must be an object".into(),
        })?;
    for (k, v) in obj {
        assert_portable_expr(v, &format!("{path}.{k}"))?;
    }
    Ok(())
}

fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
    match node {
        J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
        J::Array(a) => {
            for (i, e) in a.iter().enumerate() {
                assert_portable_expr(e, &format!("{path}[{i}]"))?;
            }
            Ok(())
        }
        J::Object(o) => {
            if o.len() == 1 {
                let (op, arg) = o.iter().next().unwrap();
                if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
                    return perr(path, format!("unknown operator '{op}' (fail-closed)"));
                }
                // obj は「任意の data key → 子式」の構築ノード(expression-ir.md §3)。arg の
                // key は operator ではなく data であり、値だけを式として再帰する(単一 data key
                // の obj が未知 operator と誤判定されないように — evaluate の意味論と一致)。
                if op == "obj" {
                    let Some(fields) = arg.as_object() else {
                        return perr(
                            &format!("{path}.obj"),
                            "{obj: ...} expects an object".to_string(),
                        );
                    };
                    for (k, v) in fields {
                        // 静的 fail-closed(defense-in-depth): own key "__proto__" は evaluator
                        // が実行時に FORBIDDEN_KEY で拒否する(expression-ir.md §2.3/§8)。
                        // guard でも静的に拒否する(言語間発散 / prototype pollution 対策)。
                        if k == FORBIDDEN_OBJECT_KEY {
                            return perr(
                                &format!("{path}.obj"),
                                format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
                            );
                        }
                        assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
                    }
                    return Ok(());
                }
                assert_portable_expr(arg, &format!("{path}.{op}"))
            } else {
                for (k, v) in o {
                    assert_portable_expr(v, &format!("{path}.{k}"))?;
                }
                Ok(())
            }
        }
    }
}