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            // #192: obj のみ optional な `name`(宣言型名)を許す。opt/arr はきっかり 1 キー(既存語彙は不変)。
87            let kind = ["opt", "arr", "obj"]
88                .iter()
89                .find(|k| o.contains_key(**k))
90                .copied();
91            let kind = match kind {
92                Some(k) if k == "obj" || o.len() == 1 => k,
93                _ => {
94                    return perr(
95                        path,
96                        format!(
97                            "compound type must be a single-key object (opt|arr|obj; obj may carry an optional 'name'), got {} keys (fail-closed)",
98                            o.len()
99                        ),
100                    );
101                }
102            };
103            let arg = o.get(kind).unwrap();
104            match kind {
105                "opt" | "arr" => assert_portable_type_notation(arg, &format!("{path}.{kind}")),
106                "obj" => {
107                    for k in o.keys() {
108                        if k != "obj" && k != "name" {
109                            return perr(
110                                path,
111                                format!("{{obj}} type allows only 'obj' and optional 'name', got '{k}' (fail-closed)"),
112                            );
113                        }
114                    }
115                    if let Some(name_v) = o.get("name") {
116                        let ok = name_v.as_str().is_some_and(|s| {
117                            let mut cs = s.chars();
118                            matches!(cs.next(), Some(c) if c == '_' || c.is_ascii_alphabetic())
119                                && cs.all(|c| c == '_' || c.is_ascii_alphanumeric())
120                        });
121                        if !ok {
122                            return perr(
123                                &format!("{path}.name"),
124                                "obj type 'name' must be a non-empty identifier (fail-closed)".to_string(),
125                            );
126                        }
127                    }
128                    let Some(fields) = arg.as_object() else {
129                        return perr(
130                            &format!("{path}.obj"),
131                            "{obj: ...} type expects a field->type object (fail-closed)".to_string(),
132                        );
133                    };
134                    for (k, v) in fields {
135                        if k == FORBIDDEN_OBJECT_KEY {
136                            return perr(
137                                &format!("{path}.obj"),
138                                format!("object type key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
139                            );
140                        }
141                        assert_portable_type_notation(v, &format!("{path}.obj.{k}"))?;
142                    }
143                    Ok(())
144                }
145                other => perr(
146                    path,
147                    format!("unknown compound type kind '{other}' (opt|arr|obj) (fail-closed)"),
148                ),
149            }
150        }
151        _ => perr(
152            path,
153            "type notation must be a scalar-type string or a single-key {opt|arr|obj} object (fail-closed)".to_string(),
154        ),
155    }
156}
157
158/// Portability Guard for a component-graph IR (scp-ir-architecture.md §5), operating
159/// on the raw `serde_json::Value` IR (P0-2). Enforces, fail-closed:
160///   1. `component` (componentRef / map) is a string catalog reference only.
161///   2. every Expression-IR node in ports / output / map.over / cond uses only the
162///      known operator closed set ([`PORTABLE_EXPR_OPERATORS`]).
163///   3. any type annotation (`outType` / `outputType`, bc#44 B0) matches the
164///      portable type-notation closed set (§5.2). Annotations are additive: an IR
165///      without them validates identically to before B0.
166pub fn assert_portable_component_graph(ir: &J) -> Result<(), PortabilityError> {
167    assert_portable_cg_at(ir, "$")
168}
169
170fn assert_portable_cg_at(ir: &J, path: &str) -> Result<(), PortabilityError> {
171    // #173: v2 carries portSchemas. #192: v3 adds optional nominal type names on obj types (additive —
172    // absent = anonymous, so v2 IR stays valid). Portable versions are 2 | 3.
173    if !matches!(
174        ir.get("irVersion").and_then(|v| v.as_i64()),
175        Some(2) | Some(3)
176    ) {
177        return perr(
178            &format!("{path}.irVersion"),
179            "irVersion must be 2 or 3 (v3 adds optional nominal type names) (fail-closed)",
180        );
181    }
182    let components = ir
183        .get("components")
184        .and_then(|c| c.as_array())
185        .ok_or_else(|| PortabilityError {
186            path: format!("{path}.components"),
187            message: "IR.components must be an array".into(),
188        })?;
189    for (ci, c) in components.iter().enumerate() {
190        assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
191    }
192    Ok(())
193}
194
195fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
196    if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
197        return perr(&format!("{path}.name"), "component.name must be a string");
198    }
199    let body = c
200        .get("body")
201        .and_then(|b| b.as_array())
202        .ok_or_else(|| PortabilityError {
203            path: format!("{path}.body"),
204            message: "component.body must be an array".into(),
205        })?;
206    for (ni, n) in body.iter().enumerate() {
207        assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
208    }
209    assert_portable_expr(
210        c.get("output").unwrap_or(&J::Null),
211        &format!("{path}.output"),
212    )?;
213    // Portable type notation (bc#44 B0): `outputType` is additive/optional.
214    if let Some(ot) = c.get("outputType") {
215        assert_portable_type_notation(ot, &format!("{path}.outputType"))?;
216    }
217    Ok(())
218}
219
220fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
221    assert_portable_body_node_kind(n, path)?;
222    // Portable type notation (bc#44 B0): a body node's `outType` is additive/optional
223    // (validated for every node kind).
224    if let Some(ot) = n.get("outType") {
225        assert_portable_type_notation(ot, &format!("{path}.outType"))?;
226    }
227    Ok(())
228}
229
230fn assert_portable_body_node_kind(n: &J, path: &str) -> Result<(), PortabilityError> {
231    if let Some(m) = n.get("map") {
232        require_string_component(m.get("component"), &format!("{path}.map.component"))?;
233        assert_portable_expr(
234            m.get("over").unwrap_or(&J::Null),
235            &format!("{path}.map.over"),
236        )?;
237        assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
238        // v2 vocabulary: `when` is an Expression IR node (unknown operators are
239        // rejected fail-closed); `into` must be a string key; `batched` a bool.
240        if let Some(w) = m.get("when") {
241            assert_portable_expr(w, &format!("{path}.map.when"))?;
242        }
243        if let Some(into) = m.get("into") {
244            if !into.is_string() {
245                return perr(&format!("{path}.map.into"), "'into' must be a string key");
246            }
247        }
248        if let Some(b) = m.get("batched") {
249            if !b.is_boolean() {
250                return perr(
251                    &format!("{path}.map.batched"),
252                    "'batched' must be a boolean",
253                );
254            }
255        }
256        // Element Error Policy Kind (scp-error.md): closed set error|skip. `skip` needs a per-element
257        // Failure, so a batched map (one outcome for the whole batch) rejects it.
258        if let Some(ep) = m.get("elementPolicy") {
259            let eps = ep.as_str();
260            if eps != Some("error") && eps != Some("skip") {
261                return perr(
262                    &format!("{path}.map.elementPolicy"),
263                    "'elementPolicy' must be \"error\" or \"skip\"",
264                );
265            }
266            if eps == Some("skip") && m.get("batched").and_then(|b| b.as_bool()) == Some(true) {
267                return perr(
268                    &format!("{path}.map.elementPolicy"),
269                    "'elementPolicy' \"skip\" needs a per-element Failure, but a batched map takes ONE outcome for the whole batch",
270                );
271            }
272        }
273        assert_port_schemas_present(m, &format!("{path}.map")) // #173
274    } else if let Some(co) = n.get("cond") {
275        assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
276        assert_portable_expr(
277            co.get("then").unwrap_or(&J::Null),
278            &format!("{path}.cond.then"),
279        )?;
280        assert_portable_expr(
281            co.get("else").unwrap_or(&J::Null),
282            &format!("{path}.cond.else"),
283        )
284    } else if n.get("component").is_some() {
285        require_string_component(n.get("component"), &format!("{path}.component"))?;
286        assert_portable_ports(n.get("ports"), &format!("{path}.ports"))?;
287        assert_port_schemas_present(n, path)
288    } else {
289        perr(path, "unknown body node kind (not componentRef/map/cond)")
290    }
291}
292
293/// #173: irVersion 2 carries the per-node port type contract. The guard requires the field's
294/// PRESENCE (an object) on the port-bearing node kinds for cross-language parity (the TS guard
295/// additionally parses its structure, since only TS codegen consumes it). Missing / non-object
296/// portSchemas is a malformed v2 IR → fail-closed.
297fn assert_port_schemas_present(holder: &J, path: &str) -> Result<(), PortabilityError> {
298    match holder.get("portSchemas") {
299        Some(ps) if ps.is_object() => Ok(()),
300        _ => perr(
301            &format!("{path}.portSchemas"),
302            "portSchemas is required (irVersion 2 carries the port type contract) and must be an object",
303        ),
304    }
305}
306
307fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
308    match v {
309        Some(J::String(_)) => Ok(()),
310        _ => perr(path, "'component' must be a string catalog reference"),
311    }
312}
313
314fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
315    let obj = ports
316        .and_then(|p| p.as_object())
317        .ok_or_else(|| PortabilityError {
318            path: path.to_string(),
319            message: "ports must be an object".into(),
320        })?;
321    for (k, v) in obj {
322        assert_portable_expr(v, &format!("{path}.{k}"))?;
323    }
324    Ok(())
325}
326
327fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
328    match node {
329        J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
330        J::Array(a) => {
331            for (i, e) in a.iter().enumerate() {
332                assert_portable_expr(e, &format!("{path}[{i}]"))?;
333            }
334            Ok(())
335        }
336        J::Object(o) => {
337            if o.len() == 1 {
338                let (op, arg) = o.iter().next().unwrap();
339                if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
340                    return perr(path, format!("unknown operator '{op}' (fail-closed)"));
341                }
342                // obj は「任意の data key → 子式」の構築ノード(expression-ir.md §3)。arg の
343                // key は operator ではなく data であり、値だけを式として再帰する(単一 data key
344                // の obj が未知 operator と誤判定されないように — evaluate の意味論と一致)。
345                if op == "obj" {
346                    let Some(fields) = arg.as_object() else {
347                        return perr(
348                            &format!("{path}.obj"),
349                            "{obj: ...} expects an object".to_string(),
350                        );
351                    };
352                    for (k, v) in fields {
353                        // 静的 fail-closed(defense-in-depth): own key "__proto__" は evaluator
354                        // が実行時に FORBIDDEN_KEY で拒否する(expression-ir.md §2.3/§8)。
355                        // guard でも静的に拒否する(言語間発散 / prototype pollution 対策)。
356                        if k == FORBIDDEN_OBJECT_KEY {
357                            return perr(
358                                &format!("{path}.obj"),
359                                format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
360                            );
361                        }
362                        assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
363                    }
364                    return Ok(());
365                }
366                assert_portable_expr(arg, &format!("{path}.{op}"))
367            } else {
368                for (k, v) in o {
369                    assert_portable_expr(v, &format!("{path}.{k}"))?;
370                }
371                Ok(())
372            }
373        }
374    }
375}