behavior-contracts 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,25 +20,71 @@ function rustErr(code, msgExpr) {
20
20
  * fail closed with ZERO bc-runtime import. Codes match run_behavior verbatim (byte-equal). `code()` /
21
21
  * `failure_code()` expose the stable code without a bc-runtime type. */
22
22
  function emitBehaviorErrorType() {
23
- return `// BehaviorError — the LOCAL concrete failure type for the covered read plane (runtime-free): a
23
+ return `// ErrorKindwhat went wrong (the closed set of scp-error.md). A concrete enum: the covered plane
24
+ // carries no strings-as-tags and no dynamic kind lookup.
25
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
26
+ pub enum ErrorKind {
27
+ TypeMismatch,
28
+ MissingField,
29
+ Overflow,
30
+ }
31
+
32
+ impl ErrorKind {
33
+ pub fn as_str(self) -> &'static str {
34
+ match self {
35
+ ErrorKind::TypeMismatch => "typeMismatch",
36
+ ErrorKind::MissingField => "missingField",
37
+ ErrorKind::Overflow => "overflow",
38
+ }
39
+ }
40
+ }
41
+
42
+ // ErrorDetail — the structured, recoverable payload a failure carries (scp-error.md "The Error
43
+ // Value"). The LEAF produces it at the wire boundary (it is the only party holding both the declared
44
+ // type and the raw wire datum); the runner transports it verbatim. Concrete fields — no boxed Value,
45
+ // no serialized blob in a string: \`expected_type\` is Portable Type Notation, a rendering of a
46
+ // STATICALLY DECLARED type, so nothing walks a type at runtime.
47
+ #[derive(Debug, Clone, PartialEq, Eq, Default)]
48
+ pub struct ErrorDetail {
49
+ pub kind: Option<ErrorKind>,
50
+ pub model: Option<String>,
51
+ pub field: Option<String>,
52
+ pub expected_type: Option<String>,
53
+ pub actual_wire_type: Option<String>,
54
+ pub raw_value: Option<String>,
55
+ pub context: std::collections::BTreeMap<String, String>,
56
+ }
57
+
58
+ // BehaviorError — the LOCAL concrete failure type for the covered read plane (runtime-free): a
24
59
  // covered runner returns \`Result<T, BehaviorError>\` over THIS local type instead of a bc-runtime
25
60
  // failure, so the fully-covered module imports ZERO bc runtime. Codes match run_behavior verbatim
26
- // (byte-equal). The observe companion (test glue, a super:: submodule) bridges this local error to the
27
- // bc-runtime BehaviorError at the observe boundary the covered module itself stays runtime-free.
61
+ // (byte-equal). \`detail\` carries the leaf's structured Error Value across the seam so a caller can
62
+ // log / skip / repair / migrate rather than parse a message. The observe companion (test glue, a
63
+ // super:: submodule) bridges this local error to the bc-runtime failure at the observe boundary —
64
+ // the covered module itself stays runtime-free.
28
65
  #[derive(Debug, Clone)]
29
66
  pub struct BehaviorError {
30
67
  pub code: String,
31
68
  pub message: String,
69
+ pub detail: Option<Box<ErrorDetail>>,
32
70
  }
33
71
 
34
72
  impl BehaviorError {
35
73
  pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
36
- BehaviorError { code: code.into(), message: message.into() }
74
+ BehaviorError { code: code.into(), message: message.into(), detail: None }
75
+ }
76
+ /// The same failure carrying the leaf's structured Error Value.
77
+ pub fn with_detail(code: impl Into<String>, message: impl Into<String>, detail: ErrorDetail) -> Self {
78
+ BehaviorError { code: code.into(), message: message.into(), detail: Some(Box::new(detail)) }
37
79
  }
38
80
  /// The stable failure code (byte-equal to run_behavior) WITHOUT a bc-runtime type.
39
81
  pub fn code(&self) -> &str {
40
82
  &self.code
41
83
  }
84
+ /// The structured payload, if this failure is about a datum.
85
+ pub fn detail(&self) -> Option<&ErrorDetail> {
86
+ self.detail.as_deref()
87
+ }
42
88
  }
43
89
 
44
90
  impl std::fmt::Display for BehaviorError {
@@ -46,7 +92,75 @@ impl std::fmt::Display for BehaviorError {
46
92
  write!(f, "{}: {}", self.code, self.message)
47
93
  }
48
94
  }
49
- impl std::error::Error for BehaviorError {}`;
95
+ impl std::error::Error for BehaviorError {}
96
+
97
+ // op_failed — wrap a leaf failure as the node's Failure under its Error Policy Kind, carrying the
98
+ // leaf's structured detail through unchanged (the runner transports the Error Value; it does not
99
+ // synthesize or re-interpret it).
100
+ #[allow(dead_code)]
101
+ fn op_failed(node: &str, policy: &str, e: BehaviorError) -> BehaviorError {
102
+ BehaviorError {
103
+ code: "OP_FAILED".to_string(),
104
+ message: format!("operation '{node}' failed under '{policy}' policy: {}", e.message),
105
+ detail: e.detail,
106
+ }
107
+ }
108
+
109
+ // de_type_mismatch / de_missing_field / de_overflow — the de-box mismatch failures the generated decode
110
+ // produces (scp-error.md "The Error Value"). Codes are byte-equal to the runtime outType check
111
+ // (TYPE_MISMATCH / MISSING_PROP) so the covered read stays equivalent to run_behavior; detail.kind
112
+ // distinguishes typeMismatch / missingField / overflow.
113
+ #[allow(dead_code)]
114
+ fn de_type_mismatch(model: &str, field: &str, expected: &str, actual_wire: String, raw: String) -> BehaviorError {
115
+ BehaviorError::with_detail(
116
+ "TYPE_MISMATCH",
117
+ format!("node '{model}': {field}: expected {expected}, got {actual_wire}"),
118
+ ErrorDetail {
119
+ kind: Some(ErrorKind::TypeMismatch),
120
+ model: Some(model.to_string()),
121
+ field: Some(field.to_string()),
122
+ expected_type: Some(expected.to_string()),
123
+ actual_wire_type: Some(actual_wire),
124
+ raw_value: Some(raw),
125
+ context: std::collections::BTreeMap::new(),
126
+ },
127
+ )
128
+ }
129
+
130
+ #[allow(dead_code)]
131
+ fn de_missing_field(model: &str, field: &str, expected: &str) -> BehaviorError {
132
+ BehaviorError::with_detail(
133
+ "MISSING_PROP",
134
+ format!("node '{model}': {field}: required field is absent"),
135
+ ErrorDetail {
136
+ kind: Some(ErrorKind::MissingField),
137
+ model: Some(model.to_string()),
138
+ field: Some(field.to_string()),
139
+ expected_type: Some(expected.to_string()),
140
+ actual_wire_type: None,
141
+ raw_value: None,
142
+ context: std::collections::BTreeMap::new(),
143
+ },
144
+ )
145
+ }
146
+
147
+ #[allow(dead_code)]
148
+ fn de_overflow(model: &str, field: &str, expected: &str, actual_wire: String, raw: String) -> BehaviorError {
149
+ let message = format!("node '{model}': {field}: {raw} is outside the range of {expected}");
150
+ BehaviorError::with_detail(
151
+ "TYPE_MISMATCH",
152
+ message,
153
+ ErrorDetail {
154
+ kind: Some(ErrorKind::Overflow),
155
+ model: Some(model.to_string()),
156
+ field: Some(field.to_string()),
157
+ expected_type: Some(expected.to_string()),
158
+ actual_wire_type: Some(actual_wire),
159
+ raw_value: Some(raw),
160
+ context: std::collections::BTreeMap::new(),
161
+ },
162
+ )
163
+ }`;
50
164
  }
51
165
  function portFieldName(wire) {
52
166
  const safe = wire.replace(/[^A-Za-z0-9_]/g, "_").toLowerCase();
@@ -78,250 +192,499 @@ function nodeMethodName(nodeId) {
78
192
  function inStructName(compName) {
79
193
  return `InNR${pascal(compName)}`;
80
194
  }
81
- function staticArrElems(node) {
82
- if (node === null || typeof node !== "object" || Array.isArray(node))
83
- return null;
84
- const keys = Object.keys(node);
85
- if (keys.length !== 1 || keys[0] !== "arr")
86
- return null;
87
- const arg = node.arr;
88
- return Array.isArray(arg) ? arg : null;
195
+ function recordTop(ref) {
196
+ if (ref.kind === "named")
197
+ return { kind: "named", innerName: ref.name };
198
+ if (ref.kind === "arr" && ref.elem.kind === "named")
199
+ return { kind: "arr", innerName: ref.elem.name };
200
+ if (ref.kind === "opt" && ref.inner.kind === "named")
201
+ return { kind: "opt", innerName: ref.inner.name };
202
+ if (ref.kind === "map" && ref.value.kind === "named")
203
+ return { kind: "map", innerName: ref.value.name };
204
+ return null;
89
205
  }
90
- /**
91
- * portArrayElemRef (#110) resolve a componentRef input port that binds a RUNTIME ARRAY (an IN-list /
92
- * array-bound WHERE head) to its native element TypeRef, or null when not a covered array. The covered
93
- * shape is a SINGLE-SEGMENT, non-opt `ref` into an INPUT ARRAY port carrying a resolvable `elemType`
94
- * (PortSchema.elemType — BC does NOT infer, consumer-interface C3). The port lowers to a native
95
- * `Vec<ElemT>` fed from `in_.<field>.clone()`. Reuses the bc#108 elemType lowering, extended from the
96
- * `map…over` element-source path to a plain componentRef port. A static string array is a different
97
- * covered shape (Vec<&'static str>) — handled ahead of this. No resolvable elemType / opt / multi-segment
98
- * ref / non-array schema / no plan → null (caller keeps its existing fail-closed behaviour). */
99
- function portArrayElemRef(node, inputPorts, plan) {
100
- if (plan === undefined)
101
- return null;
102
- if (staticStringArrElems(node) !== null)
103
- return null;
104
- const e = classifyExpr(node);
105
- if (e.kind !== "ref" || e.opt || e.path.length !== 1)
106
- return null;
107
- const schema = inputPorts?.[e.path[0]];
108
- if (schema === undefined || (schema.type !== "array" && schema.type !== "arr"))
109
- return null;
110
- const et = schema.elemType;
111
- if (et === undefined)
112
- return null;
113
- return deriveTypeRef(et, plan);
206
+ /** deBoxEligible — a componentRef node whose result is a record-containing top (named / arr(named) /
207
+ * opt(named) / map(named)). Its handler returns the wire; the generated decode probes each record. */
208
+ function deBoxEligible(ref) {
209
+ return recordTop(ref) !== null;
210
+ }
211
+ /** rustWireTypeForTop the Rust handler return type (over the associated Self::Wire) for a record top. */
212
+ function rustWireTypeForTop(top) {
213
+ switch (top.kind) {
214
+ case "named":
215
+ return "Self::Wire";
216
+ case "arr":
217
+ return "Vec<Self::Wire>";
218
+ case "opt":
219
+ return "Option<Self::Wire>";
220
+ case "map":
221
+ return "std::collections::BTreeMap<String, Self::Wire>";
222
+ }
223
+ }
224
+ /** notationOfRef the Portable Type Notation of a TypeRef (scp-error.md), resolving a named ref to its
225
+ * obj notation via the plan. Baked as the `expectedType` literal (a rendering of the STATICALLY declared
226
+ * type — nothing walks a type at runtime). */
227
+ function notationOfRef(ref, plan) {
228
+ switch (ref.kind) {
229
+ case "scalar":
230
+ return ref.scalar;
231
+ case "opt":
232
+ return `opt(${notationOfRef(ref.inner, plan)})`;
233
+ case "arr":
234
+ return `arr(${notationOfRef(ref.elem, plan)})`;
235
+ case "map":
236
+ return `map(${notationOfRef(ref.value, plan)})`;
237
+ case "named": {
238
+ const decl = findDecl(plan, ref.name);
239
+ return `obj{${decl.fields.map((f) => `${f.name}:${notationOfRef(f.type, plan)}`).join(",")}}`;
240
+ }
241
+ }
242
+ }
243
+ /** emitRustProbeWireTypes — the once-per-module probe enums + WireRow/WireList seam traits. Concrete
244
+ * enums; monomorphized traits (mutual Row/List associated types keep nesting concrete, no dyn/Box). */
245
+ function emitRustProbeWireTypes() {
246
+ return `// Probe<T> / NumProbe — the outcome of classifying one wire attribute against a declared type. Got carries
247
+ // the matched value (a NumProbe's raw numeric text, which the de-box parses + range-checks so overflow is
248
+ // BC's to detect); actual_wire_type is the producer's own wire tag (a free string, e.g. a DynamoDB
249
+ // "S"/"N"/"BOOL"); raw_value is the offending value stringified. Concrete enums — no boxed Value.
250
+ pub enum Probe<T> {
251
+ Got(T),
252
+ Wrong { actual_wire_type: String, raw_value: String },
253
+ Null { actual_wire_type: String, raw_value: String },
254
+ Absent,
114
255
  }
115
- function inferPortType(node, inputPorts, asBinding) {
116
- if (staticArrElems(node) !== null)
117
- return "Value";
118
- const e = classifyExpr(node);
119
- switch (e.kind) {
120
- case "str":
121
- case "concat":
122
- return "String";
123
- case "bool":
124
- return "bool";
125
- case "ref": {
126
- // #108: a `$as`-headed ref (map element binding) — resolve the element field's scalar type.
127
- if (asBinding !== undefined && e.path[0] === asBinding.name) {
128
- try {
129
- const { ref } = typedFieldAccess(asBinding.name, asBinding.ref, e.path.slice(1), asBinding.plan);
130
- if (ref.kind === "scalar" && ref.scalar !== "null")
131
- return rustScalar(ref.scalar);
132
- }
133
- catch {
134
- return "Value";
135
- }
136
- return "Value";
137
- }
138
- // a ref to a REQUIRED scalar input port → that scalar. An OPTIONAL port has no required-scalar
139
- // kind (its native type is Option<T>) and is lowered by the opt lane ahead of this — reaching here
140
- // with one means the shape is uncovered, and "Value" routes it to the caller's loud fail-closed.
141
- if (e.path.length === 1) {
142
- const sc = inputScalarKind(inputPorts[e.path[0]]);
143
- if (sc !== undefined)
144
- return sc;
145
- }
146
- return "Value";
147
- }
148
- default:
149
- return "Value";
150
- }
256
+
257
+ pub enum NumProbe {
258
+ Got { raw: String, actual_wire_type: String },
259
+ Wrong { actual_wire_type: String, raw_value: String },
260
+ Null { actual_wire_type: String, raw_value: String },
261
+ Absent,
151
262
  }
152
- /** A static string-array port (every element a static string literal) — the graphddb `projection`
153
- * shape. Returns the list of literal strings, or null when the node is not that shape. */
154
- function staticStringArrElems(node) {
155
- const arr = staticArrElems(node);
156
- if (arr === null)
157
- return null;
158
- const out = [];
159
- for (const el of arr) {
160
- const e = classifyExpr(el);
161
- if (e.kind !== "str")
162
- return null;
163
- out.push(e.value);
263
+
264
+ // WireRow / WireList the consumer's wire item / wire array, probed per declared field / element by the
265
+ // generated de-box. The consumer implements them over its wire payload (a DynamoDB AttributeValue map)
266
+ // and classifies each attribute's variant; the de-box owns strictness. Monomorphized (Row/List mutual
267
+ // associated types) — no dyn, no Box, no boxed Value crosses the seam.
268
+ pub trait WireRow: Sized {
269
+ type List: WireList<Row = Self>;
270
+ // the attribute keys present (the entries of a wire map / DynamoDB "M") — iterated to decode a map(V).
271
+ fn keys(&self) -> Vec<String>;
272
+ fn probe_string(&self, field: &str) -> Probe<String>;
273
+ fn probe_number(&self, field: &str) -> NumProbe;
274
+ fn probe_bool(&self, field: &str) -> Probe<bool>;
275
+ fn probe_row(&self, field: &str) -> Probe<Self>;
276
+ fn probe_list(&self, field: &str) -> Probe<Self::List>;
277
+ }
278
+
279
+ pub trait WireList: Sized {
280
+ type Row: WireRow<List = Self>;
281
+ fn len(&self) -> usize;
282
+ fn is_empty(&self) -> bool {
283
+ self.len() == 0
284
+ }
285
+ fn elem_string(&self, i: usize) -> Probe<String>;
286
+ fn elem_number(&self, i: usize) -> NumProbe;
287
+ fn elem_bool(&self, i: usize) -> Probe<bool>;
288
+ fn elem_row(&self, i: usize) -> Probe<Self::Row>;
289
+ fn elem_list(&self, i: usize) -> Probe<Self>;
290
+ }`;
291
+ }
292
+ /** rustDecodeNamedName — the decode function name for a named type (deduped across the module). */
293
+ function rustDecodeNamedName(name) {
294
+ return `decode_wire_${name.replace(/[^A-Za-z0-9_]/g, "_").toLowerCase()}`;
295
+ }
296
+ /**
297
+ * emitRustDecodeNamed — emit (once, deduped) `decode_wire_<name><W: WireRow>(w: &W) -> Result<Name,
298
+ * BehaviorError>`: probe each declared field from the type SSoT (via the single rustDecodeValue
299
+ * recursion) and build the struct, failing closed with the structured Error Value on a mismatch.
300
+ */
301
+ function emitRustDecodeNamed(name, plan, emitted, out) {
302
+ const fn = rustDecodeNamedName(name);
303
+ if (emitted.has(fn))
304
+ return fn;
305
+ emitted.add(fn);
306
+ const decl = findDecl(plan, name);
307
+ const lines = [];
308
+ lines.push(`// ${fn} — the strict de-box for '${name}': probe each declared field, fail closed on a`);
309
+ lines.push(`// wire mismatch with the structured Error Value (byte-equal codes to run_behavior).`);
310
+ lines.push(`fn ${fn}<W: WireRow>(w: &W) -> Result<${name}, BehaviorError> {`);
311
+ const ctr = { n: 0 };
312
+ const binds = [];
313
+ for (const f of decl.fields) {
314
+ const rf = rustFieldName(f.name);
315
+ const expr = rustDecodeValue(f.type, "w", rustStrLit(f.name), "row", name, f.name, plan, emitted, out, ctr, 1);
316
+ lines.push(` let ${rf} = ${expr};`);
317
+ binds.push(rf);
318
+ }
319
+ lines.push(` Ok(${name} { ${binds.join(", ")} })`);
320
+ lines.push(`}`);
321
+ out.push(lines.join("\n"));
322
+ return fn;
323
+ }
324
+ /**
325
+ * rustDecodeValue — the SINGLE recursive decode over the full TypeRef grammar (scalar / opt(scalar) /
326
+ * named / arr / map). Returns a Rust expression (a match block) that evaluates to the decoded value of
327
+ * `ref`, obtained by probing `srcExpr` at `keyExpr` (a WireRow field via `probe_*`, srcKind "row"; or a
328
+ * WireList element via `elem_*`, srcKind "list"). Every nested position (arr element, map value, obj
329
+ * field) recurses through THIS function, so arbitrary nesting works by construction. A mismatch does
330
+ * `return Err(<structured error>)`; no panic, no boxed value.
331
+ */
332
+ function rustDecodeValue(ref, srcExpr, keyExpr, srcKind, model, fieldRaw, plan, emitted, out, ctr, indent) {
333
+ const I = " ".repeat(indent);
334
+ const I1 = " ".repeat(indent + 1);
335
+ const ml = rustStrLit(model);
336
+ const fl = rustStrLit(fieldRaw);
337
+ const nl = rustStrLit(notationOfRef(ref, plan));
338
+ const id = ctr.n++;
339
+ const prefix = srcKind === "row" ? "probe_" : "elem_";
340
+ const mismatch = `return Err(de_type_mismatch(${ml}, ${fl}, ${nl}, actual_wire_type, raw_value))`;
341
+ const missing = `return Err(de_missing_field(${ml}, ${fl}, ${nl}))`;
342
+ if (ref.kind === "opt" && ref.inner.kind === "scalar" && ref.inner.scalar !== "null") {
343
+ const inner = ref.inner.scalar;
344
+ if (inner === "int" || inner === "float") {
345
+ const rustT = inner === "int" ? "i64" : "f64";
346
+ return `match ${srcExpr}.${prefix}number(${keyExpr}) {
347
+ ${I1}NumProbe::Got { raw, actual_wire_type } => match raw.parse::<${rustT}>() {
348
+ ${I1} Ok(n) => Some(n),
349
+ ${I1} Err(_) => return Err(de_overflow(${ml}, ${fl}, ${nl}, actual_wire_type, raw)),
350
+ ${I1}},
351
+ ${I1}NumProbe::Wrong { actual_wire_type, raw_value } => ${mismatch},
352
+ ${I1}NumProbe::Null { .. } | NumProbe::Absent => None,
353
+ ${I}}`;
354
+ }
355
+ const meth = inner === "string" ? "string" : "bool";
356
+ return `match ${srcExpr}.${prefix}${meth}(${keyExpr}) {
357
+ ${I1}Probe::Got(v) => Some(v),
358
+ ${I1}Probe::Wrong { actual_wire_type, raw_value } => ${mismatch},
359
+ ${I1}Probe::Null { .. } | Probe::Absent => None,
360
+ ${I}}`;
361
+ }
362
+ if (ref.kind === "scalar" && ref.scalar !== "null") {
363
+ if (ref.scalar === "int" || ref.scalar === "float") {
364
+ const rustT = ref.scalar === "int" ? "i64" : "f64";
365
+ return `match ${srcExpr}.${prefix}number(${keyExpr}) {
366
+ ${I1}NumProbe::Got { raw, actual_wire_type } => match raw.parse::<${rustT}>() {
367
+ ${I1} Ok(n) => n,
368
+ ${I1} Err(_) => return Err(de_overflow(${ml}, ${fl}, ${nl}, actual_wire_type, raw)),
369
+ ${I1}},
370
+ ${I1}NumProbe::Wrong { actual_wire_type, raw_value }
371
+ ${I1}| NumProbe::Null { actual_wire_type, raw_value } => ${mismatch},
372
+ ${I1}NumProbe::Absent => ${missing},
373
+ ${I}}`;
374
+ }
375
+ const meth = ref.scalar === "string" ? "string" : "bool";
376
+ return `match ${srcExpr}.${prefix}${meth}(${keyExpr}) {
377
+ ${I1}Probe::Got(v) => v,
378
+ ${I1}Probe::Wrong { actual_wire_type, raw_value }
379
+ ${I1}| Probe::Null { actual_wire_type, raw_value } => ${mismatch},
380
+ ${I1}Probe::Absent => ${missing},
381
+ ${I}}`;
164
382
  }
165
- return out;
383
+ if (ref.kind === "named") {
384
+ const dec = emitRustDecodeNamed(ref.name, plan, emitted, out);
385
+ const rowMeth = srcKind === "row" ? "probe_row" : "elem_row";
386
+ return `match ${srcExpr}.${rowMeth}(${keyExpr}) {
387
+ ${I1}Probe::Got(sub) => ${dec}(&sub)?,
388
+ ${I1}Probe::Wrong { actual_wire_type, raw_value }
389
+ ${I1}| Probe::Null { actual_wire_type, raw_value } => ${mismatch},
390
+ ${I1}Probe::Absent => ${missing},
391
+ ${I}}`;
392
+ }
393
+ if (ref.kind === "arr") {
394
+ const listMeth = srcKind === "row" ? "probe_list" : "elem_list";
395
+ const elemExpr = rustDecodeValue(ref.elem, `l${id}`, `i${id}`, "list", model, fieldRaw, plan, emitted, out, ctr, indent + 2);
396
+ return `match ${srcExpr}.${listMeth}(${keyExpr}) {
397
+ ${I1}Probe::Got(l${id}) => {
398
+ ${I1} let mut acc = Vec::with_capacity(l${id}.len());
399
+ ${I1} for i${id} in 0..l${id}.len() {
400
+ ${I1} acc.push(${elemExpr});
401
+ ${I1} }
402
+ ${I1} acc
403
+ ${I1}}
404
+ ${I1}Probe::Wrong { actual_wire_type, raw_value }
405
+ ${I1}| Probe::Null { actual_wire_type, raw_value } => ${mismatch},
406
+ ${I1}Probe::Absent => ${missing},
407
+ ${I}}`;
408
+ }
409
+ if (ref.kind === "map") {
410
+ // a declared map(V): the wire value is a producer map (a DynamoDB "M"); enumerate its keys and decode
411
+ // each value as V through THIS same recursion (so map-of-map / map-of-arr nest by construction).
412
+ const rowMeth = srcKind === "row" ? "probe_row" : "elem_row";
413
+ const valExpr = rustDecodeValue(ref.value, `m${id}`, `&k${id}`, "row", model, fieldRaw, plan, emitted, out, ctr, indent + 2);
414
+ return `match ${srcExpr}.${rowMeth}(${keyExpr}) {
415
+ ${I1}Probe::Got(m${id}) => {
416
+ ${I1} let mut acc = std::collections::BTreeMap::new();
417
+ ${I1} for k${id} in m${id}.keys() {
418
+ ${I1} let v${id} = ${valExpr};
419
+ ${I1} acc.insert(k${id}, v${id});
420
+ ${I1} }
421
+ ${I1} acc
422
+ ${I1}}
423
+ ${I1}Probe::Wrong { actual_wire_type, raw_value }
424
+ ${I1}| Probe::Null { actual_wire_type, raw_value } => ${mismatch},
425
+ ${I1}Probe::Absent => ${missing},
426
+ ${I}}`;
427
+ }
428
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust strict de-box: '${fieldRaw}' of '${model}' has type ${JSON.stringify(ref)} which the generated de-box does not decode (supported: scalar / opt(scalar) / named / arr / map).`);
429
+ }
430
+ /** rustDecodeElemName — the element de-box function name for a map/fanout node. */
431
+ function rustDecodeElemName(compName, nodeId) {
432
+ return `decode_wire_elem_${sanitize(compName)}_${nodeId.replace(/[^A-Za-z0-9_]/g, "_").toLowerCase()}`;
433
+ }
434
+ /** mapFanoutElemRef — the element ROW TypeRef a map/fanout node decodes (the per-element record). */
435
+ function mapFanoutElemRef(node, typedNodes, plan) {
436
+ const ref = typedNodes.get(node.id);
437
+ if ("fanout" in node)
438
+ return fanoutItemsElemRef(node, ref, plan);
439
+ return mapElemRowRef(node, ref, plan);
440
+ }
441
+ /** mapFanoutElemDeBoxEligible — a map/fanout node whose element ROW is a named record de-boxes each
442
+ * element through the generated decode_wire_elem_* (closing the scan-row silent-default hole). */
443
+ function mapFanoutElemDeBoxEligible(node, typedNodes, plan) {
444
+ return mapFanoutElemRef(node, typedNodes, plan).kind === "named";
166
445
  }
167
446
  /**
168
- * portFieldRustTypethe NATIVE Rust type for a covered port field (bc#90 / runtime-free). The covered
169
- * ports struct carries NO `Value` field: every port lowers to a concrete Rust type.
170
- * - a static string-array (projection) `Vec<&'static str>` (change #2);
171
- * - a bare int/float number literal (e.g. Query `limit: 20`) `i64` / `f64`;
172
- * - a string/bool/scalar-input port → the scalar Rust type (inferPortType).
173
- * A port that would otherwise infer to the boxed `Value` (a dynamic operator, a non-static array, a
174
- * Value-typed input) FAILS CLOSED — the covered read plane admits NO boxed escape (there is no `Value`
175
- * fallback on the native module). `where` names the emission site for the error.
447
+ * emitRustDecodeElemRowemit (once) `decode_wire_elem_<comp>_<node><W: WireRow>(w: &W) ->
448
+ * Result<RawElemNR<comp><node>, BehaviorError>` for a map/fanout node whose element ROW is a named
449
+ * record: probe each declared field via the SAME rustDecodeValue recursion as record reads and build the
450
+ * concrete RawElemNR struct, failing closed with the structured Error Value on a mismatch.
176
451
  */
177
- function portFieldRustType(node, inputPorts, where = "port", asBinding, plan, typedNodes) {
178
- // A port reading an OPTIONAL input port lowers through the shared native-expr compiler (the
179
- // Option<T> pass-through / the coalesce default). It runs FIRST so an optional input can never fall
180
- // into the required-scalar inference below (which has no way to say "absent").
181
- const optPort = optPortCompileR(node, inputPorts, where, plan);
182
- if (optPort !== null)
183
- return renderTypeRef(optPort.ref);
184
- if (staticStringArrElems(node) !== null)
185
- return "Vec<&'static str>";
186
- // #110: a componentRef port bound to a runtime array input port (with a resolvable elemType) → Vec<ElemT>.
187
- const arrElem = portArrayElemRef(node, inputPorts, plan);
188
- if (arrElem !== null)
189
- return `Vec<${renderTypeRef(arrElem)}>`;
190
- // #129: a leaf port that is a ref into a PRIOR NODE's typed arr outType (node→leaf array dataflow) → Vec<ElemT>.
191
- const priorArr = priorNodeArrElemInfo(node, typedNodes, plan);
192
- if (priorArr !== null)
193
- return `Vec<${renderTypeRef(priorArr.elemRef)}>`;
194
- const num = numLiteralRustExpr(node);
195
- if (num !== null)
196
- return typeof node === "number" && Number.isInteger(node) ? "i64" : "f64";
197
- // a ref to a `$as` element field (or an input port) whose type is a MAP or NAMED struct (NOT a bare
198
- // scalar) → the native map/struct type. #108-map: a `{map:V}`-typed field (e.g. a nested
199
- // `map<map<obj>>` write port) lowers to BTreeMap; a named field lowers to its struct. Reuses the same
200
- // typedFieldAccess resolution as the scalar path; a genuinely-untyped ref still falls through.
201
- const composite = portCompositeRef(node, inputPorts, asBinding, plan);
202
- if (composite !== null)
203
- return renderTypeRef(composite);
204
- const ft = inferPortType(node, inputPorts, asBinding);
205
- if (ft === "Value") {
206
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#90 / runtime-free): ${where} '${JSON.stringify(node)}' does not lower to a native Rust type (would need a boxed Value). The covered read plane is runtime-free and admits NO boxed escape. ${RUST_STATIC_PORT_FORMS} Regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
207
- }
208
- return ft;
452
+ function emitRustDecodeElemRow(comp, node, elemRowRef, plan, emitted, out) {
453
+ const nodeId = node.id;
454
+ const fn = rustDecodeElemName(comp.name, nodeId);
455
+ if (emitted.has(fn))
456
+ return fn;
457
+ emitted.add(fn);
458
+ const retType = rawElemStructName(comp.name, nodeId);
459
+ const decl = findDecl(plan, elemRowRef.name);
460
+ const lines = [];
461
+ lines.push(`// ${fn} the strict de-box for a '${nodeId}' element row: probe each declared field, fail`);
462
+ lines.push(`// closed on a wire mismatch with the structured Error Value (byte-equal codes to run_behavior).`);
463
+ lines.push(`fn ${fn}<W: WireRow>(w: &W) -> Result<${retType}, BehaviorError> {`);
464
+ const ctr = { n: 0 };
465
+ const binds = [];
466
+ for (const f of decl.fields) {
467
+ const rf = rustFieldName(f.name);
468
+ const expr = rustDecodeValue(f.type, "w", rustStrLit(f.name), "row", elemRowRef.name, f.name, plan, emitted, out, ctr, 1);
469
+ lines.push(` let ${rf} = ${expr};`);
470
+ binds.push(rf);
471
+ }
472
+ lines.push(` Ok(${retType} { ${binds.join(", ")} })`);
473
+ lines.push(`}`);
474
+ out.push(lines.join("\n"));
475
+ return fn;
476
+ }
477
+ /** emitRustNodeDecodeFns emit the decode functions for every de-box-eligible node: a componentRef
478
+ * record read (decode_wire_<name>) OR a map/fanout element row (decode_wire_elem_<comp>_<node>). */
479
+ /** rustDecodeResultName the top-result decode function name for a de-box componentRef node. */
480
+ function rustDecodeResultName(compName, nodeId) {
481
+ return `decode_wire_result_${sanitize(compName)}_${nodeId.replace(/[^A-Za-z0-9_]/g, "_").toLowerCase()}`;
209
482
  }
210
483
  /**
211
- * optPortCompileRthe OPTIONAL-input port lane (rust). A component may declare an input port
212
- * `{opt:T}` (PortSchema `required:false`); the only native representation of "the value is absent" is
213
- * `Option<T>`, so such a port never lowers through the required-scalar inference. This lane compiles the
214
- * port expression with the SHARED runtime-free native-expr compiler (native-expr.ts the same traversal,
215
- * type-inference and fail-closed discipline the go twin uses, so the two agree by construction):
216
- *
217
- * - `opt($.x)` / `$.x` at port position → `Option<T>` (the leaf receives the absent value as null,
218
- * exactly as run_behavior's `evalPorts` passes it).
219
- * - `coalesce(opt($.x), <literal>)` → `in_.x.unwrap_or(<literal>)` — a native `T`, no boxed Value.
220
- * - `ne(opt($.x), null)` / `eq(…, null)` → the native presence test (`is_some()` / `is_none()`).
221
- *
222
- * Returns null when the port expression reads NO optional input (the caller keeps its existing lowering).
223
- * A port that DOES read one but does not compile — or that compiles to a fallible expression, which this
224
- * position cannot hoist — FAILS CLOSED loudly rather than degrade to a required type or a zero value.
484
+ * emitRustDecodeWireResultemit (once) `decode_wire_result_<comp>_<node><W: WireRow>(wire: <WireArg>)
485
+ * -> Result<<ResultType>, BehaviorError>` for a componentRef node whose result is a record-containing
486
+ * top: it decodes each record via decode_wire_<name> and assembles the top (record / Vec / Option /
487
+ * BTreeMap). One path covers named / arr(named) / opt(named) / map(named).
225
488
  */
226
- function optPortCompileR(node, inputPorts, where, plan) {
227
- if (plan === undefined)
228
- return null;
229
- if (!exprReadsOptionalInput(node, inputPorts))
230
- return null;
231
- const resolveHead = (head) => rustInputHeadRef(head, { inputPorts }, plan);
232
- let c;
233
- try {
234
- c = new NativeExprCompiler(makeRustExprBackend(resolveHead, plan)).compilePort(node);
489
+ function emitRustDecodeWireResult(comp, node, ref, plan, emitted, out) {
490
+ const top = recordTop(ref);
491
+ const fn = rustDecodeResultName(comp.name, node.id);
492
+ if (emitted.has(fn))
493
+ return fn;
494
+ emitted.add(fn);
495
+ const innerDecode = emitRustDecodeNamed(top.innerName, plan, emitted, out);
496
+ const resultType = renderTypeRef(ref);
497
+ const lines = [];
498
+ lines.push(`// ${fn} — decode the node's record-containing '${top.kind}' result from the wire (each record`);
499
+ lines.push(`// via ${innerDecode}); a de-box mismatch fails closed with the structured Error Value.`);
500
+ switch (top.kind) {
501
+ case "named":
502
+ lines.push(`fn ${fn}<W: WireRow>(wire: W) -> Result<${resultType}, BehaviorError> {`);
503
+ lines.push(` ${innerDecode}(&wire)`);
504
+ break;
505
+ case "arr":
506
+ lines.push(`fn ${fn}<W: WireRow>(wire: Vec<W>) -> Result<${resultType}, BehaviorError> {`);
507
+ lines.push(` let mut out = Vec::with_capacity(wire.len());`);
508
+ lines.push(` for w in wire {`);
509
+ lines.push(` out.push(${innerDecode}(&w)?);`);
510
+ lines.push(` }`);
511
+ lines.push(` Ok(out)`);
512
+ break;
513
+ case "opt":
514
+ lines.push(`fn ${fn}<W: WireRow>(wire: Option<W>) -> Result<${resultType}, BehaviorError> {`);
515
+ lines.push(` match wire {`);
516
+ lines.push(` Some(w) => Ok(Some(${innerDecode}(&w)?)),`);
517
+ lines.push(` None => Ok(None),`);
518
+ lines.push(` }`);
519
+ break;
520
+ case "map":
521
+ lines.push(`fn ${fn}<W: WireRow>(wire: std::collections::BTreeMap<String, W>) -> Result<${resultType}, BehaviorError> {`);
522
+ lines.push(` let mut out = std::collections::BTreeMap::new();`);
523
+ lines.push(` for (k, w) in wire {`);
524
+ lines.push(` out.insert(k, ${innerDecode}(&w)?);`);
525
+ lines.push(` }`);
526
+ lines.push(` Ok(out)`);
527
+ break;
235
528
  }
236
- catch (e) {
237
- if (!(e instanceof GeneratorFailure))
238
- throw e;
239
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native (runtime-free): ${where} ${JSON.stringify(node)} reads an OPTIONAL input port but does not lower to a native Rust value: ${e.message}`);
529
+ lines.push(`}`);
530
+ out.push(lines.join("\n"));
531
+ return fn;
532
+ }
533
+ function emitRustNodeDecodeFns(comp, typedNodes, plan, emitted, out) {
534
+ for (const n of comp.body) {
535
+ if ("cond" in n)
536
+ continue;
537
+ if ("map" in n || "fanout" in n) {
538
+ const node = n;
539
+ if (!mapFanoutElemDeBoxEligible(node, typedNodes, plan))
540
+ continue;
541
+ emitRustDecodeElemRow(comp, node, mapFanoutElemRef(node, typedNodes, plan), plan, emitted, out);
542
+ continue;
543
+ }
544
+ const ref = typedNodes.get(n.id);
545
+ if (ref === undefined)
546
+ continue;
547
+ const top = recordTop(ref);
548
+ if (top === null)
549
+ continue;
550
+ // a NAMED top decodes in place via decode_wire_<name>; an arr/opt/map top assembles via
551
+ // decode_wire_result_* (which recurses into decode_wire_<name> per record).
552
+ if (top.kind === "named")
553
+ emitRustDecodeNamed(top.innerName, plan, emitted, out);
554
+ else
555
+ emitRustDecodeWireResult(comp, n, ref, plan, emitted, out);
240
556
  }
241
- if (c.stmts.length > 0)
242
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native (runtime-free): ${where} ${JSON.stringify(node)} reads an OPTIONAL input port through a FALLIBLE expression (one that can raise INT_OVERFLOW / NAN_OR_INF / MOD_ZERO / PRECISION_LOSS). Such an expression lowers to STATEMENTS carrying an early error-return, and a port is built inline in the ports-struct literal — a position that cannot host them. (A cond/guard position CAN host them, so the same expression is covered there — the boundary is the POSITION, not the operator.) Use a non-fallible port expression.`);
243
- return c;
244
- }
245
- /** exprReadsOptionalInput — does this port expression reference an OPTIONAL (`required:false`) input
246
- * port? Language-neutral scan for `ref`/`refOpt` heads bound to an optional port. Decides ONLY which
247
- * lane a port takes; the lane itself then proves the lowering (or fails closed). */
248
- function exprReadsOptionalInput(node, inputPorts) {
249
- if (node === null || typeof node !== "object")
250
- return false;
251
- if (Array.isArray(node))
252
- return node.some((el) => exprReadsOptionalInput(el, inputPorts));
253
- const rec = node;
254
- const keys = Object.keys(rec);
255
- if (keys.length === 1 && (keys[0] === "ref" || keys[0] === "refOpt")) {
256
- const path = rec[keys[0]];
257
- return Array.isArray(path) && typeof path[0] === "string" && inputPortIsOptional(inputPorts?.[path[0]]);
258
- }
259
- return keys.some((k) => exprReadsOptionalInput(rec[k], inputPorts));
260
- }
261
- /** portCompositeRef the full TypeRef of a port that is a `{ref:[...]}` into a `$as` element field OR
262
- * an input port whose type is a MAP or NAMED struct (a non-scalar covered field). Returns null when the
263
- * node is not such a ref (so the caller falls through to the scalar/array inference). #108-map: enables a
264
- * `{map:V}`-typed field port (e.g. a nested map write port) to lower to the native BTreeMap. */
265
- function portCompositeRef(node, inputPorts, asBinding, plan) {
266
- const e = classifyExpr(node);
267
- if (e.kind !== "ref" || plan === undefined)
268
- return null;
269
- let ref = null;
270
- if (asBinding !== undefined && e.path[0] === asBinding.name) {
271
- try {
272
- ref = typedFieldAccess(asBinding.name, asBinding.ref, e.path.slice(1), asBinding.plan).ref;
557
+ }
558
+ /** containsNamed whether a TypeRef transitively contains a named record (through arr / opt / map). */
559
+ function containsNamed(ref) {
560
+ switch (ref.kind) {
561
+ case "scalar":
562
+ return false;
563
+ case "named":
564
+ return true;
565
+ case "opt":
566
+ return containsNamed(ref.inner);
567
+ case "arr":
568
+ return containsNamed(ref.elem);
569
+ case "map":
570
+ return containsNamed(ref.value);
571
+ }
572
+ }
573
+ /**
574
+ * assertDeBoxableResults — fail closed (never a silent trust) for a covered node whose de-boxable result
575
+ * is a NON-named top (arr / opt / map) that transitively contains a record: the generated de-box decodes
576
+ * records only under a NAMED top (decode_wire_* / decode_wire_elem_*), so a record inside a non-named top
577
+ * would be materialized on the trusting `.val` path and silently default its wire fields. That is the
578
+ * silent-default red line forbids, so it is a loud generation-time error (native-codegen-standard
579
+ * §2). A NAMED top (de-boxed) or a pure-scalar non-named top (no records to de-box) is fine.
580
+ */
581
+ /** readsAPriorNode — a componentRef node is a DATAFLOW consumer iff at least one input port is a ref
582
+ * headed by a PRIOR BODY NODE (it reads a prior node's already-native result, #129), rather than a fresh
583
+ * external wire read whose ports come from input params / static / an element ($as) binding. Same
584
+ * head-is-a-body-node signal priorNodeArrElemInfo (#129) uses to lower a prior-node array feed. */
585
+ function readsAPriorNode(node, typedNodes) {
586
+ for (const expr of Object.values(node.ports ?? {})) {
587
+ const e = classifyExpr(expr);
588
+ if (e.kind === "ref" && e.path.length >= 1 && typedNodes.has(e.path[0]))
589
+ return true;
590
+ }
591
+ return false;
592
+ }
593
+ function assertDeBoxableResults(comp, typedNodes, plan) {
594
+ for (const n of comp.body) {
595
+ if ("cond" in n)
596
+ continue;
597
+ const isMapFanout = "map" in n || "fanout" in n;
598
+ const ref = isMapFanout ? mapFanoutElemRef(n, typedNodes, plan) : typedNodes.get(n.id);
599
+ const deBoxed = isMapFanout ? ref.kind === "named" : recordTop(ref) !== null;
600
+ if (deBoxed)
601
+ continue;
602
+ const nodeId = n.id;
603
+ if (containsNamed(ref)) {
604
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust strict de-box: node '${nodeId}' shape ${JSON.stringify(ref)} contains a record the de-box does not decode (a deeper nesting than a direct named / arr / opt / map of a named record). It would trust the record's wire fields (silent default) — the silent-default red line forbids. Fail closed: reshape so the record is a direct de-box top, or extend the recursion for this nesting.`);
273
605
  }
274
- catch {
275
- return null;
606
+ // a map/fanout element is ALWAYS a per-element wire read; only a named record is de-boxed
607
+ // (decode_wire_elem_*). A scalar / scalar-collection element materializes from the trusting per-element
608
+ // path — a wrong wire value silent-defaults. Fail closed.
609
+ if (isMapFanout) {
610
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust strict de-box: map/fanout node '${nodeId}' element ${JSON.stringify(ref)} is a scalar / scalar-collection per-element wire read that materializes on the trusting typed path — a wrong wire value would silent-default (diverging from run_behavior's outType check). Fail closed: declare the element as a record (de-boxed).`);
276
611
  }
277
- }
278
- else if (e.path.length >= 1) {
279
- const s = inputPorts[e.path[0]];
280
- // a whole-port ref to an input MAP port carrying an elemType → its BTreeMap type (mirrors
281
- // inputPortRustType); a deeper path into an input port is not resolved here (no field type source).
282
- if (s !== undefined && s.type === "map" && e.path.length === 1) {
283
- const et = s.elemType;
284
- if (et !== undefined)
285
- return { kind: "map", value: deriveTypeRef(et, plan) };
612
+ // a pure-scalar / scalar-collection top on a HANDLER-BACKED componentRef wire read trusts the typed
613
+ // materialization (a wrong wire value silent-defaults). A DATAFLOW consumer (a port fed by a prior
614
+ // node) computes from already-native data and generates.
615
+ if (!readsAPriorNode(n, typedNodes)) {
616
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust strict de-box: node '${nodeId}' result top ${JSON.stringify(ref)} is a handler-backed scalar / scalar-collection read that materializes on the trusting typed path a wrong wire value would silent-default (diverging from run_behavior's outType check). Fail closed: declare the result as a record (de-boxed) or feed it as dataflow from a prior node.`);
286
617
  }
287
- return null;
288
618
  }
289
- if (ref === null)
290
- return null;
291
- // only MAP / NAMED composite fields here — scalars/arrays keep their existing dedicated paths.
292
- if (ref.kind === "map" || ref.kind === "named")
293
- return ref;
294
- return null;
295
619
  }
296
- /** emitCompositePortValue — the OWNED native value expr for a port that is a ref to a `$as` element
297
- * MAP/NAMED field (or an input map port): a typed field access `.clone()` (the concrete BTreeMap/struct,
298
- * ZERO boxed Value). `elemBaseExpr` is the map arm's element base (`oel_<mapId>`) for a `$as`-headed ref;
299
- * omitted at the top level (only input-port composite refs resolve there). Returns null otherwise. */
300
- function emitCompositePortValue(node, inputPorts, asBinding, plan, elemBaseExpr) {
301
- const e = classifyExpr(node);
302
- if (e.kind !== "ref")
620
+ function staticArrElems(node) {
621
+ if (node === null || typeof node !== "object" || Array.isArray(node))
303
622
  return null;
304
- if (asBinding !== undefined && elemBaseExpr !== undefined && e.path[0] === asBinding.name) {
305
- let acc;
306
- try {
307
- acc = typedFieldAccess(elemBaseExpr, asBinding.ref, e.path.slice(1), plan);
308
- }
309
- catch {
310
- return null;
311
- }
312
- if (acc.ref.kind !== "map" && acc.ref.kind !== "named")
623
+ const keys = Object.keys(node);
624
+ if (keys.length !== 1 || keys[0] !== "arr")
625
+ return null;
626
+ const arg = node.arr;
627
+ return Array.isArray(arg) ? arg : null;
628
+ }
629
+ /**
630
+ * portFieldRustType — the NATIVE Rust type for a covered port field (bc#90 / runtime-free). The covered
631
+ * ports struct carries NO `Value` field: every port lowers to a concrete Rust type.
632
+ /** rustOwn — the ownership renderer native-expr applies at a resolved ref LEAF: an array field is cloned
633
+ * (owned) when `ownArrays` (a port) or borrowed (a cond `len`); a Copy scalar behind a guard is deref'd
634
+ * (unless a field was already accessed by value); anything else clones. */
635
+ function rustOwn(guard, ownArrays) {
636
+ return (expr, ref, fieldAccessed) => {
637
+ if (ref.kind === "arr")
638
+ return ownArrays ? `${expr}.clone()` : expr;
639
+ if (typeRefIsCopy(ref))
640
+ return guard && !fieldAccessed ? `*${expr}` : expr;
641
+ return `${expr}.clone()`;
642
+ };
643
+ }
644
+ /** nativeResolveHead — the ONE ref-head resolver the runtime-free native-expr compiler reads (cond `if`/
645
+ * branches, a map/fanout `when` guard, AND a port). A head is a `$as` element binding, a prior body node's
646
+ * typed cell (behind a RefCell borrow guard), or an input port (a by-value struct field). #139: the input
647
+ * ref comes from the inputPortTypeRef SSoT so an OPTIONAL port resolves as `{opt:…}` (native Option<T>). */
648
+ function nativeResolveHead(comp, plan, typedNodes, isPrior, opts) {
649
+ const ownArrays = opts?.ownArrays === true;
650
+ return (head) => {
651
+ if (opts?.asBinding !== undefined && opts.asBase !== undefined && head === opts.asBinding.name) {
652
+ return { expr: opts.asBase, ref: opts.asBinding.ref, own: rustOwn(true, ownArrays) };
653
+ }
654
+ if (isPrior(head)) {
655
+ const ref = typedNodes.get(head);
656
+ return { expr: `${typedCell(head)}.borrow()`, ref, own: rustOwn(true, ownArrays) };
657
+ }
658
+ const ref = inputPortTypeRef(comp.inputPorts?.[head], plan);
659
+ if (ref === undefined)
313
660
  return null;
314
- return `${acc.expr}.clone()`;
661
+ return { expr: `in_.${rustFieldName(head)}`, ref, own: rustOwn(false, ownArrays) };
662
+ };
663
+ }
664
+ /** compilePortNode — lower a port expression to its native ports-struct field via the runtime-free
665
+ * native-expr compiler: the ONE port lowering. The port's field TYPE is the compiled `ref`, its
666
+ * initializer the compiled `expr` — derived from a SINGLE lowering, so they cannot disagree. A port that
667
+ * cannot lower FAILS CLOSED; a FALLIBLE lowering (hoists statements) is returned as-is (only emitPortInit
668
+ * rejects it). Reuses the same nativeResolveHead cond/guard/port share, with `ownArrays` (a port owns). */
669
+ function compilePortNode(node, comp, plan, typedNodes, isPrior, where, opts) {
670
+ const resolveHead = nativeResolveHead(comp, plan, typedNodes, isPrior, { ...opts, ownArrays: true });
671
+ try {
672
+ return new NativeExprCompiler(makeRustExprBackend(resolveHead, plan)).compilePort(node);
315
673
  }
316
- // a WHOLE-port ref to an input MAP port (with elemType) — valid at top level OR inside a map node (a
317
- // ref head that is NOT the `$as` element binding). Clones the native BTreeMap off the input struct.
318
- if ((asBinding === undefined || e.path[0] !== asBinding.name) && e.path.length === 1) {
319
- const s = inputPorts[e.path[0]];
320
- if (s !== undefined && s.type === "map" && s.elemType !== undefined) {
321
- return `in_.${rustFieldName(e.path[0])}.clone()`;
322
- }
674
+ catch (e) {
675
+ if (!(e instanceof GeneratorFailure))
676
+ throw e;
677
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native (runtime-free): ${where} ${JSON.stringify(node)} does not lower to a native Rust value: ${e.message}`);
323
678
  }
324
- return null;
679
+ }
680
+ /**
681
+ * portFieldRustType — the NATIVE Rust type for a covered port field, read off the ONE port lowering
682
+ * (compilePortNode / native-expr). Field type = the compiled TypeRef, so it can never disagree with the
683
+ * initializer (emitPortInit), which reads the SAME lowering for its value. `where` names the site.
684
+ */
685
+ function portFieldRustType(node, comp, plan, typedNodes, where = "port", opts) {
686
+ const c = compilePortNode(node, comp, plan, typedNodes, (h) => typedNodes.has(h), where, opts);
687
+ return c.renderedType ?? renderTypeRef(c.ref);
325
688
  }
326
689
  /**
327
690
  * emitPortsStruct — the CONCRETE native ports struct for a componentRef node. Typed fields per the
@@ -336,7 +699,12 @@ function emitPortsStruct(comp, node, asBinding, plan, typedNodes) {
336
699
  if (portNames.length === 0) {
337
700
  return `// ${name} — native ports for node '${node.id}' (${node.component}); no ports.\n#[derive(Clone)]\npub struct ${name};`;
338
701
  }
339
- const types = portNames.map((p) => portFieldRustType(node.ports[p], comp.inputPorts, `port '${p}' of node '${node.id}'`, asBinding, plan, typedNodes));
702
+ // a node WITH ports always resolves through the one port lowering, which needs the type plan + the
703
+ // prior-node map (both are supplied by every caller — narrow the optional params for the fields below).
704
+ if (plan === undefined || typedNodes === undefined) {
705
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native: ports struct for node '${node.id}' needs a type plan + prior-node map`);
706
+ }
707
+ const types = portNames.map((p) => portFieldRustType(node.ports[p], comp, plan, typedNodes, `port '${p}' of node '${node.id}'`, asBinding !== undefined ? { asBinding, asBase: asBinding.name } : undefined));
340
708
  const fields = portNames
341
709
  .map((p, i) => ` pub ${portFieldName(p)}: ${types[i]}, // ${JSON.stringify(p)}`)
342
710
  .join("\n");
@@ -360,7 +728,7 @@ function emitMapPortsStructs(comp, node, typedNodes, plan) {
360
728
  // #108: element ports may read the `$as` binding — resolve the over element type for native typing.
361
729
  const { elemRef } = mapOverElemInfo(comp, node, typedNodes, plan);
362
730
  const asBinding = { name: m.as, ref: elemRef, plan };
363
- const elemStruct = emitPortsStruct(comp, { id: node.id, component: m.component, ports: m.ports }, asBinding, plan);
731
+ const elemStruct = emitPortsStruct(comp, { id: node.id, component: m.component, ports: m.ports }, asBinding, plan, typedNodes);
364
732
  return `${elemStruct}
365
733
 
366
734
  // ${batch} — CONCRETE batched ports for map '${node.id}': the Vec of per-element CONCRETE ports structs
@@ -378,7 +746,7 @@ function emitFanoutPortsStructs(comp, node, typedNodes, plan) {
378
746
  const batch = `${name}Batch`;
379
747
  const { elemRef } = fanoutOverElemInfo(comp, node, typedNodes, plan);
380
748
  const asBinding = { name: f.as, ref: elemRef, plan };
381
- const elemStruct = emitPortsStruct(comp, { id: node.id, component: f.component, ports: f.ports }, asBinding, plan);
749
+ const elemStruct = emitPortsStruct(comp, { id: node.id, component: f.component, ports: f.ports }, asBinding, plan, typedNodes);
382
750
  return `${elemStruct}
383
751
 
384
752
  // ${batch} — CONCRETE batched ports for fanout '${node.id}': the Vec of per-id CONCRETE ports structs.
@@ -398,6 +766,20 @@ let rustExprTempSeq = 0;
398
766
  /** module-level accumulator: set when the native-expr compiler emits a fallible helper call (so emit()
399
767
  * bakes the helper library). Reset at the start of each emit(). */
400
768
  const rustExprUsed = { helpers: false };
769
+ /** rustBlockBody — the body of a block-expression that runs `stmts` then yields `expr`. A trailing
770
+ * `let t = E; t` collapses to `E` (clippy `let_and_return`). */
771
+ function rustBlockBody(stmts, expr) {
772
+ const last = stmts[stmts.length - 1];
773
+ const m = last !== undefined ? /^let\s+([A-Za-z0-9_]+)(?::\s*[^=]+)?\s*=\s*(.+);$/.exec(last) : null;
774
+ if (m !== null && m[1] === expr)
775
+ return [...stmts.slice(0, -1), m[2]].join(" ");
776
+ return [...stmts, rustStripOuterParens(expr)].join(" ");
777
+ }
778
+ /** Drop a TRAILING `.clone()` — for a position that only reads the value (a presence test), so an owning
779
+ * clone added by a head resolver is pure waste. Only a clone at the very END is dropped. */
780
+ function rustStripTrailingClone(expr) {
781
+ return expr.endsWith(".clone()") ? expr.slice(0, -".clone()".length) : expr;
782
+ }
401
783
  /** makeRustExprBackend — renders native-expr for Rust. `resolveHead` maps a ref head to an OWNED typed
402
784
  * base expr (a prior-node cell clone / an input field / a $as element). Sets rustExprUsed.helpers when
403
785
  * a fallible helper is emitted. The runner is Result<_, BehaviorError>, so hoistFallible uses `?`. */
@@ -441,44 +823,39 @@ function makeRustExprBackend(resolveHead, plan) {
441
823
  notOp: (a) => `(!${a})`,
442
824
  structLit: (name, inits) => `${name} { ${inits.map((i) => `${rustFieldName(i.field)}: ${i.expr}`).join(", ")} }`,
443
825
  arrLit: (elemTy, items) => `{ let __v: Vec<${elemTy}> = vec![${items.join(", ")}]; __v }`,
444
- // an opt (`Option<T>`) NONE literal. Annotate as `Option::<T>::None` so it type-infers in ANY
445
- // position (a bare `None` can be ambiguous; the explicit turbofish keeps it a fully-typed value).
446
- optNone: (innerTy) => `Option::<${innerTy}>::None`,
447
- // opt (`Option<T>`) null-presence test: present = `expr.is_some()`, absent = `expr.is_none()`.
448
- // A presence test only READS the discriminant, so an owning clone of the operand (which a head
449
- // resolver adds so the value can be moved into a port) is dropped here — testing `Option<String>`
450
- // must not heap-allocate. Mirrors the go twin's zero-cost `!= nil`.
451
- optIsSome: (expr) => `${rustStripTrailingClone(expr)}.is_some()`,
452
- optIsNone: (expr) => `${rustStripTrailingClone(expr)}.is_none()`,
453
- // opt (`Option<T>`) defaulted to a PURE default — it cannot fail and has no effect, so its
454
- // evaluation order is UNOBSERVABLE and both forms below are correct (a FALLIBLE default, where
455
- // laziness IS observable, goes to optCoalesceGuard instead). `unwrap_or` is EAGER — fine for a plain
456
- // literal/copy. A default that allocates (an owned `String`, a struct/vec literal) uses
457
- // `unwrap_or_else`, whose closure defers the allocation to the absent branch: not a semantic
458
- // requirement here, but pointless work otherwise — and what clippy `or_fun_call` asks for.
826
+ // a projection port of static string literals `vec!["a", "b"]` infers `&'static str` (the field is
827
+ // Vec<&'static str>), ZERO heap allocation.
828
+ staticStrArr: (literals) => ({ expr: `vec![${literals.map((s) => rustStrLit(s)).join(", ")}]`, type: "Vec<&'static str>" }),
829
+ // opt (Option<T>) defaulted to a PURE default eager unwrap_or for a plain copy/path, else the lazy
830
+ // unwrap_or_else closure (defers an allocating default to the absent branch; clippy or_fun_call).
459
831
  optUnwrapOr: (optExpr, defaultExpr, _innerTy) => /^[A-Za-z0-9_.:]+$/.test(defaultExpr) ? `${optExpr}.unwrap_or(${defaultExpr})` : `${optExpr}.unwrap_or_else(|| ${defaultExpr})`,
460
832
  // opt defaulted to a FALLIBLE default — a `match`, NOT a closure: the default's hoisted `?` early
461
- // returns must propagate out of the ENCLOSING fn, which `unwrap_or_else`'s closure would swallow
462
- // (it would try to return from the closure). Only the None arm evaluates the default.
833
+ // returns must propagate out of the ENCLOSING fn. Only the None arm evaluates the default.
463
834
  optCoalesceGuard: (temp, ty, optExpr, bindTemp, dStmts, dExpr) => [
464
835
  `let ${temp}: ${ty} = match ${optExpr} {`,
465
836
  ` Some(${bindTemp}) => ${bindTemp},`,
466
837
  ` None => { ${rustBlockBody(dStmts, dExpr)} }`,
467
838
  `};`,
468
839
  ],
840
+ // an opt (`Option<T>`) NONE literal. Annotate as `Option::<T>::None` so it type-infers in ANY
841
+ // position (a bare `None` can be ambiguous; the explicit turbofish keeps it a fully-typed value).
842
+ optNone: (innerTy) => `Option::<${innerTy}>::None`,
843
+ // opt (`Option<T>`) null-presence test: present = `expr.is_some()`, absent = `expr.is_none()`.
844
+ optIsSome: (expr) => `${rustStripTrailingClone(expr)}.is_some()`,
845
+ optIsNone: (expr) => `${rustStripTrailingClone(expr)}.is_none()`,
469
846
  ternary: (cond, t, e, _ty) => `(if ${rustStripOuterParens(cond)} { ${rustStripOuterParens(t)} } else { ${rustStripOuterParens(e)} })`,
470
847
  shortCircuitBool: (op, temp, left, rStmts, rExpr) => {
471
848
  // and: let temp = if left { <rStmts> rExpr } else { false }; or: if left { true } else { <rStmts> rExpr }.
472
849
  // build a block that runs rStmts then yields rExpr (parens stripped — block-tail is not a sub-expr).
473
- const rblock = `{ ${rustBlockBody(rStmts, rExpr)} }`;
850
+ const rblock = `{ ${rStmts.join(" ")} ${rustStripOuterParens(rExpr)} }`;
474
851
  if (op === "and") {
475
852
  return [`let ${temp}: bool = if ${rustStripOuterParens(left)} ${rblock} else { false };`];
476
853
  }
477
854
  return [`let ${temp}: bool = if ${rustStripOuterParens(left)} { true } else ${rblock};`];
478
855
  },
479
856
  condGuard: (temp, ty, cond, tStmts, tExpr, eStmts, eExpr) => {
480
- const tblock = `{ ${rustBlockBody(tStmts, tExpr)} }`;
481
- const eblock = `{ ${rustBlockBody(eStmts, eExpr)} }`;
857
+ const tblock = `{ ${tStmts.join(" ")} ${rustStripOuterParens(tExpr)} }`;
858
+ const eblock = `{ ${eStmts.join(" ")} ${rustStripOuterParens(eExpr)} }`;
482
859
  return [`let ${temp}: ${ty} = if ${rustStripOuterParens(cond)} ${tblock} else ${eblock};`];
483
860
  },
484
861
  };
@@ -487,17 +864,6 @@ function makeRustExprBackend(resolveHead, plan) {
487
864
  function rustSnake(name) {
488
865
  return name.replace(/([a-z])([A-Z0-9])/g, "$1_$2").replace(/([0-9])([a-z])/g, "$1_$2").toLowerCase();
489
866
  }
490
- /** rustBlockBody — the body of a block-expression that runs `stmts` then yields `expr`. A trailing
491
- * `let t = E; t` is collapsed to `E`: binding a temp only to return it on the next line is clippy's
492
- * `let_and_return` (an error under `-D warnings`), and the hoisted-fallible shape produces exactly that
493
- * whenever the block's tail IS the hoisted temp. */
494
- function rustBlockBody(stmts, expr) {
495
- const last = stmts[stmts.length - 1];
496
- const m = last !== undefined ? /^let\s+([A-Za-z0-9_]+)(?::\s*[^=]+)?\s*=\s*(.+);$/.exec(last) : null;
497
- if (m !== null && m[1] === expr)
498
- return [...stmts.slice(0, -1), m[2]].join(" ");
499
- return [...stmts, rustStripOuterParens(expr)].join(" ");
500
- }
501
867
  /** strip ONE redundant outer paren pair from an expr (clippy `if (cond)` unused_parens). Only when the
502
868
  * whole string is a single balanced `( … )` — a `(a) && (b)` stays untouched. */
503
869
  function rustStripOuterParens(expr) {
@@ -515,12 +881,6 @@ function rustStripOuterParens(expr) {
515
881
  }
516
882
  return expr.slice(1, -1);
517
883
  }
518
- /** Drop a TRAILING `.clone()` from an expression — for a position that only reads the value (no move),
519
- * so an owning clone added by a head resolver is pure waste. Only a clone at the very END is dropped: a
520
- * `.clone()` mid-path (`t_x.borrow().clone().field`) still owns the field access that follows it. */
521
- function rustStripTrailingClone(expr) {
522
- return expr.endsWith(".clone()") ? expr.slice(0, -".clone()".length) : expr;
523
- }
524
884
  /** Rust float literal (finite; integral gets `.0`). */
525
885
  function rustFloatLitExpr(n) {
526
886
  if (Number.isInteger(n))
@@ -614,13 +974,14 @@ fn bc_expr_str_ge(a: &str, b: &str) -> bool { bc_expr_cmp_cp(a, b) != std::cmp::
614
974
  // ── CONCRETE per-node row structs + row→outType copiers (mirror the Go concrete contract) ──────────
615
975
  //
616
976
  // Each covered node has a CONCRETE row struct `RawRowNR<Comp><Node>` whose fields are the node's projected
617
- // outType fields (typed, flattened) plus a per-node error signal (is_error/err). A named-struct outType
977
+ // outType fields (typed, flattened). A row models a SUCCESS: failure travels the Result's Err arm as a
978
+ // concrete BehaviorError carrying the leaf's structured Error Value. A named-struct outType
618
979
  // flattens to one Rust field per outType field; a scalar/arr/opt node carries a single `val: <typed>`
619
980
  // payload. The per-component `HandlerNR<Comp>` trait has one method per node returning that concrete
620
981
  // struct; the runner reads `row.<field>` DIRECTLY (no `RawValue`, no `match`, no `Value::`) and copies
621
982
  // each field into the node's outType struct local.
622
983
  /** emitRawRowStructs — emit the concrete `RawRowNR<Comp><Node>` struct per covered node (+ per-element
623
- * `RawElemNR<Comp><Node>` for a covered map). Every row carries the per-node error signal (is_error/err). */
984
+ * `RawElemNR<Comp><Node>` for a covered map). A row models a SUCCESS; failure is the Result's Err arm. */
624
985
  function emitRawRowStructs(comp, typedNodes, plan) {
625
986
  const parts = [];
626
987
  for (const n of comp.body) {
@@ -631,7 +992,7 @@ function emitRawRowStructs(comp, typedNodes, plan) {
631
992
  const elemRowRef = fanoutItemsElemRef(n, ref, plan);
632
993
  parts.push(emitOneRowStruct(rawElemStructName(comp.name, n.id), elemRowRef, plan));
633
994
  parts.push(`// ${rawRowStructName(comp.name, n.id)} — the BATCH row for fanout '${n.id}': the aligned per-body rows.\n` +
634
- `#[derive(Default)]\npub struct ${rawRowStructName(comp.name, n.id)} {\n pub is_error: bool,\n pub err: String,\n pub rows: Vec<${rawElemStructName(comp.name, n.id)}>,\n}`);
995
+ `#[derive(Default)]\npub struct ${rawRowStructName(comp.name, n.id)} {\n pub rows: Vec<${rawElemStructName(comp.name, n.id)}>,\n}`);
635
996
  continue;
636
997
  }
637
998
  if ("map" in n) {
@@ -639,22 +1000,20 @@ function emitRawRowStructs(comp, typedNodes, plan) {
639
1000
  const elemRowRef = mapElemRowRef(n, arrRef, plan);
640
1001
  parts.push(emitOneRowStruct(rawElemStructName(comp.name, n.id), elemRowRef, plan));
641
1002
  parts.push(`// ${rawRowStructName(comp.name, n.id)} — the BATCH row for map '${n.id}': the aligned per-element rows.\n` +
642
- `#[derive(Default)]\npub struct ${rawRowStructName(comp.name, n.id)} {\n pub is_error: bool,\n pub err: String,\n pub rows: Vec<${rawElemStructName(comp.name, n.id)}>,\n}`);
1003
+ `#[derive(Default)]\npub struct ${rawRowStructName(comp.name, n.id)} {\n pub rows: Vec<${rawElemStructName(comp.name, n.id)}>,\n}`);
643
1004
  continue;
644
1005
  }
645
1006
  parts.push(emitOneRowStruct(rawRowStructName(comp.name, n.id), ref, plan));
646
1007
  }
647
1008
  return parts.join("\n\n");
648
1009
  }
649
- /** Emit one concrete row struct: flatten a named outType to typed fields, else a single `val` payload;
650
- * always carry is_error/err. Derives Default so a handler can build an error row cheaply (`..Default`). */
1010
+ /** Emit one concrete row struct: flatten a named outType to typed fields, else a single `val` payload.
1011
+ * A row models a SUCCESS failure is the Result's Err arm, so no in-band error signal. */
651
1012
  function emitOneRowStruct(name, ref, plan) {
652
1013
  const lines = [];
653
- lines.push(`// ${name} — CONCRETE handler-result row (typed fields = the node's outType; + error signal).`);
1014
+ lines.push(`// ${name} — CONCRETE handler-result row (typed fields = the node's outType).`);
654
1015
  lines.push(`#[derive(Default)]`);
655
1016
  lines.push(`pub struct ${name} {`);
656
- lines.push(` pub is_error: bool,`);
657
- lines.push(` pub err: String,`);
658
1017
  if (ref.kind === "named") {
659
1018
  const decl = findDecl(plan, ref.name);
660
1019
  for (const f of decl.fields) {
@@ -729,6 +1088,19 @@ function emitHandlerTrait(comp, typedNodes, plan, ap) {
729
1088
  lines.push(`// / boxed-value / dynamic accessor crosses the covered boundary — the consumer implements each`);
730
1089
  lines.push(`// node_* with a decode of its own wire payload into the concrete row (see INTEGRATION.md §6).`);
731
1090
  lines.push(`pub trait ${handlerTraitName(comp.name)} {`);
1091
+ // a record-read node returns the consumer's WIRE (an associated `Wire: WireRow`); the generated
1092
+ // decode turns it into the concrete row. Declared once when the component has a de-box-eligible node.
1093
+ // The `+ Send` bound lets the wire cross the parallel-stage runner's scoped fan-out threads (the old
1094
+ // concrete row was Send; the associated type needs the bound stated explicitly).
1095
+ const hasDeBox = comp.body.some((n) => {
1096
+ if ("cond" in n)
1097
+ return false;
1098
+ if ("map" in n || "fanout" in n)
1099
+ return mapFanoutElemDeBoxEligible(n, typedNodes, plan);
1100
+ return deBoxEligible(typedNodes.get(n.id));
1101
+ });
1102
+ if (hasDeBox)
1103
+ lines.push(` type Wire: WireRow + Send;`);
732
1104
  for (const n of comp.body) {
733
1105
  if ("cond" in n)
734
1106
  continue; // #108: a cond node has no handler method (pure Expression).
@@ -736,21 +1108,29 @@ function emitHandlerTrait(comp, typedNodes, plan, ap) {
736
1108
  const fnKw = fnKwFor(n.id);
737
1109
  const bt = boundRustType(n, typedNodes, plan);
738
1110
  if ("fanout" in n) {
739
- // fanout (v3): one batched dispatch of the deduped id list batch ports IN, aligned batch row OUT.
1111
+ // fanout (v3): one batched dispatch of the deduped id list. A record element returns the aligned
1112
+ // element WIRES the runner de-boxes (decode_wire_elem_*) then dedupe/drops. Each aligned position is
1113
+ // Option<Self::Wire> — None = a DANGLING id (the runner maps it to the zero elem row).
740
1114
  const portsT = `${portsStructName(comp.name, n.id)}Batch`;
741
- const retT = rawRowStructName(comp.name, n.id);
742
- lines.push(` ${fnKw} ${method}(&self, ports: &${portsT}, bound: ${bt}) -> Option<${retT}>;`);
1115
+ const elemDeBox = mapFanoutElemDeBoxEligible(n, typedNodes, plan);
1116
+ const retT = elemDeBox ? "Vec<Option<Self::Wire>>" : rawRowStructName(comp.name, n.id);
1117
+ lines.push(` ${fnKw} ${method}(&self, ports: &${portsT}, bound: ${bt}) -> Result<${retT}, BehaviorError>;`);
743
1118
  continue;
744
1119
  }
745
1120
  if ("map" in n) {
746
1121
  const m = n.map;
747
1122
  const batched = m.batched === true;
748
1123
  const portsT = batched ? `${portsStructName(comp.name, n.id)}Batch` : portsStructName(comp.name, n.id);
749
- const retT = batched ? rawRowStructName(comp.name, n.id) : rawElemStructName(comp.name, n.id);
750
- lines.push(` ${fnKw} ${method}(&self, ports: &${portsT}, bound: ${bt}) -> Option<${retT}>;`);
1124
+ // a record-element map returns the element WIRE(s): batched → Vec<Self::Wire>, nonbatched → Self::Wire.
1125
+ const elemDeBox = mapFanoutElemDeBoxEligible(n, typedNodes, plan);
1126
+ const retT = elemDeBox ? (batched ? "Vec<Self::Wire>" : "Self::Wire") : batched ? rawRowStructName(comp.name, n.id) : rawElemStructName(comp.name, n.id);
1127
+ lines.push(` ${fnKw} ${method}(&self, ports: &${portsT}, bound: ${bt}) -> Result<${retT}, BehaviorError>;`);
751
1128
  continue;
752
1129
  }
753
- lines.push(` ${fnKw} ${method}(&self, ports: &${portsStructName(comp.name, n.id)}, bound: ${bt}) -> Option<${rawRowStructName(comp.name, n.id)}>;`);
1130
+ const ref = typedNodes.get(n.id);
1131
+ const top = ref !== undefined ? recordTop(ref) : null;
1132
+ const retT = top !== null ? rustWireTypeForTop(top) : rawRowStructName(comp.name, n.id);
1133
+ lines.push(` ${fnKw} ${method}(&self, ports: &${portsStructName(comp.name, n.id)}, bound: ${bt}) -> Result<${retT}, BehaviorError>;`);
754
1134
  }
755
1135
  lines.push(`}`);
756
1136
  return lines.join("\n");
@@ -763,18 +1143,16 @@ function emitHandlerTrait(comp, typedNodes, plan, ap) {
763
1143
  function mapElemMaterializerName(compName, nodeId) {
764
1144
  return `materialize_map_elem_${sanitize(compName)}_${typedCell(nodeId)}`;
765
1145
  }
766
- /** mapNodeArrRef — the map node's PRODUCED-ARRAY TypeRef from its `outType`, normalizing the two IR
767
- * conventions to the one array shape every downstream site assumes (`typedNodes.get(mapId)` is an arr):
768
- * - the RECORDER SoT (authoring.ts recordMap): `node.outType` is the ELEMENT (pre-array-wrap) wrap
769
- * it to `{arr:element}` here (the map produces `[]element`), matching type-gate's outputType wrap
770
- * (`"map" in n ? {arr:ot} : ot`). This is the declarative-authoring form (graphddb batchWrite/forEach).
771
- * - legacy hand-built IR: `node.outType` is already the `{arr:element}` produced form take it as-is.
772
- * A map ELEMENT is never itself an `{arr:…}` in the covered corpus, so the `arr`-keyed discriminator is
773
- * unambiguous. Both converge to the SAME produced-array ref, so existing goldens stay byte-identical. */
1146
+ /** mapNodeArrRef — the map node's PRODUCED-ARRAY TypeRef from its `outType`.
1147
+ *
1148
+ * A map node's `outType` is its ELEMENT type (scp-error.md "Map element type"), so the produced array
1149
+ * is `{arr: element}`. This is the same reading the compiler applies when it resolves what a node
1150
+ * result reference has (`type-gate`: `"map" in n ? {arr:ot} : ot`) the emitter follows that SSoT
1151
+ * rather than inspecting the annotation's shape, so an element that is ITSELF an `arr` stays
1152
+ * expressible (element `arr(T)` produces `arr(arr(T))`). */
774
1153
  function mapNodeArrRef(node, plan) {
775
1154
  const outT = node.outType;
776
- const isArr = outT !== null && typeof outT === "object" && "arr" in outT;
777
- return deriveTypeRef((isArr ? outT : { arr: outT }), plan);
1155
+ return deriveTypeRef({ arr: outT }, plan);
778
1156
  }
779
1157
  /** mapElemRowRef — the handler's per-element RETURN shape for a covered map node: the `into` field's
780
1158
  * type (into) or the element outType directly (no-into). This is the type of RawElemNR<Comp><Node>. */
@@ -869,44 +1247,6 @@ function mapOverElemInfo(comp, node, typedNodes, plan) {
869
1247
  function mapOverElemType(comp, node, typedNodes, plan) {
870
1248
  return renderTypeRef(mapOverElemInfo(comp, node, typedNodes, plan).elemRef);
871
1249
  }
872
- /**
873
- * priorNodeArrElemInfo (#129, rust twin) — resolve a leaf port that is a `ref` into a PRIOR BODY NODE
874
- * whose annotated `outType` is (or reaches, through a field path) a native array, to its element TypeRef
875
- * + the OWNED Rust Vec expression that yields it. GENERALIZES the `map…over` prior-node-arr dataflow
876
- * (mapOverElemInfo's first branch) to ANY leaf input port: a fold/dedup leaf whose `items` port is
877
- * `{ref:["<priorNode>"]}` (or `.items` of a prior connection node) now lowers to `Vec<ElemT>` cloned
878
- * out of the prior node's typed cell — ZERO boxed Value on the read hot path.
879
- *
880
- * The element type comes STRICTLY from the prior node's EXPLICIT `outType` annotation walked through
881
- * typedFieldAccess (BC does NOT infer — consumer-interface C3 / [[graphddb-typed-native-static-link]]):
882
- * if the head is not a prior body node, or its outType (through the field path) is not an `arr`, this
883
- * returns null and the caller keeps its existing fail-closed behaviour (never a silent box). A
884
- * single-segment INPUT-array port (elemType) is a DIFFERENT covered shape (portArrayElemRef) — handled
885
- * ahead of this. Requires the typedNodes map (prior-node outType TypeRefs) and a TypePlan.
886
- */
887
- function priorNodeArrElemInfo(node, typedNodes, plan) {
888
- if (typedNodes === undefined || plan === undefined)
889
- return null;
890
- if (staticArrElems(node) !== null)
891
- return null; // a static array literal — not a prior-node ref.
892
- const e = classifyExpr(node);
893
- if (e.kind !== "ref" || e.opt || e.path.length < 1)
894
- return null;
895
- if (!typedNodes.has(e.path[0]))
896
- return null; // head is not a prior body node → not this shape.
897
- const baseRef = typedNodes.get(e.path[0]);
898
- let acc;
899
- try {
900
- acc = typedFieldAccess(`${typedCell(e.path[0])}.borrow()`, baseRef, e.path.slice(1), plan);
901
- }
902
- catch {
903
- return null; // the field path does not resolve on the prior node's outType → fail-closed at caller.
904
- }
905
- if (acc.ref.kind !== "arr")
906
- return null; // the prior-node result (through the path) is not an array.
907
- // OWNED: clone the borrowed slice out of the RefCell so the ports struct owns its Vec<ElemT>.
908
- return { elemRef: acc.ref.elem, overExpr: `${acc.expr}.clone()` };
909
- }
910
1250
  // ── eligibility ─────────────────────────────────────────────────────────────────────
911
1251
  function reconstructR(e) {
912
1252
  switch (e.kind) {
@@ -947,31 +1287,46 @@ function numLiteralRustExpr(node) {
947
1287
  // fractional / exponent literal → checked f64 (finite, guaranteed above).
948
1288
  return `Value::Float(${node}f64)`;
949
1289
  }
950
- /** A port node is static (literal / ref / concat of those / static arr / bare number literal / an
951
- * optional-input read) — resolvable to a native value with no Obj / no evaluate interpreter. This is the
952
- * SHAPE gate that decides native ELIGIBILITY; the lowering itself (portFieldRustType / emitPortInit) is
953
- * what proves each shape, and fails closed loudly on one it cannot lower. */
954
- function portIsStatic(node, inputPorts) {
955
- const arr = staticArrElems(node);
956
- if (arr !== null)
957
- return arr.every((el) => portIsStatic(el, inputPorts));
958
- if (numLiteralRustExpr(node) !== null)
959
- return true; // bare number literal (e.g. Query `limit`)
960
- // An optional-input read (`opt($.x)` / `coalesce(opt($.x), 20)` / `ne(opt($.x), null)`) is a
961
- // statically-resolvable port — the opt lane lowers it (or fails closed with the specific reason).
962
- if (inputPorts !== undefined && exprReadsOptionalInput(node, inputPorts))
1290
+ /** A port node is static (literal / ref / concat of those / static arr / bare number literal)
1291
+ * resolvable to a native Value with no Obj / no evaluate interpreter. */
1292
+ /** planForComp a TypePlan for a single component (its named types are component-scoped). */
1293
+ function planForComp(comp) {
1294
+ return buildTypePlan({ irVersion: 1, exprVersion: 2, components: [comp] });
1295
+ }
1296
+ /** buildTypedNodes — the prior-node typed-cell TypeRef map (control-gate nodes typeless; a map node
1297
+ * carries its produced-array ref). An untyped node is omitted. */
1298
+ function buildTypedNodes(comp, plan) {
1299
+ const typedNodes = new Map();
1300
+ for (const n of comp.body) {
1301
+ if (isControlGate(n))
1302
+ continue;
1303
+ try {
1304
+ typedNodes.set(n.id, "map" in n ? mapNodeArrRef(n, plan) : deriveTypeRef(n.outType, plan));
1305
+ }
1306
+ catch {
1307
+ /* untyped node — omit */
1308
+ }
1309
+ }
1310
+ return typedNodes;
1311
+ }
1312
+ /** mapAsBinding — the `$as` element binding for a map node's element ports, or undefined (uncovered). */
1313
+ function mapAsBinding(comp, node, typedNodes, plan) {
1314
+ try {
1315
+ return { name: node.map.as, ref: mapOverElemInfo(comp, node, typedNodes, plan).elemRef, plan };
1316
+ }
1317
+ catch {
1318
+ return undefined;
1319
+ }
1320
+ }
1321
+ /** portIsStatic — the ELIGIBILITY gate ASKS the one port lowering (compilePortNode / native-expr) whether
1322
+ * it can lower this port. A port that does not lower (or lowers fallibly) fails closed. */
1323
+ function portIsStatic(node, comp, plan, typedNodes, asBinding) {
1324
+ try {
1325
+ compilePortNode(node, comp, plan, typedNodes, (h) => typedNodes.has(h), "port", asBinding !== undefined ? { asBinding, asBase: asBinding.name } : undefined);
963
1326
  return true;
964
- const e = classifyExpr(node);
965
- switch (e.kind) {
966
- case "str":
967
- case "bool":
968
- case "null":
969
- case "ref":
970
- return true;
971
- case "concat":
972
- return e.parts.every((p) => portIsStatic(reconstructR(p), inputPorts));
973
- default:
974
- return false;
1327
+ }
1328
+ catch {
1329
+ return false;
975
1330
  }
976
1331
  }
977
1332
  /** The output must lower to a typed struct/value with no scope read: a ref to a BODY NODE, an
@@ -1037,8 +1392,11 @@ function coveredMapNode(n, bodyIds, comp) {
1037
1392
  return false;
1038
1393
  }
1039
1394
  const ports = m.ports;
1395
+ const plan = planForComp(comp);
1396
+ const typedNodes = buildTypedNodes(comp, plan);
1397
+ const asBinding = mapAsBinding(comp, n, typedNodes, plan);
1040
1398
  for (const p of Object.keys(ports))
1041
- if (!portIsStatic(ports[p], comp.inputPorts))
1399
+ if (!portIsStatic(ports[p], comp, plan, typedNodes, asBinding))
1042
1400
  return false;
1043
1401
  return true;
1044
1402
  }
@@ -1064,8 +1422,17 @@ function coveredFanoutNode(n, bodyIds, comp) {
1064
1422
  return false;
1065
1423
  }
1066
1424
  const ports = f.ports;
1425
+ const plan = planForComp(comp);
1426
+ const typedNodes = buildTypedNodes(comp, plan);
1427
+ let asBinding;
1428
+ try {
1429
+ asBinding = { name: f.as, ref: fanoutOverElemInfo(comp, n, typedNodes, plan).elemRef, plan };
1430
+ }
1431
+ catch {
1432
+ asBinding = undefined;
1433
+ }
1067
1434
  for (const p of Object.keys(ports))
1068
- if (!portIsStatic(ports[p], comp.inputPorts))
1435
+ if (!portIsStatic(ports[p], comp, plan, typedNodes, asBinding))
1069
1436
  return false;
1070
1437
  return true;
1071
1438
  }
@@ -1112,6 +1479,8 @@ function isSequentialComponentRefRead(comp) {
1112
1479
  * can never disagree. This is the message the fail-closed dispatch surfaces per non-native component.
1113
1480
  */
1114
1481
  function coverageRejectReason(comp) {
1482
+ const crPlan = planForComp(comp);
1483
+ const crTypedNodes = buildTypedNodes(comp, crPlan);
1115
1484
  const ep = execPlan(comp);
1116
1485
  if (ep === null)
1117
1486
  return "component is not a straight-line/staged sequential exec plan (no static exec plan)";
@@ -1176,7 +1545,7 @@ function coverageRejectReason(comp) {
1176
1545
  if (cr.relationKind !== undefined && cr.relationKind !== "single" && cr.relationKind !== "connection")
1177
1546
  return `relationKind '${cr.relationKind}' not native-covered (node '${n.id}')`;
1178
1547
  for (const p of Object.keys(cr.ports))
1179
- if (portIsStatic(cr.ports[p], comp.inputPorts) === false)
1548
+ if (portIsStatic(cr.ports[p], comp, crPlan, crTypedNodes) === false)
1180
1549
  return `node '${n.id}' port '${p}' is not statically resolvable`;
1181
1550
  }
1182
1551
  if (!outputIsNativeLowerable(comp))
@@ -1204,31 +1573,17 @@ function assertTypedCoverage(comp) {
1204
1573
  throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#77): component '${comp.name}' is a sequential componentRef read but lacks outType/outputType on every node — the combined emitter de-boxes the RESULT into the node's outType struct, so a covered read MUST be fully typed (bc#45). Add outType annotations, or regenerate on rust-straightline-native (boxed result).`);
1205
1574
  }
1206
1575
  // ── typed input struct (InNR<Comp>) + NATIVE scalar port lowering ────────────────────────────
1207
- /**
1208
- * inputPortRustType — the native Rust type for an input port's declared type. Scalars lower to the
1209
- * concrete scalar; `array`/`map` with a declared elemType to `Vec<ElemT>` / `BTreeMap<String, ElemT>`;
1210
- * an OPTIONAL port (`{opt:T}` → `required:false`) to `Option<T>` — the native representation of "the
1211
- * value is absent". A declared type with no native lowering stays the boxed `Value`.
1212
- *
1213
- * An OPTIONAL port whose inner type has no native lowering FAILS CLOSED: `Option<Value>` would be a
1214
- * boxed escape, and emitting the bare inner type would silently turn "absent" into a zero value. A port
1215
- * whose optionality cannot be represented is never silently coerced.
1216
- */
1576
+ /** Rust type for an input port's declared type (scalar → concrete; array+elemType → Vec<ElemT>; else Value). */
1217
1577
  function inputPortRustType(schema, plan, where = "input port") {
1218
1578
  const ref = inputPortTypeRef(schema, plan);
1219
1579
  if (ref !== undefined)
1220
1580
  return renderTypeRef(ref);
1221
1581
  if (inputPortIsOptional(schema))
1222
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native (runtime-free): ${where} is OPTIONAL (declared {opt:…}) but its inner type ${JSON.stringify(schema?.type)} has no native Rust lowering, so "absent" has no native representation (an Option<Value> would be a boxed escape, and the bare inner type would silently read absent as a zero value). Declare an elemType for an array/map, or use a natively-lowerable scalar.`);
1582
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native (runtime-free): ${where} is OPTIONAL (declared {opt:…}) but its inner type ${JSON.stringify(schema?.type)} has no native Rust lowering, so "absent" has no native representation. Declare an elemType for an array/map, or use a natively-lowerable scalar.`);
1223
1583
  return "Value";
1224
1584
  }
1225
- /** The scalar kind a REQUIRED input port declares, or undefined when it is not a required native scalar.
1226
- * An OPTIONAL port is deliberately NOT a scalar kind here: its native type is `Option<T>`, and the
1227
- * callers of this function build REQUIRED scalar slots (`in_.<field>` read straight into a non-opt port
1228
- * field). Answering "i64" for an `{opt:"int"}` port is exactly how "absent" would silently become `0`. */
1585
+ /** The scalar kind an input port declares, or undefined if it is not a native scalar. */
1229
1586
  function inputScalarKind(schema) {
1230
- if (inputPortIsOptional(schema))
1231
- return undefined;
1232
1587
  switch (schema?.type) {
1233
1588
  case "string":
1234
1589
  case "literal": // a `"literal"` union is a constrained String (see inputPortRustType).
@@ -1252,6 +1607,8 @@ function emitInStruct(comp, plan) {
1252
1607
  if (ports.length === 0) {
1253
1608
  return `// ${name} — the CONCRETE input for '${comp.name}' (no input ports).\n#[derive(Default)]\npub struct ${name};`;
1254
1609
  }
1610
+ // #139: inputPortRustType reads the inputPortTypeRef SSoT — an OPTIONAL port is `Option<T>` (absent →
1611
+ // None); an optional whose inner type has no native lowering FAILS CLOSED (never a boxed escape).
1255
1612
  const fields = ports
1256
1613
  .map((p) => ` pub ${rustFieldName(p)}: ${inputPortRustType(comp.inputPorts[p], plan, `input port '${p}' of component '${comp.name}'`)}, // ${JSON.stringify(p)}`)
1257
1614
  .join("\n");
@@ -1262,64 +1619,10 @@ pub struct ${name} {
1262
1619
  ${fields}
1263
1620
  }`;
1264
1621
  }
1265
- /** The port forms the STATIC (non-optional) lane lowers named in its reject message so the message
1266
- * describes what is actually covered rather than a stale corpus list. */
1267
- const RUST_STATIC_PORT_FORMS = "This port does not read an optional input, so it lowers through the STATIC port forms: a string / bool / number literal, a static string-array (projection), a ref to a REQUIRED scalar input port, a ref to an input array/map port or a prior node's array (declared elemType), a ref to a `$as` element field, or a concat of native strings. A dynamic operator at port position is not covered on this lane. (A port that DOES read an optional input lowers through the full native expression compiler, which admits the whole closed operator set — so the covered operator set at port position currently depends on the lane, not on the operator.)";
1268
- /**
1269
- * emitNativeScalar — lower a static port expr to a NATIVE Rust expression of the concrete `want` scalar
1270
- * type, or null if it cannot be statically proven that type. A string literal is native for
1271
- * `want==="string"`, a bool literal for `want==="bool"`. A ref resolves via `resolveRef`. A concat lowers
1272
- * ONLY into `string` and only when EVERY part is a native string, emitting a single `format!("{}{}…", …)`.
1273
- */
1274
- function emitNativeScalar(node, want, resolveRef) {
1275
- if (staticArrElems(node) !== null)
1276
- return null;
1277
- const e = classifyExpr(node);
1278
- switch (e.kind) {
1279
- case "str":
1280
- return want === "String" ? `${rustStrLit(e.value)}.to_string()` : null;
1281
- case "bool":
1282
- return want === "bool" ? (e.value ? "true" : "false") : null;
1283
- case "null":
1284
- return null;
1285
- case "ref": {
1286
- const sc = resolveRef(e.path[0], e.path.slice(1), e.opt);
1287
- if (sc === null || sc.scalar !== want)
1288
- return null;
1289
- return sc.expr;
1290
- }
1291
- case "concat": {
1292
- if (want !== "String")
1293
- return null;
1294
- // a single-part concat is just the owned String of that part.
1295
- if (e.parts.length === 1)
1296
- return emitNativeScalar(reconstructR(e.parts[0]), "String", resolveRef);
1297
- // multi-part: fold into ONE format! (statically strings only — the concat's strings-only
1298
- // TYPE_MISMATCH can never fire, so dropping the check is sound). To stay clippy-clean
1299
- // (to_string_in_format_args), a string-literal part is baked into the format STRING itself
1300
- // (escaped) rather than passed as a `{}` arg; a ref part becomes a `{}` arg (Display on String).
1301
- let fmt = "";
1302
- const args = [];
1303
- for (const p of e.parts) {
1304
- const sub = classifyExpr(reconstructR(p));
1305
- if (sub.kind === "str") {
1306
- fmt += sub.value.replace(/[{}]/g, (c) => c + c); // escape { } for the format string
1307
- continue;
1308
- }
1309
- const arg = emitNativeScalar(reconstructR(p), "String", resolveRef);
1310
- if (arg === null)
1311
- return null;
1312
- fmt += "{}";
1313
- args.push(arg);
1314
- }
1315
- if (args.length === 0)
1316
- return `${rustStrLit(fmt)}.to_string()`;
1317
- return `format!(${rustStrLit(fmt)}, ${args.join(", ")})`;
1318
- }
1319
- default:
1320
- return null;
1321
- }
1322
- }
1622
+ /** A ref resolver for native lowering: given a ref head + remaining field path, return a native scalar
1623
+ * Rust expression (typed input field / prior-node typed struct scalar / element binding field), or null
1624
+ * when the ref does not resolve to a REQUIRED (non-opt) native scalar (then the caller falls back to the
1625
+ * Value path). Returned exprs are OWNED (String cloned / scalar copied) — usable in a `format!` / literal. */
1323
1626
  // bc#90 / runtime-free: the old `emitStaticR` / `inputLeafR` / `valueLeafR` port lowering (which built
1324
1627
  // `Value` leaves via the primitives::* helpers and struct-field serializers) is REMOVED — every covered
1325
1628
  // port now lowers to a FULLY NATIVE Rust value in emitPortInit (string / bool / static-string-array /
@@ -1336,100 +1639,13 @@ function emitNativeScalar(node, want, resolveRef) {
1336
1639
  * a boxed escape would re-introduce the bc-runtime coupling this module removes). `fieldRustType` is the
1337
1640
  * ports-struct field type (from portFieldRustType); `resolveRef` resolves a ref head for the scalar path.
1338
1641
  */
1339
- function emitPortInit(pn, expr, fieldRustType, resolveRef, inputPorts, plan, typedNodes, asBinding, elemBaseExpr) {
1340
- const field = portFieldName(pn);
1341
- // The OPTIONAL-input lane (Option<T> pass-through / coalesce default) — same shared compile as
1342
- // portFieldRustType, so the field type and its initializer are derived from ONE lowering.
1343
- const optPort = optPortCompileR(expr, inputPorts ?? {}, `port '${pn}'`, plan);
1344
- if (optPort !== null)
1345
- return `${field}: ${optPort.expr}`;
1346
- // #108-map: a port that is a ref to a `$as` element MAP/NAMED field (or an input map port) → CLONE the
1347
- // typed composite value directly off the element/input (native BTreeMap / struct — ZERO boxed Value).
1348
- // Reuses portCompositeRef for the type; the value is the typed field access `.clone()`.
1349
- if (plan !== undefined) {
1350
- const compExpr = emitCompositePortValue(expr, inputPorts ?? {}, asBinding, plan, elemBaseExpr);
1351
- if (compExpr !== null)
1352
- return `${field}: ${compExpr}`;
1353
- }
1354
- // #110: a componentRef port bound to a runtime array input port (with elemType) → the native Vec<ElemT>,
1355
- // cloned out of the concrete input struct (`in_.<field>.clone()`) — ZERO boxed Value on the read hot path.
1356
- if (inputPorts !== undefined && portArrayElemRef(expr, inputPorts, plan) !== null) {
1357
- const e = classifyExpr(expr);
1358
- if (e.kind === "ref" && e.path.length === 1) {
1359
- return `${field}: in_.${rustFieldName(e.path[0])}.clone()`;
1360
- }
1361
- }
1362
- // #129: a leaf port that is a ref into a PRIOR NODE's typed arr outType (node→leaf array dataflow) →
1363
- // the native Vec<ElemT> cloned off the prior node's typed cell (`t_<node>.borrow().field….clone()`) —
1364
- // ZERO boxed Value on the read hot path (a fold/dedup leaf whose `items` = a prior node's array result).
1365
- const priorArr = priorNodeArrElemInfo(expr, typedNodes, plan);
1366
- if (priorArr !== null)
1367
- return `${field}: ${priorArr.overExpr}`;
1368
- // change #2: a static string array (projection) → a native `vec![...]` of &'static str.
1369
- const strArr = staticStringArrElems(expr);
1370
- if (strArr !== null) {
1371
- if (fieldRustType !== "Vec<&'static str>") {
1372
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#90): port '${pn}' is a static string array but its field type is '${fieldRustType}' (expected Vec<&'static str>).`);
1373
- }
1374
- return `${field}: vec![${strArr.map((s) => rustStrLit(s)).join(", ")}]`;
1375
- }
1376
- // a bare number literal (e.g. Query `limit: 20`): the SSoT range check ran in numLiteralRustExpr —
1377
- // emit the concrete native i64/f64 (unwrap the Value::Int/Float box: it was only there for the old
1378
- // boxed path; the range check is what matters, and it already passed).
1379
- const num = numLiteralRustExpr(expr);
1380
- if (num !== null) {
1381
- if (fieldRustType === "i64")
1382
- return `${field}: ${expr}i64`;
1383
- if (fieldRustType === "f64")
1384
- return `${field}: ${expr}f64`;
1385
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#90): port '${pn}' is a number literal but its field type is '${fieldRustType}'.`);
1386
- }
1387
- // string / bool / scalar-input / concat → native scalar expression (no Value, no `match`).
1388
- const native = emitNativeScalar(expr, fieldRustType, resolveRef);
1389
- if (native !== null) {
1390
- return `${field}: ${native}`;
1391
- }
1392
- // FAIL CLOSED: the covered read plane is runtime-free — no boxed Value fallback (that would re-couple
1393
- // the covered module to bc-runtime). If a genuinely-dynamic port reaches here, the component is not
1394
- // native-eligible and must regenerate on the boxed straight-line path.
1395
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#90 / runtime-free): port '${pn}' (${JSON.stringify(expr)}) does not lower to a native Rust value of type '${fieldRustType}'. The covered read plane admits NO boxed Value escape. ${RUST_STATIC_PORT_FORMS} Regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
1642
+ function emitPortInit(pn, expr, comp, plan, typedNodes, isPrior, opts) {
1643
+ const c = compilePortNode(expr, comp, plan, typedNodes, isPrior, `port '${pn}'`, opts);
1644
+ if (c.stmts.length > 0)
1645
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native (runtime-free): port '${pn}' ${JSON.stringify(expr)} lowers to a FALLIBLE expression (INT_OVERFLOW / NAN_OR_INF / MOD_ZERO / PRECISION_LOSS) — statements with an early error-return, which a ports-struct literal cannot host. Use a non-fallible port expression.`);
1646
+ return `${portFieldName(pn)}: ${c.expr}`;
1396
1647
  }
1397
1648
  // ── the combined runner (STRUCT-returning; INLINE sequential; native ports in; concrete row out) ──
1398
- /** resolveRefAt for the top-level runner: a ref head is a PRIOR NODE (its typed scalar field) or an
1399
- * INPUT PORT (in_.<field>). A REQUIRED scalar resolves to an OWNED native Rust expression; opt / non-
1400
- * scalar / refOpt → null (fall back to the Value path). */
1401
- function runnerResolveRef(priorNodeAt, atPos, typedNodes, plan, inputPorts) {
1402
- return (head, restPath, opt) => {
1403
- if (opt)
1404
- return null;
1405
- if (priorNodeAt(head, atPos)) {
1406
- const baseRef = typedNodes.get(head);
1407
- let acc;
1408
- let expr;
1409
- try {
1410
- const r = typedFieldAccess(`${typedCell(head)}.borrow()`, baseRef, restPath, plan);
1411
- expr = r.expr;
1412
- acc = r.ref;
1413
- }
1414
- catch {
1415
- return null;
1416
- }
1417
- if (acc.kind !== "scalar" || acc.scalar === "null")
1418
- return null;
1419
- // OWNED: a string field is cloned; int/float/bool are Copy (deref off the Ref).
1420
- const owned = acc.scalar === "string" ? `(${expr}).clone()` : `*(${expr})`;
1421
- return { expr: owned, scalar: rustScalar(acc.scalar) };
1422
- }
1423
- if (restPath.length !== 0)
1424
- return null;
1425
- const scalar = inputScalarKind(inputPorts?.[head]);
1426
- if (scalar === undefined)
1427
- return null;
1428
- const field = `in_.${rustFieldName(head)}`;
1429
- const owned = scalar === "String" ? `${field}.clone()` : field;
1430
- return { expr: owned, scalar };
1431
- };
1432
- }
1433
1649
  function rustScalar(s) {
1434
1650
  return s === "string" ? "String" : s === "int" ? "i64" : s === "float" ? "f64" : "bool";
1435
1651
  }
@@ -1621,12 +1837,9 @@ function emitNativeRawRunner(comp, plan, ap) {
1621
1837
  indent += " ";
1622
1838
  }
1623
1839
  }
1624
- const resolveRef = runnerResolveRef(priorNodeAt, k, typedNodes, plan, comp.inputPorts);
1625
1840
  const inits = [];
1626
1841
  for (const pn of portNames) {
1627
- const expr = node.ports[pn];
1628
- const ty = portFieldRustType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`, undefined, plan, typedNodes);
1629
- inits.push(emitPortInit(pn, expr, ty, resolveRef, comp.inputPorts, plan, typedNodes));
1842
+ inits.push(emitPortInit(pn, node.ports[pn], comp, plan, typedNodes, (h) => priorNodeAt(h, k)));
1630
1843
  }
1631
1844
  const boundArg = node.bindField !== undefined ? `bound_${s}` : "None";
1632
1845
  // bc#97: derived per-node await — this point-read's future is awaited HERE (its result-consumption
@@ -1634,22 +1847,57 @@ function emitNativeRawRunner(comp, plan, ap) {
1634
1847
  // directly (no `.await`). The runner being `async fn` does not force every call site to await.
1635
1848
  const awaitSfx = ap.nodeIsAsync(node.id) ? ".await" : "";
1636
1849
  lines.push(`${indent}let ports_${s} = ${structName} { ${inits.join(", ")} };`);
1637
- lines.push(`${indent}let row_${s} = match handlers.${nodeMethodName(node.id)}(&ports_${s}, ${boundArg})${awaitSfx} {`);
1638
- lines.push(`${indent} Some(r) => r,`);
1639
- lines.push(`${indent} None => return Err(unknown_component(${rustStrLit(node.component)})),`);
1640
- lines.push(`${indent}};`);
1850
+ // a de-box-eligible node returns the wire (Self::Wire / Vec / Option / BTreeMap); the generated
1851
+ // decode_wire_result_* probes each record strictly and produces the structured error on a mismatch
1852
+ // (byte-equal codes to run_behavior's outType check, so ≡ holds). A non-de-box node keeps its direct
1853
+ // concrete-row move.
1854
+ const top = recordTop(ref);
1855
+ const namedTop = top !== null && top.kind === "named";
1856
+ const deBox = top !== null;
1641
1857
  if (meta.policy === "continue") {
1642
- lines.push(`${indent}if !row_${s}.is_error {`);
1643
- lines.push(`${indent} ${emitRowMove(`*${typedCell(node.id)}.borrow_mut()`, `row_${s}`, ref, plan)}`);
1644
- lines.push(`${indent} produced_${s}.set(true);`);
1645
- lines.push(`${indent}}`);
1858
+ // continue: a failed node (leaf failure OR de-box mismatch) produces no Port — the downstream
1859
+ // Skip propagation is what `continue` means. Only a clean decode commits the result.
1860
+ if (namedTop) {
1861
+ lines.push(`${indent}if let Ok(wire_${s}) = handlers.${nodeMethodName(node.id)}(&ports_${s}, ${boundArg})${awaitSfx} {`);
1862
+ lines.push(`${indent} if let Ok(row_${s}) = ${rustDecodeNamedName(top.innerName)}(&wire_${s}) {`);
1863
+ lines.push(`${indent} ${emitRowMove(`*${typedCell(node.id)}.borrow_mut()`, `row_${s}`, ref, plan)}`);
1864
+ lines.push(`${indent} produced_${s}.set(true);`);
1865
+ lines.push(`${indent} }`);
1866
+ lines.push(`${indent}}`);
1867
+ }
1868
+ else if (deBox) {
1869
+ lines.push(`${indent}if let Ok(wire_${s}) = handlers.${nodeMethodName(node.id)}(&ports_${s}, ${boundArg})${awaitSfx} {`);
1870
+ lines.push(`${indent} if let Ok(res_${s}) = ${rustDecodeResultName(comp.name, node.id)}(wire_${s}) {`);
1871
+ lines.push(`${indent} *${typedCell(node.id)}.borrow_mut() = res_${s};`);
1872
+ lines.push(`${indent} produced_${s}.set(true);`);
1873
+ lines.push(`${indent} }`);
1874
+ lines.push(`${indent}}`);
1875
+ }
1876
+ else {
1877
+ lines.push(`${indent}if let Ok(row_${s}) = handlers.${nodeMethodName(node.id)}(&ports_${s}, ${boundArg})${awaitSfx} {`);
1878
+ lines.push(`${indent} ${emitRowMove(`*${typedCell(node.id)}.borrow_mut()`, `row_${s}`, ref, plan)}`);
1879
+ lines.push(`${indent} produced_${s}.set(true);`);
1880
+ lines.push(`${indent}}`);
1881
+ }
1646
1882
  }
1647
1883
  else {
1648
- const label = meta.policy === "retry" ? "retry' policy (exhausted)" : "fail' policy";
1649
- lines.push(`${indent}if row_${s}.is_error {`);
1650
- lines.push(`${indent} return Err(BehaviorError::new("OP_FAILED", format!("operation '{}' failed under '${label}: {}", ${rustStrLit(node.id)}, row_${s}.err)));`);
1651
- lines.push(`${indent}}`);
1652
- lines.push(`${indent}${emitRowMove(`*${typedCell(node.id)}.borrow_mut()`, `row_${s}`, ref, plan)}`);
1884
+ const policyLit = meta.policy === "retry" ? "retry' policy (exhausted)" : "fail' policy";
1885
+ const resultVar = deBox ? `wire_${s}` : `row_${s}`;
1886
+ lines.push(`${indent}let ${resultVar} = match handlers.${nodeMethodName(node.id)}(&ports_${s}, ${boundArg})${awaitSfx} {`);
1887
+ lines.push(`${indent} Ok(r) => r,`);
1888
+ lines.push(`${indent} Err(e) => return Err(BehaviorError { code: "OP_FAILED".to_string(), message: format!("operation '{}' failed under '${policyLit}: {}", ${rustStrLit(node.id)}, e.message), detail: e.detail }),`);
1889
+ lines.push(`${indent}};`);
1890
+ if (namedTop) {
1891
+ lines.push(`${indent}let row_${s} = ${rustDecodeNamedName(top.innerName)}(&wire_${s})?;`);
1892
+ lines.push(`${indent}${emitRowMove(`*${typedCell(node.id)}.borrow_mut()`, `row_${s}`, ref, plan)}`);
1893
+ }
1894
+ else if (deBox) {
1895
+ lines.push(`${indent}let res_${s} = ${rustDecodeResultName(comp.name, node.id)}(wire_${s})?;`);
1896
+ lines.push(`${indent}*${typedCell(node.id)}.borrow_mut() = res_${s};`);
1897
+ }
1898
+ else {
1899
+ lines.push(`${indent}${emitRowMove(`*${typedCell(node.id)}.borrow_mut()`, `row_${s}`, ref, plan)}`);
1900
+ }
1653
1901
  lines.push(`${indent}produced_${s}.set(true);`);
1654
1902
  }
1655
1903
  for (const c of closers)
@@ -1754,12 +2002,9 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
1754
2002
  ind += " ";
1755
2003
  }
1756
2004
  }
1757
- const resolveRef = runnerResolveRef(priorNodeAt, atPos, typedNodes, plan, comp.inputPorts);
1758
2005
  const inits = [];
1759
2006
  for (const pn of Object.keys(node.ports)) {
1760
- const expr = node.ports[pn];
1761
- const ty = portFieldRustType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`, undefined, plan, typedNodes);
1762
- inits.push(emitPortInit(pn, expr, ty, resolveRef, comp.inputPorts, plan, typedNodes));
2007
+ inits.push(emitPortInit(pn, node.ports[pn], comp, plan, typedNodes, (h) => priorNodeAt(h, atPos)));
1763
2008
  }
1764
2009
  if (gated) {
1765
2010
  lines.push(`${ind}ports_${s} = Some(${structName} { ${inits.join(", ")} });`);
@@ -1777,10 +2022,26 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
1777
2022
  const nMembers = refMembers.length;
1778
2023
  const boundN = Math.min(concurrency, nMembers);
1779
2024
  const jobsVar = `jobs_${sanitize(comp.name)}`;
2025
+ const hWire = `<H as ${handlerTraitName(comp.name)}>::Wire`;
2026
+ const hWireTop = (top) => {
2027
+ switch (top.kind) {
2028
+ case "named":
2029
+ return hWire;
2030
+ case "arr":
2031
+ return `Vec<${hWire}>`;
2032
+ case "opt":
2033
+ return `Option<${hWire}>`;
2034
+ case "map":
2035
+ return `std::collections::BTreeMap<String, ${hWire}>`;
2036
+ }
2037
+ };
1780
2038
  for (const i of refMembers) {
1781
2039
  const s = sanitize(comp.body[i].id);
1782
- const rowT = rawRowStructName(comp.name, comp.body[i].id);
1783
- lines.push(` let slot_${s}: std::sync::Mutex<Option<Option<${rowT}>>> = std::sync::Mutex::new(None);`);
2040
+ // a de-box member's thread returns the WIRE (shaped by the record top); a non-de-box member returns
2041
+ // its concrete row directly.
2042
+ const memTop = recordTop(typedNodes.get(comp.body[i].id));
2043
+ const slotT = memTop !== null ? hWireTop(memTop) : rawRowStructName(comp.name, comp.body[i].id);
2044
+ lines.push(` let slot_${s}: std::sync::Mutex<Option<Result<${slotT}, BehaviorError>>> = std::sync::Mutex::new(None);`);
1784
2045
  }
1785
2046
  lines.push(` let mut ${jobsVar}: Vec<usize> = Vec::new();`);
1786
2047
  refMembers.forEach((i, slot) => {
@@ -1818,22 +2079,52 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
1818
2079
  const s = sanitize(node.id);
1819
2080
  const ref = typedNodes.get(node.id);
1820
2081
  lines.push(` if ports_${s}.is_some() {`);
1821
- lines.push(` let row_${s} = match slot_${s}.lock().unwrap().take() {`);
1822
- lines.push(` Some(Some(r)) => r,`);
1823
- lines.push(` _ => return Err(unknown_component(${rustStrLit(node.component)})),`);
1824
- lines.push(` };`);
2082
+ lines.push(` let out_${s} = slot_${s}.lock().unwrap().take().expect("runnable member wrote its slot");`);
2083
+ const top = recordTop(ref);
2084
+ const namedTop = top !== null && top.kind === "named";
2085
+ const deBox = top !== null;
1825
2086
  if (meta.policy === "continue") {
1826
- lines.push(` if !row_${s}.is_error {`);
1827
- lines.push(` ${emitRowMove(`*${typedCell(node.id)}.borrow_mut()`, `row_${s}`, ref, plan)}`);
1828
- lines.push(` produced_${s}.set(true);`);
1829
- lines.push(` }`);
2087
+ if (namedTop) {
2088
+ lines.push(` if let Ok(wire_${s}) = out_${s} {`);
2089
+ lines.push(` if let Ok(row_${s}) = ${rustDecodeNamedName(top.innerName)}(&wire_${s}) {`);
2090
+ lines.push(` ${emitRowMove(`*${typedCell(node.id)}.borrow_mut()`, `row_${s}`, ref, plan)}`);
2091
+ lines.push(` produced_${s}.set(true);`);
2092
+ lines.push(` }`);
2093
+ lines.push(` }`);
2094
+ }
2095
+ else if (deBox) {
2096
+ lines.push(` if let Ok(wire_${s}) = out_${s} {`);
2097
+ lines.push(` if let Ok(res_${s}) = ${rustDecodeResultName(comp.name, node.id)}(wire_${s}) {`);
2098
+ lines.push(` *${typedCell(node.id)}.borrow_mut() = res_${s};`);
2099
+ lines.push(` produced_${s}.set(true);`);
2100
+ lines.push(` }`);
2101
+ lines.push(` }`);
2102
+ }
2103
+ else {
2104
+ lines.push(` if let Ok(row_${s}) = out_${s} {`);
2105
+ lines.push(` ${emitRowMove(`*${typedCell(node.id)}.borrow_mut()`, `row_${s}`, ref, plan)}`);
2106
+ lines.push(` produced_${s}.set(true);`);
2107
+ lines.push(` }`);
2108
+ }
1830
2109
  }
1831
2110
  else {
1832
- const label = meta.policy === "retry" ? "retry' policy (exhausted)" : "fail' policy";
1833
- lines.push(` if row_${s}.is_error {`);
1834
- lines.push(` return Err(BehaviorError::new("OP_FAILED", format!("operation '{}' failed under '${label}: {}", ${rustStrLit(node.id)}, row_${s}.err)));`);
1835
- lines.push(` }`);
1836
- lines.push(` ${emitRowMove(`*${typedCell(node.id)}.borrow_mut()`, `row_${s}`, ref, plan)}`);
2111
+ const policyLit = meta.policy === "retry" ? "retry' policy (exhausted)" : "fail' policy";
2112
+ const resultVar = deBox ? `wire_${s}` : `row_${s}`;
2113
+ lines.push(` let ${resultVar} = match out_${s} {`);
2114
+ lines.push(` Ok(r) => r,`);
2115
+ lines.push(` Err(e) => return Err(BehaviorError { code: "OP_FAILED".to_string(), message: format!("operation '{}' failed under '${policyLit}: {}", ${rustStrLit(node.id)}, e.message), detail: e.detail }),`);
2116
+ lines.push(` };`);
2117
+ if (namedTop) {
2118
+ lines.push(` let row_${s} = ${rustDecodeNamedName(top.innerName)}(&wire_${s})?;`);
2119
+ lines.push(` ${emitRowMove(`*${typedCell(node.id)}.borrow_mut()`, `row_${s}`, ref, plan)}`);
2120
+ }
2121
+ else if (deBox) {
2122
+ lines.push(` let res_${s} = ${rustDecodeResultName(comp.name, node.id)}(wire_${s})?;`);
2123
+ lines.push(` *${typedCell(node.id)}.borrow_mut() = res_${s};`);
2124
+ }
2125
+ else {
2126
+ lines.push(` ${emitRowMove(`*${typedCell(node.id)}.borrow_mut()`, `row_${s}`, ref, plan)}`);
2127
+ }
1837
2128
  lines.push(` produced_${s}.set(true);`);
1838
2129
  }
1839
2130
  lines.push(` }`);
@@ -1870,62 +2161,18 @@ isAsync = false) {
1870
2161
  const portNames = Object.keys(m.ports);
1871
2162
  const elemArrRef = typedNodes.get(node.id);
1872
2163
  const elemName = renderTypeRef(elemArrRef.elem);
1873
- const resolveRef = (head, restPath, opt) => {
1874
- if (opt)
1875
- return null;
1876
- let baseExpr;
1877
- let baseRef;
1878
- if (head === asName) {
1879
- baseExpr = `oel_${s}`;
1880
- baseRef = overElemRef;
1881
- }
1882
- else if (priorHere(head)) {
1883
- baseExpr = `${typedCell(head)}.borrow()`;
1884
- baseRef = typedNodes.get(head);
1885
- }
1886
- else {
1887
- if (restPath.length !== 0)
1888
- return null;
1889
- const scalar = inputScalarKind(comp.inputPorts?.[head]);
1890
- if (scalar === undefined)
1891
- return null;
1892
- const field = `in_.${rustFieldName(head)}`;
1893
- return { expr: scalar === "String" ? `${field}.clone()` : field, scalar };
1894
- }
1895
- let acc;
1896
- let expr;
1897
- try {
1898
- const r = typedFieldAccess(baseExpr, baseRef, restPath, plan);
1899
- expr = r.expr;
1900
- acc = r.ref;
1901
- }
1902
- catch {
1903
- return null;
1904
- }
1905
- if (acc.kind !== "scalar" || acc.scalar === "null")
1906
- return null;
1907
- // OWNED value. When restPath is EMPTY, `expr` is a bare REFERENCE (`oel_${s}` = &Elem-scalar, or
1908
- // `cell.borrow()` = Ref<scalar>) — deref with `*` (Copy) / `.clone()` via deref (String). When a
1909
- // `.field` was appended, `expr` is a value PLACE (auto-deref) — read it directly (`.clone()` String).
1910
- const isBareRef = restPath.length === 0;
1911
- let owned;
1912
- if (acc.scalar === "string") {
1913
- owned = isBareRef ? `(*${expr}).clone()` : `${expr}.clone()`;
1914
- }
1915
- else {
1916
- owned = isBareRef ? `*${expr}` : expr;
1917
- }
1918
- return { expr: owned, scalar: rustScalar(acc.scalar) };
1919
- };
2164
+ // a record element is de-boxed strictly: the handler returns the element WIRE(s) and the arm decodes via
2165
+ // decode_wire_elem_* (closing the scan-row hole). A de-box mismatch fails closed regardless of
2166
+ // elementPolicy (matching the interpreter's whole-node outType throw).
2167
+ const elemDeBox = mapFanoutElemDeBoxEligible(node, typedNodes, plan);
2168
+ const decElemFn = elemDeBox ? rustDecodeElemName(comp.name, node.id) : "";
1920
2169
  // build the per-element CONCRETE native ports struct. `il` = indent inside the element loop.
1921
2170
  const buildPorts = (il) => {
1922
2171
  const out = [];
1923
2172
  const inits = [];
1924
2173
  const asBinding = { name: asName, ref: overElemRef, plan };
1925
2174
  for (const pn of portNames) {
1926
- const ty = portFieldRustType(m.ports[pn], comp.inputPorts, `map port '${pn}' of node '${node.id}'`, asBinding, plan);
1927
- // #108-map: pass the element binding + base expr so a `$as` MAP/NAMED field port clones the typed composite.
1928
- inits.push(emitPortInit(pn, m.ports[pn], ty, resolveRef, comp.inputPorts, plan, undefined, asBinding, `oel_${s}`));
2175
+ inits.push(emitPortInit(pn, m.ports[pn], comp, plan, typedNodes, priorHere, { asBinding, asBase: `oel_${s}` }));
1929
2176
  }
1930
2177
  out.push(`${il}let ep_${s} = ${structName} { ${inits.join(", ")} };`);
1931
2178
  return out;
@@ -1935,13 +2182,7 @@ isAsync = false) {
1935
2182
  const emitGuard = (il) => {
1936
2183
  if (!hasGuard)
1937
2184
  return [];
1938
- const resolveHead = (head) => {
1939
- if (head === asName)
1940
- return { expr: `oel_${s}.clone()`, ref: overElemRef };
1941
- if (priorHere(head))
1942
- return { expr: `${typedCell(head)}.borrow().clone()`, ref: typedNodes.get(head) };
1943
- return rustInputHeadRef(head, comp, plan);
1944
- };
2185
+ const resolveHead = nativeResolveHead(comp, plan, typedNodes, priorHere, { asBinding: { name: asName, ref: overElemRef, plan }, asBase: `oel_${s}` });
1945
2186
  const g = new NativeExprCompiler(makeRustExprBackend(resolveHead, plan)).compileBool(m.when);
1946
2187
  const out = [];
1947
2188
  for (const st of g.stmts)
@@ -1981,26 +2222,30 @@ isAsync = false) {
1981
2222
  lines.push(`${indent} let want_${s} = items_${s}.len();`);
1982
2223
  lines.push(`${indent} let bp_${s} = ${batch} { items: items_${s} };`);
1983
2224
  lines.push(`${indent} let row_${s} = match handlers.${nodeMethodName(node.id)}(&bp_${s}, None)${awaitSfx} {`);
1984
- lines.push(`${indent} Some(r) => r,`);
1985
- lines.push(`${indent} None => return Err(unknown_component(${rustStrLit(m.component)})),`);
2225
+ lines.push(`${indent} Ok(r) => r,`);
2226
+ lines.push(`${indent} Err(e) => return Err(op_failed(${rustStrLit(node.id)}, "fail", e)),`);
1986
2227
  lines.push(`${indent} };`);
1987
- lines.push(`${indent} if row_${s}.is_error {`);
1988
- lines.push(`${indent} return Err(BehaviorError::new("OP_FAILED", format!("operation '{}' failed under 'fail' policy: {}", ${rustStrLit(node.id)}, row_${s}.err)));`);
1989
- lines.push(`${indent} }`);
1990
- lines.push(`${indent} if row_${s}.rows.len() != want_${s} {`);
2228
+ const batchLen = elemDeBox ? `row_${s}.len()` : `row_${s}.rows.len()`;
2229
+ const batchIter = elemDeBox ? `row_${s}.into_iter()` : `row_${s}.rows.into_iter()`;
2230
+ lines.push(`${indent} if ${batchLen} != want_${s} {`);
1991
2231
  lines.push(`${indent} return Err(BehaviorError::new("MAP_BATCH_RESULT_MISMATCH", format!("map '{}': batched handler must return a list aligned to items (want {})", ${rustStrLit(node.id)}, want_${s})));`);
1992
2232
  lines.push(`${indent} }`);
1993
2233
  if (hasInto) {
1994
- // zip the aligned batch rows onto each KEPT over element (into materializer).
1995
- lines.push(`${indent} let mut rows_iter_${s} = row_${s}.rows.into_iter();`);
2234
+ // zip the aligned batch rows onto each KEPT over element (into materializer). A record element's
2235
+ // wire is de-boxed strictly first.
2236
+ lines.push(`${indent} let mut rows_iter_${s} = ${batchIter};`);
1996
2237
  lines.push(`${indent} for over_el_${s} in kept_${s}.iter() {`);
1997
2238
  lines.push(`${indent} let er_${s} = rows_iter_${s}.next().expect("aligned batch row");`);
2239
+ if (elemDeBox)
2240
+ lines.push(`${indent} let er_${s} = ${decElemFn}(&er_${s})?;`);
1998
2241
  lines.push(`${indent} built_${s}.push(${elemMat}(over_el_${s}, er_${s}));`);
1999
2242
  lines.push(`${indent} }`);
2000
2243
  }
2001
2244
  else {
2002
2245
  // no-into: collect each element DIRECTLY from the aligned per-element row (result = kept rows).
2003
- lines.push(`${indent} for er_${s} in row_${s}.rows.into_iter() {`);
2246
+ lines.push(`${indent} for er_${s} in ${batchIter} {`);
2247
+ if (elemDeBox)
2248
+ lines.push(`${indent} let er_${s} = ${decElemFn}(&er_${s})?;`);
2004
2249
  lines.push(`${indent} ${emitElemMove(`let el_${s}`, `er_${s}`, elemArrRef.elem, plan)}`);
2005
2250
  lines.push(`${indent} built_${s}.push(el_${s});`);
2006
2251
  lines.push(`${indent} }`);
@@ -2014,13 +2259,21 @@ isAsync = false) {
2014
2259
  lines.push(l);
2015
2260
  for (const l of buildPorts(indent + " "))
2016
2261
  lines.push(l);
2262
+ // Element Error Policy (scp-error.md): `skip` drops the failing element and proceeds (order is
2263
+ // preserved; the leaf keeps the Error Value it produced). `error` (default) promotes the element
2264
+ // Failure to the map's Component Failure, carrying the leaf's detail through.
2017
2265
  lines.push(`${indent} let er_${s} = match handlers.${nodeMethodName(node.id)}(&ep_${s}, None)${awaitSfx} {`);
2018
- lines.push(`${indent} Some(r) => r,`);
2019
- lines.push(`${indent} None => return Err(unknown_component(${rustStrLit(m.component)})),`);
2266
+ lines.push(`${indent} Ok(r) => r,`);
2267
+ if (m.elementPolicy === "skip") {
2268
+ lines.push(`${indent} Err(_) => continue,`);
2269
+ }
2270
+ else {
2271
+ lines.push(`${indent} Err(e) => return Err(op_failed(${rustStrLit(node.id)}, "fail", e)),`);
2272
+ }
2020
2273
  lines.push(`${indent} };`);
2021
- lines.push(`${indent} if er_${s}.is_error {`);
2022
- lines.push(`${indent} return Err(BehaviorError::new("OP_FAILED", format!("operation '{}' failed under 'fail' policy: {}", ${rustStrLit(node.id)}, er_${s}.err)));`);
2023
- lines.push(`${indent} }`);
2274
+ // a record element's wire is de-boxed strictly (fail-closed regardless of elementPolicy).
2275
+ if (elemDeBox)
2276
+ lines.push(`${indent} let er_${s} = ${decElemFn}(&er_${s})?;`);
2024
2277
  if (hasInto) {
2025
2278
  lines.push(`${indent} built_${s}.push(${elemMat}(oel_${s}, er_${s}));`);
2026
2279
  }
@@ -2064,54 +2317,11 @@ function emitFanoutArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync
2064
2317
  if (!dkField || dkField.type.kind !== "scalar" || dkField.type.scalar !== "string")
2065
2318
  throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust fanout (v3): node '${node.id}' dedupeKey '${f.dedupeKey}' must be a string field of the connection items element type '${elemRef.name}'.`);
2066
2319
  const dkRust = rustFieldName(f.dedupeKey);
2067
- const resolveRef = (head, restPath, opt) => {
2068
- if (opt)
2069
- return null;
2070
- let baseExpr;
2071
- let baseRef;
2072
- if (head === asName) {
2073
- baseExpr = `oel_${s}`;
2074
- baseRef = overElemRef;
2075
- }
2076
- else if (priorHere(head)) {
2077
- baseExpr = `${typedCell(head)}.borrow()`;
2078
- baseRef = typedNodes.get(head);
2079
- }
2080
- else {
2081
- if (restPath.length !== 0)
2082
- return null;
2083
- const scalar = inputScalarKind(comp.inputPorts?.[head]);
2084
- if (scalar === undefined)
2085
- return null;
2086
- const field = `in_.${rustFieldName(head)}`;
2087
- return { expr: scalar === "String" ? `${field}.clone()` : field, scalar };
2088
- }
2089
- let acc;
2090
- let expr;
2091
- try {
2092
- const r = typedFieldAccess(baseExpr, baseRef, restPath, plan);
2093
- expr = r.expr;
2094
- acc = r.ref;
2095
- }
2096
- catch {
2097
- return null;
2098
- }
2099
- if (acc.kind !== "scalar" || acc.scalar === "null")
2100
- return null;
2101
- const isBareRef = restPath.length === 0;
2102
- let owned;
2103
- if (acc.scalar === "string")
2104
- owned = isBareRef ? `(*${expr}).clone()` : `${expr}.clone()`;
2105
- else
2106
- owned = isBareRef ? `*${expr}` : expr;
2107
- return { expr: owned, scalar: rustScalar(acc.scalar) };
2108
- };
2109
2320
  const buildPorts = (il) => {
2110
2321
  const inits = [];
2111
2322
  const asBinding = { name: asName, ref: overElemRef, plan };
2112
2323
  for (const pn of portNames) {
2113
- const ty = portFieldRustType(f.ports[pn], comp.inputPorts, `fanout port '${pn}' of node '${node.id}'`, asBinding, plan);
2114
- inits.push(emitPortInit(pn, f.ports[pn], ty, resolveRef, comp.inputPorts, plan));
2324
+ inits.push(emitPortInit(pn, f.ports[pn], comp, plan, typedNodes, priorHere, { asBinding, asBase: `oel_${s}` }));
2115
2325
  }
2116
2326
  return [`${il}let ep_${s} = ${structName} { ${inits.join(", ")} };`];
2117
2327
  };
@@ -2136,20 +2346,26 @@ function emitFanoutArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync
2136
2346
  lines.push(`${indent} let want_${s} = pi_${s}.len();`);
2137
2347
  lines.push(`${indent} let bp_${s} = ${batch} { items: pi_${s} };`);
2138
2348
  lines.push(`${indent} let row_${s} = match handlers.${nodeMethodName(node.id)}(&bp_${s}, None)${awaitSfx} {`);
2139
- lines.push(`${indent} Some(r) => r,`);
2140
- lines.push(`${indent} None => return Err(unknown_component(${rustStrLit(f.component)})),`);
2349
+ lines.push(`${indent} Ok(r) => r,`);
2350
+ lines.push(`${indent} Err(e) => return Err(op_failed(${rustStrLit(node.id)}, "fail", e)),`);
2141
2351
  lines.push(`${indent} };`);
2142
- lines.push(`${indent} if row_${s}.is_error {`);
2143
- lines.push(`${indent} return Err(BehaviorError::new("OP_FAILED", format!("operation '{}' failed under 'fail' policy: {}", ${rustStrLit(node.id)}, row_${s}.err)));`);
2144
- lines.push(`${indent} }`);
2145
- lines.push(`${indent} if row_${s}.rows.len() != want_${s} {`);
2352
+ // the fanout element row is a named record → the handler returns the aligned element WIRES
2353
+ // (Vec<Option<Self::Wire>>) and each present body is de-boxed strictly (a de-box mismatch fails closed).
2354
+ // A DANGLING id is None → the zero element row (empty dedupeKey), which the dangling drop excludes.
2355
+ const decElemFn = rustDecodeElemName(comp.name, node.id);
2356
+ const rawElemT = rawElemStructName(comp.name, node.id);
2357
+ lines.push(`${indent} if row_${s}.len() != want_${s} {`);
2146
2358
  lines.push(`${indent} return Err(BehaviorError::new("FANOUT_BATCH_RESULT_MISMATCH", format!("fanout '{}': batched handler must return a list aligned to the deduped id list (want {})", ${rustStrLit(node.id)}, want_${s})));`);
2147
2359
  lines.push(`${indent} }`);
2148
2360
  // ── THE ONE dedupe/drop (mirrors fanoutDedupDrop verbatim): first-seen dedupe by dedupeKey field;
2149
2361
  // drop dangling (empty dedupeKey ≡ interpreter's null/absent-key body); implicitSource strip is
2150
2362
  // structural (the elem type already omits it). cursor is always None (connection wrap).
2151
2363
  lines.push(`${indent} let mut seen_${s}: std::collections::HashSet<String> = std::collections::HashSet::new();`);
2152
- lines.push(`${indent} for er_${s} in row_${s}.rows.into_iter() {`);
2364
+ lines.push(`${indent} for opt_er_${s} in row_${s}.into_iter() {`);
2365
+ lines.push(`${indent} let er_${s}: ${rawElemT} = match opt_er_${s} {`);
2366
+ lines.push(`${indent} Some(wire_${s}) => ${decElemFn}(&wire_${s})?,`);
2367
+ lines.push(`${indent} None => Default::default(),`);
2368
+ lines.push(`${indent} };`);
2153
2369
  lines.push(`${indent} ${emitElemMove(`let el_${s}`, `er_${s}`, elemRef, plan)}`);
2154
2370
  lines.push(`${indent} let key_${s} = el_${s}.${dkRust}.clone();`);
2155
2371
  if (f.drop === "dangling") {
@@ -2166,30 +2382,6 @@ function emitFanoutArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync
2166
2382
  lines.push(c);
2167
2383
  return lines.join("\n");
2168
2384
  }
2169
- /**
2170
- * rustInputHeadRef (#108) — resolve an INPUT port head for a native-expr ref (rust). The port's declared
2171
- * type comes from the shared `inputPortTypeRef` SSoT, so an OPTIONAL port resolves as `{opt:…}` (native
2172
- * `Option<T>`) and the compiler can see its optionality: a null-compare becomes a real presence test and
2173
- * a coalesce a real `unwrap_or`, while a REQUIRED-scalar slot fed by an opt fails closed. Exprs are OWNED
2174
- * (String / Option<String> cloned; Copy scalars as-is), except an array — the only array use inside a
2175
- * cond/guard expr is `len`, which BORROWS (`&in_.xs`).
2176
- */
2177
- function rustInputHeadRef(head, comp, plan) {
2178
- const schema = comp.inputPorts?.[head];
2179
- const ref = inputPortTypeRef(schema, plan);
2180
- if (ref === undefined)
2181
- return null;
2182
- const field = `in_.${rustFieldName(head)}`;
2183
- return { expr: rustOwnedHeadExpr(field, ref), ref };
2184
- }
2185
- /** The OWNED native expression for a head of type `ref` read off a by-value struct field. A String (or an
2186
- * Option/collection containing one) is cloned; a Copy scalar (and an Option/array of Copy scalars) is
2187
- * read directly — cloning a Copy value trips clippy's `clone_on_copy`. An array stays borrowed (`len`). */
2188
- function rustOwnedHeadExpr(field, ref) {
2189
- if (ref.kind === "arr")
2190
- return field; // borrowed by `len`
2191
- return typeRefIsCopy(ref) ? field : `${field}.clone()`;
2192
- }
2193
2385
  /**
2194
2386
  * emitCondArm — the struct-native exec of a covered cond node (#108, rust). Mirrors run_behavior's cond
2195
2387
  * lower: compile `if` to a native bool, materialize the TAKEN branch to the node's outType (only the
@@ -2200,11 +2392,7 @@ function emitCondArm(comp, node, atPos, typedNodes, plan, priorNodeAt) {
2200
2392
  const c = node.cond;
2201
2393
  const s = sanitize(node.id);
2202
2394
  const priorHere = (head) => priorNodeAt(head, atPos);
2203
- const resolveHead = (head) => {
2204
- if (priorHere(head))
2205
- return { expr: `${typedCell(head)}.borrow().clone()`, ref: typedNodes.get(head) };
2206
- return rustInputHeadRef(head, comp, plan);
2207
- };
2395
+ const resolveHead = nativeResolveHead(comp, plan, typedNodes, priorHere);
2208
2396
  const compiler = new NativeExprCompiler(makeRustExprBackend(resolveHead, plan));
2209
2397
  const cond = compiler.compileBool(c.if);
2210
2398
  // #125: a when(...) control-gate cond は出力データ型を持たない制御スキップマーカー。typed cell へ何も
@@ -2335,6 +2523,13 @@ pub const COMPONENT_NAMES_NATIVE_RAW: [&str; ${names.length}] = [${names.join(",
2335
2523
  const UNKNOWN_COMPONENT_HELPER = `#[allow(dead_code)]
2336
2524
  fn unknown_component(component: &str) -> BehaviorError {
2337
2525
  BehaviorError::new("UNKNOWN_COMPONENT", format!("component '{component}' has no handler (fail-closed)"))
2526
+ }
2527
+
2528
+ // leaf_failure — a failure the LEAF reports (test glue): the runner assigns the node's failure code
2529
+ // from its Error Policy Kind, so what a leaf carries is its message and its structured Error Value.
2530
+ #[allow(dead_code)]
2531
+ fn leaf_failure(message: impl Into<String>) -> BehaviorError {
2532
+ BehaviorError::new("LEAF_FAILURE", message)
2338
2533
  }`;
2339
2534
  // ── module assembly ──────────────────────────────────────────────────────────────────
2340
2535
  function emit(ctx) {
@@ -2384,6 +2579,9 @@ ${emitStructSurface([])}
2384
2579
  const handlerTraits = [];
2385
2580
  const inStructs = [];
2386
2581
  const elemMaterializers = [];
2582
+ // the generated strict de-box functions (deduped by named type across all covered components).
2583
+ const decodeFns = [];
2584
+ const decodeEmitted = new Set();
2387
2585
  for (const c of native) {
2388
2586
  const typedNodes = new Map();
2389
2587
  // build the full typed-node map FIRST so a map's over-element resolution can see every node.
@@ -2401,8 +2599,11 @@ ${emitStructSurface([])}
2401
2599
  else
2402
2600
  structs.push(emitPortsStruct(c, n, undefined, plan, typedNodes)); // #129: typedNodes for prior-node arr leaf ports.
2403
2601
  }
2602
+ // fail closed for a record inside a non-named top (never a silent trust) — before emitting anything.
2603
+ assertDeBoxableResults(c, typedNodes, plan);
2404
2604
  rowStructs.push(emitRawRowStructs(c, typedNodes, plan));
2405
2605
  handlerTraits.push(emitHandlerTrait(c, typedNodes, plan, asyncPlans.get(c.name)));
2606
+ emitRustNodeDecodeFns(c, typedNodes, plan, decodeEmitted, decodeFns);
2406
2607
  inStructs.push(emitInStruct(c, plan));
2407
2608
  const mm = emitMapElemMaterializers(c, typedNodes, plan);
2408
2609
  if (mm)
@@ -2445,6 +2646,12 @@ use std::cell::RefCell;`;
2445
2646
  : undefined,
2446
2647
  `// CONCRETE per-component input structs (fields = inputPorts).\n${inStructs.join("\n\n")}`,
2447
2648
  `// CONCRETE per-component handler traits (one node_* method per node — native ports IN, row OUT).\n${handlerTraits.join("\n\n")}`,
2649
+ decodeFns.length
2650
+ ? `// strict de-box wire seam — the consumer implements WireRow/WireList over its wire payload (variant\n// classification); the generated decode_wire_* owns strictness (required/optional, present/absent, error\n// assembly, fail-closed). Concrete enums + monomorphized traits — no boxed value, no trait object, no\n// heap allocation crosses the seam.\n${emitRustProbeWireTypes()}`
2651
+ : undefined,
2652
+ decodeFns.length
2653
+ ? `// Generated strict de-box functions (per named record type; structured Error Value on mismatch).\n${decodeFns.join("\n\n")}`
2654
+ : undefined,
2448
2655
  elemMaterializers.length
2449
2656
  ? `// map augmented-element copiers (pure struct field assignment — no dynamic value read).\n${elemMaterializers.join("\n\n")}`
2450
2657
  : undefined,
@@ -2476,6 +2683,160 @@ export function rustTypedNativeObserve(ir, ioModel = "sync") {
2476
2683
  const anyAsyncRunner = native.some((c) => asyncPlans.get(c.name).runnerIsAsync);
2477
2684
  // ── per-component input decoders: generic &[(String,Value)] -> concrete InNR_<comp> (test glue). ──
2478
2685
  const inDecoders = native.map((c) => emitInDecoder(c, plan)).join("\n\n");
2686
+ // does any covered read de-box a named record — a componentRef record OR a map/fanout element row
2687
+ // (→ the scripted handler returns a ScriptedWireRow / Vec<ScriptedWireRow>)?
2688
+ const anyDeBox = native.some((c) => {
2689
+ const tn = new Map();
2690
+ for (const n of c.body)
2691
+ if (!isControlGate(n))
2692
+ tn.set(n.id, "map" in n ? mapNodeArrRef(n, plan) : deriveTypeRef(n.outType, plan));
2693
+ return c.body.some((n) => {
2694
+ if ("cond" in n)
2695
+ return false;
2696
+ if ("map" in n || "fanout" in n)
2697
+ return mapFanoutElemDeBoxEligible(n, tn, plan);
2698
+ return deBoxEligible(tn.get(n.id));
2699
+ });
2700
+ });
2701
+ // scripted wire adapter (TEST glue): wraps a scripted Value as a WireRow/WireList and classifies each
2702
+ // attribute's native type into a DynamoDB-flavored wire tag (S/N/BOOL/L/M/NULL). A real consumer
2703
+ // implements WireRow over its own AttributeValue map; this drives the generated de-box from the same
2704
+ // scripted vectors run_behavior uses — so actualWireType observed in a mismatch is the PRODUCER tag.
2705
+ const wireAdapter = anyDeBox
2706
+ ? `// ── scripted wire adapter (TEST glue) ──
2707
+ fn scripted_wire_tag(v: &Value) -> String {
2708
+ match v {
2709
+ Value::Null => "NULL",
2710
+ Value::Bool(_) => "BOOL",
2711
+ Value::Int(_) | Value::Float(_) => "N",
2712
+ Value::Str(_) => "S",
2713
+ Value::Arr(_) => "L",
2714
+ Value::Obj(_) => "M",
2715
+ }
2716
+ .to_string()
2717
+ }
2718
+
2719
+ fn scripted_wire_raw(v: &Value) -> String {
2720
+ match v {
2721
+ Value::Null => "null".to_string(),
2722
+ Value::Bool(b) => b.to_string(),
2723
+ Value::Int(n) => n.to_string(),
2724
+ Value::Float(f) => f.to_string(),
2725
+ Value::Str(s) => s.clone(),
2726
+ Value::Arr(_) | Value::Obj(_) => "[composite]".to_string(),
2727
+ }
2728
+ }
2729
+
2730
+ fn probe_string_val(v: Option<&Value>) -> Probe<String> {
2731
+ match v {
2732
+ None => Probe::Absent,
2733
+ Some(Value::Null) => Probe::Null { actual_wire_type: "NULL".to_string(), raw_value: "null".to_string() },
2734
+ Some(Value::Str(s)) => Probe::Got(s.clone()),
2735
+ Some(other) => Probe::Wrong { actual_wire_type: scripted_wire_tag(other), raw_value: scripted_wire_raw(other) },
2736
+ }
2737
+ }
2738
+
2739
+ fn probe_number_val(v: Option<&Value>) -> NumProbe {
2740
+ match v {
2741
+ None => NumProbe::Absent,
2742
+ Some(Value::Null) => NumProbe::Null { actual_wire_type: "NULL".to_string(), raw_value: "null".to_string() },
2743
+ Some(Value::Int(n)) => NumProbe::Got { raw: n.to_string(), actual_wire_type: "N".to_string() },
2744
+ Some(Value::Float(f)) => NumProbe::Got { raw: f.to_string(), actual_wire_type: "N".to_string() },
2745
+ Some(other) => NumProbe::Wrong { actual_wire_type: scripted_wire_tag(other), raw_value: scripted_wire_raw(other) },
2746
+ }
2747
+ }
2748
+
2749
+ fn probe_bool_val(v: Option<&Value>) -> Probe<bool> {
2750
+ match v {
2751
+ None => Probe::Absent,
2752
+ Some(Value::Null) => Probe::Null { actual_wire_type: "NULL".to_string(), raw_value: "null".to_string() },
2753
+ Some(Value::Bool(b)) => Probe::Got(*b),
2754
+ Some(other) => Probe::Wrong { actual_wire_type: scripted_wire_tag(other), raw_value: scripted_wire_raw(other) },
2755
+ }
2756
+ }
2757
+
2758
+ fn probe_row_val(v: Option<&Value>) -> Probe<ScriptedWireRow> {
2759
+ match v {
2760
+ None => Probe::Absent,
2761
+ Some(Value::Null) => Probe::Null { actual_wire_type: "NULL".to_string(), raw_value: "null".to_string() },
2762
+ Some(Value::Obj(pairs)) => Probe::Got(ScriptedWireRow { obj: pairs.clone() }),
2763
+ Some(other) => Probe::Wrong { actual_wire_type: scripted_wire_tag(other), raw_value: scripted_wire_raw(other) },
2764
+ }
2765
+ }
2766
+
2767
+ fn probe_list_val(v: Option<&Value>) -> Probe<ScriptedWireList> {
2768
+ match v {
2769
+ None => Probe::Absent,
2770
+ Some(Value::Null) => Probe::Null { actual_wire_type: "NULL".to_string(), raw_value: "null".to_string() },
2771
+ Some(Value::Arr(items)) => Probe::Got(ScriptedWireList { items: items.clone() }),
2772
+ Some(other) => Probe::Wrong { actual_wire_type: scripted_wire_tag(other), raw_value: scripted_wire_raw(other) },
2773
+ }
2774
+ }
2775
+
2776
+ pub struct ScriptedWireRow {
2777
+ obj: Vec<(String, Value)>,
2778
+ }
2779
+
2780
+ impl ScriptedWireRow {
2781
+ pub fn new(v: Value) -> Self {
2782
+ match v {
2783
+ Value::Obj(pairs) => ScriptedWireRow { obj: pairs },
2784
+ _ => ScriptedWireRow { obj: Vec::new() },
2785
+ }
2786
+ }
2787
+ fn get(&self, field: &str) -> Option<&Value> {
2788
+ self.obj.iter().find(|(k, _)| k.as_str() == field).map(|(_, v)| v)
2789
+ }
2790
+ }
2791
+
2792
+ pub struct ScriptedWireList {
2793
+ items: Vec<Value>,
2794
+ }
2795
+
2796
+ impl WireRow for ScriptedWireRow {
2797
+ type List = ScriptedWireList;
2798
+ fn keys(&self) -> Vec<String> {
2799
+ self.obj.iter().map(|(k, _)| k.clone()).collect()
2800
+ }
2801
+ fn probe_string(&self, field: &str) -> Probe<String> {
2802
+ probe_string_val(self.get(field))
2803
+ }
2804
+ fn probe_number(&self, field: &str) -> NumProbe {
2805
+ probe_number_val(self.get(field))
2806
+ }
2807
+ fn probe_bool(&self, field: &str) -> Probe<bool> {
2808
+ probe_bool_val(self.get(field))
2809
+ }
2810
+ fn probe_row(&self, field: &str) -> Probe<Self> {
2811
+ probe_row_val(self.get(field))
2812
+ }
2813
+ fn probe_list(&self, field: &str) -> Probe<Self::List> {
2814
+ probe_list_val(self.get(field))
2815
+ }
2816
+ }
2817
+
2818
+ impl WireList for ScriptedWireList {
2819
+ type Row = ScriptedWireRow;
2820
+ fn len(&self) -> usize {
2821
+ self.items.len()
2822
+ }
2823
+ fn elem_string(&self, i: usize) -> Probe<String> {
2824
+ probe_string_val(self.items.get(i))
2825
+ }
2826
+ fn elem_number(&self, i: usize) -> NumProbe {
2827
+ probe_number_val(self.items.get(i))
2828
+ }
2829
+ fn elem_bool(&self, i: usize) -> Probe<bool> {
2830
+ probe_bool_val(self.items.get(i))
2831
+ }
2832
+ fn elem_row(&self, i: usize) -> Probe<Self::Row> {
2833
+ probe_row_val(self.items.get(i))
2834
+ }
2835
+ fn elem_list(&self, i: usize) -> Probe<Self> {
2836
+ probe_list_val(self.items.get(i))
2837
+ }
2838
+ }`
2839
+ : "";
2479
2840
  // ── concrete scripted handler: implements every HandlerNR<comp> trait by decoding a scripted Value
2480
2841
  // into the concrete per-node row (the generic scripted-source queue meets the concrete seam). ──
2481
2842
  const decodeFns = [];
@@ -2499,22 +2860,39 @@ export function rustTypedNativeObserve(ir, ioModel = "sync") {
2499
2860
  // Option<K> (NOT a boxed Value) — computed per node so the impl signature matches the trait.
2500
2861
  const bt = boundRustType(n, typedNodes, plan);
2501
2862
  if ("fanout" in n) {
2502
- // v3: scripted fanout impl — drain one batched outcome, decode the aligned array into per-body
2503
- // elem rows (a null array element decodes to the zero elem row = dangling → dropped by the arm).
2504
2863
  const f = n.fanout;
2505
- const elemRowRef = fanoutItemsElemRef(n, ref, plan);
2864
+ const elemDeBox = mapFanoutElemDeBoxEligible(n, typedNodes, plan);
2506
2865
  const elemT = rawElemStructName(c.name, n.id);
2507
2866
  const batchT = rawRowStructName(c.name, n.id);
2508
- const elemDecode = emitDecodeRow(c.name, n.id, elemT, elemRowRef, plan, emittedDecode, decodeFns, "Elem");
2509
2867
  const portsT = `${portsStructName(c.name, n.id)}Batch`;
2510
- methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${batchT}> {
2511
- let (is_err, err, ok, _echo) = self.next(${rustStrLit(f.component)})?;
2868
+ if (elemDeBox) {
2869
+ // a record-element fanout returns the aligned element WIRES; a null aligned body is a DANGLING
2870
+ // id → None (the covered runner maps it to the zero elem row).
2871
+ methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Result<Vec<Option<ScriptedWireRow>>, BehaviorError> {
2872
+ let (is_err, err, ok, _echo) = self.next(${rustStrLit(f.component)}).ok_or_else(|| unknown_component(${rustStrLit(f.component)}))?;
2512
2873
  if is_err {
2513
- return Some(${batchT} { is_error: true, err, ..Default::default() });
2874
+ return Err(leaf_failure(err));
2875
+ }
2876
+ let arr = match ok { Some(Value::Arr(a)) => a, _ => Vec::new() };
2877
+ let wires = arr
2878
+ .into_iter()
2879
+ .map(|ev| match ev {
2880
+ Value::Null => None,
2881
+ _ => Some(ScriptedWireRow::new(ev)),
2882
+ })
2883
+ .collect();
2884
+ Ok(wires)
2885
+ }`);
2886
+ continue;
2887
+ }
2888
+ const elemRowRef = fanoutItemsElemRef(n, ref, plan);
2889
+ const elemDecode = emitDecodeRow(c.name, n.id, elemT, elemRowRef, plan, emittedDecode, decodeFns, "Elem");
2890
+ methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Result<${batchT}, BehaviorError> {
2891
+ let (is_err, err, ok, _echo) = self.next(${rustStrLit(f.component)}).ok_or_else(|| unknown_component(${rustStrLit(f.component)}))?;
2892
+ if is_err {
2893
+ return Err(leaf_failure(err));
2514
2894
  }
2515
2895
  let arr = match ok { Some(Value::Arr(a)) => a, _ => Vec::new() };
2516
- // v3: a null aligned body is a DANGLING id — decode it to the zero elem row (whose dedupeKey
2517
- // field is empty), so the runner's dangling-drop excludes it (byte-equal to fanoutDedupDrop).
2518
2896
  let rows = arr
2519
2897
  .iter()
2520
2898
  .map(|ev| match ev {
@@ -2522,7 +2900,7 @@ export function rustTypedNativeObserve(ir, ioModel = "sync") {
2522
2900
  _ => ${elemDecode}(ev),
2523
2901
  })
2524
2902
  .collect();
2525
- Some(${batchT} { rows, ..Default::default() })
2903
+ Ok(${batchT} { rows })
2526
2904
  }`);
2527
2905
  continue;
2528
2906
  }
@@ -2530,65 +2908,172 @@ export function rustTypedNativeObserve(ir, ioModel = "sync") {
2530
2908
  const m = n.map;
2531
2909
  const batched = m.batched === true;
2532
2910
  const arrRef = ref;
2533
- const elemRowRef = mapElemRowRef(n, arrRef, plan);
2911
+ const elemDeBox = mapFanoutElemDeBoxEligible(n, typedNodes, plan);
2534
2912
  const elemT = rawElemStructName(c.name, n.id);
2535
2913
  const batchT = rawRowStructName(c.name, n.id);
2536
- const elemDecode = emitDecodeRow(c.name, n.id, elemT, elemRowRef, plan, emittedDecode, decodeFns, "Elem");
2537
2914
  const portsT = batched ? `${portsStructName(c.name, n.id)}Batch` : portsStructName(c.name, n.id);
2538
- const retT = batched ? batchT : elemT;
2915
+ if (elemDeBox) {
2916
+ // a record-element map returns the element WIRE(s); the covered runner de-boxes each strictly.
2917
+ if (batched) {
2918
+ methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Result<Vec<ScriptedWireRow>, BehaviorError> {
2919
+ let (is_err, err, ok, _echo) = self.next(${rustStrLit(m.component)}).ok_or_else(|| unknown_component(${rustStrLit(m.component)}))?;
2920
+ if is_err {
2921
+ return Err(leaf_failure(err));
2922
+ }
2923
+ let arr = match ok { Some(Value::Arr(a)) => a, _ => Vec::new() };
2924
+ let wires = arr.into_iter().map(ScriptedWireRow::new).collect();
2925
+ Ok(wires)
2926
+ }`);
2927
+ }
2928
+ else {
2929
+ methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Result<ScriptedWireRow, BehaviorError> {
2930
+ let (is_err, err, ok, _echo) = self.next(${rustStrLit(m.component)}).ok_or_else(|| unknown_component(${rustStrLit(m.component)}))?;
2931
+ if is_err {
2932
+ return Err(leaf_failure(err));
2933
+ }
2934
+ Ok(ScriptedWireRow::new(ok.unwrap_or(Value::Null)))
2935
+ }`);
2936
+ }
2937
+ continue;
2938
+ }
2939
+ const elemRowRef = mapElemRowRef(n, arrRef, plan);
2940
+ const elemDecode = emitDecodeRow(c.name, n.id, elemT, elemRowRef, plan, emittedDecode, decodeFns, "Elem");
2539
2941
  if (batched) {
2540
- methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${batchT}> {
2541
- let (is_err, err, ok, _echo) = self.next(${rustStrLit(m.component)})?;
2942
+ methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Result<${batchT}, BehaviorError> {
2943
+ let (is_err, err, ok, _echo) = self.next(${rustStrLit(m.component)}).ok_or_else(|| unknown_component(${rustStrLit(m.component)}))?;
2542
2944
  if is_err {
2543
- return Some(${batchT} { is_error: true, err, ..Default::default() });
2945
+ return Err(leaf_failure(err));
2544
2946
  }
2545
2947
  let arr = match ok { Some(Value::Arr(a)) => a, _ => Vec::new() };
2546
2948
  let rows = arr.iter().map(${elemDecode}).collect();
2547
- Some(${batchT} { rows, ..Default::default() })
2949
+ Ok(${batchT} { rows })
2548
2950
  }`);
2549
2951
  }
2550
2952
  else {
2551
- methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${elemT}> {
2552
- let (is_err, err, ok, _echo) = self.next(${rustStrLit(m.component)})?;
2953
+ methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Result<${elemT}, BehaviorError> {
2954
+ let (is_err, err, ok, _echo) = self.next(${rustStrLit(m.component)}).ok_or_else(|| unknown_component(${rustStrLit(m.component)}))?;
2553
2955
  if is_err {
2554
- return Some(${elemT} { is_error: true, err, ..Default::default() });
2956
+ return Err(leaf_failure(err));
2555
2957
  }
2556
2958
  let v = ok.unwrap_or(Value::Null);
2557
- Some(${elemDecode}(&v))
2959
+ Ok(${elemDecode}(&v))
2558
2960
  }`);
2559
2961
  }
2560
- void retT;
2561
2962
  continue;
2562
2963
  }
2563
2964
  const node = n;
2564
2965
  const rowT = rawRowStructName(c.name, node.id);
2966
+ // a de-box-eligible node's scripted handler returns a ScriptedWireRow (the scripted Value wrapped as
2967
+ // a WireRow that classifies each attribute's native type into a DynamoDB-flavored wire tag); the
2968
+ // covered module's generated decode_wire_* then probes it. A real consumer returns its wire payload
2969
+ // for BC to de-box — the de-box no longer lives here.
2970
+ if (deBoxEligible(ref)) {
2971
+ // a record-containing top returns its wire (ScriptedWireRow / Vec / Option / BTreeMap) — the
2972
+ // covered runner de-boxes each record. #129: an arr node with an `items` port echoes the NATIVE
2973
+ // port array, serialized back to Vec<ScriptedWireRow> so the de-box round-trips.
2974
+ const top = recordTop(ref);
2975
+ const wireT = (() => {
2976
+ switch (top.kind) {
2977
+ case "named":
2978
+ return "ScriptedWireRow";
2979
+ case "arr":
2980
+ return "Vec<ScriptedWireRow>";
2981
+ case "opt":
2982
+ return "Option<ScriptedWireRow>";
2983
+ case "map":
2984
+ return "std::collections::BTreeMap<String, ScriptedWireRow>";
2985
+ }
2986
+ })();
2987
+ // echo-into-record (#141 fixtures): a named de-box node whose result WRAPS an input port — the
2988
+ // scripted leaf echoes port P into the record's single field, so the input-port value flows into
2989
+ // the de-boxed result and the runtime non-vacuity #139/#143/#144 assert survives the record wrap.
2990
+ let namedEcho = "";
2991
+ if (top.kind === "named") {
2992
+ const decl = findDecl(plan, top.innerName);
2993
+ const recordRust = renderTypeRef(ref);
2994
+ const field = decl !== undefined && decl.fields.length === 1 ? decl.fields[0] : undefined;
2995
+ const fieldRust = field !== undefined ? renderTypeRef(field.type) : null;
2996
+ for (const pn of Object.keys(node.ports)) {
2997
+ let portRust = null;
2998
+ try {
2999
+ portRust = portFieldRustType(node.ports[pn], c, plan, typedNodes, `echo port '${pn}'`);
3000
+ }
3001
+ catch {
3002
+ portRust = null;
3003
+ }
3004
+ if (portRust === recordRust) {
3005
+ // case 2: the port IS the whole record → echo it DIRECTLY (no field wrap), matching
3006
+ // run_behavior's plain echo:port of a whole-record port.
3007
+ const own = typeRefIsCopy(ref) ? `ports.${portFieldName(pn)}` : `ports.${portFieldName(pn)}.clone()`;
3008
+ namedEcho += ` if echo == ${rustStrLit(pn)} {\n let tv = ${own};\n return Ok(ScriptedWireRow::new(${serializeTyped("tv", ref, plan)}));\n }\n`;
3009
+ }
3010
+ else if (fieldRust !== null && field !== undefined && portRust === fieldRust) {
3011
+ // case 1: a scalar port wraps into the record's single field ({v: ports.X}).
3012
+ const own = typeRefIsCopy(field.type) ? `ports.${portFieldName(pn)}` : `ports.${portFieldName(pn)}.clone()`;
3013
+ namedEcho += ` if echo == ${rustStrLit(pn)} {\n let tv = ${own};\n return Ok(ScriptedWireRow::new(Value::Obj(vec![(${rustStrLit(field.name)}.to_string(), ${serializeTyped("tv", field.type, plan)})])));\n }\n`;
3014
+ }
3015
+ }
3016
+ }
3017
+ const canEcho = (top.kind === "arr" && Object.prototype.hasOwnProperty.call(node.ports, "items")) || namedEcho !== "";
3018
+ const portsParam = canEcho ? "ports" : "_ports";
3019
+ const okBind = canEcho ? "(is_err, err, ok, echo)" : "(is_err, err, ok, _echo)";
3020
+ let body;
3021
+ if (top.kind === "named") {
3022
+ body = `${namedEcho} Ok(ScriptedWireRow::new(ok.unwrap_or(Value::Null)))`;
3023
+ }
3024
+ else if (top.kind === "arr") {
3025
+ const elemRef = ref.elem;
3026
+ const itemsF = portFieldName("items");
3027
+ const echoBranch = canEcho
3028
+ ? ` if echo == "items" {\n let wires: Vec<ScriptedWireRow> = ports.${itemsF}.iter().map(|e| { let tv = e.clone(); ScriptedWireRow::new(${serializeTyped("tv", elemRef, plan)}) }).collect();\n return Ok(wires);\n }\n`
3029
+ : "";
3030
+ body = `${echoBranch} let arr = match ok { Some(Value::Arr(a)) => a, _ => Vec::new() };\n Ok(arr.into_iter().map(ScriptedWireRow::new).collect())`;
3031
+ }
3032
+ else if (top.kind === "opt") {
3033
+ body = ` match ok {\n None | Some(Value::Null) => Ok(None),\n Some(v) => Ok(Some(ScriptedWireRow::new(v))),\n }`;
3034
+ }
3035
+ else {
3036
+ body = ` let obj = match ok { Some(Value::Obj(pairs)) => pairs, _ => Vec::new() };\n Ok(obj.into_iter().map(|(k, v)| (k, ScriptedWireRow::new(v))).collect())`;
3037
+ }
3038
+ methods.push(` ${fnKw} ${nodeMethodName(node.id)}(&self, ${portsParam}: &${portsStructName(c.name, node.id)}, _bound: ${bt}) -> Result<${wireT}, BehaviorError> {
3039
+ let ${okBind} = self.next(${rustStrLit(node.component)}).ok_or_else(|| unknown_component(${rustStrLit(node.component)}))?;
3040
+ if is_err {
3041
+ return Err(leaf_failure(err));
3042
+ }
3043
+ ${body}
3044
+ }`);
3045
+ continue;
3046
+ }
2565
3047
  const decode = emitDecodeRow(c.name, node.id, rowT, ref, plan, emittedDecode, decodeFns, "");
2566
- // #129 (TEST glue): support the reference scriptedHandlers' echo forms (PROTOCOL.md §3.5) the
2567
- // adapter normalizes `{"echo":"port","port":"X"}` / `{"echo":"items"}` to the port NAME, and the
2568
- // scripted handler
2569
- // ECHOES a NATIVE port back as its result. This makes a port-plane pin non-vacuous: the node's
2570
- // observed result IS the native value that flowed into its ports struct, so a dropped / re-boxed /
2571
- // zero-defaulted port diverges from run_behavior instead of passing. A port is echoable when its
2572
- // native type IS the node's outType (that is exactly when the row's `val` can hold it); `ports` is
2573
- // bound only when at least one port qualifies.
2574
- const echoable = echoablePorts(c, node, ref, plan, typedNodes);
2575
- const portsParam = echoable.length > 0 ? "ports" : "_ports";
2576
- // a Copy port (i64/f64/bool/Option<scalar>) is read directly cloning it trips clippy clone_on_copy.
2577
- const echoRead = (p) => `${portsParam}.${portFieldName(p)}${typeRefIsCopy(ref) ? "" : ".clone()"}`;
2578
- const echoBranch = echoable
2579
- .map((p) => `\n if echo == ${rustStrLit(p)} {\n return Some(${rowT} { val: ${echoRead(p)}, ..Default::default() });\n }`)
2580
- .join("");
2581
- const okBind = echoable.length > 0 ? "(is_err, err, ok, echo)" : "(is_err, err, ok, _echo)";
2582
- methods.push(` ${fnKw} ${nodeMethodName(node.id)}(&self, ${portsParam}: &${portsStructName(c.name, node.id)}, _bound: ${bt}) -> Option<${rowT}> {
2583
- let ${okBind} = self.next(${rustStrLit(node.component)})?;
3048
+ // #129 (TEST glue): if this node has an `items` port and its outType is a bare arr, support the
3049
+ // reference scriptedHandlers' `echo === "items"` the scripted handler ECHOES the NATIVE `f_items`
3050
+ // port back as its result. This makes the node→leaf array dataflow test non-vacuous: the leaf's
3051
+ // observed result is exactly the native array that flowed into its ports struct, so a dropped or
3052
+ // boxed native array would diverge from run_behavior. `ports` is bound only on the echo path.
3053
+ const canEcho = ref.kind === "arr" && Object.prototype.hasOwnProperty.call(node.ports, "items");
3054
+ const portsParam = canEcho ? "ports" : "_ports";
3055
+ const echoBranch = canEcho
3056
+ ? `\n if echo == "items" {\n return Ok(${rowT} { val: ${portsParam}.${portFieldName("items")}.clone() });\n }`
3057
+ : "";
3058
+ const okBind = canEcho ? "(is_err, err, ok, echo)" : "(is_err, err, ok, _echo)";
3059
+ methods.push(` ${fnKw} ${nodeMethodName(node.id)}(&self, ${portsParam}: &${portsStructName(c.name, node.id)}, _bound: ${bt}) -> Result<${rowT}, BehaviorError> {
3060
+ let ${okBind} = self.next(${rustStrLit(node.component)}).ok_or_else(|| unknown_component(${rustStrLit(node.component)}))?;
2584
3061
  if is_err {
2585
- return Some(${rowT} { is_error: true, err, ..Default::default() });
3062
+ return Err(leaf_failure(err));
2586
3063
  }${echoBranch}
2587
3064
  let v = ok.unwrap_or(Value::Null);
2588
- Some(${decode}(&v))
3065
+ Ok(${decode}(&v))
2589
3066
  }`);
2590
3067
  }
2591
- traitImpls.push(`impl ${handlerTraitName(c.name)} for ScriptedNativeRaw {\n${methods.join("\n")}\n}`);
3068
+ const cHasDeBox = c.body.some((n) => {
3069
+ if ("cond" in n)
3070
+ return false;
3071
+ if ("map" in n || "fanout" in n)
3072
+ return mapFanoutElemDeBoxEligible(n, typedNodes, plan);
3073
+ return deBoxEligible(typedNodes.get(n.id));
3074
+ });
3075
+ const wireAssoc = cHasDeBox ? ` type Wire = ScriptedWireRow;\n` : "";
3076
+ traitImpls.push(`impl ${handlerTraitName(c.name)} for ScriptedNativeRaw {\n${wireAssoc}${methods.join("\n")}\n}`);
2592
3077
  }
2593
3078
  const arms = native
2594
3079
  .map((c) => {
@@ -2637,7 +3122,7 @@ impl ScriptedNativeRaw {
2637
3122
  }
2638
3123
 
2639
3124
  // drain one scripted outcome for a component (last entry repeats — mirrors the reference scripted
2640
- // handlers); None when the component has no scripted queue (fail-closed). Returns (is_error, err,
3125
+ // handlers); None when the component has no scripted queue. Returns (is_error, err,
2641
3126
  // ok_value, echo). The bc#87 parallel-stage runner calls node_* from multiple threads, so the drain
2642
3127
  // is Mutex-guarded. #129: \`echo\` carries the reference scriptedHandlers' \`echo\` directive (e.g.
2643
3128
  // "items") so a scripted node can ECHO a named input PORT back — used to make the node→leaf array
@@ -2647,13 +3132,11 @@ impl ScriptedNativeRaw {
2647
3132
  let mut queues = self.queues.lock().unwrap();
2648
3133
  let (queue, single) = queues.get_mut(component)?;
2649
3134
  let raw = if *single || queue.len() <= 1 { queue[0].clone() } else { queue.remove(0) };
2650
- // PROTOCOL.md §3.5 echo forms, normalized to the port NAME: {"echo":"port","port":"X"} -> "X"
2651
- // ({"echo":"items"} is already that shape). The generated node method just compares this
2652
- // against its echoable port names.
2653
- let echo = match raw.get("echo").and_then(|e| e.as_str()) {
2654
- Some("port") => raw.get("port").and_then(|p| p.as_str()).unwrap_or("").to_string(),
2655
- Some(other) => other.to_string(),
2656
- None => String::new(),
3135
+ // normalize echo:port to the port NAME X so a de-box handler's echo comparison fires;
3136
+ // echo:items already names the port directly.
3137
+ let echo = {
3138
+ let e = raw.get("echo").and_then(|e| e.as_str()).unwrap_or("");
3139
+ if e == "port" { raw.get("port").and_then(|p| p.as_str()).unwrap_or("").to_string() } else { e.to_string() }
2657
3140
  };
2658
3141
  if let Some(okv) = raw.get("ok") {
2659
3142
  let v = behavior_contracts::decode_value(okv).expect("decode ok");
@@ -2681,9 +3164,27 @@ use behavior_contracts::Value;
2681
3164
  use super::*;
2682
3165
 
2683
3166
  // bridge the covered module's LOCAL BehaviorError (super::BehaviorError) to the bc-runtime failure type
2684
- // the conformance harness reads (same code + message) — the ONLY place the two meet (test glue).
3167
+ // the conformance harness reads (same code + message + structured detail) — the ONLY place the two meet
3168
+ // (test glue). The de-box's structured Error Value is carried over so the harness can assert it.
2685
3169
  fn to_rt_err(e: BehaviorError) -> behavior_contracts::BehaviorError {
2686
- behavior_contracts::BehaviorError::new(e.code, e.message)
3170
+ let mut rt = behavior_contracts::BehaviorError::new(e.code, e.message);
3171
+ if let Some(d) = e.detail {
3172
+ let d = *d;
3173
+ rt.detail = Some(Box::new(behavior_contracts::ErrorDetail {
3174
+ kind: d.kind.map(|k| match k {
3175
+ ErrorKind::TypeMismatch => behavior_contracts::ErrorKind::TypeMismatch,
3176
+ ErrorKind::MissingField => behavior_contracts::ErrorKind::MissingField,
3177
+ ErrorKind::Overflow => behavior_contracts::ErrorKind::Overflow,
3178
+ }),
3179
+ model: d.model,
3180
+ field: d.field,
3181
+ expected_type: d.expected_type,
3182
+ actual_wire_type: d.actual_wire_type,
3183
+ raw_value: d.raw_value,
3184
+ context: d.context,
3185
+ }));
3186
+ }
3187
+ rt
2687
3188
  }
2688
3189
  ${anyAsyncRunner
2689
3190
  ? `// bc#97 (TEST glue) — a minimal std-only block-on that drives an async runner Future to completion so
@@ -2723,6 +3224,9 @@ ${decodeFns.join("\n\n")}
2723
3224
  // input decoders (generic &[(String,Value)] -> concrete InNR_<comp>; TEST glue, one decode at the boundary).
2724
3225
  ${inDecoders}
2725
3226
 
3227
+ // scripted wire adapter (TEST glue) — wraps a scripted Value as a WireRow the generated de-box probes.
3228
+ ${wireAdapter}
3229
+
2726
3230
  ${adapter}
2727
3231
 
2728
3232
  // scripted handler trait impls (one per covered component — the concrete node_* seam, test glue).
@@ -2745,10 +3249,8 @@ ${arms}
2745
3249
  }
2746
3250
  /** emitInDecoder — a TEST-glue decoder `decode_InNR_<comp>(input) -> Result<InNR_<comp>, BehaviorError>`.
2747
3251
  * Reads each declared input port from the generic &[(String,Value)] into the concrete field (scalar →
2748
- * typed read; opt → Some/None; Value-typed → the raw Value clone). A key that is absent, or present as
2749
- * `Value::Null`, leaves the field's Default for an OPTIONAL port that is `None` (absent), which is the
2750
- * same value `refCore` observes for the `null` binding run_behavior is given. Lives ONLY in the observe
2751
- * companion. */
3252
+ * typed read; Value-typed → the raw Value clone). A missing key leaves Default (every covered input is
3253
+ * required + supplied by the corpus). Lives ONLY in the observe companion. */
2752
3254
  function emitInDecoder(comp, plan) {
2753
3255
  const name = inStructName(comp.name);
2754
3256
  const ports = Object.keys(comp.inputPorts ?? {});
@@ -2770,15 +3272,7 @@ function emitInDecoder(comp, plan) {
2770
3272
  const et = schema.elemType;
2771
3273
  const head = i === 0 ? " if" : " } else if";
2772
3274
  lines.push(`${head} k == ${rustStrLit(p)} {`);
2773
- if (inputPortIsOptional(schema)) {
2774
- // An OPTIONAL port decodes into its native Option<T>. materializeExpr's opt de-box IS the wire
2775
- // rule: `Value::Null` → None (absent), any other value → Some(<inner>). A key that never appears
2776
- // keeps the struct Default (None) — the same absent value. Checked FIRST: optionality wraps every
2777
- // inner type (an optional array is Option<Vec<T>>, not Vec<T>).
2778
- const optRef = inputPortTypeRef(schema, plan);
2779
- lines.push(` in_.${field} = ${materializeExpr("v", optRef, plan)};`);
2780
- }
2781
- else if ((schema?.type === "array" || schema?.type === "arr") && et !== undefined) {
3275
+ if ((schema?.type === "array" || schema?.type === "arr") && et !== undefined) {
2782
3276
  // #108: decode the boxed Value::Arr into the native Vec<ElemT> (test glue — materialize each elem).
2783
3277
  const elemRef = deriveTypeRef(et, plan);
2784
3278
  const elemTy = renderTypeRef(elemRef);
@@ -2798,6 +3292,11 @@ function emitInDecoder(comp, plan) {
2798
3292
  const mapRef = { kind: "map", value: deriveTypeRef(et, plan) };
2799
3293
  lines.push(` in_.${field} = ${materializeExpr("v", mapRef, plan)};`);
2800
3294
  }
3295
+ else if (inputPortIsOptional(schema)) {
3296
+ // #139: an OPTIONAL scalar port decodes into Option<T> via the inputPortTypeRef SSoT (a present key
3297
+ // materializes Some(v); an absent key never enters this block, leaving the Default None).
3298
+ lines.push(` in_.${field} = ${materializeExpr("v", inputPortTypeRef(schema, plan), plan)};`);
3299
+ }
2801
3300
  else if (scalar === undefined) {
2802
3301
  lines.push(` in_.${field} = v.clone();`);
2803
3302
  }
@@ -2816,16 +3315,6 @@ function emitInDecoder(comp, plan) {
2816
3315
  lines.push(`}`);
2817
3316
  return lines.join("\n");
2818
3317
  }
2819
- /** echoablePorts (TEST glue) — the node's ports whose NATIVE type is exactly the node's outType, in
2820
- * declaration order. Those are the ports a scripted `echo: "<port>"` can return as the row's `val`. A
2821
- * port with a different type is not echoable (the row could not hold it), and a NAMED outType has no
2822
- * `val` at all (its row carries the struct's fields flattened — see emitDecodeRow). */
2823
- function echoablePorts(comp, node, ref, plan, typedNodes) {
2824
- if (ref.kind === "named")
2825
- return [];
2826
- const want = renderTypeRef(ref);
2827
- return Object.keys(node.ports).filter((p) => portFieldRustType(node.ports[p], comp.inputPorts, `port '${p}'`, undefined, plan, typedNodes) === want);
2828
- }
2829
3318
  /** emitDecodeRow — emit (once, deduped) a decoder `decode_row_<comp>_<node><suffix>(v: &Value) -> <rowT>`
2830
3319
  * that materializes the scripted ok Value into the concrete row struct. Named: reuse the Value->struct
2831
3320
  * marshaller + move fields; scalar/arr/opt: materialize into .val. TEST glue only. */
@@ -2840,12 +3329,12 @@ function emitDecodeRow(compName, nodeId, rowT, ref, plan, emitted, out, suffix)
2840
3329
  const decl = findDecl(plan, ref.name);
2841
3330
  lines.push(` let t = marshal_${ref.name}(v).expect("scripted row decode");`);
2842
3331
  const inits = decl.fields.map((f) => `${rustFieldName(f.name)}: t.${rustFieldName(f.name)}`).join(", ");
2843
- lines.push(` ${rowT} { ${inits}, ..Default::default() }`);
3332
+ lines.push(` ${rowT} { ${inits} }`);
2844
3333
  }
2845
3334
  else {
2846
3335
  const mat = materializeExpr("v", ref, plan);
2847
3336
  lines.push(` let val = (|| -> Result<${renderTypeRef(ref)}, BehaviorError> { Ok(${mat}) })().expect("scripted row decode");`);
2848
- lines.push(` ${rowT} { val, ..Default::default() }`);
3337
+ lines.push(` ${rowT} { val }`);
2849
3338
  }
2850
3339
  lines.push(`}`);
2851
3340
  out.push(lines.join("\n"));