diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs
new file mode 100644
index 00000000..43b2cb77
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs
@@ -0,0 +1,117 @@
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+
+/// One data-sourced flattened attribute as the Configure page sees it.
+/// Path-qualified flattened name, e.g. LeftMESReceiver.MoveInFlag.
+/// Template/composed default reference (pre-override), may be null.
+/// Bound data connection id, or null when unbound.
+/// Per-instance reference override, or null when the default applies.
+public sealed record BindingRow(string CanonicalName, string? DefaultReference, int? ConnectionId, string? Override);
+
+public enum BindingConnectionState { None, Single, Mixed }
+
+/// A member of a : the row plus its tail relative to the group.
+public sealed record BindingGroupMember(BindingRow Row, string MemberTail)
+{
+ public bool IsUnbound => Row.ConnectionId is null or <= 0;
+ /// Effective reference: override if set, else the default.
+ public string? EffectiveReference => string.IsNullOrWhiteSpace(Row.Override) ? Row.DefaultReference : Row.Override;
+}
+
+///
+/// One module's worth of binding rows. is the first segment of the
+/// canonical name ( for direct
+/// attributes); nested modules fold into their first segment.
+///
+public sealed record BindingGroup(
+ string Key,
+ IReadOnlyList Members,
+ IReadOnlyList NestedModules,
+ string? InferredObject,
+ bool IsObjectMixed,
+ BindingConnectionState ConnectionState,
+ int? CommonConnectionId,
+ int UnboundCount)
+{
+ public bool IsInstanceGroup => Key == BindingGroupBuilder.InstanceGroupKey;
+}
+
+///
+/// Pure grouping / prefix-inference / bulk-apply logic behind the Configure page's
+/// Connection Bindings panel (design 2026-08-20). No Blazor dependencies so it is
+/// unit-testable without rendering the page.
+///
+public static class BindingGroupBuilder
+{
+ public const string InstanceGroupKey = "(instance)";
+
+ public static IReadOnlyList Build(IEnumerable rows)
+ {
+ var byKey = new Dictionary>(StringComparer.Ordinal);
+ var nested = new Dictionary>(StringComparer.Ordinal);
+
+ foreach (var row in rows.OrderBy(r => r.CanonicalName, StringComparer.Ordinal))
+ {
+ var dot = row.CanonicalName.IndexOf('.');
+ string key, tail;
+ if (dot < 0) { key = InstanceGroupKey; tail = row.CanonicalName; }
+ else
+ {
+ key = row.CanonicalName[..dot];
+ tail = row.CanonicalName[(dot + 1)..];
+ var tailDot = tail.LastIndexOf('.');
+ if (tailDot > 0)
+ {
+ if (!nested.TryGetValue(key, out var set)) nested[key] = set = new SortedSet(StringComparer.Ordinal);
+ set.Add(tail[..tailDot]);
+ }
+ }
+ if (!byKey.TryGetValue(key, out var list)) byKey[key] = list = new List();
+ list.Add(new BindingGroupMember(row, tail));
+ }
+
+ var ordered = byKey.Keys
+ .OrderBy(k => k == InstanceGroupKey ? 0 : 1)
+ .ThenBy(k => k, StringComparer.Ordinal);
+
+ var result = new List();
+ foreach (var key in ordered)
+ {
+ var members = byKey[key];
+ var (obj, mixed) = InferObject(members);
+ var conns = members.Select(m => m.Row.ConnectionId is > 0 ? m.Row.ConnectionId : null).Distinct().ToList();
+ var state = conns.Count == 1 && conns[0] is not null ? BindingConnectionState.Single
+ : conns.All(c => c is null) ? BindingConnectionState.None
+ : BindingConnectionState.Mixed;
+ result.Add(new BindingGroup(
+ key, members,
+ nested.TryGetValue(key, out var n) ? n.ToList() : [],
+ obj, mixed, state,
+ state == BindingConnectionState.Single ? conns[0] : null,
+ members.Count(m => m.IsUnbound)));
+ }
+ return result;
+ }
+
+ ///
+ /// Longest object prefix shared by every member that has an override: each override
+ /// must be {prefix}.{memberTail}. Returns (null,false) when no member has an
+ /// override; (null,true) when members disagree or an override doesn't end in its tail.
+ ///
+ private static (string? Object, bool Mixed) InferObject(IReadOnlyList members)
+ {
+ string? candidate = null;
+ var any = false;
+ foreach (var m in members)
+ {
+ if (string.IsNullOrWhiteSpace(m.Row.Override)) continue;
+ any = true;
+ var suffix = "." + m.MemberTail;
+ if (!m.Row.Override.EndsWith(suffix, StringComparison.Ordinal)) return (null, true);
+ var prefix = m.Row.Override[..^suffix.Length];
+ if (prefix.Length == 0) return (null, true);
+ if (candidate is null) candidate = prefix;
+ else if (!string.Equals(candidate, prefix, StringComparison.Ordinal)) return (null, true);
+ }
+ return any ? (candidate, false) : (null, false);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs
new file mode 100644
index 00000000..21b969a4
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs
@@ -0,0 +1,114 @@
+using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
+
+public class BindingGroupBuilderTests
+{
+ private static BindingRow Row(string canonical, string? dflt, int? conn, string? ovr) =>
+ new(canonical, dflt, conn, ovr);
+
+ [Fact]
+ public void Groups_DirectAttributesFirst_ThenModules_InNameOrder()
+ {
+ var rows = new[]
+ {
+ Row("RightMESReceiver.MoveInFlag", "MoveInFlag", 1, "MESReceiver_022.MoveInFlag"),
+ Row("ChlorineFlow", "ChlorineFlow", 1, "Z28061.ChlorineFlow"),
+ Row("LeftMESReceiver.MoveInFlag", "MoveInFlag", 1, "MESReceiver_023.MoveInFlag"),
+ };
+ var groups = BindingGroupBuilder.Build(rows);
+ Assert.Equal(new[] { BindingGroupBuilder.InstanceGroupKey, "LeftMESReceiver", "RightMESReceiver" },
+ groups.Select(g => g.Key));
+ }
+
+ [Fact]
+ public void NestedModule_FoldsIntoFirstSegment_AndKeepsNestedTail()
+ {
+ var rows = new[]
+ {
+ Row("LeftReactorSide.AIR_SVC", "AIR_SVC", 1, "Left_002.AIR_SVC"),
+ Row("LeftReactorSide.LeakTest.DeltaVac", "DeltaVac", 1, "Left_002.LeakTest.DeltaVac"),
+ };
+ var g = Assert.Single(BindingGroupBuilder.Build(rows));
+ Assert.Equal("LeftReactorSide", g.Key);
+ Assert.Equal(new[] { "AIR_SVC", "LeakTest.DeltaVac" }, g.Members.Select(m => m.MemberTail));
+ Assert.Equal(new[] { "LeakTest" }, g.NestedModules);
+ Assert.Equal("Left_002", g.InferredObject);
+ }
+
+ [Fact]
+ public void InferredObject_NestedObjectPath()
+ {
+ var rows = new[]
+ {
+ Row("LeftDelmiaReceiver.ReadyFlag", "ReadyFlag", 1, "Left_002.DelmiaReceiver.ReadyFlag"),
+ Row("LeftDelmiaReceiver.PartNumber", "PartNumber", 1, "Left_002.DelmiaReceiver.PartNumber"),
+ };
+ Assert.Equal("Left_002.DelmiaReceiver", Assert.Single(BindingGroupBuilder.Build(rows)).InferredObject);
+ }
+
+ [Fact]
+ public void InferredObject_NullWhenMixed_AndStateMixed()
+ {
+ var rows = new[]
+ {
+ Row("M.A", "A", 1, "Obj1.A"),
+ Row("M.B", "B", 1, "Obj2.B"),
+ };
+ var g = Assert.Single(BindingGroupBuilder.Build(rows));
+ Assert.Null(g.InferredObject);
+ Assert.True(g.IsObjectMixed);
+ }
+
+ [Fact]
+ public void InferredObject_NullWhenNoOverrides_NotMixed()
+ {
+ var rows = new[] { Row("M.A", "A", 1, null), Row("M.B", "B", 1, null) };
+ var g = Assert.Single(BindingGroupBuilder.Build(rows));
+ Assert.Null(g.InferredObject);
+ Assert.False(g.IsObjectMixed);
+ }
+
+ [Fact]
+ public void InstanceGroup_InfersObjectFromOverrides()
+ {
+ var rows = new[] { Row("ChlorineFlow", "ChlorineFlow", 1, "Z28061.ChlorineFlow"), Row("MachineID", "MachineID", 1, "Z28061.MachineID") };
+ var g = Assert.Single(BindingGroupBuilder.Build(rows));
+ Assert.Equal(BindingGroupBuilder.InstanceGroupKey, g.Key);
+ Assert.Equal("Z28061", g.InferredObject);
+ }
+
+ [Fact]
+ public void Counts_UnboundAndConnectionState()
+ {
+ var rows = new[]
+ {
+ Row("M.A", "A", 1, "O.A"),
+ Row("M.B", "B", null, null),
+ Row("M.C", "C", 2, "O.C"),
+ };
+ var g = Assert.Single(BindingGroupBuilder.Build(rows));
+ Assert.Equal(3, g.Members.Count);
+ Assert.Equal(1, g.UnboundCount);
+ Assert.Equal(BindingConnectionState.Mixed, g.ConnectionState);
+ Assert.Null(g.CommonConnectionId);
+ }
+
+ [Fact]
+ public void ConnectionState_SingleWhenAllAgree()
+ {
+ var rows = new[] { Row("M.A", "A", 1, null), Row("M.B", "B", 1, null) };
+ var g = Assert.Single(BindingGroupBuilder.Build(rows));
+ Assert.Equal(BindingConnectionState.Single, g.ConnectionState);
+ Assert.Equal(1, g.CommonConnectionId);
+ }
+
+ [Fact]
+ public void MemberTail_OverrideThatDoesNotEndWithTail_CountsAsMixed()
+ {
+ // An operator typed a reference that does not end in the member tail —
+ // the prefix cannot be inferred for that row, so the group is mixed.
+ var rows = new[] { Row("M.A", "A", 1, "Obj.A"), Row("M.B", "B", 1, "Obj.Other") };
+ Assert.True(Assert.Single(BindingGroupBuilder.Build(rows)).IsObjectMixed);
+ }
+}