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 {
109 out_type.is_some_and(portable_type_has_value)
110}
111
112fn assert_portable_type_notation(node: &J, path: &str) -> Result<(), PortabilityError> {
118 match node {
119 J::String(s) => {
120 if !PORTABLE_SCALAR_TYPES.contains(&s.as_str()) && s != PORTABLE_VALUE_TYPE {
121 return perr(path, format!("unknown scalar type '{s}' (fail-closed)"));
122 }
123 Ok(())
124 }
125 J::Object(o) => {
126 let kind = PORTABLE_COMPOUND_KINDS
128 .iter()
129 .find(|k| o.contains_key(**k))
130 .copied();
131 let kind = match kind {
132 Some(k) if k == "obj" || o.len() == 1 => k,
133 _ => {
134 return perr(
135 path,
136 format!(
137 "compound type must be a single-key object (opt|arr|map|obj; obj may carry an optional 'name'), got {} keys (fail-closed)",
138 o.len()
139 ),
140 );
141 }
142 };
143 let arg = o.get(kind).unwrap();
144 match kind {
145 "opt" | "arr" | "map" => assert_portable_type_notation(arg, &format!("{path}.{kind}")),
146 "obj" => {
147 for k in o.keys() {
148 if k != "obj" && k != "name" {
149 return perr(
150 path,
151 format!("{{obj}} type allows only 'obj' and optional 'name', got '{k}' (fail-closed)"),
152 );
153 }
154 }
155 if let Some(name_v) = o.get("name") {
156 let ok = name_v.as_str().is_some_and(|s| {
157 let mut cs = s.chars();
158 matches!(cs.next(), Some(c) if c == '_' || c.is_ascii_alphabetic())
159 && cs.all(|c| c == '_' || c.is_ascii_alphanumeric())
160 });
161 if !ok {
162 return perr(
163 &format!("{path}.name"),
164 "obj type 'name' must be a non-empty identifier (fail-closed)".to_string(),
165 );
166 }
167 }
168 let Some(fields) = arg.as_object() else {
169 return perr(
170 &format!("{path}.obj"),
171 "{obj: ...} type expects a field->type object (fail-closed)".to_string(),
172 );
173 };
174 for (k, v) in fields {
175 if k == FORBIDDEN_OBJECT_KEY {
176 return perr(
177 &format!("{path}.obj"),
178 format!("object type key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
179 );
180 }
181 assert_portable_type_notation(v, &format!("{path}.obj.{k}"))?;
182 }
183 Ok(())
184 }
185 other => perr(
186 path,
187 format!("unknown compound type kind '{other}' (opt|arr|map|obj) (fail-closed)"),
188 ),
189 }
190 }
191 _ => perr(
192 path,
193 "type notation must be a scalar-type string or a single-key {opt|arr|map|obj} object (fail-closed)".to_string(),
194 ),
195 }
196}
197
198pub fn assert_portable_component_graph(ir: &J) -> Result<(), PortabilityError> {
207 assert_portable_cg_at(ir, "$")
208}
209
210fn assert_portable_cg_at(ir: &J, path: &str) -> Result<(), PortabilityError> {
211 if !matches!(
214 ir.get("irVersion").and_then(|v| v.as_i64()),
215 Some(2) | Some(3)
216 ) {
217 return perr(
218 &format!("{path}.irVersion"),
219 "irVersion must be 2 or 3 (v3 adds optional nominal type names) (fail-closed)",
220 );
221 }
222 let components = ir
223 .get("components")
224 .and_then(|c| c.as_array())
225 .ok_or_else(|| PortabilityError {
226 path: format!("{path}.components"),
227 message: "IR.components must be an array".into(),
228 })?;
229 for (ci, c) in components.iter().enumerate() {
230 assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
231 }
232 Ok(())
233}
234
235fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
236 if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
237 return perr(&format!("{path}.name"), "component.name must be a string");
238 }
239 let body = c
240 .get("body")
241 .and_then(|b| b.as_array())
242 .ok_or_else(|| PortabilityError {
243 path: format!("{path}.body"),
244 message: "component.body must be an array".into(),
245 })?;
246 for (ni, n) in body.iter().enumerate() {
247 assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
248 }
249 assert_portable_expr(
250 c.get("output").unwrap_or(&J::Null),
251 &format!("{path}.output"),
252 )?;
253 if let Some(ot) = c.get("outputType") {
255 assert_portable_type_notation(ot, &format!("{path}.outputType"))?;
256 }
259 Ok(())
260}
261
262fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
263 assert_portable_body_node_kind(n, path)?;
264 if let Some(ot) = n.get("outType") {
267 assert_portable_type_notation(ot, &format!("{path}.outType"))?;
268 }
269 let out_type = n.get("outType");
274 match n.get("wirePassthrough") {
275 Some(J::Bool(_)) | None => {}
276 Some(_) => {
277 return perr(
278 &format!("{path}.wirePassthrough"),
279 "'wirePassthrough' must be a boolean",
280 );
281 }
282 }
283 let passthrough = n.get("wirePassthrough").and_then(|v| v.as_bool()) == Some(true);
284 if passthrough && !wire_passthrough_out_type_ok(out_type) {
285 return perr(
286 &format!("{path}.outType"),
287 "an output-passthrough node (wirePassthrough:true) must declare outType 'value' or {arr:\"value\"} (the opaque-wire shape) (fail-closed)",
288 );
289 }
290 let constructs_value = n
294 .get("map")
295 .is_some_and(|m| m.get("component").is_none() && m.get("transform").is_some());
296 if !constructs_value && out_type.is_some_and(portable_type_has_value) && !passthrough {
297 return perr(
298 &format!("{path}.outType"),
299 "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)",
300 );
301 }
302 Ok(())
303}
304
305fn assert_portable_body_node_kind(n: &J, path: &str) -> Result<(), PortabilityError> {
306 if let Some(m) = n.get("map") {
307 let is_transform = m.get("transform").is_some();
311 if is_transform && (m.get("component").is_some() || m.get("ports").is_some()) {
312 return perr(
313 &format!("{path}.map.transform"),
314 "'transform' (a pure element expression) and 'component'/'ports' (a Component call) are exclusive",
315 );
316 }
317 if is_transform {
318 assert_portable_expr(
319 m.get("transform").unwrap(),
320 &format!("{path}.map.transform"),
321 )?;
322 for k in [
323 "into",
324 "batched",
325 "elementPolicy",
326 "policy",
327 "portSchemas",
328 "leafSymbol",
329 ] {
330 if m.get(k).is_some() {
331 return perr(
332 &format!("{path}.map.{k}"),
333 "this key has no meaning on a pure-expression map — no Component is called, so there is no per-element outcome",
334 );
335 }
336 }
337 } else {
338 require_string_component(m.get("component"), &format!("{path}.map.component"))?;
339 assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
340 }
341 assert_portable_expr(
342 m.get("over").unwrap_or(&J::Null),
343 &format!("{path}.map.over"),
344 )?;
345 if let Some(w) = m.get("when") {
348 assert_portable_expr(w, &format!("{path}.map.when"))?;
349 }
350 if let Some(into) = m.get("into") {
351 if !into.is_string() {
352 return perr(&format!("{path}.map.into"), "'into' must be a string key");
353 }
354 }
355 if let Some(b) = m.get("batched") {
356 if !b.is_boolean() {
357 return perr(
358 &format!("{path}.map.batched"),
359 "'batched' must be a boolean",
360 );
361 }
362 }
363 if let Some(ep) = m.get("elementPolicy") {
366 let eps = ep.as_str();
367 if eps != Some("error") && eps != Some("skip") {
368 return perr(
369 &format!("{path}.map.elementPolicy"),
370 "'elementPolicy' must be \"error\" or \"skip\"",
371 );
372 }
373 if eps == Some("skip") && m.get("batched").and_then(|b| b.as_bool()) == Some(true) {
374 return perr(
375 &format!("{path}.map.elementPolicy"),
376 "'elementPolicy' \"skip\" needs a per-element Failure, but a batched map takes ONE outcome for the whole batch",
377 );
378 }
379 }
380 if is_transform {
382 Ok(())
383 } else {
384 assert_port_schemas_present(m, &format!("{path}.map")) }
386 } else if let Some(fo) = n.get("fanout") {
387 require_string_component(fo.get("component"), &format!("{path}.fanout.component"))?;
392 assert_portable_expr(
393 fo.get("over").unwrap_or(&J::Null),
394 &format!("{path}.fanout.over"),
395 )?;
396 assert_portable_ports(fo.get("ports"), &format!("{path}.fanout.ports"))?;
397 for (field, label) in [("as", "an element-binding"), ("dedupeKey", "a dedupe key")] {
398 match fo.get(field).and_then(|v| v.as_str()) {
399 Some(sv) if !sv.is_empty() => {}
400 _ => {
401 return perr(
402 &format!("{path}.fanout.{field}"),
403 format!("fanout '{field}' must be a non-empty {label} string"),
404 );
405 }
406 }
407 }
408 match fo.get("drop").and_then(|v| v.as_str()) {
409 Some("dangling") | Some("none") => {}
410 _ => {
411 return perr(
412 &format!("{path}.fanout.drop"),
413 "fanout 'drop' must be \"dangling\" or \"none\" (fail-closed)",
414 );
415 }
416 }
417 if let Some(is) = fo.get("implicitSource") {
418 if !is.is_string() {
419 return perr(
420 &format!("{path}.fanout.implicitSource"),
421 "fanout 'implicitSource' must be a string or absent",
422 );
423 }
424 }
425 if fo.get("relationKind").and_then(|v| v.as_str()) != Some("connection") {
426 return perr(
427 &format!("{path}.fanout.relationKind"),
428 "fanout 'relationKind' must be \"connection\" (fail-closed)",
429 );
430 }
431 assert_port_schemas_present(fo, &format!("{path}.fanout")) } else if let Some(co) = n.get("cond") {
433 assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
434 assert_portable_expr(
435 co.get("then").unwrap_or(&J::Null),
436 &format!("{path}.cond.then"),
437 )?;
438 assert_portable_expr(
439 co.get("else").unwrap_or(&J::Null),
440 &format!("{path}.cond.else"),
441 )
442 } else if n.get("component").is_some() {
443 require_string_component(n.get("component"), &format!("{path}.component"))?;
444 assert_portable_ports(n.get("ports"), &format!("{path}.ports"))?;
445 assert_port_schemas_present(n, path)
446 } else {
447 perr(
448 path,
449 "unknown body node kind (not componentRef/map/cond/fanout)",
450 )
451 }
452}
453
454fn assert_port_schemas_present(holder: &J, path: &str) -> Result<(), PortabilityError> {
459 match holder.get("portSchemas") {
460 Some(ps) if ps.is_object() => Ok(()),
461 _ => perr(
462 &format!("{path}.portSchemas"),
463 "portSchemas is required (irVersion 2 carries the port type contract) and must be an object",
464 ),
465 }
466}
467
468fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
469 match v {
470 Some(J::String(_)) => Ok(()),
471 _ => perr(path, "'component' must be a string catalog reference"),
472 }
473}
474
475fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
476 let obj = ports
477 .and_then(|p| p.as_object())
478 .ok_or_else(|| PortabilityError {
479 path: path.to_string(),
480 message: "ports must be an object".into(),
481 })?;
482 for (k, v) in obj {
483 assert_portable_expr(v, &format!("{path}.{k}"))?;
484 }
485 Ok(())
486}
487
488fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
489 match node {
490 J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
491 J::Array(a) => {
492 for (i, e) in a.iter().enumerate() {
493 assert_portable_expr(e, &format!("{path}[{i}]"))?;
494 }
495 Ok(())
496 }
497 J::Object(o) => {
498 if o.len() == 1 {
499 let (op, arg) = o.iter().next().unwrap();
500 if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
501 return perr(path, format!("unknown operator '{op}' (fail-closed)"));
502 }
503 if op == "obj" {
507 let Some(fields) = arg.as_object() else {
508 return perr(
509 &format!("{path}.obj"),
510 "{obj: ...} expects an object".to_string(),
511 );
512 };
513 for (k, v) in fields {
514 if k == FORBIDDEN_OBJECT_KEY {
518 return perr(
519 &format!("{path}.obj"),
520 format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
521 );
522 }
523 assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
524 }
525 return Ok(());
526 }
527 assert_portable_expr(arg, &format!("{path}.{op}"))
528 } else {
529 for (k, v) in o {
530 assert_portable_expr(v, &format!("{path}.{k}"))?;
531 }
532 Ok(())
533 }
534 }
535 }
536}