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|map|obj: ...}`.
70pub const PORTABLE_SCALAR_TYPES: &[&str] = &["string", "int", "float", "bool", "null"];
71
72/// The generic opaque-value literal (bc#156): the declared type of a driver boundary's generic bound
73/// value (`WireValue`). Not a concrete scalar, but in the type notation it appears as the leaf string
74/// `"value"` (`{arr:"value"}` = a list of bound values), accepted with the same strength as a scalar
75/// (it is a DECLARED opaque type, not an `"any"` escape hatch). Same as the TS guard's
76/// `PORTABLE_VALUE_TYPE`.
77pub const PORTABLE_VALUE_TYPE: &str = "value";
78
79/// The compound type constructors of the portable type notation (same closed set as the TS guard's
80/// `PORTABLE_COMPOUND_KINDS`).
81pub const PORTABLE_COMPOUND_KINDS: &[&str] = &["opt", "arr", "map", "obj"];
82
83/// Does the portable type carry the opaque wire value `"value"` ANYWHERE (recursive)? The COUNTERPART
84/// of [`wire_passthrough_out_type_ok`]: an output type that contains `value` without being the legal
85/// passthrough shape (nested in a record field / map value / nested array) has no de-box contract.
86/// Same rule as the TS `portableTypeHasValue` (behavior.ts).
87pub fn portable_type_has_value(t: &J) -> bool {
88    if t.as_str() == Some(PORTABLE_VALUE_TYPE) {
89        return true;
90    }
91    let Some(o) = t.as_object() else { return false };
92    for k in ["opt", "arr", "map"] {
93        if let Some(v) = o.get(k) {
94            return portable_type_has_value(v);
95        }
96    }
97    match o.get("obj").and_then(|f| f.as_object()) {
98        Some(fields) => fields.values().any(portable_type_has_value),
99        None => false,
100    }
101}
102
103/// Is an output-passthrough node's `outType` the legal opaque shape (`value` / `{arr:"value"}`)?
104/// Passthrough only covers "the whole output" and "the DIRECT element of an output array" — a `value`
105/// anywhere else has no de-box contract. Same rule as the TS `wirePassthroughOutTypeOk` (behavior.ts).
106pub fn wire_passthrough_out_type_ok(out_type: Option<&J>) -> bool {
107    match out_type {
108        Some(t) if t.as_str() == Some(PORTABLE_VALUE_TYPE) => true,
109        Some(t) => match t.as_object() {
110            Some(o) => {
111                o.len() == 1 && o.get("arr").and_then(|v| v.as_str()) == Some(PORTABLE_VALUE_TYPE)
112            }
113            None => false,
114        },
115        None => false,
116    }
117}
118
119/// Validate a portable type-notation node (§5.2) with the same fail-closed
120/// strength as the Expression IR guard. Closed set: a scalar-type string
121/// ([`PORTABLE_SCALAR_TYPES`], no `"any"` escape hatch); `{opt: T}` / `{arr: T}`
122/// (single-key object, value a type `T`); `{obj: {field: T, ...}}` (single-key
123/// object, value a field->type map with the `__proto__` own key forbidden).
124fn assert_portable_type_notation(node: &J, path: &str) -> Result<(), PortabilityError> {
125    match node {
126        J::String(s) => {
127            if !PORTABLE_SCALAR_TYPES.contains(&s.as_str()) && s != PORTABLE_VALUE_TYPE {
128                return perr(path, format!("unknown scalar type '{s}' (fail-closed)"));
129            }
130            Ok(())
131        }
132        J::Object(o) => {
133            // #192: obj のみ optional な `name`(宣言型名)を許す。opt/arr はきっかり 1 キー(既存語彙は不変)。
134            let kind = PORTABLE_COMPOUND_KINDS
135                .iter()
136                .find(|k| o.contains_key(**k))
137                .copied();
138            let kind = match kind {
139                Some(k) if k == "obj" || o.len() == 1 => k,
140                _ => {
141                    return perr(
142                        path,
143                        format!(
144                            "compound type must be a single-key object (opt|arr|map|obj; obj may carry an optional 'name'), got {} keys (fail-closed)",
145                            o.len()
146                        ),
147                    );
148                }
149            };
150            let arg = o.get(kind).unwrap();
151            match kind {
152                "opt" | "arr" | "map" => assert_portable_type_notation(arg, &format!("{path}.{kind}")),
153                "obj" => {
154                    for k in o.keys() {
155                        if k != "obj" && k != "name" {
156                            return perr(
157                                path,
158                                format!("{{obj}} type allows only 'obj' and optional 'name', got '{k}' (fail-closed)"),
159                            );
160                        }
161                    }
162                    if let Some(name_v) = o.get("name") {
163                        let ok = name_v.as_str().is_some_and(|s| {
164                            let mut cs = s.chars();
165                            matches!(cs.next(), Some(c) if c == '_' || c.is_ascii_alphabetic())
166                                && cs.all(|c| c == '_' || c.is_ascii_alphanumeric())
167                        });
168                        if !ok {
169                            return perr(
170                                &format!("{path}.name"),
171                                "obj type 'name' must be a non-empty identifier (fail-closed)".to_string(),
172                            );
173                        }
174                    }
175                    let Some(fields) = arg.as_object() else {
176                        return perr(
177                            &format!("{path}.obj"),
178                            "{obj: ...} type expects a field->type object (fail-closed)".to_string(),
179                        );
180                    };
181                    for (k, v) in fields {
182                        if k == FORBIDDEN_OBJECT_KEY {
183                            return perr(
184                                &format!("{path}.obj"),
185                                format!("object type key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
186                            );
187                        }
188                        assert_portable_type_notation(v, &format!("{path}.obj.{k}"))?;
189                    }
190                    Ok(())
191                }
192                other => perr(
193                    path,
194                    format!("unknown compound type kind '{other}' (opt|arr|map|obj) (fail-closed)"),
195                ),
196            }
197        }
198        _ => perr(
199            path,
200            "type notation must be a scalar-type string or a single-key {opt|arr|map|obj} object (fail-closed)".to_string(),
201        ),
202    }
203}
204
205/// Portability Guard for a component-graph IR (scp-ir-architecture.md §5), operating
206/// on the raw `serde_json::Value` IR (P0-2). Enforces, fail-closed:
207///   1. `component` (componentRef / map) is a string catalog reference only.
208///   2. every Expression-IR node in ports / output / map.over / cond uses only the
209///      known operator closed set ([`PORTABLE_EXPR_OPERATORS`]).
210///   3. any type annotation (`outType` / `outputType`, bc#44 B0) matches the
211///      portable type-notation closed set (§5.2). Annotations are additive: an IR
212///      without them validates identically to before B0.
213pub fn assert_portable_component_graph(ir: &J) -> Result<(), PortabilityError> {
214    assert_portable_cg_at(ir, "$")
215}
216
217fn assert_portable_cg_at(ir: &J, path: &str) -> Result<(), PortabilityError> {
218    // #173: v2 carries portSchemas. #192: v3 adds optional nominal type names on obj types (additive —
219    // absent = anonymous, so v2 IR stays valid). Portable versions are 2 | 3.
220    if !matches!(
221        ir.get("irVersion").and_then(|v| v.as_i64()),
222        Some(2) | Some(3)
223    ) {
224        return perr(
225            &format!("{path}.irVersion"),
226            "irVersion must be 2 or 3 (v3 adds optional nominal type names) (fail-closed)",
227        );
228    }
229    let components = ir
230        .get("components")
231        .and_then(|c| c.as_array())
232        .ok_or_else(|| PortabilityError {
233            path: format!("{path}.components"),
234            message: "IR.components must be an array".into(),
235        })?;
236    for (ci, c) in components.iter().enumerate() {
237        assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
238    }
239    Ok(())
240}
241
242fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
243    if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
244        return perr(&format!("{path}.name"), "component.name must be a string");
245    }
246    let body = c
247        .get("body")
248        .and_then(|b| b.as_array())
249        .ok_or_else(|| PortabilityError {
250            path: format!("{path}.body"),
251            message: "component.body must be an array".into(),
252        })?;
253    for (ni, n) in body.iter().enumerate() {
254        assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
255    }
256    assert_portable_expr(
257        c.get("output").unwrap_or(&J::Null),
258        &format!("{path}.output"),
259    )?;
260    // Portable type notation (bc#44 B0): `outputType` is additive/optional.
261    if let Some(ot) = c.get("outputType") {
262        assert_portable_type_notation(ot, &format!("{path}.outputType"))?;
263        // §D4b (#201/#205): output-side opacity is a NODE's wire passthrough (`wirePassthrough`), and a
264        // component carries no such flag — an opaque component output would leave the caller with no
265        // de-box contract. The predicate is the shared SSoT (no rule copy).
266        if portable_type_has_value(ot) {
267            return perr(
268                &format!("{path}.outputType"),
269                "component declares the opaque wire value in its output type — output-side opacity is a NODE's wire passthrough (wirePassthrough) and a component carries no such flag, so the caller would get no de-box contract (strict-typing-and-debox.md §D4b, fail-closed)",
270            );
271        }
272    }
273    Ok(())
274}
275
276fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
277    assert_portable_body_node_kind(n, path)?;
278    // Portable type notation (bc#44 B0): a body node's `outType` is additive/optional
279    // (validated for every node kind).
280    if let Some(ot) = n.get("outType") {
281        assert_portable_type_notation(ot, &format!("{path}.outType"))?;
282    }
283    // Output wire passthrough (bc#164, additive/optional): a bool only, and the flag and the output type
284    // are EQUIVALENT (#205) — "declared opaque" and "passes through" are two faces of one fact, so both
285    // directions are checked: (1) flag => opaque shape, (2) an output type carrying `value` => opaque
286    // shape AND flag. Either half alone lets a raw IR through with no de-box contract for the caller.
287    let out_type = n.get("outType");
288    match n.get("wirePassthrough") {
289        Some(J::Bool(_)) | None => {}
290        Some(_) => {
291            return perr(
292                &format!("{path}.wirePassthrough"),
293                "'wirePassthrough' must be a boolean",
294            );
295        }
296    }
297    let passthrough = n.get("wirePassthrough").and_then(|v| v.as_bool()) == Some(true);
298    if passthrough && !wire_passthrough_out_type_ok(out_type) {
299        return perr(
300            &format!("{path}.outType"),
301            "an output-passthrough node (wirePassthrough:true) must declare outType 'value' or {arr:\"value\"} (the opaque-wire shape) (fail-closed)",
302        );
303    }
304    if out_type.is_some_and(portable_type_has_value)
305        && !(passthrough && wire_passthrough_out_type_ok(out_type))
306    {
307        return perr(
308            &format!("{path}.outType"),
309            "an output type carrying the opaque wire value must BE the passthrough shape ('value' or {arr:\"value\"}) and the node must declare wirePassthrough:true — a nested/unflagged opaque output gives the caller no de-box contract (strict-typing-and-debox.md §D4b, fail-closed)",
310        );
311    }
312    Ok(())
313}
314
315fn assert_portable_body_node_kind(n: &J, path: &str) -> Result<(), PortabilityError> {
316    if let Some(m) = n.get("map") {
317        require_string_component(m.get("component"), &format!("{path}.map.component"))?;
318        assert_portable_expr(
319            m.get("over").unwrap_or(&J::Null),
320            &format!("{path}.map.over"),
321        )?;
322        assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
323        // v2 vocabulary: `when` is an Expression IR node (unknown operators are
324        // rejected fail-closed); `into` must be a string key; `batched` a bool.
325        if let Some(w) = m.get("when") {
326            assert_portable_expr(w, &format!("{path}.map.when"))?;
327        }
328        if let Some(into) = m.get("into") {
329            if !into.is_string() {
330                return perr(&format!("{path}.map.into"), "'into' must be a string key");
331            }
332        }
333        if let Some(b) = m.get("batched") {
334            if !b.is_boolean() {
335                return perr(
336                    &format!("{path}.map.batched"),
337                    "'batched' must be a boolean",
338                );
339            }
340        }
341        // Element Error Policy Kind (scp-error.md): closed set error|skip. `skip` needs a per-element
342        // Failure, so a batched map (one outcome for the whole batch) rejects it.
343        if let Some(ep) = m.get("elementPolicy") {
344            let eps = ep.as_str();
345            if eps != Some("error") && eps != Some("skip") {
346                return perr(
347                    &format!("{path}.map.elementPolicy"),
348                    "'elementPolicy' must be \"error\" or \"skip\"",
349                );
350            }
351            if eps == Some("skip") && m.get("batched").and_then(|b| b.as_bool()) == Some(true) {
352                return perr(
353                    &format!("{path}.map.elementPolicy"),
354                    "'elementPolicy' \"skip\" needs a per-element Failure, but a batched map takes ONE outcome for the whole batch",
355                );
356            }
357        }
358        assert_port_schemas_present(m, &format!("{path}.map")) // #173
359    } else if let Some(fo) = n.get("fanout") {
360        // fanout (the behaviorVersion 3 first-class node kind — same rules as the TS guard): `over` is an
361        // Expression IR node, `component` a catalog name, `ports` Expression IR nodes; `as`/`dedupeKey` are
362        // non-empty strings, `drop` is "dangling"|"none", `implicitSource` a string or absent, and
363        // `relationKind` is fixed to "connection".
364        require_string_component(fo.get("component"), &format!("{path}.fanout.component"))?;
365        assert_portable_expr(
366            fo.get("over").unwrap_or(&J::Null),
367            &format!("{path}.fanout.over"),
368        )?;
369        assert_portable_ports(fo.get("ports"), &format!("{path}.fanout.ports"))?;
370        for (field, label) in [("as", "an element-binding"), ("dedupeKey", "a dedupe key")] {
371            match fo.get(field).and_then(|v| v.as_str()) {
372                Some(sv) if !sv.is_empty() => {}
373                _ => {
374                    return perr(
375                        &format!("{path}.fanout.{field}"),
376                        format!("fanout '{field}' must be a non-empty {label} string"),
377                    );
378                }
379            }
380        }
381        match fo.get("drop").and_then(|v| v.as_str()) {
382            Some("dangling") | Some("none") => {}
383            _ => {
384                return perr(
385                    &format!("{path}.fanout.drop"),
386                    "fanout 'drop' must be \"dangling\" or \"none\" (fail-closed)",
387                );
388            }
389        }
390        if let Some(is) = fo.get("implicitSource") {
391            if !is.is_string() {
392                return perr(
393                    &format!("{path}.fanout.implicitSource"),
394                    "fanout 'implicitSource' must be a string or absent",
395                );
396            }
397        }
398        if fo.get("relationKind").and_then(|v| v.as_str()) != Some("connection") {
399            return perr(
400                &format!("{path}.fanout.relationKind"),
401                "fanout 'relationKind' must be \"connection\" (fail-closed)",
402            );
403        }
404        assert_port_schemas_present(fo, &format!("{path}.fanout")) // #173
405    } else if let Some(co) = n.get("cond") {
406        assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
407        assert_portable_expr(
408            co.get("then").unwrap_or(&J::Null),
409            &format!("{path}.cond.then"),
410        )?;
411        assert_portable_expr(
412            co.get("else").unwrap_or(&J::Null),
413            &format!("{path}.cond.else"),
414        )
415    } else if n.get("component").is_some() {
416        require_string_component(n.get("component"), &format!("{path}.component"))?;
417        assert_portable_ports(n.get("ports"), &format!("{path}.ports"))?;
418        assert_port_schemas_present(n, path)
419    } else {
420        perr(
421            path,
422            "unknown body node kind (not componentRef/map/cond/fanout)",
423        )
424    }
425}
426
427/// #173: irVersion 2 carries the per-node port type contract. The guard requires the field's
428/// PRESENCE (an object) on the port-bearing node kinds for cross-language parity (the TS guard
429/// additionally parses its structure, since only TS codegen consumes it). Missing / non-object
430/// portSchemas is a malformed v2 IR → fail-closed.
431fn assert_port_schemas_present(holder: &J, path: &str) -> Result<(), PortabilityError> {
432    match holder.get("portSchemas") {
433        Some(ps) if ps.is_object() => Ok(()),
434        _ => perr(
435            &format!("{path}.portSchemas"),
436            "portSchemas is required (irVersion 2 carries the port type contract) and must be an object",
437        ),
438    }
439}
440
441fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
442    match v {
443        Some(J::String(_)) => Ok(()),
444        _ => perr(path, "'component' must be a string catalog reference"),
445    }
446}
447
448fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
449    let obj = ports
450        .and_then(|p| p.as_object())
451        .ok_or_else(|| PortabilityError {
452            path: path.to_string(),
453            message: "ports must be an object".into(),
454        })?;
455    for (k, v) in obj {
456        assert_portable_expr(v, &format!("{path}.{k}"))?;
457    }
458    Ok(())
459}
460
461fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
462    match node {
463        J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
464        J::Array(a) => {
465            for (i, e) in a.iter().enumerate() {
466                assert_portable_expr(e, &format!("{path}[{i}]"))?;
467            }
468            Ok(())
469        }
470        J::Object(o) => {
471            if o.len() == 1 {
472                let (op, arg) = o.iter().next().unwrap();
473                if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
474                    return perr(path, format!("unknown operator '{op}' (fail-closed)"));
475                }
476                // obj は「任意の data key → 子式」の構築ノード(expression-ir.md §3)。arg の
477                // key は operator ではなく data であり、値だけを式として再帰する(単一 data key
478                // の obj が未知 operator と誤判定されないように — evaluate の意味論と一致)。
479                if op == "obj" {
480                    let Some(fields) = arg.as_object() else {
481                        return perr(
482                            &format!("{path}.obj"),
483                            "{obj: ...} expects an object".to_string(),
484                        );
485                    };
486                    for (k, v) in fields {
487                        // 静的 fail-closed(defense-in-depth): own key "__proto__" は evaluator
488                        // が実行時に FORBIDDEN_KEY で拒否する(expression-ir.md §2.3/§8)。
489                        // guard でも静的に拒否する(言語間発散 / prototype pollution 対策)。
490                        if k == FORBIDDEN_OBJECT_KEY {
491                            return perr(
492                                &format!("{path}.obj"),
493                                format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
494                            );
495                        }
496                        assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
497                    }
498                    return Ok(());
499                }
500                assert_portable_expr(arg, &format!("{path}.{op}"))
501            } else {
502                for (k, v) in o {
503                    assert_portable_expr(v, &format!("{path}.{k}"))?;
504                }
505                Ok(())
506            }
507        }
508    }
509}