diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CreateBindingPlanner.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CreateBindingPlanner.cs
new file mode 100644
index 00000000..c54dee0a
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CreateBindingPlanner.cs
@@ -0,0 +1,129 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+
+///
+/// Pure logic behind the Create Instance page's Connection Bindings card
+/// (design 2026-08-21): Prefill from a root object, Object inference from a
+/// browser pick, and the binding list sent with Create. Groups come from
+/// over a template+site preview flatten.
+/// No Blazor dependencies, so it is unit-testable without rendering the page.
+///
+public static class CreateBindingPlanner
+{
+ ///
+ /// Per-group operator input. 0 = none.
+ /// marks an Object that derived
+ /// from the root object rather than one the operator typed or the template carried —
+ /// it drives the row's provenance pill only, never the binding itself.
+ ///
+ public sealed record GroupInput(int ConnectionId, string? Object, bool Suggested = false);
+
+ /// Where a group's current Object value came from.
+ public enum ObjectSource { None, Typed, Suggested, FromTemplate }
+
+ ///
+ /// Sets on every group and fills BLANK Objects only:
+ /// the instance group gets ; a module group whose
+ /// defaults already share a prefix keeps it ();
+ /// otherwise, when a root is given, {root}.{groupKey} is suggested. A typed
+ /// Object is never overwritten.
+ ///
+ /// The groups built from the preview flatten.
+ /// The card's current per-group inputs.
+ /// Data connection to apply to every group.
+ /// Root object path; blank leaves bare-default groups unset.
+ /// The complete new input map — one entry per group.
+ public static IReadOnlyDictionary Prefill(
+ IReadOnlyList groups,
+ IReadOnlyDictionary current,
+ int connectionId,
+ string? rootObject)
+ {
+ var root = (rootObject ?? string.Empty).Trim().TrimEnd('.');
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (var g in groups)
+ {
+ current.TryGetValue(g.Key, out var existing);
+ var obj = existing?.Object;
+ var suggested = false;
+ if (string.IsNullOrWhiteSpace(obj))
+ {
+ if (g.IsInstanceGroup)
+ {
+ obj = root.Length == 0 ? null : root;
+ }
+ else if (g.InferredObject is { Length: > 0 } inferred)
+ {
+ obj = inferred;
+ }
+ else if (root.Length == 0)
+ {
+ obj = null;
+ }
+ else
+ {
+ obj = root + "." + g.Key;
+ suggested = true;
+ }
+ }
+ result[g.Key] = new GroupInput(connectionId, obj, suggested);
+ }
+ return result;
+ }
+
+ /// Where a group's current Object value came from, for the row's provenance pill.
+ /// The group the input belongs to.
+ /// The group's current input, or null when untouched.
+ /// The provenance of 's Object.
+ public static ObjectSource SourceOf(BindingGroup group, GroupInput? input)
+ {
+ var obj = input?.Object?.Trim().TrimEnd('.');
+ if (string.IsNullOrEmpty(obj)) return ObjectSource.None;
+ if (!group.IsInstanceGroup && string.Equals(obj, group.InferredObject, StringComparison.Ordinal))
+ return ObjectSource.FromTemplate;
+ return input!.Suggested ? ObjectSource.Suggested : ObjectSource.Typed;
+ }
+
+ ///
+ /// Object for a browser pick: an Object node is used as-is; a Variable whose id ends
+ /// with .{memberTail} of any member of the group has that tail stripped, so
+ /// picking a leaf under the module still binds the whole group.
+ ///
+ /// The group whose Browse… button was used.
+ /// Node id the operator selected.
+ /// The object path to put in the group's Object input.
+ public static string ObjectFromPick(BindingGroup group, string pickedNodeId)
+ {
+ foreach (var m in group.Members)
+ {
+ var suffix = "." + m.MemberTail;
+ if (pickedNodeId.EndsWith(suffix, StringComparison.Ordinal) && pickedNodeId.Length > suffix.Length)
+ return pickedNodeId[..^suffix.Length];
+ }
+ return pickedNodeId;
+ }
+
+ ///
+ /// The bindings to send with Create: every member of every group that has BOTH a
+ /// connection and a non-blank Object, rewritten via .
+ /// Groups missing either are left unbound for Configure.
+ ///
+ /// The groups built from the preview flatten.
+ /// The card's per-group inputs.
+ /// The binding list for SetConnectionBindingsAsync.
+ public static IReadOnlyList BuildBindings(
+ IReadOnlyList groups,
+ IReadOnlyDictionary inputs)
+ {
+ var list = new List();
+ foreach (var g in groups)
+ {
+ if (!inputs.TryGetValue(g.Key, out var input) || input.ConnectionId <= 0 || string.IsNullOrWhiteSpace(input.Object))
+ continue;
+ foreach (var c in BindingGroupBuilder.ApplyObject(g, input.ConnectionId, input.Object))
+ list.Add(new ConnectionBinding(c.CanonicalName, c.ConnectionId!.Value, c.Override));
+ }
+ return list;
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs
new file mode 100644
index 00000000..8cf52a90
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs
@@ -0,0 +1,84 @@
+using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
+
+///
+/// Pins the pure logic behind the Create Instance page's Connection Bindings card
+/// (design 2026-08-21): Prefill from a root object, Object inference from a browser
+/// pick, and the binding list sent with Create.
+///
+public class CreateBindingPlannerTests
+{
+ private static IReadOnlyList Groups() => BindingGroupBuilder.Build(
+ [
+ new BindingRow("Running", "Running", null, null), // (instance)
+ new BindingRow("Left.MoveInFlag", "MoveInFlag", null, null), // bare default → suggest
+ new BindingRow("Left.MoveOutFlag", "MoveOutFlag", null, null),
+ new BindingRow("Recv.Count", "MESReceiver_023.Count", null, null), // prefixed default → from template
+ new BindingRow("Recv.State", "MESReceiver_023.State", null, null),
+ ]);
+
+ [Fact]
+ public void Prefill_fills_blank_objects_only()
+ {
+ var inputs = new Dictionary
+ {
+ ["Left"] = new(0, "Typed_Left"),
+ };
+ var result = CreateBindingPlanner.Prefill(Groups(), inputs, connectionId: 7, rootObject: "Z28063");
+
+ Assert.Equal(new CreateBindingPlanner.GroupInput(7, "Z28063"), result[BindingGroupBuilder.InstanceGroupKey]);
+ Assert.Equal(new CreateBindingPlanner.GroupInput(7, "Typed_Left"), result["Left"]); // typed wins
+ Assert.Equal(new CreateBindingPlanner.GroupInput(7, "MESReceiver_023"), result["Recv"]); // from template
+ Assert.Equal(CreateBindingPlanner.ObjectSource.Typed, CreateBindingPlanner.SourceOf(Groups()[1], result["Left"]));
+ Assert.Equal(CreateBindingPlanner.ObjectSource.FromTemplate, CreateBindingPlanner.SourceOf(Groups()[2], result["Recv"]));
+ }
+
+ [Fact]
+ public void Prefill_suggests_root_dot_module_for_bare_defaults()
+ {
+ var result = CreateBindingPlanner.Prefill(Groups(), new Dictionary(), 7, "Z28063");
+ Assert.Equal("Z28063.Left", result["Left"].Object);
+ Assert.Equal(CreateBindingPlanner.ObjectSource.Suggested, CreateBindingPlanner.SourceOf(Groups()[1], result["Left"]));
+ }
+
+ [Fact]
+ public void Prefill_with_blank_root_sets_connection_only()
+ {
+ var result = CreateBindingPlanner.Prefill(Groups(), new Dictionary(), 7, "");
+ Assert.Equal(new CreateBindingPlanner.GroupInput(7, null), result[BindingGroupBuilder.InstanceGroupKey]);
+ Assert.Equal(new CreateBindingPlanner.GroupInput(7, null), result["Left"]);
+ Assert.Equal(new CreateBindingPlanner.GroupInput(7, "MESReceiver_023"), result["Recv"]);
+ }
+
+ [Theory]
+ [InlineData("Z28063.Left.MoveInFlag", "Z28063.Left")] // leaf under the group → strip tail
+ [InlineData("Z28063.Left", "Z28063.Left")] // object node → as-is
+ [InlineData("ns=2;s=Plant.Left.MoveOutFlag", "ns=2;s=Plant.Left")]
+ [InlineData("Other.Thing", "Other.Thing")] // no member tail matches → as-is
+ public void ObjectFromPick_strips_member_tail(string picked, string expected)
+ => Assert.Equal(expected, CreateBindingPlanner.ObjectFromPick(Groups()[1], picked));
+
+ [Fact]
+ public void BuildBindings_skips_groups_without_connection_or_object()
+ {
+ var inputs = new Dictionary
+ {
+ [BindingGroupBuilder.InstanceGroupKey] = new(7, "Z28063"),
+ ["Left"] = new(7, null), // connection, no object → skipped (stays unbound)
+ ["Recv"] = new(0, "MESReceiver_023"), // object, no connection → skipped
+ };
+ var bindings = CreateBindingPlanner.BuildBindings(Groups(), inputs);
+ var single = Assert.Single(bindings);
+ Assert.Equal(("Running", 7, "Z28063.Running"), (single.AttributeName, single.DataConnectionId, single.DataSourceReferenceOverride));
+ }
+
+ [Fact]
+ public void BuildBindings_rewrites_every_member_of_a_bound_group()
+ {
+ var inputs = new Dictionary { ["Left"] = new(7, "Z28063.Left") };
+ var bindings = CreateBindingPlanner.BuildBindings(Groups(), inputs);
+ Assert.Equal(2, bindings.Count);
+ Assert.Contains(bindings, b => b.AttributeName == "Left.MoveInFlag" && b.DataSourceReferenceOverride == "Z28063.Left.MoveInFlag");
+ }
+}