1use crate::expr::FORBIDDEN_OBJECT_KEY;
10use crate::value::Value;
11use serde_json::Value as J;
12
13#[derive(Debug, Clone)]
14pub struct PortabilityError {
15 pub path: String,
16 pub message: String,
17}
18
19impl std::fmt::Display for PortabilityError {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 write!(f, "Portability Guard at {}: {}", self.path, self.message)
22 }
23}
24impl std::error::Error for PortabilityError {}
25
26pub fn assert_portable(value: &Value) -> Result<(), PortabilityError> {
29 assert_portable_at(value, "$")
30}
31
32fn assert_portable_at(value: &Value, path: &str) -> Result<(), PortabilityError> {
33 match value {
34 Value::Float(f) if !f.is_finite() => Err(PortabilityError {
35 path: path.to_string(),
36 message: format!("non-finite float {f} cannot be placed in portable IR"),
37 }),
38 Value::Arr(a) => {
39 for (i, v) in a.iter().enumerate() {
40 assert_portable_at(v, &format!("{path}[{i}]"))?;
41 }
42 Ok(())
43 }
44 Value::Obj(o) => {
45 for (k, v) in o {
46 assert_portable_at(v, &format!("{path}.{k}"))?;
47 }
48 Ok(())
49 }
50 _ => Ok(()),
51 }
52}
53
54pub const PORTABLE_EXPR_OPERATORS: &[&str] = &[
56 "int", "float", "ref", "refOpt", "obj", "arr", "add", "sub", "mul", "neg", "div", "mod",
57 "concat", "eq", "ne", "lt", "le", "gt", "ge", "and", "or", "not", "coalesce", "cond", "len",
58];
59
60fn perr<T>(path: &str, message: impl Into<String>) -> Result<T, PortabilityError> {
61 Err(PortabilityError {
62 path: path.to_string(),
63 message: message.into(),
64 })
65}
66
67pub const PORTABLE_SCALAR_TYPES: &[&str] = &["string", "int", "float", "bool", "null"];
71
72pub const PORTABLE_VALUE_TYPE: &str = "value";
78
79pub const PORTABLE_COMPOUND_KINDS: &[&str] = &["opt", "arr", "map", "obj"];
82
83pub fn portable_type_has_value(t: &J) -> bool {
88 if t.as_str() == Some(PORTABLE_VALUE_TYPE) {
89 return true;
90 }
91 let Some(o) = t.as_object() else { return false };
92 for k in ["opt", "arr", "map"] {
93 if let Some(v) = o.get(k) {
94 return portable_type_has_value(v);
95 }
96 }
97 match o.get("obj").and_then(|f| f.as_object()) {
98 Some(fields) => fields.values().any(portable_type_has_value),
99 None => false,
100 }
101}
102
103pub fn wire_passthrough_out_type_ok(out_type: Option<&J>) -> bool {
107 match out_type {
108 Some(t) if t.as_str() == Some(PORTABLE_VALUE_TYPE) => true,
109 Some(t) => match t.as_object() {
110 Some(o) => {
111 o.len() == 1 && o.get("arr").and_then(|v| v.as_str()) == Some(PORTABLE_VALUE_TYPE)
112 }
113 None => false,
114 },
115 None => false,
116 }
117}
118
119fn assert_portable_type_notation(node: &J, path: &str) -> Result<(), PortabilityError> {
125 match node {
126 J::String(s) => {
127 if !PORTABLE_SCALAR_TYPES.contains(&s.as_str()) && s != PORTABLE_VALUE_TYPE {
128 return perr(path, format!("unknown scalar type '{s}' (fail-closed)"));
129 }
130 Ok(())
131 }
132 J::Object(o) => {
133 let kind = PORTABLE_COMPOUND_KINDS
135 .iter()
136 .find(|k| o.contains_key(**k))
137 .copied();
138 let kind = match kind {
139 Some(k) if k == "obj" || o.len() == 1 => k,
140 _ => {
141 return perr(
142 path,
143 format!(
144 "compound type must be a single-key object (opt|arr|map|obj; obj may carry an optional 'name'), got {} keys (fail-closed)",
145 o.len()
146 ),
147 );
148 }
149 };
150 let arg = o.get(kind).unwrap();
151 match kind {
152 "opt" | "arr" | "map" => assert_portable_type_notation(arg, &format!("{path}.{kind}")),
153 "obj" => {
154 for k in o.keys() {
155 if k != "obj" && k != "name" {
156 return perr(
157 path,
158 format!("{{obj}} type allows only 'obj' and optional 'name', got '{k}' (fail-closed)"),
159 );
160 }
161 }
162 if let Some(name_v) = o.get("name") {
163 let ok = name_v.as_str().is_some_and(|s| {
164 let mut cs = s.chars();
165 matches!(cs.next(), Some(c) if c == '_' || c.is_ascii_alphabetic())
166 && cs.all(|c| c == '_' || c.is_ascii_alphanumeric())
167 });
168 if !ok {
169 return perr(
170 &format!("{path}.name"),
171 "obj type 'name' must be a non-empty identifier (fail-closed)".to_string(),
172 );
173 }
174 }
175 let Some(fields) = arg.as_object() else {
176 return perr(
177 &format!("{path}.obj"),
178 "{obj: ...} type expects a field->type object (fail-closed)".to_string(),
179 );
180 };
181 for (k, v) in fields {
182 if k == FORBIDDEN_OBJECT_KEY {
183 return perr(
184 &format!("{path}.obj"),
185 format!("object type key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
186 );
187 }
188 assert_portable_type_notation(v, &format!("{path}.obj.{k}"))?;
189 }
190 Ok(())
191 }
192 other => perr(
193 path,
194 format!("unknown compound type kind '{other}' (opt|arr|map|obj) (fail-closed)"),
195 ),
196 }
197 }
198 _ => perr(
199 path,
200 "type notation must be a scalar-type string or a single-key {opt|arr|map|obj} object (fail-closed)".to_string(),
201 ),
202 }
203}
204
205pub fn assert_portable_component_graph(ir: &J) -> Result<(), PortabilityError> {
214 assert_portable_cg_at(ir, "$")
215}
216
217fn assert_portable_cg_at(ir: &J, path: &str) -> Result<(), PortabilityError> {
218 if !matches!(
221 ir.get("irVersion").and_then(|v| v.as_i64()),
222 Some(2) | Some(3)
223 ) {
224 return perr(
225 &format!("{path}.irVersion"),
226 "irVersion must be 2 or 3 (v3 adds optional nominal type names) (fail-closed)",
227 );
228 }
229 let components = ir
230 .get("components")
231 .and_then(|c| c.as_array())
232 .ok_or_else(|| PortabilityError {
233 path: format!("{path}.components"),
234 message: "IR.components must be an array".into(),
235 })?;
236 for (ci, c) in components.iter().enumerate() {
237 assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
238 }
239 Ok(())
240}
241
242fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
243 if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
244 return perr(&format!("{path}.name"), "component.name must be a string");
245 }
246 let body = c
247 .get("body")
248 .and_then(|b| b.as_array())
249 .ok_or_else(|| PortabilityError {
250 path: format!("{path}.body"),
251 message: "component.body must be an array".into(),
252 })?;
253 for (ni, n) in body.iter().enumerate() {
254 assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
255 }
256 assert_portable_expr(
257 c.get("output").unwrap_or(&J::Null),
258 &format!("{path}.output"),
259 )?;
260 if let Some(ot) = c.get("outputType") {
262 assert_portable_type_notation(ot, &format!("{path}.outputType"))?;
263 if portable_type_has_value(ot) {
267 return perr(
268 &format!("{path}.outputType"),
269 "component declares the opaque wire value in its output type — output-side opacity is a NODE's wire passthrough (wirePassthrough) and a component carries no such flag, so the caller would get no de-box contract (strict-typing-and-debox.md §D4b, fail-closed)",
270 );
271 }
272 }
273 Ok(())
274}
275
276fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
277 assert_portable_body_node_kind(n, path)?;
278 if let Some(ot) = n.get("outType") {
281 assert_portable_type_notation(ot, &format!("{path}.outType"))?;
282 }
283 let out_type = n.get("outType");
288 match n.get("wirePassthrough") {
289 Some(J::Bool(_)) | None => {}
290 Some(_) => {
291 return perr(
292 &format!("{path}.wirePassthrough"),
293 "'wirePassthrough' must be a boolean",
294 );
295 }
296 }
297 let passthrough = n.get("wirePassthrough").and_then(|v| v.as_bool()) == Some(true);
298 if passthrough && !wire_passthrough_out_type_ok(out_type) {
299 return perr(
300 &format!("{path}.outType"),
301 "an output-passthrough node (wirePassthrough:true) must declare outType 'value' or {arr:\"value\"} (the opaque-wire shape) (fail-closed)",
302 );
303 }
304 let constructs_value = n
308 .get("map")
309 .is_some_and(|m| m.get("component").is_none() && m.get("transform").is_some());
310 if !constructs_value
311 && out_type.is_some_and(portable_type_has_value)
312 && !(passthrough && wire_passthrough_out_type_ok(out_type))
313 {
314 return perr(
315 &format!("{path}.outType"),
316 "an output type carrying the opaque wire value must BE the passthrough shape ('value' or {arr:\"value\"}) and the node must declare wirePassthrough:true — a nested/unflagged opaque output gives the caller no de-box contract (strict-typing-and-debox.md §D4b, fail-closed)",
317 );
318 }
319 Ok(())
320}
321
322fn assert_portable_body_node_kind(n: &J, path: &str) -> Result<(), PortabilityError> {
323 if let Some(m) = n.get("map") {
324 let is_transform = m.get("transform").is_some();
328 if is_transform && (m.get("component").is_some() || m.get("ports").is_some()) {
329 return perr(
330 &format!("{path}.map.transform"),
331 "'transform' (a pure element expression) and 'component'/'ports' (a Component call) are exclusive",
332 );
333 }
334 if is_transform {
335 assert_portable_expr(
336 m.get("transform").unwrap(),
337 &format!("{path}.map.transform"),
338 )?;
339 for k in [
340 "into",
341 "batched",
342 "elementPolicy",
343 "policy",
344 "portSchemas",
345 "leafSymbol",
346 ] {
347 if m.get(k).is_some() {
348 return perr(
349 &format!("{path}.map.{k}"),
350 "this key has no meaning on a pure-expression map — no Component is called, so there is no per-element outcome",
351 );
352 }
353 }
354 } else {
355 require_string_component(m.get("component"), &format!("{path}.map.component"))?;
356 assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
357 }
358 assert_portable_expr(
359 m.get("over").unwrap_or(&J::Null),
360 &format!("{path}.map.over"),
361 )?;
362 if let Some(w) = m.get("when") {
365 assert_portable_expr(w, &format!("{path}.map.when"))?;
366 }
367 if let Some(into) = m.get("into") {
368 if !into.is_string() {
369 return perr(&format!("{path}.map.into"), "'into' must be a string key");
370 }
371 }
372 if let Some(b) = m.get("batched") {
373 if !b.is_boolean() {
374 return perr(
375 &format!("{path}.map.batched"),
376 "'batched' must be a boolean",
377 );
378 }
379 }
380 if let Some(ep) = m.get("elementPolicy") {
383 let eps = ep.as_str();
384 if eps != Some("error") && eps != Some("skip") {
385 return perr(
386 &format!("{path}.map.elementPolicy"),
387 "'elementPolicy' must be \"error\" or \"skip\"",
388 );
389 }
390 if eps == Some("skip") && m.get("batched").and_then(|b| b.as_bool()) == Some(true) {
391 return perr(
392 &format!("{path}.map.elementPolicy"),
393 "'elementPolicy' \"skip\" needs a per-element Failure, but a batched map takes ONE outcome for the whole batch",
394 );
395 }
396 }
397 if is_transform {
399 Ok(())
400 } else {
401 assert_port_schemas_present(m, &format!("{path}.map")) }
403 } else if let Some(fo) = n.get("fanout") {
404 require_string_component(fo.get("component"), &format!("{path}.fanout.component"))?;
409 assert_portable_expr(
410 fo.get("over").unwrap_or(&J::Null),
411 &format!("{path}.fanout.over"),
412 )?;
413 assert_portable_ports(fo.get("ports"), &format!("{path}.fanout.ports"))?;
414 for (field, label) in [("as", "an element-binding"), ("dedupeKey", "a dedupe key")] {
415 match fo.get(field).and_then(|v| v.as_str()) {
416 Some(sv) if !sv.is_empty() => {}
417 _ => {
418 return perr(
419 &format!("{path}.fanout.{field}"),
420 format!("fanout '{field}' must be a non-empty {label} string"),
421 );
422 }
423 }
424 }
425 match fo.get("drop").and_then(|v| v.as_str()) {
426 Some("dangling") | Some("none") => {}
427 _ => {
428 return perr(
429 &format!("{path}.fanout.drop"),
430 "fanout 'drop' must be \"dangling\" or \"none\" (fail-closed)",
431 );
432 }
433 }
434 if let Some(is) = fo.get("implicitSource") {
435 if !is.is_string() {
436 return perr(
437 &format!("{path}.fanout.implicitSource"),
438 "fanout 'implicitSource' must be a string or absent",
439 );
440 }
441 }
442 if fo.get("relationKind").and_then(|v| v.as_str()) != Some("connection") {
443 return perr(
444 &format!("{path}.fanout.relationKind"),
445 "fanout 'relationKind' must be \"connection\" (fail-closed)",
446 );
447 }
448 assert_port_schemas_present(fo, &format!("{path}.fanout")) } else if let Some(co) = n.get("cond") {
450 assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
451 assert_portable_expr(
452 co.get("then").unwrap_or(&J::Null),
453 &format!("{path}.cond.then"),
454 )?;
455 assert_portable_expr(
456 co.get("else").unwrap_or(&J::Null),
457 &format!("{path}.cond.else"),
458 )
459 } else if n.get("component").is_some() {
460 require_string_component(n.get("component"), &format!("{path}.component"))?;
461 assert_portable_ports(n.get("ports"), &format!("{path}.ports"))?;
462 assert_port_schemas_present(n, path)
463 } else {
464 perr(
465 path,
466 "unknown body node kind (not componentRef/map/cond/fanout)",
467 )
468 }
469}
470
471fn assert_port_schemas_present(holder: &J, path: &str) -> Result<(), PortabilityError> {
476 match holder.get("portSchemas") {
477 Some(ps) if ps.is_object() => Ok(()),
478 _ => perr(
479 &format!("{path}.portSchemas"),
480 "portSchemas is required (irVersion 2 carries the port type contract) and must be an object",
481 ),
482 }
483}
484
485fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
486 match v {
487 Some(J::String(_)) => Ok(()),
488 _ => perr(path, "'component' must be a string catalog reference"),
489 }
490}
491
492fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
493 let obj = ports
494 .and_then(|p| p.as_object())
495 .ok_or_else(|| PortabilityError {
496 path: path.to_string(),
497 message: "ports must be an object".into(),
498 })?;
499 for (k, v) in obj {
500 assert_portable_expr(v, &format!("{path}.{k}"))?;
501 }
502 Ok(())
503}
504
505fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
506 match node {
507 J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
508 J::Array(a) => {
509 for (i, e) in a.iter().enumerate() {
510 assert_portable_expr(e, &format!("{path}[{i}]"))?;
511 }
512 Ok(())
513 }
514 J::Object(o) => {
515 if o.len() == 1 {
516 let (op, arg) = o.iter().next().unwrap();
517 if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
518 return perr(path, format!("unknown operator '{op}' (fail-closed)"));
519 }
520 if op == "obj" {
524 let Some(fields) = arg.as_object() else {
525 return perr(
526 &format!("{path}.obj"),
527 "{obj: ...} expects an object".to_string(),
528 );
529 };
530 for (k, v) in fields {
531 if k == FORBIDDEN_OBJECT_KEY {
535 return perr(
536 &format!("{path}.obj"),
537 format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
538 );
539 }
540 assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
541 }
542 return Ok(());
543 }
544 assert_portable_expr(arg, &format!("{path}.{op}"))
545 } else {
546 for (k, v) in o {
547 assert_portable_expr(v, &format!("{path}.{k}"))?;
548 }
549 Ok(())
550 }
551 }
552 }
553}