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}
78
79impl BehaviorError {
80    /// Construct a coded behavior error. Public so the straight-line codegen surface
81    /// (bc#39) can raise the same fail-closed codes/messages as `run_behavior`
82    /// (`UNKNOWN_COMPONENT` / `UNKNOWN_ENTRY` / `UNKNOWN_NODE_KIND`) without depending
83    /// on the private `BehaviorFailureCode` enum.
84    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
85        BehaviorError {
86            code: code.into(),
87            message: message.into(),
88        }
89    }
90
91    /// The stable string code used for conformance matching.
92    pub fn code(&self) -> &str {
93        &self.code
94    }
95}
96
97impl std::fmt::Display for BehaviorError {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        write!(f, "{}: {}", self.code, self.message)
100    }
101}
102impl std::error::Error for BehaviorError {}
103
104impl From<ExprFailure> for BehaviorError {
105    fn from(e: ExprFailure) -> Self {
106        BehaviorError {
107            code: e.code.as_str().to_string(),
108            message: e.message,
109        }
110    }
111}
112impl From<PlanFailure> for BehaviorError {
113    fn from(e: PlanFailure) -> Self {
114        BehaviorError {
115            code: e.code.as_str().to_string(),
116            message: e.message,
117        }
118    }
119}
120
121#[cfg(feature = "ir")]
122fn bfail<T>(code: BehaviorFailureCode, message: impl Into<String>) -> Result<T, BehaviorError> {
123    Err(BehaviorError {
124        code: code.as_str().to_string(),
125        message: message.into(),
126    })
127}
128
129/// The boundary-injected handler registry seam (§7.1). Resolves a catalog name to
130/// a leaf implementation and executes it with evaluated ports (+ the map element
131/// `bound` value, if any). Returns `None` when the name is unknown so `run_behavior`
132/// can fail closed with `UNKNOWN_COMPONENT`.
133pub trait ComponentExec {
134    fn exec(
135        &mut self,
136        component: &str,
137        ports: &[(String, Value)],
138        bound: Option<&Value>,
139    ) -> Option<ExecOutcome>;
140
141    /// behaviorVersion 2: like [`ComponentExec::exec`] but carrying the handler ctx
142    /// (`node_id` = body node id, `component` = catalog name). `run_behavior` calls
143    /// this seam for every handler invocation. The default forwards to `exec`, so
144    /// existing implementations keep working unchanged; override it to observe the
145    /// node identity (error context / tracing).
146    fn exec_ctx(
147        &mut self,
148        node_id: &str,
149        component: &str,
150        ports: &[(String, Value)],
151        bound: Option<&Value>,
152    ) -> Option<ExecOutcome> {
153        let _ = node_id;
154        self.exec(component, ports, bound)
155    }
156}
157
158/// Blanket forwarding impl so a **trait object** handler registry
159/// (`&mut dyn ComponentExec`) itself satisfies [`ComponentExec`] (bc#68).
160///
161/// The generic straight-line / typed codegen surface exposes generic-by-value
162/// entries (`bind<H: ComponentExec>(handlers: H)` / `run_*<H: ComponentExec>(
163/// handlers: &mut H, …)`). A consumer that holds its handler registry behind a
164/// trait object (`handlers: &mut dyn ComponentExec` — exactly how [`run_behavior`]
165/// takes it) could not name a concrete `H` to hand those entries. This impl makes
166/// `H = &mut dyn ComponentExec` a valid instantiation, so the trait-object handler
167/// drives the generated straight-line/typed module through the *unchanged* generic
168/// path (no `bind_dyn` variant needed, no emitter change). It is purely additive:
169/// every existing concrete `H` keeps working exactly as before.
170///
171/// Both methods forward to the underlying `dyn` (a plain reborrow), so calling a
172/// generated module through `&mut dyn` runs the SAME straight-line/typed code as a
173/// concrete handler — it never falls back to `run_behavior`.
174impl ComponentExec for &mut dyn ComponentExec {
175    fn exec(
176        &mut self,
177        component: &str,
178        ports: &[(String, Value)],
179        bound: Option<&Value>,
180    ) -> Option<ExecOutcome> {
181        (**self).exec(component, ports, bound)
182    }
183
184    fn exec_ctx(
185        &mut self,
186        node_id: &str,
187        component: &str,
188        ports: &[(String, Value)],
189        bound: Option<&Value>,
190    ) -> Option<ExecOutcome> {
191        (**self).exec_ctx(node_id, component, ports, bound)
192    }
193}
194
195#[cfg(feature = "ir")]
196fn scope_get<'a>(scope: &'a [(String, Value)], key: &str) -> Option<&'a Value> {
197    scope.iter().find(|(k, _)| k == key).map(|(_, v)| v)
198}
199
200/// fanout_dedup_drop — THE ONE dedup/drop definition (behaviorVersion 3) — Rust twin of behavior.ts
201/// fanoutDedupDrop. Applies first-seen dedupe (by the body's `dedupe_key` field) + dangling drop
202/// (Null / non-object / absent-key body when `drop=="dangling"`) + implicitSource strip to the aligned
203/// raw list; returns the connection items (the caller wraps into {items,cursor:Null}). Byte-equal to
204/// the TS/Python/PHP/Go definitions. Bodies are `Value::Obj`; a Null / non-Obj body is dangling.
205#[cfg(feature = "ir")]
206fn fanout_dedup_drop(
207    aligned_bodies: Vec<Value>,
208    dedupe_key: &str,
209    drop: &str,
210    implicit_source: Option<&str>,
211) -> Vec<Value> {
212    use std::collections::HashSet;
213    let mut items: Vec<Value> = Vec::with_capacity(aligned_bodies.len());
214    let mut seen: HashSet<String> = HashSet::new();
215    for body in aligned_bodies.into_iter() {
216        let fields = match &body {
217            Value::Obj(f) => Some(f),
218            _ => None,
219        };
220        let key_val = fields.and_then(|f| f.iter().find(|(k, _)| k == dedupe_key).map(|(_, v)| v));
221        let has_key = matches!(key_val, Some(v) if !matches!(v, Value::Null));
222        if !has_key {
223            if drop == "dangling" {
224                continue;
225            }
226            items.push(body); // drop:none keeps a dangling body
227            continue;
228        }
229        let seen_key = match key_val.unwrap() {
230            Value::Str(s) => format!("s:{s}"),
231            other => format!("j:{other:?}"),
232        };
233        if seen.contains(&seen_key) {
234            continue;
235        }
236        seen.insert(seen_key);
237        match (implicit_source, fields) {
238            (Some(src), Some(f)) if f.iter().any(|(k, _)| k == src) => {
239                let stripped: Vec<(String, Value)> =
240                    f.iter().filter(|(k, _)| k != src).cloned().collect();
241                items.push(Value::Obj(stripped));
242            }
243            _ => items.push(body),
244        }
245    }
246    items
247}
248
249#[cfg(feature = "ir")]
250fn node_kind(n: &J) -> Result<&'static str, BehaviorError> {
251    if n.get("fanout").is_some() {
252        Ok("fanout")
253    } else if n.get("map").is_some() {
254        Ok("map")
255    } else if n.get("cond").is_some() {
256        Ok("cond")
257    } else if n.get("component").is_some() {
258        Ok("componentRef")
259    } else {
260        bfail(
261            BehaviorFailureCode::UnknownNodeKind,
262            format!(
263                "body node '{}' is not componentRef/map/cond/fanout",
264                n.get("id").and_then(|v| v.as_str()).unwrap_or("?")
265            ),
266        )
267    }
268}
269
270#[cfg(feature = "ir")]
271fn node_sub(n: &J) -> &J {
272    if let Some(f) = n.get("fanout") {
273        f
274    } else if let Some(m) = n.get("map") {
275        m
276    } else if let Some(c) = n.get("cond") {
277        c
278    } else {
279        n
280    }
281}
282
283#[cfg(feature = "ir")]
284fn node_parent(n: &J) -> Option<&str> {
285    node_sub(n).get("parent").and_then(|v| v.as_str())
286}
287
288#[cfg(feature = "ir")]
289fn node_bind_field(n: &J) -> Option<String> {
290    if n.get("map").is_some() || n.get("cond").is_some() || n.get("fanout").is_some() {
291        return None;
292    }
293    n.get("bindField")
294        .and_then(|v| v.as_str())
295        .map(str::to_string)
296}
297
298#[cfg(feature = "ir")]
299fn node_relation_kind(n: &J) -> Option<RelationKind> {
300    if n.get("cond").is_some() {
301        return None;
302    }
303    let sub = if n.get("map").is_some() || n.get("fanout").is_some() {
304        node_sub(n)
305    } else {
306        n
307    };
308    match sub.get("relationKind").and_then(|v| v.as_str()) {
309        Some("connection") => Some(RelationKind::Connection),
310        Some(_) => Some(RelationKind::Single),
311        None => None,
312    }
313}
314
315#[cfg(feature = "ir")]
316fn node_relation_kind_str(n: &J) -> Option<&str> {
317    if n.get("cond").is_some() {
318        return None;
319    }
320    let sub = if n.get("map").is_some() || n.get("fanout").is_some() {
321        node_sub(n)
322    } else {
323        n
324    };
325    sub.get("relationKind").and_then(|v| v.as_str())
326}
327
328#[cfg(feature = "ir")]
329fn node_policy(n: &J) -> Option<String> {
330    if n.get("cond").is_some() {
331        return None;
332    }
333    let sub = if n.get("map").is_some() || n.get("fanout").is_some() {
334        node_sub(n)
335    } else {
336        n
337    };
338    sub.get("policy")
339        .and_then(|v| v.as_str())
340        .map(str::to_string)
341}
342
343#[cfg(feature = "ir")]
344fn eval_ports(ports: &J, scope: &[(String, Value)]) -> Result<Vec<(String, Value)>, BehaviorError> {
345    let obj = ports.as_object().ok_or_else(|| BehaviorError {
346        code: BehaviorFailureCode::UnknownNodeKind.as_str().to_string(),
347        message: "ports must be an object".into(),
348    })?;
349    let mut out = Vec::with_capacity(obj.len());
350    for (k, v) in obj {
351        out.push((k.clone(), evaluate_expression(v, scope)?));
352    }
353    Ok(out)
354}
355
356/// Parse an `ExecutionPlanSpec` from a component's `plan` field (`null`/absent → None).
357#[cfg(feature = "ir")]
358fn parse_plan(plan: Option<&J>) -> Option<ExecutionPlanSpec> {
359    let p = plan?;
360    if p.is_null() {
361        return None;
362    }
363    let groups = p
364        .get("groups")?
365        .as_array()?
366        .iter()
367        .map(|g| {
368            g.as_array()
369                .map(|row| {
370                    row.iter()
371                        .filter_map(|i| i.as_u64().map(|x| x as usize))
372                        .collect()
373                })
374                .unwrap_or_default()
375        })
376        .collect();
377    let concurrency = p.get("concurrency").and_then(|c| c.as_i64()).unwrap_or(1);
378    Some(ExecutionPlanSpec {
379        groups,
380        concurrency,
381    })
382}
383
384/// Run a component-graph IR (scp-ir-architecture.md §7).
385///
386/// * `ir`      — the portable component-graph IR (`{components:[...]}`).
387/// * `handlers`— the boundary-injected [`ComponentExec`] seam (catalog name → leaf impl).
388/// * `input`   — the entry component's inputPorts bindings (param values).
389/// * `entry`   — the component name to run (`None` = first component).
390///
391/// Returns the evaluated `output` (Φ merge) or a coded [`BehaviorError`].
392#[cfg(feature = "ir")]
393pub fn run_behavior(
394    ir: &J,
395    handlers: &mut dyn ComponentExec,
396    input: &[(String, Value)],
397    entry: Option<&str>,
398) -> Result<Value, BehaviorError> {
399    let components = ir
400        .get("components")
401        .and_then(|c| c.as_array())
402        .ok_or_else(|| BehaviorError {
403            code: BehaviorFailureCode::UnknownEntry.as_str().to_string(),
404            message: "IR.components must be an array".into(),
405        })?;
406    let comp = match entry {
407        Some(name) => components
408            .iter()
409            .find(|c| c.get("name").and_then(|n| n.as_str()) == Some(name)),
410        None => components.first(),
411    };
412    let comp = match comp {
413        Some(c) => c,
414        None => {
415            return bfail(
416                BehaviorFailureCode::UnknownEntry,
417                format!("component '{}' not found in IR", entry.unwrap_or("<first>")),
418            )
419        }
420    };
421
422    let body: Vec<J> = comp
423        .get("body")
424        .and_then(|b| b.as_array())
425        .cloned()
426        .unwrap_or_default();
427
428    let index_of = |id: &str| {
429        body.iter()
430            .position(|n| n.get("id").and_then(|v| v.as_str()) == Some(id))
431    };
432
433    // Build OpSpecs for run_plan staging (parent id → index via Wire).
434    let mut ops: Vec<OpSpec> = Vec::with_capacity(body.len());
435    for n in &body {
436        let parent = node_parent(n).and_then(index_of);
437        ops.push(OpSpec {
438            id: n
439                .get("id")
440                .and_then(|v| v.as_str())
441                .unwrap_or_default()
442                .to_string(),
443            parent,
444            bind_field: node_bind_field(n),
445            relation_kind: node_relation_kind(n),
446            policy: node_policy(n),
447        });
448    }
449
450    run_behavior_inner(
451        &body,
452        &ops,
453        parse_plan(comp.get("plan")),
454        comp.get("output").cloned().unwrap_or(J::Null),
455        &bind_input_scope(comp, input),
456        handlers,
457    )
458}
459
460/// bind_input_scope — the entry component's input bindings, read against its declaration
461/// (`inputPorts`).
462///
463/// An input port declared `{opt:T}` (PortSchema `required:false`) is OMITTABLE and its value domain is
464/// `T | null`. "Key omitted" and "key present as null" are two spellings of the same "no value", so an
465/// omitted key binds to null. This only reads a declaration the portable IR already carries — no new
466/// information, no new operator.
467///
468/// Required ports and undeclared names are NOT bound: referencing one unbound is still
469/// `UNKNOWN_BINDING` (the fail-closed for a typo / an unwired param stands — only a port DECLARED
470/// omittable is relaxed).
471#[cfg(feature = "ir")]
472fn bind_input_scope(comp: &J, input: &[(String, Value)]) -> Vec<(String, Value)> {
473    let mut scope: Vec<(String, Value)> = input.to_vec();
474    if let Some(ports) = comp.get("inputPorts").and_then(|p| p.as_object()) {
475        for (name, schema) in ports {
476            let optional = schema.get("required").and_then(|r| r.as_bool()) == Some(false);
477            if optional && !scope.iter().any(|(k, _)| k == name) {
478                scope.push((name.clone(), Value::Null));
479            }
480        }
481    }
482    scope
483}
484
485/// Single-closure implementation: builds the exec closure that owns `handlers`,
486/// `results`, and `pending_err`, delegates staging to `run_plan`, then evaluates
487/// the component `output`. Kept separate to sidestep split-borrow layering.
488#[cfg(feature = "ir")]
489fn run_behavior_inner(
490    body: &[J],
491    ops: &[OpSpec],
492    plan: Option<ExecutionPlanSpec>,
493    output: J,
494    input: &[(String, Value)],
495    handlers: &mut dyn ComponentExec,
496) -> Result<Value, BehaviorError> {
497    let index_of = |id: &str| {
498        body.iter()
499            .position(|n| n.get("id").and_then(|v| v.as_str()) == Some(id))
500    };
501
502    let mut results: Vec<(String, Value)> = Vec::new();
503    let mut pending_err: Option<BehaviorError> = None;
504
505    let run = {
506        let results_cell = &mut results;
507        let err_cell = &mut pending_err;
508        let exec = |op: &OpSpec, _bound: Option<&Value>| -> ExecOutcome {
509            if err_cell.is_some() {
510                return ExecOutcome::Error("aborted".into());
511            }
512            let idx = match index_of(&op.id) {
513                Some(i) => i,
514                None => {
515                    *err_cell = Some(BehaviorError {
516                        code: BehaviorFailureCode::UnknownNodeKind.as_str().to_string(),
517                        message: format!("no body node for op '{}'", op.id),
518                    });
519                    return ExecOutcome::Error("aborted".into());
520                }
521            };
522            let node = &body[idx];
523
524            let base_scope = |extra: Option<(&str, &Value)>| -> Vec<(String, Value)> {
525                let mut s: Vec<(String, Value)> = input.to_vec();
526                for (k, v) in results_cell.iter() {
527                    s.push((k.clone(), v.clone()));
528                }
529                if let Some((k, v)) = extra {
530                    s.push((k.to_string(), v.clone()));
531                }
532                s
533            };
534
535            let outcome = match node_kind(node) {
536                Err(e) => {
537                    *err_cell = Some(e);
538                    return ExecOutcome::Error("aborted".into());
539                }
540                Ok("cond") => {
541                    let c = node.get("cond").unwrap();
542                    let cond_expr = serde_json::json!({
543                        "cond": [c.get("if"), c.get("then"), c.get("else")]
544                    });
545                    match evaluate_expression(&cond_expr, &base_scope(None)) {
546                        Ok(v) => ExecOutcome::Ok(v),
547                        Err(e) => {
548                            *err_cell = Some(e.into());
549                            return ExecOutcome::Error("aborted".into());
550                        }
551                    }
552                }
553                Ok("map") => {
554                    let m = node.get("map").unwrap();
555                    let over = match evaluate_expression(
556                        m.get("over").unwrap_or(&J::Null),
557                        &base_scope(None),
558                    ) {
559                        Ok(v) => v,
560                        Err(e) => {
561                            *err_cell = Some(e.into());
562                            return ExecOutcome::Error("aborted".into());
563                        }
564                    };
565                    let arr = match &over {
566                        Value::Arr(a) => a.clone(),
567                        _ => {
568                            *err_cell = Some(BehaviorError {
569                                code: BehaviorFailureCode::MapOverNotArray.as_str().to_string(),
570                                message: format!("map '{}': 'over' is not an array", op.id),
571                            });
572                            return ExecOutcome::Error("aborted".into());
573                        }
574                    };
575                    let component = m.get("component").and_then(|v| v.as_str()).unwrap_or("");
576                    let as_name = m.get("as").and_then(|v| v.as_str()).unwrap_or("$");
577                    let ports_j = m
578                        .get("ports")
579                        .cloned()
580                        .unwrap_or(J::Object(Default::default()));
581                    let when = m.get("when");
582                    let batched = m.get("batched").and_then(|v| v.as_bool()).unwrap_or(false);
583                    let into = m.get("into").and_then(|v| v.as_str());
584
585                    // per-element guard (v2): lowered to `{cond:[when,true,false]}`
586                    // (strict bool — a non-bool guard fails closed via the cond operator).
587                    let keep = |scope: &[(String, Value)]| -> Result<bool, BehaviorError> {
588                        match when {
589                            None => Ok(true),
590                            Some(w) => {
591                                let cond_expr = serde_json::json!({ "cond": [w, true, false] });
592                                Ok(matches!(
593                                    evaluate_expression(&cond_expr, scope)?,
594                                    Value::Bool(true)
595                                ))
596                            }
597                        }
598                    };
599
600                    let mut kept_idx: Vec<usize> = Vec::new(); // guard-passing over indices (for into)
601                    let collected: Vec<Value>;
602                    if batched {
603                        // batched (v2): evaluate all guard-passing ports first, call handler ONCE.
604                        let mut items: Vec<Value> = Vec::new();
605                        for (i, el) in arr.iter().enumerate() {
606                            let scope = base_scope(Some((as_name, el)));
607                            match keep(&scope) {
608                                Ok(false) => continue,
609                                Ok(true) => {}
610                                Err(e) => {
611                                    *err_cell = Some(e);
612                                    return ExecOutcome::Error("aborted".into());
613                                }
614                            }
615                            match eval_ports(&ports_j, &scope) {
616                                Ok(p) => items.push(Value::Obj(p)),
617                                Err(e) => {
618                                    *err_cell = Some(e);
619                                    return ExecOutcome::Error("aborted".into());
620                                }
621                            }
622                            kept_idx.push(i);
623                        }
624                        if items.is_empty() {
625                            collected = Vec::new(); // all filtered: handler is not called
626                        } else {
627                            let want = items.len();
628                            let batch_ports = vec![("items".to_string(), Value::Arr(items))];
629                            match handlers.exec_ctx(&op.id, component, &batch_ports, None) {
630                                None => {
631                                    *err_cell = Some(BehaviorError {
632                                        code: BehaviorFailureCode::UnknownComponent
633                                            .as_str()
634                                            .to_string(),
635                                        message: format!(
636                                            "component '{component}' has no handler (fail-closed)"
637                                        ),
638                                    });
639                                    return ExecOutcome::Error("aborted".into());
640                                }
641                                Some(ExecOutcome::Error(e)) => return ExecOutcome::Error(e),
642                                Some(ExecOutcome::Ok(Value::Arr(r))) if r.len() == want => {
643                                    collected = r;
644                                }
645                                Some(ExecOutcome::Ok(_)) => {
646                                    *err_cell = Some(BehaviorError {
647                                        code: BehaviorFailureCode::MapBatchResultMismatch
648                                            .as_str()
649                                            .to_string(),
650                                        message: format!(
651                                            "map '{}': batched handler must return a list aligned to items (want {want})",
652                                            op.id
653                                        ),
654                                    });
655                                    return ExecOutcome::Error("aborted".into());
656                                }
657                            }
658                        }
659                    } else {
660                        let mut out: Vec<Value> = Vec::with_capacity(arr.len());
661                        for (i, el) in arr.iter().enumerate() {
662                            let scope = base_scope(Some((as_name, el)));
663                            match keep(&scope) {
664                                Ok(false) => continue,
665                                Ok(true) => {}
666                                Err(e) => {
667                                    *err_cell = Some(e);
668                                    return ExecOutcome::Error("aborted".into());
669                                }
670                            }
671                            let ports = match eval_ports(&ports_j, &scope) {
672                                Ok(p) => p,
673                                Err(e) => {
674                                    *err_cell = Some(e);
675                                    return ExecOutcome::Error("aborted".into());
676                                }
677                            };
678                            match handlers.exec_ctx(&op.id, component, &ports, Some(el)) {
679                                None => {
680                                    *err_cell = Some(BehaviorError {
681                                        code: BehaviorFailureCode::UnknownComponent
682                                            .as_str()
683                                            .to_string(),
684                                        message: format!(
685                                            "component '{component}' has no handler (fail-closed)"
686                                        ),
687                                    });
688                                    return ExecOutcome::Error("aborted".into());
689                                }
690                                Some(ExecOutcome::Error(e)) => return ExecOutcome::Error(e),
691                                Some(ExecOutcome::Ok(v)) => out.push(v),
692                            }
693                            kept_idx.push(i);
694                        }
695                        collected = out;
696                    }
697
698                    match into {
699                        None => ExecOutcome::Ok(Value::Arr(collected)),
700                        Some(key) => {
701                            // into (v2): result = over list with kept elements shallow-copied
702                            // + `into` key attached; skipped elements pass through unchanged.
703                            let mut augmented: Vec<Value> = Vec::with_capacity(arr.len());
704                            let mut k = 0usize;
705                            for (i, el) in arr.iter().enumerate() {
706                                if k < kept_idx.len() && kept_idx[k] == i {
707                                    let fields = match el {
708                                        Value::Obj(f) => f.clone(),
709                                        _ => {
710                                            *err_cell = Some(BehaviorError {
711                                                code: BehaviorFailureCode::MapIntoElementNotObject
712                                                    .as_str()
713                                                    .to_string(),
714                                                message: format!(
715                                                    "map '{}': 'into' requires object elements (element {i} is not an object)",
716                                                    op.id
717                                                ),
718                                            });
719                                            return ExecOutcome::Error("aborted".into());
720                                        }
721                                    };
722                                    let mut fields = fields;
723                                    let val = collected[k].clone();
724                                    match fields.iter_mut().find(|(fk, _)| fk == key) {
725                                        Some(slot) => slot.1 = val,
726                                        None => fields.push((key.to_string(), val)),
727                                    }
728                                    augmented.push(Value::Obj(fields));
729                                    k += 1;
730                                } else {
731                                    augmented.push(el.clone());
732                                }
733                            }
734                            ExecOutcome::Ok(Value::Arr(augmented))
735                        }
736                    }
737                }
738                Ok("fanout") => {
739                    // fanout (v3): over (id-list) → deduped ONE batched handler → dedupe/drop/strip →
740                    // connection {items, cursor:null}. The MAP_BATCH_RESULT_MISMATCH alignment does NOT
741                    // apply (the result is a connection, not an aligned list).
742                    let f = node.get("fanout").unwrap();
743                    let over = match evaluate_expression(
744                        f.get("over").unwrap_or(&J::Null),
745                        &base_scope(None),
746                    ) {
747                        Ok(v) => v,
748                        Err(e) => {
749                            *err_cell = Some(e.into());
750                            return ExecOutcome::Error("aborted".into());
751                        }
752                    };
753                    let arr = match &over {
754                        Value::Arr(a) => a.clone(),
755                        _ => {
756                            *err_cell = Some(BehaviorError {
757                                code: BehaviorFailureCode::FanoutOverNotArray.as_str().to_string(),
758                                message: format!("fanout '{}': 'over' is not an array", op.id),
759                            });
760                            return ExecOutcome::Error("aborted".into());
761                        }
762                    };
763                    let component = f.get("component").and_then(|v| v.as_str()).unwrap_or("");
764                    let as_name = f.get("as").and_then(|v| v.as_str()).unwrap_or("$");
765                    let ports_j = f
766                        .get("ports")
767                        .cloned()
768                        .unwrap_or(J::Object(Default::default()));
769                    let dedupe_key = f.get("dedupeKey").and_then(|v| v.as_str()).unwrap_or("");
770                    let drop = f.get("drop").and_then(|v| v.as_str()).unwrap_or("dangling");
771                    let implicit_source = f.get("implicitSource").and_then(|v| v.as_str());
772                    let mut items: Vec<Value> = Vec::with_capacity(arr.len());
773                    for el in arr.iter() {
774                        let scope = base_scope(Some((as_name, el)));
775                        match eval_ports(&ports_j, &scope) {
776                            Ok(p) => items.push(Value::Obj(p)),
777                            Err(e) => {
778                                *err_cell = Some(e);
779                                return ExecOutcome::Error("aborted".into());
780                            }
781                        }
782                    }
783                    if items.is_empty() {
784                        ExecOutcome::Ok(Value::Obj(vec![
785                            ("items".into(), Value::Arr(vec![])),
786                            ("cursor".into(), Value::Null),
787                        ]))
788                    } else {
789                        let want = items.len();
790                        let batch_ports = vec![("items".to_string(), Value::Arr(items))];
791                        match handlers.exec_ctx(&op.id, component, &batch_ports, None) {
792                            None => {
793                                *err_cell = Some(BehaviorError {
794                                    code: BehaviorFailureCode::UnknownComponent
795                                        .as_str()
796                                        .to_string(),
797                                    message: format!(
798                                        "component '{component}' has no handler (fail-closed)"
799                                    ),
800                                });
801                                ExecOutcome::Error("aborted".into())
802                            }
803                            Some(ExecOutcome::Error(e)) => ExecOutcome::Error(e),
804                            Some(ExecOutcome::Ok(Value::Arr(r))) if r.len() == want => {
805                                let deduped =
806                                    fanout_dedup_drop(r, dedupe_key, drop, implicit_source);
807                                ExecOutcome::Ok(Value::Obj(vec![
808                                    ("items".into(), Value::Arr(deduped)),
809                                    ("cursor".into(), Value::Null),
810                                ]))
811                            }
812                            Some(ExecOutcome::Ok(_)) => {
813                                *err_cell = Some(BehaviorError {
814                                    code: BehaviorFailureCode::FanoutBatchResultMismatch
815                                        .as_str()
816                                        .to_string(),
817                                    message: format!(
818                                        "fanout '{}': batched handler must return a list aligned to the deduped id list (want {want})",
819                                        op.id
820                                    ),
821                                });
822                                ExecOutcome::Error("aborted".into())
823                            }
824                        }
825                    }
826                }
827                Ok(_) => {
828                    let component = node.get("component").and_then(|v| v.as_str()).unwrap_or("");
829                    let ports_j = node
830                        .get("ports")
831                        .cloned()
832                        .unwrap_or(J::Object(Default::default()));
833                    let ports = match eval_ports(&ports_j, &base_scope(None)) {
834                        Ok(p) => p,
835                        Err(e) => {
836                            *err_cell = Some(e);
837                            return ExecOutcome::Error("aborted".into());
838                        }
839                    };
840                    match handlers.exec_ctx(&op.id, component, &ports, None) {
841                        None => {
842                            *err_cell = Some(BehaviorError {
843                                code: BehaviorFailureCode::UnknownComponent.as_str().to_string(),
844                                message: format!(
845                                    "component '{component}' has no handler (fail-closed)"
846                                ),
847                            });
848                            return ExecOutcome::Error("aborted".into());
849                        }
850                        Some(o) => o,
851                    }
852                }
853            };
854
855            if let ExecOutcome::Ok(v) = &outcome {
856                results_cell.push((op.id.clone(), v.clone()));
857            }
858            outcome
859        };
860
861        run_plan(plan.as_ref(), ops, exec)
862    };
863
864    // A BehaviorError raised inside exec takes precedence over the synthetic plan
865    // OP_FAILED it produced (we injected an "aborted" error to unwind run_plan).
866    if let Some(e) = pending_err {
867        return Err(e);
868    }
869    let run = run?;
870
871    // Skipped nodes contribute their unproduced representation to the scope so the
872    // component `output` can `ref`/`coalesce` over them.
873    for (i, id_val) in run.skipped.iter().enumerate() {
874        let _ = i;
875        if let Some(idx) = index_of(id_val) {
876            let rk = node_relation_kind_str(&body[idx]);
877            let unproduced = if rk == Some("connection") {
878                Value::Obj(vec![
879                    ("items".into(), Value::Arr(vec![])),
880                    ("cursor".into(), Value::Null),
881                ])
882            } else {
883                Value::Null
884            };
885            if scope_get(&results, id_val).is_none() {
886                results.push((id_val.clone(), unproduced));
887            }
888        }
889    }
890
891    let scope: Vec<(String, Value)> = {
892        let mut s: Vec<(String, Value)> = input.to_vec();
893        s.extend(results.iter().cloned());
894        s
895    };
896    Ok(evaluate_expression(&output, &scope)?)
897}