Skip to main content

behavior_contracts/
behavior.rs

1//! behavior — component-graph IR + `run_behavior` unified execution IF.
2//!
3//! Ports the TS reference `ts/src/behavior.ts` (scp-ir-architecture.md §5-§7).
4//!
5//! A portable component-graph IR (`components[]{name, inputPorts, body[], output, plan}`)
6//! is executed on top of the existing COMMON primitives: `run_plan` (stage exec /
7//! Skip propagation / Policy Kind) + `evaluate` (Expression IR). The concrete
8//! per-component (leaf) implementation is resolved by name through a [`ComponentExec`]
9//! seam — the boundary-injected handler registry (IR + {effects,config,hooks}).
10//!
11//! Body node kinds:
12//!   - componentRef: `{id, component, ports, parent?, bindField?, relationKind?, policy?}`
13//!   - map:          `{id, map:{over, as, component, ports, when?, into?, batched?, parent?,
14//!                    relationKind?, policy?}}` (`when`/`into`/`batched` are behaviorVersion 2)
15//!   - cond:         `{id, cond:{if, then, else, parent?}}` (pure Expression; no handler)
16//!
17//! behaviorVersion 2 (bc#22):
18//!   - `map.when`    — per-element guard, lowered to `{cond:[when,true,false]}` (strict bool;
19//!     non-bool fails closed with TYPE_MISMATCH). Falsy elements are skipped (handler not
20//!     called, excluded from the result list; order preserved).
21//!   - `map.into`    — zip-attach: the node result becomes the `over` list with each
22//!     guard-passing element shallow-copied and augmented with `into: <handler result>`
23//!     (same length/order as `over`; skipped elements pass through unchanged). Parent
24//!     results are never mutated. Non-object kept elements fail closed
25//!     (MAP_INTO_ELEMENT_NOT_OBJECT).
26//!   - `map.batched` — evaluates the ports of all guard-passing elements first, then calls
27//!     the handler ONCE with `{items:[<ports>...]}`; the handler must return a list aligned
28//!     to items (violations fail closed with MAP_BATCH_RESULT_MISMATCH). Zero kept elements
29//!     means the handler is not called and the result is the empty list.
30//!   - handler ctx   — every handler call carries the node identity (nodeId + component)
31//!     via the [`ComponentExec::exec_ctx`] seam (additive; default forwards to `exec`).
32
33#[cfg(feature = "ir")]
34use crate::expr::evaluate as evaluate_expression;
35use crate::expr::ExprFailure;
36#[cfg(feature = "ir")]
37use crate::plan::{run_plan, ExecutionPlanSpec, OpSpec, RelationKind};
38use crate::plan::{ExecOutcome, PlanFailure};
39use crate::value::Value;
40#[cfg(feature = "ir")]
41use serde_json::Value as J;
42
43/// Stable behavior-execution failure codes (in addition to plan/expr codes).
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum BehaviorFailureCode {
46    UnknownComponent,
47    UnknownNodeKind,
48    MapOverNotArray,
49    MapIntoElementNotObject,
50    MapBatchResultMismatch,
51    FanoutOverNotArray,
52    FanoutBatchResultMismatch,
53    UnknownEntry,
54}
55
56impl BehaviorFailureCode {
57    pub fn as_str(self) -> &'static str {
58        match self {
59            BehaviorFailureCode::UnknownComponent => "UNKNOWN_COMPONENT",
60            BehaviorFailureCode::UnknownNodeKind => "UNKNOWN_NODE_KIND",
61            BehaviorFailureCode::MapOverNotArray => "MAP_OVER_NOT_ARRAY",
62            BehaviorFailureCode::MapIntoElementNotObject => "MAP_INTO_ELEMENT_NOT_OBJECT",
63            BehaviorFailureCode::MapBatchResultMismatch => "MAP_BATCH_RESULT_MISMATCH",
64            BehaviorFailureCode::FanoutOverNotArray => "FANOUT_OVER_NOT_ARRAY",
65            BehaviorFailureCode::FanoutBatchResultMismatch => "FANOUT_BATCH_RESULT_MISMATCH",
66            BehaviorFailureCode::UnknownEntry => "UNKNOWN_ENTRY",
67        }
68    }
69}
70
71/// A unified failure for `run_behavior`, carrying a stable string code so the
72/// conformance runner can compare against expression / plan / behavior codes alike.
73#[derive(Debug, Clone)]
74pub struct BehaviorError {
75    code: String,
76    pub message: String,
77    /// The structured, recoverable payload a failure carries (scp-error.md "The Error Value").
78    /// Transported verbatim from the leaf / the outType conformance check; `None` when the failure
79    /// is not about a datum. Boxed to keep the `Err` variant small.
80    pub detail: Option<Box<crate::plan::ErrorDetail>>,
81}
82
83impl BehaviorError {
84    /// Construct a coded behavior error. Public so the straight-line codegen surface
85    /// (bc#39) can raise the same fail-closed codes/messages as `run_behavior`
86    /// (`UNKNOWN_COMPONENT` / `UNKNOWN_ENTRY` / `UNKNOWN_NODE_KIND`) without depending
87    /// on the private `BehaviorFailureCode` enum.
88    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
89        BehaviorError {
90            code: code.into(),
91            message: message.into(),
92            detail: None,
93        }
94    }
95
96    /// The stable string code used for conformance matching.
97    pub fn code(&self) -> &str {
98        &self.code
99    }
100
101    /// The structured payload, if this failure is about a datum.
102    pub fn detail(&self) -> Option<&crate::plan::ErrorDetail> {
103        self.detail.as_deref()
104    }
105}
106
107impl std::fmt::Display for BehaviorError {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        write!(f, "{}: {}", self.code, self.message)
110    }
111}
112impl std::error::Error for BehaviorError {}
113
114impl From<ExprFailure> for BehaviorError {
115    fn from(e: ExprFailure) -> Self {
116        BehaviorError {
117            code: e.code.as_str().to_string(),
118            message: e.message,
119            detail: e.detail,
120        }
121    }
122}
123impl From<PlanFailure> for BehaviorError {
124    fn from(e: PlanFailure) -> Self {
125        BehaviorError {
126            code: e.code.as_str().to_string(),
127            message: e.message,
128            detail: e.detail,
129        }
130    }
131}
132
133#[cfg(feature = "ir")]
134fn bfail<T>(code: BehaviorFailureCode, message: impl Into<String>) -> Result<T, BehaviorError> {
135    Err(BehaviorError {
136        code: code.as_str().to_string(),
137        message: message.into(),
138        detail: None,
139    })
140}
141
142// ── outType conformance (scp-error.md "The outType conformance check") ──────────────────────
143/// The wire type name of an observed value (ErrorDetail.actual_wire_type vocabulary).
144#[cfg(feature = "ir")]
145fn wire_type_name(v: &Value) -> &'static str {
146    v.type_name()
147}
148
149/// The offending value, stringified (never re-parsed to recover a type).
150#[cfg(feature = "ir")]
151fn raw_value_of(v: &Value) -> String {
152    match v {
153        Value::Str(s) => s.clone(),
154        Value::Int(i) => i.to_string(),
155        Value::Float(f) => crate::canonical::py_float_repr(*f).unwrap_or_else(|_| f.to_string()),
156        Value::Bool(b) => b.to_string(),
157        Value::Null => "null".to_string(),
158        _ => crate::canonical::canonical_json(v).unwrap_or_else(|_| v.type_name().to_string()),
159    }
160}
161
162/// Portable Type Notation of a declared type node (scp-error.md). The `outType` arrives as IR JSON.
163#[cfg(feature = "ir")]
164fn portable_type_notation(t: &J) -> String {
165    if let Some(s) = t.as_str() {
166        return s.to_string();
167    }
168    for key in ["opt", "arr", "map"] {
169        if let Some(inner) = t.get(key) {
170            return format!("{key}({})", portable_type_notation(inner));
171        }
172    }
173    if let Some(J::Object(o)) = t.get("obj") {
174        let parts: Vec<String> = o
175            .iter()
176            .map(|(k, v)| format!("{k}:{}", portable_type_notation(v)))
177            .collect();
178        return format!("obj{{{}}}", parts.join(","));
179    }
180    "?".to_string()
181}
182
183/// The node's declared result type: a map node's `outType` is its ELEMENT type, so its result is an
184/// array of that element (the same reading the compiler applies). Other node kinds: the type itself.
185#[cfg(feature = "ir")]
186fn result_type_of(node: &J, out_type: &J) -> J {
187    if node.get("map").is_some() {
188        serde_json::json!({ "arr": out_type })
189    } else {
190        out_type.clone()
191    }
192}
193
194// ── structural declared-type resolution (#177 (A) opt-over empty iteration) ──────────────
195// Rust twin of behavior.ts portableTypeOfInputPort / descend / overIsDeclaredOptArr — resolves whether
196// a fanout/map `over` is a bare ref to a declared {opt:{arr}} so a null over iterates EMPTY.
197
198/// portable_type_of_input_port — an input port schema (J) → determined PortableType (J), else None.
199#[cfg(feature = "ir")]
200fn portable_type_of_input_port(schema: &J) -> Option<J> {
201    let base: J = match schema.get("type").and_then(|v| v.as_str()) {
202        Some(t @ ("string" | "int" | "float" | "bool" | "null")) => J::String(t.to_string()),
203        Some("array") => serde_json::json!({ "arr": schema.get("elemType")? }),
204        Some("map") => serde_json::json!({ "map": schema.get("elemType")? }),
205        _ => return None,
206    };
207    if schema.get("required") == Some(&J::Bool(false)) {
208        Some(serde_json::json!({ "opt": base }))
209    } else {
210        Some(base)
211    }
212}
213
214/// descend — the type of `field` one level into a determined type ({obj} field / {opt} re-wrap), else None.
215#[cfg(feature = "ir")]
216fn descend(t: &J, field: &str) -> Option<J> {
217    if t.is_string() {
218        return None;
219    }
220    if let Some(obj) = t.get("obj") {
221        return obj.get(field).cloned();
222    }
223    if let Some(inner) = t.get("opt") {
224        let d = descend(inner, field)?;
225        return Some(serde_json::json!({ "opt": d }));
226    }
227    None
228}
229
230/// bare_ref_path — the static string path of a bare {ref:[…]}/{refOpt:[…]}, else None.
231#[cfg(feature = "ir")]
232fn bare_ref_path(over: &J) -> Option<Vec<String>> {
233    let obj = over.as_object()?;
234    if obj.len() != 1 {
235        return None;
236    }
237    let (k, v) = obj.iter().next()?;
238    if k != "ref" && k != "refOpt" {
239        return None;
240    }
241    let arr = v.as_array()?;
242    if arr.is_empty() {
243        return None;
244    }
245    arr.iter()
246        .map(|p| p.as_str().map(|s| s.to_string()))
247        .collect()
248}
249
250/// is_opt_arr_type — whether t is the {opt:{arr:…}} shape (a declared optional collection).
251#[cfg(feature = "ir")]
252fn is_opt_arr_type(t: &J) -> bool {
253    t.get("opt").and_then(|inner| inner.get("arr")).is_some()
254}
255
256/// over_is_declared_opt_arr — whether a fanout/map `over` (bare ref only) is a declared {opt:{arr}}
257/// (#177 (A)): a prior-node ref's result type descended by path, or an optional input array port (whose
258/// opt-collection semantics do not depend on the native-only elemType annotation).
259#[cfg(feature = "ir")]
260fn over_is_declared_opt_arr(
261    over: &J,
262    body: &[J],
263    input_ports: Option<&serde_json::Map<String, J>>,
264) -> bool {
265    let path = match bare_ref_path(over) {
266        Some(p) => p,
267        None => return false,
268    };
269    let (head, rest) = (&path[0], &path[1..]);
270    let node = body
271        .iter()
272        .find(|n| n.get("id").and_then(|v| v.as_str()) == Some(head.as_str()));
273    let mut cur: J = if let Some(node) = node {
274        match node.get("outType") {
275            Some(ot) => result_type_of(node, ot),
276            None => return false,
277        }
278    } else {
279        let schema = match input_ports.and_then(|ip| ip.get(head)) {
280            Some(s) => s,
281            None => return false,
282        };
283        if rest.is_empty() {
284            return schema.get("required") == Some(&J::Bool(false))
285                && schema.get("type").and_then(|v| v.as_str()) == Some("array");
286        }
287        match portable_type_of_input_port(schema) {
288            Some(t) => t,
289            None => return false,
290        }
291    };
292    for f in rest {
293        cur = match descend(&cur, f) {
294            Some(t) => t,
295            None => return false,
296        };
297    }
298    is_opt_arr_type(&cur)
299}
300
301/// resolve_over_array — the SOLE gate resolving a fanout/map `over` to its iterated array: an array
302/// passes; a null over whose declared type is {opt:{arr}} iterates EMPTY (#177 (A)); any other non-array
303/// fails closed with (FANOUT|MAP)_OVER_NOT_ARRAY (strict typing preserved).
304#[cfg(feature = "ir")]
305fn resolve_over_array(
306    over: &Value,
307    over_expr: &J,
308    body: &[J],
309    input_ports: Option<&serde_json::Map<String, J>>,
310    code: BehaviorFailureCode,
311    op_id: &str,
312    label: &str,
313) -> Result<Vec<Value>, BehaviorError> {
314    if let Value::Arr(a) = over {
315        return Ok(a.clone());
316    }
317    if matches!(over, Value::Null) && over_is_declared_opt_arr(over_expr, body, input_ports) {
318        return Ok(Vec::new());
319    }
320    Err(BehaviorError {
321        code: code.as_str().to_string(),
322        message: format!("{label} '{op_id}': 'over' is not an array"),
323        detail: None,
324    })
325}
326
327#[cfg(feature = "ir")]
328fn conform_fail(
329    code: crate::expr::ExprFailureCode,
330    message: String,
331    kind: crate::plan::ErrorKind,
332    node_id: &str,
333    field: &str,
334    expected: &J,
335    actual: Option<&Value>,
336) -> ExprFailure {
337    let mut detail = crate::plan::ErrorDetail {
338        kind: Some(kind),
339        model: Some(node_id.to_string()),
340        field: Some(field.to_string()),
341        expected_type: Some(portable_type_notation(expected)),
342        ..Default::default()
343    };
344    if let Some(a) = actual {
345        detail.actual_wire_type = Some(wire_type_name(a).to_string());
346        detail.raw_value = Some(raw_value_of(a));
347    }
348    ExprFailure {
349        code,
350        message,
351        detail: Some(Box::new(detail)),
352    }
353}
354
355/// Set a key on an insertion-ordered object pair list: replace in place if present, else append.
356#[cfg(feature = "ir")]
357fn obj_set(pairs: &mut Vec<(String, Value)>, key: &str, val: Value) {
358    if let Some(slot) = pairs.iter_mut().find(|(k, _)| k == key) {
359        slot.1 = val;
360    } else {
361        pairs.push((key.to_string(), val));
362    }
363}
364
365/// Check a value against a declared `outType` (Portable Type Notation as IR JSON), failing closed with
366/// TYPE_MISMATCH / MISSING_PROP carrying the structured Error Value, and return the value NORMALIZED to
367/// the declared type. Semantics match the emitters' de-box so generated code and the runtime agree: a
368/// `float` slot given an int WIDENS the value to `Value::Float`; an absent declared `opt` field
369/// materializes as `Value::Null` (de-box's absent→None); a required field's absence is MISSING_PROP;
370/// undeclared extra keys pass through.
371#[cfg(feature = "ir")]
372fn assert_conforms_to_out_type(
373    node_id: &str,
374    v: &Value,
375    t: &J,
376    field: &str,
377) -> Result<Value, ExprFailure> {
378    use crate::expr::ExprFailureCode::{MissingProp, TypeMismatch};
379    use crate::plan::ErrorKind;
380    if let Some(scalar) = t.as_str() {
381        if scalar == "float" {
382            if let Value::Int(i) = v {
383                return Ok(Value::Float(*i as f64)); // int → float widen (de-box: ParseFloat と同一)
384            }
385        }
386        let ok = match scalar {
387            "string" => matches!(v, Value::Str(_)),
388            "int" => matches!(v, Value::Int(_)),
389            "float" => matches!(v, Value::Float(_)),
390            "bool" => matches!(v, Value::Bool(_)),
391            "null" => matches!(v, Value::Null),
392            _ => true,
393        };
394        if !ok {
395            return Err(conform_fail(
396                TypeMismatch,
397                format!(
398                    "node '{node_id}': {field}: expected {scalar}, got {}",
399                    wire_type_name(v)
400                ),
401                ErrorKind::TypeMismatch,
402                node_id,
403                field,
404                t,
405                Some(v),
406            ));
407        }
408        return Ok(v.clone());
409    }
410    if let Some(inner) = t.get("opt") {
411        if matches!(v, Value::Null) {
412            return Ok(Value::Null);
413        }
414        return assert_conforms_to_out_type(node_id, v, inner, field);
415    }
416    if let Some(inner) = t.get("arr") {
417        match v {
418            Value::Arr(items) => {
419                let mut out = Vec::with_capacity(items.len());
420                for (i, el) in items.iter().enumerate() {
421                    out.push(assert_conforms_to_out_type(
422                        node_id,
423                        el,
424                        inner,
425                        &format!("{field}[{i}]"),
426                    )?);
427                }
428                return Ok(Value::Arr(out));
429            }
430            _ => {
431                return Err(conform_fail(
432                    TypeMismatch,
433                    format!(
434                        "node '{node_id}': {field}: expected arr, got {}",
435                        wire_type_name(v)
436                    ),
437                    ErrorKind::TypeMismatch,
438                    node_id,
439                    field,
440                    t,
441                    Some(v),
442                ))
443            }
444        }
445    }
446    if let Some(inner) = t.get("map") {
447        match v {
448            Value::Obj(pairs) => {
449                let mut out = Vec::with_capacity(pairs.len());
450                for (k, mv) in pairs {
451                    out.push((
452                        k.clone(),
453                        assert_conforms_to_out_type(node_id, mv, inner, &format!("{field}.{k}"))?,
454                    ));
455                }
456                return Ok(Value::Obj(out));
457            }
458            _ => {
459                return Err(conform_fail(
460                    TypeMismatch,
461                    format!(
462                        "node '{node_id}': {field}: expected map, got {}",
463                        wire_type_name(v)
464                    ),
465                    ErrorKind::TypeMismatch,
466                    node_id,
467                    field,
468                    t,
469                    Some(v),
470                ))
471            }
472        }
473    }
474    // obj: check each declared field (absent opt → Null, absent required → MISSING_PROP); keep extras.
475    if let Some(J::Object(fields)) = t.get("obj") {
476        let mut out: Vec<(String, Value)> = match v {
477            Value::Obj(pairs) => pairs.clone(),
478            _ => {
479                return Err(conform_fail(
480                    TypeMismatch,
481                    format!(
482                        "node '{node_id}': {field}: expected obj, got {}",
483                        wire_type_name(v)
484                    ),
485                    ErrorKind::TypeMismatch,
486                    node_id,
487                    field,
488                    t,
489                    Some(v),
490                ));
491            }
492        };
493        for (k, ft) in fields {
494            match v.obj_get(k) {
495                None => {
496                    if ft.get("opt").is_some() {
497                        obj_set(&mut out, k, Value::Null); // absent opt → null (de-box: absent→None)
498                    } else {
499                        return Err(conform_fail(
500                            MissingProp,
501                            format!("node '{node_id}': {field}: missing property .{k}"),
502                            ErrorKind::MissingField,
503                            node_id,
504                            &format!("{field}.{k}"),
505                            ft,
506                            None,
507                        ));
508                    }
509                }
510                Some(fv) => {
511                    let nv = assert_conforms_to_out_type(node_id, fv, ft, &format!("{field}.{k}"))?;
512                    obj_set(&mut out, k, nv);
513                }
514            }
515        }
516        return Ok(Value::Obj(out));
517    }
518    Ok(v.clone())
519}
520
521/// The boundary-injected handler registry seam (§7.1). Resolves a catalog name to
522/// a leaf implementation and executes it with evaluated ports (+ the map element
523/// `bound` value, if any). Returns `None` when the name is unknown so `run_behavior`
524/// can fail closed with `UNKNOWN_COMPONENT`.
525pub trait ComponentExec {
526    fn exec(
527        &mut self,
528        component: &str,
529        ports: &[(String, Value)],
530        bound: Option<&Value>,
531    ) -> Option<ExecOutcome>;
532
533    /// behaviorVersion 2: like [`ComponentExec::exec`] but carrying the handler ctx
534    /// (`node_id` = body node id, `component` = catalog name). `run_behavior` calls
535    /// this seam for every handler invocation. The default forwards to `exec`, so
536    /// existing implementations keep working unchanged; override it to observe the
537    /// node identity (error context / tracing).
538    fn exec_ctx(
539        &mut self,
540        node_id: &str,
541        component: &str,
542        ports: &[(String, Value)],
543        bound: Option<&Value>,
544    ) -> Option<ExecOutcome> {
545        let _ = node_id;
546        self.exec(component, ports, bound)
547    }
548}
549
550/// Blanket forwarding impl so a **trait object** handler registry
551/// (`&mut dyn ComponentExec`) itself satisfies [`ComponentExec`] (bc#68).
552///
553/// The generic straight-line / typed codegen surface exposes generic-by-value
554/// entries (`bind<H: ComponentExec>(handlers: H)` / `run_*<H: ComponentExec>(
555/// handlers: &mut H, …)`). A consumer that holds its handler registry behind a
556/// trait object (`handlers: &mut dyn ComponentExec` — exactly how [`run_behavior`]
557/// takes it) could not name a concrete `H` to hand those entries. This impl makes
558/// `H = &mut dyn ComponentExec` a valid instantiation, so the trait-object handler
559/// drives the generated straight-line/typed module through the *unchanged* generic
560/// path (no `bind_dyn` variant needed, no emitter change). It is purely additive:
561/// every existing concrete `H` keeps working exactly as before.
562///
563/// Both methods forward to the underlying `dyn` (a plain reborrow), so calling a
564/// generated module through `&mut dyn` runs the SAME straight-line/typed code as a
565/// concrete handler — it never falls back to `run_behavior`.
566impl ComponentExec for &mut dyn ComponentExec {
567    fn exec(
568        &mut self,
569        component: &str,
570        ports: &[(String, Value)],
571        bound: Option<&Value>,
572    ) -> Option<ExecOutcome> {
573        (**self).exec(component, ports, bound)
574    }
575
576    fn exec_ctx(
577        &mut self,
578        node_id: &str,
579        component: &str,
580        ports: &[(String, Value)],
581        bound: Option<&Value>,
582    ) -> Option<ExecOutcome> {
583        (**self).exec_ctx(node_id, component, ports, bound)
584    }
585}
586
587#[cfg(feature = "ir")]
588fn scope_get<'a>(scope: &'a [(String, Value)], key: &str) -> Option<&'a Value> {
589    scope.iter().find(|(k, _)| k == key).map(|(_, v)| v)
590}
591
592/// fanout_dedup_drop — THE ONE dedup/drop definition (behaviorVersion 3) — Rust twin of behavior.ts
593/// fanoutDedupDrop. Applies first-seen dedupe (by the body's `dedupe_key` field) + dangling drop
594/// (Null / non-object / absent-key body when `drop=="dangling"`) + implicitSource strip to the aligned
595/// raw list; returns the connection items (the caller wraps into {items,cursor:Null}). Byte-equal to
596/// the TS/Python/PHP/Go definitions. Bodies are `Value::Obj`; a Null / non-Obj body is dangling.
597#[cfg(feature = "ir")]
598fn fanout_dedup_drop(
599    aligned_bodies: Vec<Value>,
600    dedupe_key: &str,
601    drop: &str,
602    implicit_source: Option<&str>,
603) -> Vec<Value> {
604    use std::collections::HashSet;
605    let mut items: Vec<Value> = Vec::with_capacity(aligned_bodies.len());
606    let mut seen: HashSet<String> = HashSet::new();
607    for body in aligned_bodies.into_iter() {
608        let fields = match &body {
609            Value::Obj(f) => Some(f),
610            _ => None,
611        };
612        let key_val = fields.and_then(|f| f.iter().find(|(k, _)| k == dedupe_key).map(|(_, v)| v));
613        let has_key = matches!(key_val, Some(v) if !matches!(v, Value::Null));
614        if !has_key {
615            if drop == "dangling" {
616                continue;
617            }
618            items.push(body); // drop:none keeps a dangling body
619            continue;
620        }
621        let seen_key = match key_val.unwrap() {
622            Value::Str(s) => format!("s:{s}"),
623            other => format!("j:{other:?}"),
624        };
625        if seen.contains(&seen_key) {
626            continue;
627        }
628        seen.insert(seen_key);
629        match (implicit_source, fields) {
630            (Some(src), Some(f)) if f.iter().any(|(k, _)| k == src) => {
631                let stripped: Vec<(String, Value)> =
632                    f.iter().filter(|(k, _)| k != src).cloned().collect();
633                items.push(Value::Obj(stripped));
634            }
635            _ => items.push(body),
636        }
637    }
638    items
639}
640
641#[cfg(feature = "ir")]
642fn node_kind(n: &J) -> Result<&'static str, BehaviorError> {
643    if n.get("fanout").is_some() {
644        Ok("fanout")
645    } else if n.get("map").is_some() {
646        Ok("map")
647    } else if n.get("cond").is_some() {
648        Ok("cond")
649    } else if n.get("component").is_some() {
650        Ok("componentRef")
651    } else {
652        bfail(
653            BehaviorFailureCode::UnknownNodeKind,
654            format!(
655                "body node '{}' is not componentRef/map/cond/fanout",
656                n.get("id").and_then(|v| v.as_str()).unwrap_or("?")
657            ),
658        )
659    }
660}
661
662#[cfg(feature = "ir")]
663fn node_sub(n: &J) -> &J {
664    if let Some(f) = n.get("fanout") {
665        f
666    } else if let Some(m) = n.get("map") {
667        m
668    } else if let Some(c) = n.get("cond") {
669        c
670    } else {
671        n
672    }
673}
674
675#[cfg(feature = "ir")]
676fn node_parent(n: &J) -> Option<&str> {
677    node_sub(n).get("parent").and_then(|v| v.as_str())
678}
679
680#[cfg(feature = "ir")]
681fn node_bind_field(n: &J) -> Option<String> {
682    if n.get("map").is_some() || n.get("cond").is_some() || n.get("fanout").is_some() {
683        return None;
684    }
685    n.get("bindField")
686        .and_then(|v| v.as_str())
687        .map(str::to_string)
688}
689
690#[cfg(feature = "ir")]
691fn node_relation_kind(n: &J) -> Option<RelationKind> {
692    if n.get("cond").is_some() {
693        return None;
694    }
695    let sub = if n.get("map").is_some() || n.get("fanout").is_some() {
696        node_sub(n)
697    } else {
698        n
699    };
700    match sub.get("relationKind").and_then(|v| v.as_str()) {
701        Some("connection") => Some(RelationKind::Connection),
702        Some(_) => Some(RelationKind::Single),
703        None => None,
704    }
705}
706
707#[cfg(feature = "ir")]
708fn node_relation_kind_str(n: &J) -> Option<&str> {
709    if n.get("cond").is_some() {
710        return None;
711    }
712    let sub = if n.get("map").is_some() || n.get("fanout").is_some() {
713        node_sub(n)
714    } else {
715        n
716    };
717    sub.get("relationKind").and_then(|v| v.as_str())
718}
719
720#[cfg(feature = "ir")]
721fn node_policy(n: &J) -> Option<String> {
722    if n.get("cond").is_some() {
723        return None;
724    }
725    let sub = if n.get("map").is_some() || n.get("fanout").is_some() {
726        node_sub(n)
727    } else {
728        n
729    };
730    sub.get("policy")
731        .and_then(|v| v.as_str())
732        .map(str::to_string)
733}
734
735#[cfg(feature = "ir")]
736fn eval_ports(ports: &J, scope: &[(String, Value)]) -> Result<Vec<(String, Value)>, BehaviorError> {
737    let obj = ports.as_object().ok_or_else(|| BehaviorError {
738        code: BehaviorFailureCode::UnknownNodeKind.as_str().to_string(),
739        message: "ports must be an object".into(),
740        detail: None,
741    })?;
742    let mut out = Vec::with_capacity(obj.len());
743    for (k, v) in obj {
744        out.push((k.clone(), evaluate_expression(v, scope)?));
745    }
746    Ok(out)
747}
748
749/// Parse an `ExecutionPlanSpec` from a component's `plan` field (`null`/absent → None).
750#[cfg(feature = "ir")]
751fn parse_plan(plan: Option<&J>) -> Option<ExecutionPlanSpec> {
752    let p = plan?;
753    if p.is_null() {
754        return None;
755    }
756    let groups = p
757        .get("groups")?
758        .as_array()?
759        .iter()
760        .map(|g| {
761            g.as_array()
762                .map(|row| {
763                    row.iter()
764                        .filter_map(|i| i.as_u64().map(|x| x as usize))
765                        .collect()
766                })
767                .unwrap_or_default()
768        })
769        .collect();
770    let concurrency = p.get("concurrency").and_then(|c| c.as_i64()).unwrap_or(1);
771    Some(ExecutionPlanSpec {
772        groups,
773        concurrency,
774    })
775}
776
777/// Run a component-graph IR (scp-ir-architecture.md §7).
778///
779/// * `ir`      — the portable component-graph IR (`{components:[...]}`).
780/// * `handlers`— the boundary-injected [`ComponentExec`] seam (catalog name → leaf impl).
781/// * `input`   — the entry component's inputPorts bindings (param values).
782/// * `entry`   — the component name to run (`None` = first component).
783///
784/// Returns the evaluated `output` (Φ merge) or a coded [`BehaviorError`].
785#[cfg(feature = "ir")]
786pub fn run_behavior(
787    ir: &J,
788    handlers: &mut dyn ComponentExec,
789    input: &[(String, Value)],
790    entry: Option<&str>,
791) -> Result<Value, BehaviorError> {
792    let components = ir
793        .get("components")
794        .and_then(|c| c.as_array())
795        .ok_or_else(|| BehaviorError {
796            code: BehaviorFailureCode::UnknownEntry.as_str().to_string(),
797            message: "IR.components must be an array".into(),
798            detail: None,
799        })?;
800    let comp = match entry {
801        Some(name) => components
802            .iter()
803            .find(|c| c.get("name").and_then(|n| n.as_str()) == Some(name)),
804        None => components.first(),
805    };
806    let comp = match comp {
807        Some(c) => c,
808        None => {
809            return bfail(
810                BehaviorFailureCode::UnknownEntry,
811                format!("component '{}' not found in IR", entry.unwrap_or("<first>")),
812            )
813        }
814    };
815
816    let body: Vec<J> = comp
817        .get("body")
818        .and_then(|b| b.as_array())
819        .cloned()
820        .unwrap_or_default();
821
822    let index_of = |id: &str| {
823        body.iter()
824            .position(|n| n.get("id").and_then(|v| v.as_str()) == Some(id))
825    };
826
827    // Build OpSpecs for run_plan staging (parent id → index via Wire).
828    let mut ops: Vec<OpSpec> = Vec::with_capacity(body.len());
829    for n in &body {
830        let parent = node_parent(n).and_then(index_of);
831        ops.push(OpSpec {
832            id: n
833                .get("id")
834                .and_then(|v| v.as_str())
835                .unwrap_or_default()
836                .to_string(),
837            parent,
838            bind_field: node_bind_field(n),
839            relation_kind: node_relation_kind(n),
840            policy: node_policy(n),
841        });
842    }
843
844    run_behavior_inner(
845        &body,
846        &ops,
847        parse_plan(comp.get("plan")),
848        comp.get("output").cloned().unwrap_or(J::Null),
849        &bind_input_scope(comp, input),
850        comp.get("inputPorts").and_then(|p| p.as_object()),
851        handlers,
852    )
853}
854
855/// bind_input_scope — the entry component's input bindings, read against its declaration
856/// (`inputPorts`).
857///
858/// An input port declared `{opt:T}` (PortSchema `required:false`) is OMITTABLE and its value domain is
859/// `T | null`. "Key omitted" and "key present as null" are two spellings of the same "no value", so an
860/// omitted key binds to null. This only reads a declaration the portable IR already carries — no new
861/// information, no new operator.
862///
863/// Required ports and undeclared names are NOT bound: referencing one unbound is still
864/// `UNKNOWN_BINDING` (the fail-closed for a typo / an unwired param stands — only a port DECLARED
865/// omittable is relaxed).
866#[cfg(feature = "ir")]
867fn bind_input_scope(comp: &J, input: &[(String, Value)]) -> Vec<(String, Value)> {
868    let mut scope: Vec<(String, Value)> = input.to_vec();
869    if let Some(ports) = comp.get("inputPorts").and_then(|p| p.as_object()) {
870        for (name, schema) in ports {
871            let optional = schema.get("required").and_then(|r| r.as_bool()) == Some(false);
872            if optional && !scope.iter().any(|(k, _)| k == name) {
873                scope.push((name.clone(), Value::Null));
874            }
875        }
876    }
877    scope
878}
879
880/// Single-closure implementation: builds the exec closure that owns `handlers`,
881/// `results`, and `pending_err`, delegates staging to `run_plan`, then evaluates
882/// the component `output`. Kept separate to sidestep split-borrow layering.
883#[cfg(feature = "ir")]
884fn run_behavior_inner(
885    body: &[J],
886    ops: &[OpSpec],
887    plan: Option<ExecutionPlanSpec>,
888    output: J,
889    input: &[(String, Value)],
890    input_ports: Option<&serde_json::Map<String, J>>,
891    handlers: &mut dyn ComponentExec,
892) -> Result<Value, BehaviorError> {
893    let index_of = |id: &str| {
894        body.iter()
895            .position(|n| n.get("id").and_then(|v| v.as_str()) == Some(id))
896    };
897
898    let mut results: Vec<(String, Value)> = Vec::new();
899    let mut pending_err: Option<BehaviorError> = None;
900
901    let run = {
902        let results_cell = &mut results;
903        let err_cell = &mut pending_err;
904        let exec = |op: &OpSpec, _bound: Option<&Value>| -> ExecOutcome {
905            if err_cell.is_some() {
906                return ExecOutcome::error("aborted");
907            }
908            let idx = match index_of(&op.id) {
909                Some(i) => i,
910                None => {
911                    *err_cell = Some(BehaviorError {
912                        code: BehaviorFailureCode::UnknownNodeKind.as_str().to_string(),
913                        message: format!("no body node for op '{}'", op.id),
914                        detail: None,
915                    });
916                    return ExecOutcome::error("aborted");
917                }
918            };
919            let node = &body[idx];
920
921            let base_scope = |extra: Option<(&str, &Value)>| -> Vec<(String, Value)> {
922                let mut s: Vec<(String, Value)> = input.to_vec();
923                for (k, v) in results_cell.iter() {
924                    s.push((k.clone(), v.clone()));
925                }
926                if let Some((k, v)) = extra {
927                    s.push((k.to_string(), v.clone()));
928                }
929                s
930            };
931
932            let outcome = match node_kind(node) {
933                Err(e) => {
934                    *err_cell = Some(e);
935                    return ExecOutcome::error("aborted");
936                }
937                Ok("cond") => {
938                    let c = node.get("cond").unwrap();
939                    let cond_expr = serde_json::json!({
940                        "cond": [c.get("if"), c.get("then"), c.get("else")]
941                    });
942                    match evaluate_expression(&cond_expr, &base_scope(None)) {
943                        Ok(v) => ExecOutcome::Ok(v),
944                        Err(e) => {
945                            *err_cell = Some(e.into());
946                            return ExecOutcome::error("aborted");
947                        }
948                    }
949                }
950                Ok("map") => {
951                    let m = node.get("map").unwrap();
952                    let over = match evaluate_expression(
953                        m.get("over").unwrap_or(&J::Null),
954                        &base_scope(None),
955                    ) {
956                        Ok(v) => v,
957                        Err(e) => {
958                            *err_cell = Some(e.into());
959                            return ExecOutcome::error("aborted");
960                        }
961                    };
962                    let arr = match resolve_over_array(
963                        &over,
964                        m.get("over").unwrap_or(&J::Null),
965                        body,
966                        input_ports,
967                        BehaviorFailureCode::MapOverNotArray,
968                        &op.id,
969                        "map",
970                    ) {
971                        Ok(a) => a,
972                        Err(e) => {
973                            *err_cell = Some(e);
974                            return ExecOutcome::error("aborted");
975                        }
976                    };
977                    let component = m.get("component").and_then(|v| v.as_str()).unwrap_or("");
978                    let as_name = m.get("as").and_then(|v| v.as_str()).unwrap_or("$");
979                    let ports_j = m
980                        .get("ports")
981                        .cloned()
982                        .unwrap_or(J::Object(Default::default()));
983                    let when = m.get("when");
984                    let batched = m.get("batched").and_then(|v| v.as_bool()).unwrap_or(false);
985                    // Element Error Policy Kind (scp-error.md). Default `error`; an unknown kind
986                    // fails closed. `skip` needs a per-element Failure, so a batched map (one outcome
987                    // for the whole batch) rejects it.
988                    let element_policy = {
989                        let raw = m
990                            .get("elementPolicy")
991                            .and_then(|v| v.as_str())
992                            .unwrap_or("error");
993                        match crate::plan::ElementPolicyKind::parse(raw) {
994                            None => {
995                                *err_cell = Some(BehaviorError {
996                                    code: "UNKNOWN_ELEMENT_POLICY".to_string(),
997                                    message: format!(
998                                        "map '{}': unknown element policy '{raw}' (fail-closed)",
999                                        op.id
1000                                    ),
1001                                    detail: None,
1002                                });
1003                                return ExecOutcome::error("aborted");
1004                            }
1005                            Some(crate::plan::ElementPolicyKind::Skip) if batched => {
1006                                *err_cell = Some(BehaviorError {
1007                                    code: "ELEMENT_POLICY_NOT_APPLICABLE".to_string(),
1008                                    message: format!("map '{}': elementPolicy 'skip' needs a per-element Failure, but a batched map takes ONE outcome for the whole batch (fail-closed)", op.id),
1009                                    detail: None,
1010                                });
1011                                return ExecOutcome::error("aborted");
1012                            }
1013                            Some(p) => p,
1014                        }
1015                    };
1016                    let into = m.get("into").and_then(|v| v.as_str());
1017
1018                    // per-element guard (v2): lowered to `{cond:[when,true,false]}`
1019                    // (strict bool — a non-bool guard fails closed via the cond operator).
1020                    let keep = |scope: &[(String, Value)]| -> Result<bool, BehaviorError> {
1021                        match when {
1022                            None => Ok(true),
1023                            Some(w) => {
1024                                let cond_expr = serde_json::json!({ "cond": [w, true, false] });
1025                                Ok(matches!(
1026                                    evaluate_expression(&cond_expr, scope)?,
1027                                    Value::Bool(true)
1028                                ))
1029                            }
1030                        }
1031                    };
1032
1033                    let mut kept_idx: Vec<usize> = Vec::new(); // guard-passing over indices (for into)
1034                    let collected: Vec<Value>;
1035                    if batched {
1036                        // batched (v2): evaluate all guard-passing ports first, call handler ONCE.
1037                        let mut items: Vec<Value> = Vec::new();
1038                        for (i, el) in arr.iter().enumerate() {
1039                            let scope = base_scope(Some((as_name, el)));
1040                            match keep(&scope) {
1041                                Ok(false) => continue,
1042                                Ok(true) => {}
1043                                Err(e) => {
1044                                    *err_cell = Some(e);
1045                                    return ExecOutcome::error("aborted");
1046                                }
1047                            }
1048                            match eval_ports(&ports_j, &scope) {
1049                                Ok(p) => items.push(Value::Obj(p)),
1050                                Err(e) => {
1051                                    *err_cell = Some(e);
1052                                    return ExecOutcome::error("aborted");
1053                                }
1054                            }
1055                            kept_idx.push(i);
1056                        }
1057                        if items.is_empty() {
1058                            collected = Vec::new(); // all filtered: handler is not called
1059                        } else {
1060                            let want = items.len();
1061                            let batch_ports = vec![("items".to_string(), Value::Arr(items))];
1062                            match handlers.exec_ctx(&op.id, component, &batch_ports, None) {
1063                                None => {
1064                                    *err_cell = Some(BehaviorError {
1065                                        code: BehaviorFailureCode::UnknownComponent
1066                                            .as_str()
1067                                            .to_string(),
1068                                        message: format!(
1069                                            "component '{component}' has no handler (fail-closed)"
1070                                        ),
1071                                        detail: None,
1072                                    });
1073                                    return ExecOutcome::error("aborted");
1074                                }
1075                                Some(ExecOutcome::Error(e, d)) => return ExecOutcome::Error(e, d),
1076                                Some(ExecOutcome::Ok(Value::Arr(r))) if r.len() == want => {
1077                                    collected = r;
1078                                }
1079                                Some(ExecOutcome::Ok(_)) => {
1080                                    *err_cell = Some(BehaviorError {
1081                                        code: BehaviorFailureCode::MapBatchResultMismatch
1082                                            .as_str()
1083                                            .to_string(),
1084                                        message: format!(
1085                                            "map '{}': batched handler must return a list aligned to items (want {want})",
1086                                            op.id
1087                                        ),
1088                        detail: None,
1089                    });
1090                                    return ExecOutcome::error("aborted");
1091                                }
1092                            }
1093                        }
1094                    } else {
1095                        let mut out: Vec<Value> = Vec::with_capacity(arr.len());
1096                        for (i, el) in arr.iter().enumerate() {
1097                            let scope = base_scope(Some((as_name, el)));
1098                            match keep(&scope) {
1099                                Ok(false) => continue,
1100                                Ok(true) => {}
1101                                Err(e) => {
1102                                    *err_cell = Some(e);
1103                                    return ExecOutcome::error("aborted");
1104                                }
1105                            }
1106                            let ports = match eval_ports(&ports_j, &scope) {
1107                                Ok(p) => p,
1108                                Err(e) => {
1109                                    *err_cell = Some(e);
1110                                    return ExecOutcome::error("aborted");
1111                                }
1112                            };
1113                            match handlers.exec_ctx(&op.id, component, &ports, Some(el)) {
1114                                None => {
1115                                    *err_cell = Some(BehaviorError {
1116                                        code: BehaviorFailureCode::UnknownComponent
1117                                            .as_str()
1118                                            .to_string(),
1119                                        message: format!(
1120                                            "component '{component}' has no handler (fail-closed)"
1121                                        ),
1122                                        detail: None,
1123                                    });
1124                                    return ExecOutcome::error("aborted");
1125                                }
1126                                // Element Error Policy (scp-error.md): `skip` drops the failing
1127                                // element and proceeds (order preserved; the leaf keeps its Error
1128                                // Value); `error` promotes it to the map's Component Failure.
1129                                Some(ExecOutcome::Error(e, d)) => {
1130                                    if element_policy == crate::plan::ElementPolicyKind::Skip {
1131                                        continue;
1132                                    }
1133                                    return ExecOutcome::Error(e, d);
1134                                }
1135                                Some(ExecOutcome::Ok(v)) => out.push(v),
1136                            }
1137                            kept_idx.push(i);
1138                        }
1139                        collected = out;
1140                    }
1141
1142                    match into {
1143                        None => ExecOutcome::Ok(Value::Arr(collected)),
1144                        Some(key) => {
1145                            // into (v2): result = over list with kept elements shallow-copied
1146                            // + `into` key attached; skipped elements pass through unchanged.
1147                            let mut augmented: Vec<Value> = Vec::with_capacity(arr.len());
1148                            let mut k = 0usize;
1149                            for (i, el) in arr.iter().enumerate() {
1150                                if k < kept_idx.len() && kept_idx[k] == i {
1151                                    let fields = match el {
1152                                        Value::Obj(f) => f.clone(),
1153                                        _ => {
1154                                            *err_cell = Some(BehaviorError {
1155                                                code: BehaviorFailureCode::MapIntoElementNotObject
1156                                                    .as_str()
1157                                                    .to_string(),
1158                                                message: format!(
1159                                                    "map '{}': 'into' requires object elements (element {i} is not an object)",
1160                                                    op.id
1161                                                ),
1162                        detail: None,
1163                    });
1164                                            return ExecOutcome::error("aborted");
1165                                        }
1166                                    };
1167                                    let mut fields = fields;
1168                                    let val = collected[k].clone();
1169                                    match fields.iter_mut().find(|(fk, _)| fk == key) {
1170                                        Some(slot) => slot.1 = val,
1171                                        None => fields.push((key.to_string(), val)),
1172                                    }
1173                                    augmented.push(Value::Obj(fields));
1174                                    k += 1;
1175                                } else {
1176                                    augmented.push(el.clone());
1177                                }
1178                            }
1179                            ExecOutcome::Ok(Value::Arr(augmented))
1180                        }
1181                    }
1182                }
1183                Ok("fanout") => {
1184                    // fanout (v3): over (id-list) → deduped ONE batched handler → dedupe/drop/strip →
1185                    // connection {items, cursor:null}. The MAP_BATCH_RESULT_MISMATCH alignment does NOT
1186                    // apply (the result is a connection, not an aligned list).
1187                    let f = node.get("fanout").unwrap();
1188                    let over = match evaluate_expression(
1189                        f.get("over").unwrap_or(&J::Null),
1190                        &base_scope(None),
1191                    ) {
1192                        Ok(v) => v,
1193                        Err(e) => {
1194                            *err_cell = Some(e.into());
1195                            return ExecOutcome::error("aborted");
1196                        }
1197                    };
1198                    let arr = match resolve_over_array(
1199                        &over,
1200                        f.get("over").unwrap_or(&J::Null),
1201                        body,
1202                        input_ports,
1203                        BehaviorFailureCode::FanoutOverNotArray,
1204                        &op.id,
1205                        "fanout",
1206                    ) {
1207                        Ok(a) => a,
1208                        Err(e) => {
1209                            *err_cell = Some(e);
1210                            return ExecOutcome::error("aborted");
1211                        }
1212                    };
1213                    let component = f.get("component").and_then(|v| v.as_str()).unwrap_or("");
1214                    let as_name = f.get("as").and_then(|v| v.as_str()).unwrap_or("$");
1215                    let ports_j = f
1216                        .get("ports")
1217                        .cloned()
1218                        .unwrap_or(J::Object(Default::default()));
1219                    let dedupe_key = f.get("dedupeKey").and_then(|v| v.as_str()).unwrap_or("");
1220                    let drop = f.get("drop").and_then(|v| v.as_str()).unwrap_or("dangling");
1221                    let implicit_source = f.get("implicitSource").and_then(|v| v.as_str());
1222                    let mut items: Vec<Value> = Vec::with_capacity(arr.len());
1223                    for el in arr.iter() {
1224                        let scope = base_scope(Some((as_name, el)));
1225                        match eval_ports(&ports_j, &scope) {
1226                            Ok(p) => items.push(Value::Obj(p)),
1227                            Err(e) => {
1228                                *err_cell = Some(e);
1229                                return ExecOutcome::error("aborted");
1230                            }
1231                        }
1232                    }
1233                    if items.is_empty() {
1234                        ExecOutcome::Ok(Value::Obj(vec![
1235                            ("items".into(), Value::Arr(vec![])),
1236                            ("cursor".into(), Value::Null),
1237                        ]))
1238                    } else {
1239                        let want = items.len();
1240                        let batch_ports = vec![("items".to_string(), Value::Arr(items))];
1241                        match handlers.exec_ctx(&op.id, component, &batch_ports, None) {
1242                            None => {
1243                                *err_cell = Some(BehaviorError {
1244                                    code: BehaviorFailureCode::UnknownComponent
1245                                        .as_str()
1246                                        .to_string(),
1247                                    message: format!(
1248                                        "component '{component}' has no handler (fail-closed)"
1249                                    ),
1250                                    detail: None,
1251                                });
1252                                ExecOutcome::error("aborted")
1253                            }
1254                            Some(ExecOutcome::Error(e, d)) => ExecOutcome::Error(e, d),
1255                            Some(ExecOutcome::Ok(Value::Arr(r))) if r.len() == want => {
1256                                let deduped =
1257                                    fanout_dedup_drop(r, dedupe_key, drop, implicit_source);
1258                                ExecOutcome::Ok(Value::Obj(vec![
1259                                    ("items".into(), Value::Arr(deduped)),
1260                                    ("cursor".into(), Value::Null),
1261                                ]))
1262                            }
1263                            Some(ExecOutcome::Ok(_)) => {
1264                                *err_cell = Some(BehaviorError {
1265                                    code: BehaviorFailureCode::FanoutBatchResultMismatch
1266                                        .as_str()
1267                                        .to_string(),
1268                                    message: format!(
1269                                        "fanout '{}': batched handler must return a list aligned to the deduped id list (want {want})",
1270                                        op.id
1271                                    ),
1272                        detail: None,
1273                    });
1274                                ExecOutcome::error("aborted")
1275                            }
1276                        }
1277                    }
1278                }
1279                Ok(_) => {
1280                    let component = node.get("component").and_then(|v| v.as_str()).unwrap_or("");
1281                    let ports_j = node
1282                        .get("ports")
1283                        .cloned()
1284                        .unwrap_or(J::Object(Default::default()));
1285                    let ports = match eval_ports(&ports_j, &base_scope(None)) {
1286                        Ok(p) => p,
1287                        Err(e) => {
1288                            *err_cell = Some(e);
1289                            return ExecOutcome::error("aborted");
1290                        }
1291                    };
1292                    match handlers.exec_ctx(&op.id, component, &ports, None) {
1293                        None => {
1294                            *err_cell = Some(BehaviorError {
1295                                code: BehaviorFailureCode::UnknownComponent.as_str().to_string(),
1296                                message: format!(
1297                                    "component '{component}' has no handler (fail-closed)"
1298                                ),
1299                                detail: None,
1300                            });
1301                            return ExecOutcome::error("aborted");
1302                        }
1303                        Some(o) => o,
1304                    }
1305                }
1306            };
1307
1308            if let ExecOutcome::Ok(v) = &outcome {
1309                // outType conformance (scp-error.md): a declared node result type is a claim; check
1310                // it here (the runtime holds both the declared type and the returned value), fail
1311                // closed with the structured Error Value on a mismatch, and NORMALIZE the value to
1312                // the declared type (int→float widen / absent opt→null) so results match the de-box.
1313                let mut value = v.clone();
1314                if let Some(ot) = node.get("outType") {
1315                    let want = result_type_of(node, ot);
1316                    match assert_conforms_to_out_type(&op.id, &value, &want, "result") {
1317                        Ok(normalized) => value = normalized,
1318                        Err(e) => {
1319                            *err_cell = Some(e.into());
1320                            return ExecOutcome::error("aborted");
1321                        }
1322                    }
1323                }
1324                results_cell.push((op.id.clone(), value));
1325            }
1326            outcome
1327        };
1328
1329        run_plan(plan.as_ref(), ops, exec)
1330    };
1331
1332    // A BehaviorError raised inside exec takes precedence over the synthetic plan
1333    // OP_FAILED it produced (we injected an "aborted" error to unwind run_plan).
1334    if let Some(e) = pending_err {
1335        return Err(e);
1336    }
1337    let run = run?;
1338
1339    // Skipped nodes contribute their unproduced representation to the scope so the
1340    // component `output` can `ref`/`coalesce` over them.
1341    for (i, id_val) in run.skipped.iter().enumerate() {
1342        let _ = i;
1343        if let Some(idx) = index_of(id_val) {
1344            let rk = node_relation_kind_str(&body[idx]);
1345            let unproduced = if rk == Some("connection") {
1346                Value::Obj(vec![
1347                    ("items".into(), Value::Arr(vec![])),
1348                    ("cursor".into(), Value::Null),
1349                ])
1350            } else {
1351                Value::Null
1352            };
1353            if scope_get(&results, id_val).is_none() {
1354                results.push((id_val.clone(), unproduced));
1355            }
1356        }
1357    }
1358
1359    let scope: Vec<(String, Value)> = {
1360        let mut s: Vec<(String, Value)> = input.to_vec();
1361        s.extend(results.iter().cloned());
1362        s
1363    };
1364    Ok(evaluate_expression(&output, &scope)?)
1365}