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