diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs
index 8e343438..30e84fe7 100644
--- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs
@@ -135,6 +135,20 @@ public static class ParameterValidator
/// Validation has already passed, so this only shapes the value: scalars to
/// their primitive type, objects to ,
/// arrays to .
+ ///
+ ///
+ /// InboundAPI-#55: coercion is RECURSIVE. Nested object fields and list
+ /// elements are coerced to typed CLR values too (string/long/double/bool,
+ /// or nested Dictionary/List), so NO raw survives
+ /// anywhere in the returned graph. Previously the object case used
+ /// JsonSerializer.Deserialize<Dictionary<string, object?>> ,
+ /// which leaves every nested field value as a — so
+ /// nested scalars reached the script as JsonElement-backed values rather than
+ /// the typed CLR values the top-level scalar path already produces. The
+ /// declared field schemas are walked here so nested coercion matches the
+ /// shape the validator already verified (an Object recurses into its declared
+ /// fields; an array coerces each element to its element type).
+ ///
///
private static object? Materialize(JsonElement element, InboundApiSchema schema)
{
@@ -149,12 +163,50 @@ public static class ParameterValidator
"integer" => element.GetInt64(),
"number" => element.GetDouble(),
"string" => element.GetString(),
- "object" => JsonSerializer.Deserialize>(element.GetRawText()),
+ "object" => MaterializeObject(element, schema),
"array" => MaterializeArray(element, schema.Items),
- _ => JsonSerializer.Deserialize(element.GetRawText()),
+ // Undeclared / shape-only ("ref" placeholder or any non-extended type):
+ // coerce structurally so no raw JsonElement survives.
+ _ => MaterializeJsonValue(element),
};
}
+ ///
+ /// Materializes a JSON object to a Dictionary<string, object?> of
+ /// typed CLR values. Each declared field's value is recursively coerced via
+ /// against the field's schema, so nested scalars
+ /// arrive as their CLR primitive (string/long/double/bool) rather than as a
+ /// raw . Any field present in the body but NOT declared
+ /// (only reachable on a shape-only {"type":"object"} node with no declared
+ /// fields, since the validator rejects undeclared fields where fields ARE
+ /// declared) is coerced structurally via .
+ ///
+ private static object? MaterializeObject(JsonElement element, InboundApiSchema schema)
+ {
+ // Shape-only object (no declared fields): coerce structurally so the whole
+ // graph is typed CLR with no JsonElement leakage.
+ if (schema.Fields.Count == 0)
+ {
+ return MaterializeJsonValue(element);
+ }
+
+ var fieldSchemas = new Dictionary(StringComparer.Ordinal);
+ foreach (var field in schema.Fields)
+ {
+ fieldSchemas[field.Name] = field.Schema;
+ }
+
+ var dict = new Dictionary();
+ foreach (var prop in element.EnumerateObject())
+ {
+ dict[prop.Name] = fieldSchemas.TryGetValue(prop.Name, out var fieldSchema)
+ ? Materialize(prop.Value, fieldSchema)
+ : MaterializeJsonValue(prop.Value);
+ }
+
+ return dict;
+ }
+
///
/// Materializes a JSON array to a STRONGLY-TYPED list (List<string>,
/// List<long>, List<double>, List<bool>) per the element schema,
@@ -197,11 +249,22 @@ public static class ParameterValidator
list.Add(e.ValueKind == JsonValueKind.Null ? string.Empty : e.GetString() ?? string.Empty);
return list;
}
+ case "object":
+ case "array":
+ {
+ // Declared object/array element: recurse via Materialize so each
+ // element follows its declared (nested) schema — nested scalars
+ // become typed CLR values, never raw JsonElement (#55).
+ var list = new List(element.GetArrayLength());
+ foreach (var e in element.EnumerateArray()) list.Add(Materialize(e, items));
+ return list;
+ }
default:
{
- // No declared (or non-scalar) element type: materialize each element
- // to its CLR value so no raw JsonElement survives (raw JsonElement is
- // not cleanly Akka-serializable and stalls the List-attribute encode).
+ // No declared (or non-extended) element type: materialize each
+ // element structurally so no raw JsonElement survives (raw
+ // JsonElement is not cleanly Akka-serializable and stalls the
+ // List-attribute encode).
var list = new List(element.GetArrayLength());
foreach (var e in element.EnumerateArray()) list.Add(MaterializeJsonValue(e));
return list;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs
index de384994..5425a3ef 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs
@@ -350,6 +350,194 @@ public class ParameterValidatorTests
Assert.True(result.IsValid);
}
+ // ── Recursive materialization: nested values coerced to typed CLR (#55) ─────
+
+ ///
+ /// Recursively asserts that no value anywhere in a materialized parameter
+ /// graph is a raw — every scalar must be a typed CLR
+ /// value (string/long/double/bool/null) and every container a
+ /// Dictionary<string, object?> / List<object?> (or a
+ /// typed scalar list) of likewise-coerced values.
+ ///
+ private static void AssertNoJsonElement(object? value)
+ {
+ Assert.IsNotType(value);
+
+ switch (value)
+ {
+ case IDictionary dict:
+ foreach (var v in dict.Values)
+ {
+ AssertNoJsonElement(v);
+ }
+
+ break;
+ case System.Collections.IEnumerable enumerable when value is not string:
+ foreach (var item in enumerable)
+ {
+ AssertNoJsonElement(item);
+ }
+
+ break;
+ }
+ }
+
+ [Fact]
+ public void NestedObjectFieldScalar_IsTypedClr_NotJsonElement()
+ {
+ const string body = """
+ {"order":{
+ "id":7,
+ "customer":{"name":"Acme","vip":true},
+ "items":[]
+ }}
+ """;
+
+ var result = ParameterValidator.Validate(Body(body), NestedDef);
+ Assert.True(result.IsValid);
+
+ var order = Assert.IsType>(result.Parameters["order"]);
+ AssertNoJsonElement(order);
+
+ // The nested scalar fields are the CLR types the top-level path produces:
+ // integer→long, string→string, boolean→bool.
+ Assert.Equal((long)7, order["id"]);
+
+ var customer = Assert.IsType>(order["customer"]);
+ Assert.Equal("Acme", customer["name"]);
+ Assert.Equal(true, customer["vip"]);
+ Assert.IsType(customer["vip"]);
+ Assert.IsType(order["id"]);
+ }
+
+ [Fact]
+ public void ListOfScalars_FullyCoerced_NoJsonElement()
+ {
+ const string def = """{"type":"object","properties":{"nums":{"type":"array","items":{"type":"integer"}}},"required":["nums"]}""";
+
+ var result = ParameterValidator.Validate(Body("{\"nums\":[1,2,3]}"), def);
+ Assert.True(result.IsValid);
+
+ // Declared scalar element → strongly-typed List.
+ var nums = Assert.IsType>(result.Parameters["nums"]);
+ Assert.Equal(new List { 1, 2, 3 }, nums);
+ AssertNoJsonElement(nums);
+ }
+
+ [Fact]
+ public void ListOfObjects_ElementFieldsCoerced_NoJsonElement()
+ {
+ const string def = """
+ {"type":"object","properties":{
+ "items":{"type":"array","items":{
+ "type":"object",
+ "properties":{"sku":{"type":"string"},"quantity":{"type":"integer"},"price":{"type":"number"}},
+ "required":["sku","quantity"]
+ }}
+ },"required":["items"]}
+ """;
+
+ const string body = """{"items":[{"sku":"A1","quantity":3,"price":9.5},{"sku":"B2","quantity":1,"price":2.0}]}""";
+
+ var result = ParameterValidator.Validate(Body(body), def);
+ Assert.True(result.IsValid);
+
+ var items = Assert.IsType>(result.Parameters["items"]);
+ AssertNoJsonElement(items);
+
+ var first = Assert.IsType>(items[0]);
+ Assert.Equal("A1", first["sku"]);
+ Assert.Equal((long)3, first["quantity"]);
+ Assert.IsType(first["quantity"]);
+ Assert.Equal(9.5, first["price"]);
+ Assert.IsType(first["price"]);
+ }
+
+ [Fact]
+ public void DeeplyNested_ObjectInListInObject_FullyCoerced_NoJsonElement()
+ {
+ // order.items[*] is an object that itself carries a list of tag objects:
+ // object → array → object → scalar, the worst-case leak path.
+ const string def = """
+ {"type":"object","properties":{
+ "order":{"type":"object","properties":{
+ "id":{"type":"integer"},
+ "items":{"type":"array","items":{
+ "type":"object","properties":{
+ "sku":{"type":"string"},
+ "tags":{"type":"array","items":{
+ "type":"object","properties":{
+ "key":{"type":"string"},
+ "weight":{"type":"number"},
+ "active":{"type":"boolean"}
+ },"required":["key"]
+ }}
+ },"required":["sku"]
+ }}
+ },"required":["id","items"]}
+ },"required":["order"]}
+ """;
+
+ const string body = """
+ {"order":{
+ "id":7,
+ "items":[
+ {"sku":"A1","tags":[{"key":"hot","weight":1.5,"active":true},{"key":"cold","weight":0.25,"active":false}]}
+ ]
+ }}
+ """;
+
+ var result = ParameterValidator.Validate(Body(body), def);
+ Assert.True(result.IsValid);
+
+ // The whole graph must be free of JsonElement at every depth.
+ AssertNoJsonElement(result.Parameters["order"]);
+
+ var order = Assert.IsType>(result.Parameters["order"]);
+ Assert.Equal((long)7, order["id"]);
+
+ var items = Assert.IsType>(order["items"]);
+ var item0 = Assert.IsType>(items[0]);
+ Assert.Equal("A1", item0["sku"]);
+
+ var tags = Assert.IsType>(item0["tags"]);
+ var tag0 = Assert.IsType>(tags[0]);
+ Assert.Equal("hot", tag0["key"]);
+ Assert.Equal(1.5, tag0["weight"]);
+ Assert.IsType(tag0["weight"]);
+ Assert.Equal(true, tag0["active"]);
+ Assert.IsType(tag0["active"]);
+
+ var tag1 = Assert.IsType>(tags[1]);
+ Assert.Equal(false, tag1["active"]);
+ }
+
+ [Fact]
+ public void ShapeOnlyNestedObject_StillFullyCoerced_NoJsonElement()
+ {
+ // A declared-but-shape-only object field ({"type":"object"} with no
+ // properties) is still coerced structurally — no JsonElement survives.
+ // Without a declared field schema there is no integer/float distinction,
+ // so (per the pre-existing structural-coercion path) numbers come back as
+ // double; the point of THIS test is the absence of JsonElement, not the
+ // integer-vs-double choice (the declared paths cover that).
+ const string def = """{"type":"object","properties":{"data":{"type":"object"}},"required":["data"]}""";
+
+ const string body = """{"data":{"name":"x","count":5,"ratio":1.5,"flag":true,"nested":{"inner":2}}}""";
+
+ var result = ParameterValidator.Validate(Body(body), def);
+ Assert.True(result.IsValid);
+
+ var data = Assert.IsType>(result.Parameters["data"]);
+ AssertNoJsonElement(data);
+ Assert.Equal("x", data["name"]);
+ Assert.Equal(5d, data["count"]);
+ Assert.Equal(1.5, data["ratio"]);
+ Assert.Equal(true, data["flag"]);
+ var nested = Assert.IsType>(data["nested"]);
+ Assert.Equal(2d, nested["inner"]);
+ }
+
// ── Legacy flat-array backward-compat ──────────────────────────────────────
[Fact]