Skip to main content

behavior_contracts/
guard.rs

1//! guard — Portability Guard (`assert_portable`).
2//!
3//! DSL-agnostic IR portability invariant: a portable IR must not contain
4//! non-serializable values. In Rust the runtime works over `serde_json::Value`
5//! (already JSON-serializable) plus the native [`crate::value::Value`], so the
6//! only structurally non-portable case is a non-finite float (NaN/±Inf) which
7//! cannot be represented in JSON. `assert_portable` walks a value and rejects it.
8
9use crate::expr::FORBIDDEN_OBJECT_KEY;
10use crate::value::Value;
11use serde_json::Value as J;
12
13#[derive(Debug, Clone)]
14pub struct PortabilityError {
15    pub path: String,
16    pub message: String,
17}
18
19impl std::fmt::Display for PortabilityError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        write!(f, "Portability Guard at {}: {}", self.path, self.message)
22    }
23}
24impl std::error::Error for PortabilityError {}
25
26/// Assert that a runtime [`Value`] is portable (JSON-serializable). The only
27/// non-portable scalar in the Rust value model is a non-finite float.
28pub fn assert_portable(value: &Value) -> Result<(), PortabilityError> {
29    assert_portable_at(value, "$")
30}
31
32fn assert_portable_at(value: &Value, path: &str) -> Result<(), PortabilityError> {
33    match value {
34        Value::Float(f) if !f.is_finite() => Err(PortabilityError {
35            path: path.to_string(),
36            message: format!("non-finite float {f} cannot be placed in portable IR"),
37        }),
38        Value::Arr(a) => {
39            for (i, v) in a.iter().enumerate() {
40                assert_portable_at(v, &format!("{path}[{i}]"))?;
41            }
42            Ok(())
43        }
44        Value::Obj(o) => {
45            for (k, v) in o {
46                assert_portable_at(v, &format!("{path}.{k}"))?;
47            }
48            Ok(())
49        }
50        _ => Ok(()),
51    }
52}
53
54/// The closed set of Expression-IR operators allowed in a portable IR (expression-ir.md §3).
55pub const PORTABLE_EXPR_OPERATORS: &[&str] = &[
56    "int", "float", "ref", "refOpt", "obj", "arr", "add", "sub", "mul", "neg", "div", "mod",
57    "concat", "eq", "ne", "lt", "le", "gt", "ge", "and", "or", "not", "coalesce", "cond", "len",
58];
59
60fn perr<T>(path: &str, message: impl Into<String>) -> Result<T, PortabilityError> {
61    Err(PortabilityError {
62        path: path.to_string(),
63        message: message.into(),
64    })
65}
66
67/// The closed set of scalar types in the portable type notation
68/// (scp-ir-architecture.md §5.2, bc#44 B0). Compound types are the single-key
69/// objects `{opt|arr|obj: ...}`.
70pub const PORTABLE_SCALAR_TYPES: &[&str] = &["string", "int", "float", "bool", "null"];
71
72/// Validate a portable type-notation node (§5.2) with the same fail-closed
73/// strength as the Expression IR guard. Closed set: a scalar-type string
74/// ([`PORTABLE_SCALAR_TYPES`], no `"any"` escape hatch); `{opt: T}` / `{arr: T}`
75/// (single-key object, value a type `T`); `{obj: {field: T, ...}}` (single-key
76/// object, value a field->type map with the `__proto__` own key forbidden).
77fn assert_portable_type_notation(node: &J, path: &str) -> Result<(), PortabilityError> {
78    match node {
79        J::String(s) => {
80            if !PORTABLE_SCALAR_TYPES.contains(&s.as_str()) {
81                return perr(path, format!("unknown scalar type '{s}' (fail-closed)"));
82            }
83            Ok(())
84        }
85        J::Object(o) => {
86            if o.len() != 1 {
87                return perr(
88                    path,
89                    format!(
90                        "compound type must be a single-key object (opt|arr|obj), got {} keys (fail-closed)",
91                        o.len()
92                    ),
93                );
94            }
95            let (kind, arg) = o.iter().next().unwrap();
96            match kind.as_str() {
97                "opt" | "arr" => assert_portable_type_notation(arg, &format!("{path}.{kind}")),
98                "obj" => {
99                    let Some(fields) = arg.as_object() else {
100                        return perr(
101                            &format!("{path}.obj"),
102                            "{obj: ...} type expects a field->type object (fail-closed)".to_string(),
103                        );
104                    };
105                    for (k, v) in fields {
106                        if k == FORBIDDEN_OBJECT_KEY {
107                            return perr(
108                                &format!("{path}.obj"),
109                                format!("object type key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
110                            );
111                        }
112                        assert_portable_type_notation(v, &format!("{path}.obj.{k}"))?;
113                    }
114                    Ok(())
115                }
116                other => perr(
117                    path,
118                    format!("unknown compound type kind '{other}' (opt|arr|obj) (fail-closed)"),
119                ),
120            }
121        }
122        _ => perr(
123            path,
124            "type notation must be a scalar-type string or a single-key {opt|arr|obj} object (fail-closed)".to_string(),
125        ),
126    }
127}
128
129/// Portability Guard for a component-graph IR (scp-ir-architecture.md §5), operating
130/// on the raw `serde_json::Value` IR (P0-2). Enforces, fail-closed:
131///   1. `component` (componentRef / map) is a string catalog reference only.
132///   2. every Expression-IR node in ports / output / map.over / cond uses only the
133///      known operator closed set ([`PORTABLE_EXPR_OPERATORS`]).
134///   3. any type annotation (`outType` / `outputType`, bc#44 B0) matches the
135///      portable type-notation closed set (§5.2). Annotations are additive: an IR
136///      without them validates identically to before B0.
137pub fn assert_portable_component_graph(ir: &J) -> Result<(), PortabilityError> {
138    assert_portable_cg_at(ir, "$")
139}
140
141fn assert_portable_cg_at(ir: &J, path: &str) -> Result<(), PortabilityError> {
142    // #173: irVersion 2 is the ONLY portable IR version (v1 retired — every node carries the mandatory
143    // port type contract portSchemas). Rejected in every language for cross-language parity.
144    if ir.get("irVersion").and_then(|v| v.as_i64()) != Some(2) {
145        return perr(
146            &format!("{path}.irVersion"),
147            "irVersion must be 2 (portable IR carries the mandatory port type contract portSchemas; v1 is retired) (fail-closed)",
148        );
149    }
150    let components = ir
151        .get("components")
152        .and_then(|c| c.as_array())
153        .ok_or_else(|| PortabilityError {
154            path: format!("{path}.components"),
155            message: "IR.components must be an array".into(),
156        })?;
157    for (ci, c) in components.iter().enumerate() {
158        assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
159    }
160    Ok(())
161}
162
163fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
164    if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
165        return perr(&format!("{path}.name"), "component.name must be a string");
166    }
167    let body = c
168        .get("body")
169        .and_then(|b| b.as_array())
170        .ok_or_else(|| PortabilityError {
171            path: format!("{path}.body"),
172            message: "component.body must be an array".into(),
173        })?;
174    for (ni, n) in body.iter().enumerate() {
175        assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
176    }
177    assert_portable_expr(
178        c.get("output").unwrap_or(&J::Null),
179        &format!("{path}.output"),
180    )?;
181    // Portable type notation (bc#44 B0): `outputType` is additive/optional.
182    if let Some(ot) = c.get("outputType") {
183        assert_portable_type_notation(ot, &format!("{path}.outputType"))?;
184    }
185    Ok(())
186}
187
188fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
189    assert_portable_body_node_kind(n, path)?;
190    // Portable type notation (bc#44 B0): a body node's `outType` is additive/optional
191    // (validated for every node kind).
192    if let Some(ot) = n.get("outType") {
193        assert_portable_type_notation(ot, &format!("{path}.outType"))?;
194    }
195    Ok(())
196}
197
198fn assert_portable_body_node_kind(n: &J, path: &str) -> Result<(), PortabilityError> {
199    if let Some(m) = n.get("map") {
200        require_string_component(m.get("component"), &format!("{path}.map.component"))?;
201        assert_portable_expr(
202            m.get("over").unwrap_or(&J::Null),
203            &format!("{path}.map.over"),
204        )?;
205        assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
206        // v2 vocabulary: `when` is an Expression IR node (unknown operators are
207        // rejected fail-closed); `into` must be a string key; `batched` a bool.
208        if let Some(w) = m.get("when") {
209            assert_portable_expr(w, &format!("{path}.map.when"))?;
210        }
211        if let Some(into) = m.get("into") {
212            if !into.is_string() {
213                return perr(&format!("{path}.map.into"), "'into' must be a string key");
214            }
215        }
216        if let Some(b) = m.get("batched") {
217            if !b.is_boolean() {
218                return perr(
219                    &format!("{path}.map.batched"),
220                    "'batched' must be a boolean",
221                );
222            }
223        }
224        // Element Error Policy Kind (scp-error.md): closed set error|skip. `skip` needs a per-element
225        // Failure, so a batched map (one outcome for the whole batch) rejects it.
226        if let Some(ep) = m.get("elementPolicy") {
227            let eps = ep.as_str();
228            if eps != Some("error") && eps != Some("skip") {
229                return perr(
230                    &format!("{path}.map.elementPolicy"),
231                    "'elementPolicy' must be \"error\" or \"skip\"",
232                );
233            }
234            if eps == Some("skip") && m.get("batched").and_then(|b| b.as_bool()) == Some(true) {
235                return perr(
236                    &format!("{path}.map.elementPolicy"),
237                    "'elementPolicy' \"skip\" needs a per-element Failure, but a batched map takes ONE outcome for the whole batch",
238                );
239            }
240        }
241        assert_port_schemas_present(m, &format!("{path}.map")) // #173
242    } else if let Some(co) = n.get("cond") {
243        assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
244        assert_portable_expr(
245            co.get("then").unwrap_or(&J::Null),
246            &format!("{path}.cond.then"),
247        )?;
248        assert_portable_expr(
249            co.get("else").unwrap_or(&J::Null),
250            &format!("{path}.cond.else"),
251        )
252    } else if n.get("component").is_some() {
253        require_string_component(n.get("component"), &format!("{path}.component"))?;
254        assert_portable_ports(n.get("ports"), &format!("{path}.ports"))?;
255        assert_port_schemas_present(n, path)
256    } else {
257        perr(path, "unknown body node kind (not componentRef/map/cond)")
258    }
259}
260
261/// #173: irVersion 2 carries the per-node port type contract. The guard requires the field's
262/// PRESENCE (an object) on the port-bearing node kinds for cross-language parity (the TS guard
263/// additionally parses its structure, since only TS codegen consumes it). Missing / non-object
264/// portSchemas is a malformed v2 IR → fail-closed.
265fn assert_port_schemas_present(holder: &J, path: &str) -> Result<(), PortabilityError> {
266    match holder.get("portSchemas") {
267        Some(ps) if ps.is_object() => Ok(()),
268        _ => perr(
269            &format!("{path}.portSchemas"),
270            "portSchemas is required (irVersion 2 carries the port type contract) and must be an object",
271        ),
272    }
273}
274
275fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
276    match v {
277        Some(J::String(_)) => Ok(()),
278        _ => perr(path, "'component' must be a string catalog reference"),
279    }
280}
281
282fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
283    let obj = ports
284        .and_then(|p| p.as_object())
285        .ok_or_else(|| PortabilityError {
286            path: path.to_string(),
287            message: "ports must be an object".into(),
288        })?;
289    for (k, v) in obj {
290        assert_portable_expr(v, &format!("{path}.{k}"))?;
291    }
292    Ok(())
293}
294
295fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
296    match node {
297        J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
298        J::Array(a) => {
299            for (i, e) in a.iter().enumerate() {
300                assert_portable_expr(e, &format!("{path}[{i}]"))?;
301            }
302            Ok(())
303        }
304        J::Object(o) => {
305            if o.len() == 1 {
306                let (op, arg) = o.iter().next().unwrap();
307                if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
308                    return perr(path, format!("unknown operator '{op}' (fail-closed)"));
309                }
310                // obj は「任意の data key → 子式」の構築ノード(expression-ir.md §3)。arg の
311                // key は operator ではなく data であり、値だけを式として再帰する(単一 data key
312                // の obj が未知 operator と誤判定されないように — evaluate の意味論と一致)。
313                if op == "obj" {
314                    let Some(fields) = arg.as_object() else {
315                        return perr(
316                            &format!("{path}.obj"),
317                            "{obj: ...} expects an object".to_string(),
318                        );
319                    };
320                    for (k, v) in fields {
321                        // 静的 fail-closed(defense-in-depth): own key "__proto__" は evaluator
322                        // が実行時に FORBIDDEN_KEY で拒否する(expression-ir.md §2.3/§8)。
323                        // guard でも静的に拒否する(言語間発散 / prototype pollution 対策)。
324                        if k == FORBIDDEN_OBJECT_KEY {
325                            return perr(
326                                &format!("{path}.obj"),
327                                format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
328                            );
329                        }
330                        assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
331                    }
332                    return Ok(());
333                }
334                assert_portable_expr(arg, &format!("{path}.{op}"))
335            } else {
336                for (k, v) in o {
337                    assert_portable_expr(v, &format!("{path}.{k}"))?;
338                }
339                Ok(())
340            }
341        }
342    }
343}