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    // A pure-expression map (#222) CONSTRUCTS its value — it never crosses the wire, so this rule does not
305    // apply: a `value` position in its declared type is D4b's INPUT-side opaque (the downstream port boxes
306    // it once), not an output without a de-box contract.
307    let constructs_value = n
308        .get("map")
309        .is_some_and(|m| m.get("component").is_none() && m.get("transform").is_some());
310    if !constructs_value
311        && out_type.is_some_and(portable_type_has_value)
312        && !(passthrough && wire_passthrough_out_type_ok(out_type))
313    {
314        return perr(
315            &format!("{path}.outType"),
316            "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)",
317        );
318    }
319    Ok(())
320}
321
322fn assert_portable_body_node_kind(n: &J, path: &str) -> Result<(), PortabilityError> {
323    if let Some(m) = n.get("map") {
324        // The element body has two EXCLUSIVE forms: a Component call (`component` + `ports`) or a pure
325        // element expression (`transform`, #222). Iteration belongs to the SCP structural layer, so the
326        // Expression IR carries no iteration operator (expression-ir.md §3).
327        let is_transform = m.get("transform").is_some();
328        if is_transform && (m.get("component").is_some() || m.get("ports").is_some()) {
329            return perr(
330                &format!("{path}.map.transform"),
331                "'transform' (a pure element expression) and 'component'/'ports' (a Component call) are exclusive",
332            );
333        }
334        if is_transform {
335            assert_portable_expr(
336                m.get("transform").unwrap(),
337                &format!("{path}.map.transform"),
338            )?;
339            for k in [
340                "into",
341                "batched",
342                "elementPolicy",
343                "policy",
344                "portSchemas",
345                "leafSymbol",
346            ] {
347                if m.get(k).is_some() {
348                    return perr(
349                        &format!("{path}.map.{k}"),
350                        "this key has no meaning on a pure-expression map — no Component is called, so there is no per-element outcome",
351                    );
352                }
353            }
354        } else {
355            require_string_component(m.get("component"), &format!("{path}.map.component"))?;
356            assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
357        }
358        assert_portable_expr(
359            m.get("over").unwrap_or(&J::Null),
360            &format!("{path}.map.over"),
361        )?;
362        // v2 vocabulary: `when` is an Expression IR node (unknown operators are
363        // rejected fail-closed); `into` must be a string key; `batched` a bool.
364        if let Some(w) = m.get("when") {
365            assert_portable_expr(w, &format!("{path}.map.when"))?;
366        }
367        if let Some(into) = m.get("into") {
368            if !into.is_string() {
369                return perr(&format!("{path}.map.into"), "'into' must be a string key");
370            }
371        }
372        if let Some(b) = m.get("batched") {
373            if !b.is_boolean() {
374                return perr(
375                    &format!("{path}.map.batched"),
376                    "'batched' must be a boolean",
377                );
378            }
379        }
380        // Element Error Policy Kind (scp-error.md): closed set error|skip. `skip` needs a per-element
381        // Failure, so a batched map (one outcome for the whole batch) rejects it.
382        if let Some(ep) = m.get("elementPolicy") {
383            let eps = ep.as_str();
384            if eps != Some("error") && eps != Some("skip") {
385                return perr(
386                    &format!("{path}.map.elementPolicy"),
387                    "'elementPolicy' must be \"error\" or \"skip\"",
388                );
389            }
390            if eps == Some("skip") && m.get("batched").and_then(|b| b.as_bool()) == Some(true) {
391                return perr(
392                    &format!("{path}.map.elementPolicy"),
393                    "'elementPolicy' \"skip\" needs a per-element Failure, but a batched map takes ONE outcome for the whole batch",
394                );
395            }
396        }
397        // A pure-expression map has no ports, hence no port type contract (#222).
398        if is_transform {
399            Ok(())
400        } else {
401            assert_port_schemas_present(m, &format!("{path}.map")) // #173
402        }
403    } else if let Some(fo) = n.get("fanout") {
404        // fanout (the behaviorVersion 3 first-class node kind — same rules as the TS guard): `over` is an
405        // Expression IR node, `component` a catalog name, `ports` Expression IR nodes; `as`/`dedupeKey` are
406        // non-empty strings, `drop` is "dangling"|"none", `implicitSource` a string or absent, and
407        // `relationKind` is fixed to "connection".
408        require_string_component(fo.get("component"), &format!("{path}.fanout.component"))?;
409        assert_portable_expr(
410            fo.get("over").unwrap_or(&J::Null),
411            &format!("{path}.fanout.over"),
412        )?;
413        assert_portable_ports(fo.get("ports"), &format!("{path}.fanout.ports"))?;
414        for (field, label) in [("as", "an element-binding"), ("dedupeKey", "a dedupe key")] {
415            match fo.get(field).and_then(|v| v.as_str()) {
416                Some(sv) if !sv.is_empty() => {}
417                _ => {
418                    return perr(
419                        &format!("{path}.fanout.{field}"),
420                        format!("fanout '{field}' must be a non-empty {label} string"),
421                    );
422                }
423            }
424        }
425        match fo.get("drop").and_then(|v| v.as_str()) {
426            Some("dangling") | Some("none") => {}
427            _ => {
428                return perr(
429                    &format!("{path}.fanout.drop"),
430                    "fanout 'drop' must be \"dangling\" or \"none\" (fail-closed)",
431                );
432            }
433        }
434        if let Some(is) = fo.get("implicitSource") {
435            if !is.is_string() {
436                return perr(
437                    &format!("{path}.fanout.implicitSource"),
438                    "fanout 'implicitSource' must be a string or absent",
439                );
440            }
441        }
442        if fo.get("relationKind").and_then(|v| v.as_str()) != Some("connection") {
443            return perr(
444                &format!("{path}.fanout.relationKind"),
445                "fanout 'relationKind' must be \"connection\" (fail-closed)",
446            );
447        }
448        assert_port_schemas_present(fo, &format!("{path}.fanout")) // #173
449    } else if let Some(co) = n.get("cond") {
450        assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
451        assert_portable_expr(
452            co.get("then").unwrap_or(&J::Null),
453            &format!("{path}.cond.then"),
454        )?;
455        assert_portable_expr(
456            co.get("else").unwrap_or(&J::Null),
457            &format!("{path}.cond.else"),
458        )
459    } else if n.get("component").is_some() {
460        require_string_component(n.get("component"), &format!("{path}.component"))?;
461        assert_portable_ports(n.get("ports"), &format!("{path}.ports"))?;
462        assert_port_schemas_present(n, path)
463    } else {
464        perr(
465            path,
466            "unknown body node kind (not componentRef/map/cond/fanout)",
467        )
468    }
469}
470
471/// #173: irVersion 2 carries the per-node port type contract. The guard requires the field's
472/// PRESENCE (an object) on the port-bearing node kinds for cross-language parity (the TS guard
473/// additionally parses its structure, since only TS codegen consumes it). Missing / non-object
474/// portSchemas is a malformed v2 IR → fail-closed.
475fn assert_port_schemas_present(holder: &J, path: &str) -> Result<(), PortabilityError> {
476    match holder.get("portSchemas") {
477        Some(ps) if ps.is_object() => Ok(()),
478        _ => perr(
479            &format!("{path}.portSchemas"),
480            "portSchemas is required (irVersion 2 carries the port type contract) and must be an object",
481        ),
482    }
483}
484
485fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
486    match v {
487        Some(J::String(_)) => Ok(()),
488        _ => perr(path, "'component' must be a string catalog reference"),
489    }
490}
491
492fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
493    let obj = ports
494        .and_then(|p| p.as_object())
495        .ok_or_else(|| PortabilityError {
496            path: path.to_string(),
497            message: "ports must be an object".into(),
498        })?;
499    for (k, v) in obj {
500        assert_portable_expr(v, &format!("{path}.{k}"))?;
501    }
502    Ok(())
503}
504
505fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
506    match node {
507        J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
508        J::Array(a) => {
509            for (i, e) in a.iter().enumerate() {
510                assert_portable_expr(e, &format!("{path}[{i}]"))?;
511            }
512            Ok(())
513        }
514        J::Object(o) => {
515            if o.len() == 1 {
516                let (op, arg) = o.iter().next().unwrap();
517                if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
518                    return perr(path, format!("unknown operator '{op}' (fail-closed)"));
519                }
520                // obj は「任意の data key → 子式」の構築ノード(expression-ir.md §3)。arg の
521                // key は operator ではなく data であり、値だけを式として再帰する(単一 data key
522                // の obj が未知 operator と誤判定されないように — evaluate の意味論と一致)。
523                if op == "obj" {
524                    let Some(fields) = arg.as_object() else {
525                        return perr(
526                            &format!("{path}.obj"),
527                            "{obj: ...} expects an object".to_string(),
528                        );
529                    };
530                    for (k, v) in fields {
531                        // 静的 fail-closed(defense-in-depth): own key "__proto__" は evaluator
532                        // が実行時に FORBIDDEN_KEY で拒否する(expression-ir.md §2.3/§8)。
533                        // guard でも静的に拒否する(言語間発散 / prototype pollution 対策)。
534                        if k == FORBIDDEN_OBJECT_KEY {
535                            return perr(
536                                &format!("{path}.obj"),
537                                format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
538                            );
539                        }
540                        assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
541                    }
542                    return Ok(());
543                }
544                assert_portable_expr(arg, &format!("{path}.{op}"))
545            } else {
546                for (k, v) in o {
547                    assert_portable_expr(v, &format!("{path}.{k}"))?;
548                }
549                Ok(())
550            }
551        }
552    }
553}