Files
ScadaBridge/docs/plans/2026-08-20-grouped-connection-bindings.md
T

57 KiB

Grouped Connection Bindings Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Make the Configure Instance page show and bulk-edit connection bindings for every flattened attribute (composed module members included), grouped by module with a per-group "Object" apply, plus a CLI instance bind-module command that performs the same rewrite.

Architecture: The page switches its bindings row source from the template's direct attributes to the flattened configuration it already computes (IFlatteningPipeline.FlattenAndValidateAsync(id, validateScripts:false)). A new additive ResolvedAttribute.DefaultDataSourceReference carries the pre-binding default. A pure BindingGroupBuilder helper (CentralUI) does grouping, object-prefix inference and bulk apply so the logic is unit-tested without rendering the page. The CLI command chains GetInstance → SetConnectionBindings (existing upsert) client-side; no new server command.

Tech Stack: .NET 10, Blazor Server (Bootstrap 5, no component libs), xUnit, System.CommandLine (CLI). Design: docs/plans/2026-08-20-grouped-connection-bindings-design.md.

Conventions: work in a dedicated worktree (see memory work-in-separate-worktree); targeted test runs per task (dotnet test tests/<proj> --filter ...), full build only at the end; commit per task. Never git stash.


Task 1: ResolvedAttribute.DefaultDataSourceReference (TemplateEngine)

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 2, Task 3, Task 7

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs:57-100 (ResolvedAttribute)
  • Modify: src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:367-391 (ApplyConnectionBindings)
  • Test: tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DefaultDataSourceReferenceTests.cs (create)

Step 1: Write the failing tests

using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;

namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening;

/// <summary>
/// Design 2026-08-20 (grouped connection bindings): the flattened attribute
/// carries the data-source reference as it was BEFORE the instance binding
/// override, so the Configure page can show the template default next to the
/// effective reference. The field is deliberately NOT part of the revision hash.
/// </summary>
public class DefaultDataSourceReferenceTests
{
    [Fact]
    public void RevisionHash_IgnoresDefaultDataSourceReference()
    {
        var svc = new RevisionHashService();
        var a = new ResolvedAttribute { CanonicalName = "LeftMESReceiver.MoveInFlag", DataType = "Boolean",
            DataSourceReference = "MESReceiver_022.MoveInFlag", BoundDataConnectionId = 1 };
        var without = new FlattenedConfiguration { InstanceUniqueName = "Z28061", Attributes = [a] };
        var with = without with { Attributes = [a with { DefaultDataSourceReference = "MoveInFlag" }] };

        Assert.Equal(svc.ComputeHash(without), svc.ComputeHash(with));
    }

    [Fact]
    public void ResolvedAttribute_DefaultDataSourceReference_IsNullByDefault()
    {
        var a = new ResolvedAttribute { CanonicalName = "X" };
        Assert.Null(a.DefaultDataSourceReference);
    }
}

Add a third test to the EXISTING flattening integration fixture that exercises ApplyConnectionBindings (find it with grep -rn "DataSourceReferenceOverride" tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/*.cs | head; reuse its builder). Assert that for a bound attribute with override "MESReceiver_022.MoveInFlag" and template reference "MoveInFlag": DataSourceReference == "MESReceiver_022.MoveInFlag" and DefaultDataSourceReference == "MoveInFlag"; and for an unbound data-sourced attribute both equal the template reference.

Step 2: Run to verify failure

Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter "FullyQualifiedName~DefaultDataSourceReference" -v q Expected: build error 'ResolvedAttribute' does not contain a definition for 'DefaultDataSourceReference'.

Step 3: Implement

In FlattenedConfiguration.cs, after DataSourceReference (line ~79):

    /// <summary>
    /// The data-source reference as resolved from the template chain BEFORE any
    /// per-instance <c>InstanceConnectionBinding.DataSourceReferenceOverride</c> was
    /// applied — i.e. the template/composed default the Configure page shows as the
    /// placeholder. Equals <see cref="DataSourceReference"/> when no override is set.
    /// Central-side presentation data only: deliberately excluded from the revision
    /// hash (<c>RevisionHashService</c> projects through <c>HashableAttribute</c>).
    /// </summary>
    public string? DefaultDataSourceReference { get; init; }

In FlatteningService.cs: the default must be populated for every data-sourced attribute, not only bound ones. At the top of ApplyConnectionBindings, before the foreach, add:

        foreach (var key in attributes.Keys.ToList())
        {
            var attr = attributes[key];
            if (attr.DataSourceReference is not null && attr.DefaultDataSourceReference is null)
                attributes[key] = attr with { DefaultDataSourceReference = attr.DataSourceReference };
        }

Then in the existing with { ... } block, add DefaultDataSourceReference = existing.DefaultDataSourceReference ?? existing.DataSourceReference, (the pre-loop already set it, this is belt-and-braces).

Step 4: Run to verify pass

Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter "FullyQualifiedName~Flattening" -v q Expected: all pass (the full Flattening namespace, not just the new file — hash/diff tests must stay green).

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/
git commit -m "feat(templateengine): ResolvedAttribute.DefaultDataSourceReference — pre-binding default for the Configure page; excluded from the revision hash (pinned)"

Task 2: BindingGroupBuilder — grouping + object-prefix inference (pure)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 7

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs
  • Test: tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs (create)

Step 1: Write the failing tests

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);
    }
}

Step 2: Run to verify failure

Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~BindingGroupBuilderTests" -v q Expected: build error, BindingGroupBuilder not found.

Step 3: Implement

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;

/// <summary>One data-sourced flattened attribute as the Configure page sees it.</summary>
/// <param name="CanonicalName">Path-qualified flattened name, e.g. <c>LeftMESReceiver.MoveInFlag</c>.</param>
/// <param name="DefaultReference">Template/composed default reference (pre-override), may be null.</param>
/// <param name="ConnectionId">Bound data connection id, or null when unbound.</param>
/// <param name="Override">Per-instance reference override, or null when the default applies.</param>
public sealed record BindingRow(string CanonicalName, string? DefaultReference, int? ConnectionId, string? Override);

public enum BindingConnectionState { None, Single, Mixed }

/// <summary>A member of a <see cref="BindingGroup"/>: the row plus its tail relative to the group.</summary>
public sealed record BindingGroupMember(BindingRow Row, string MemberTail)
{
    public bool IsUnbound => Row.ConnectionId is null or <= 0;
    /// <summary>Effective reference: override if set, else the default.</summary>
    public string? EffectiveReference => string.IsNullOrWhiteSpace(Row.Override) ? Row.DefaultReference : Row.Override;
}

/// <summary>
/// One module's worth of binding rows. <see cref="Key"/> is the first segment of the
/// canonical name (<see cref="BindingGroupBuilder.InstanceGroupKey"/> for direct
/// attributes); nested modules fold into their first segment.
/// </summary>
public sealed record BindingGroup(
    string Key,
    IReadOnlyList<BindingGroupMember> Members,
    IReadOnlyList<string> NestedModules,
    string? InferredObject,
    bool IsObjectMixed,
    BindingConnectionState ConnectionState,
    int? CommonConnectionId,
    int UnboundCount)
{
    public bool IsInstanceGroup => Key == BindingGroupBuilder.InstanceGroupKey;
}

/// <summary>
/// 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.
/// </summary>
public static class BindingGroupBuilder
{
    public const string InstanceGroupKey = "(instance)";

    public static IReadOnlyList<BindingGroup> Build(IEnumerable<BindingRow> rows)
    {
        var byKey = new Dictionary<string, List<BindingGroupMember>>(StringComparer.Ordinal);
        var nested = new Dictionary<string, SortedSet<string>>(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<string>(StringComparer.Ordinal);
                    set.Add(tail[..tailDot]);
                }
            }
            if (!byKey.TryGetValue(key, out var list)) byKey[key] = list = new List<BindingGroupMember>();
            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<BindingGroup>();
        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;
    }

    /// <summary>
    /// Longest object prefix shared by every member that has an override: each override
    /// must be <c>{prefix}.{memberTail}</c>. Returns (null,false) when no member has an
    /// override; (null,true) when members disagree or an override doesn't end in its tail.
    /// </summary>
    private static (string? Object, bool Mixed) InferObject(IReadOnlyList<BindingGroupMember> 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);
    }
}

Step 4: Run to verify pass

Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~BindingGroupBuilderTests" -v q Expected: 9 passed.

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs
git commit -m "feat(centralui): BindingGroupBuilder — group flattened binding rows by module, infer the shared MxAccess object prefix, count unbound"

Task 3: BindingGroupBuilder.ApplyObject (pure)

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 1, Task 7 Depends on: Task 2

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs
  • Test: tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs

Step 1: Write the failing tests (append to the test class)

    [Fact]
    public void ApplyObject_RewritesEveryMember_AndSetsConnection()
    {
        var g = Assert.Single(BindingGroupBuilder.Build(new[]
        {
            Row("RightMESReceiver.MoveInFlag", "MoveInFlag", 1, "MESReceiver_024.MoveInFlag"),
            Row("RightMESReceiver.ScanStateCmd", "ScanStateCmd", null, null),
        }));
        var changes = BindingGroupBuilder.ApplyObject(g, connectionId: 1, objectPath: " MESReceiver_022. ");
        Assert.Equal(2, changes.Count);
        Assert.Equal(("RightMESReceiver.MoveInFlag", 1, "MESReceiver_022.MoveInFlag"), (changes[0].CanonicalName, changes[0].ConnectionId, changes[0].Override));
        Assert.Equal(("RightMESReceiver.ScanStateCmd", 1, "MESReceiver_022.ScanStateCmd"), (changes[1].CanonicalName, changes[1].ConnectionId, changes[1].Override));
    }

    [Fact]
    public void ApplyObject_NestedTail_KeepsNesting()
    {
        var g = Assert.Single(BindingGroupBuilder.Build(new[] { Row("LeftReactorSide.LeakTest.DeltaVac", "DeltaVac", 1, null) }));
        var c = Assert.Single(BindingGroupBuilder.ApplyObject(g, 1, "Left_002"));
        Assert.Equal("Left_002.LeakTest.DeltaVac", c.Override);
    }

    [Fact]
    public void ApplyObject_EmptyObject_ClearsOverrides_KeepsConnection()
    {
        var g = Assert.Single(BindingGroupBuilder.Build(new[] { Row("M.A", "A", 1, "Obj.A") }));
        var c = Assert.Single(BindingGroupBuilder.ApplyObject(g, 2, ""));
        Assert.Null(c.Override);
        Assert.Equal(2, c.ConnectionId);
    }

Step 2: Run to verify failure — dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~ApplyObject" -v q → build error.

Step 3: Implement (add to BindingGroupBuilder)

    /// <summary>Result of a bulk apply for one member.</summary>
    public sealed record BindingChange(string CanonicalName, int? ConnectionId, string? Override);

    /// <summary>
    /// Rewrites every member of <paramref name="group"/> to
    /// <c>{objectPath}.{memberTail}</c> on <paramref name="connectionId"/>. A blank
    /// <paramref name="objectPath"/> clears the overrides (template default applies) and
    /// leaves the connection as given. Surrounding whitespace and a trailing '.' are tolerated.
    /// </summary>
    public static IReadOnlyList<BindingChange> ApplyObject(BindingGroup group, int? connectionId, string? objectPath)
    {
        var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.');
        return group.Members
            .Select(m => new BindingChange(m.Row.CanonicalName, connectionId,
                obj.Length == 0 ? null : obj + "." + m.MemberTail))
            .ToList();
    }

Step 4: Run to verify pass — same filter, expect 3 passed; then run the whole BindingGroupBuilderTests class (12 passed).

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs
git commit -m "feat(centralui): BindingGroupBuilder.ApplyObject — per-module bulk rewrite to <object>.<member>, blank clears"

Task 4: Configure page — source binding rows from the flattened config

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (same file as Task 5) Depends on: Task 1, Task 3

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor (code block: fields ~line 624-660, load ~line 760-771, GetTemplateDefault ~874, BuildTestableRows ~930, SaveBindings ~963, BuildFlattenedAttributesAsync ~1644)
  • Test: tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingRowsTests.cs (create; structural, same style as InstanceConfigureListOverrideTests)

Step 1: Write the failing structural test

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;

/// <summary>
/// Design 2026-08-20: the Connection Bindings panel must be driven by the flattened
/// configuration (every composed module member), not by the template's direct
/// attributes — 264 of Z28061's 280 bindings were invisible on QA. Structural pins
/// over the page source, matching the existing InstanceConfigure coverage style.
/// </summary>
public class InstanceConfigureBindingRowsTests
{
    private static string Markup
    {
        get
        {
            var dir = AppContext.BaseDirectory;
            for (var i = 0; i < 6 && dir is not null; i++) dir = Directory.GetParent(dir)?.FullName;
            return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
                "Components", "Pages", "Deployment", "InstanceConfigure.razor"));
        }
    }

    [Fact]
    public void BindingRows_ComeFromFlattenedConfiguration_NotTemplateAttributes()
    {
        var m = Markup;
        Assert.Contains("LoadBindingRowsAsync", m);
        Assert.Contains("BindingGroupBuilder.Build(", m);
        // The old direct-attribute projection must be gone from the bindings path.
        Assert.DoesNotContain("_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference))", m);
        Assert.Contains("DefaultDataSourceReference", m);
    }

    [Fact]
    public void BindingRows_FallBackToDirectAttributes_WithBanner_WhenFlattenFails()
    {
        var m = Markup;
        Assert.Contains("_bindingSourceWarning", m);
        Assert.Contains("data-test=\"binding-source-warning\"", m);
    }

    [Fact]
    public void SaveAndTest_CoverEveryRow()
    {
        var m = Markup;
        // Save posts the whole selection dictionary (hidden-row survival property).
        Assert.Contains("_bindingSelections\n                .Select(kv => new ConnectionBinding(", m.Replace("\r\n", "\n"));
        // Test Bindings iterates the grouped rows, not the legacy attr list.
        Assert.Contains("foreach (var g in _bindingGroups)", m);
    }
}

Step 2: Run to verify failure — dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~InstanceConfigureBindingRowsTests" -v q → 3 failed.

Step 3: Implement (code block only in this task; markup is Task 5)

Add @using ZB.MOM.WW.ScadaBridge.CentralUI.Services near the other usings (check it isn't already imported via _Imports.razor).

Replace the _bindingDataSourceAttrs field and its load with:

    // ── Bindings (design 2026-08-20: rows = flattened attributes, grouped by module) ──
    private List<BindingRow> _bindingRows = new();
    private IReadOnlyList<BindingGroup> _bindingGroups = Array.Empty<BindingGroup>();
    private string? _bindingSourceWarning;
    /// <summary>Template default per canonical name (placeholder text).</summary>
    private readonly Dictionary<string, string?> _bindingDefaults = new(StringComparer.Ordinal);
    private FlattenedConfiguration? _flattenedForBindings;

In OnInitializedAsync, replace the two lines at ~762-763 with await LoadBindingRowsAsync(attrs); placed AFTER existingBindings are read into _bindingSelections/_bindingOverrides (move the existingBindings block above it), and add:

    /// <summary>
    /// Builds the binding rows from the flattened configuration so composed module
    /// members appear. Falls back to the template's direct attributes — with a visible
    /// warning — only when the flatten itself fails; a validation failure (e.g. unbound
    /// attributes, which is exactly what this panel exists to fix) is NOT a reason to
    /// fall back.
    /// </summary>
    private async Task LoadBindingRowsAsync(IReadOnlyList<TemplateAttribute> directAttrs)
    {
        _bindingSourceWarning = null;
        IEnumerable<(string Name, string? Default)> source;
        try
        {
            var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id, validateScripts: false);
            if (flat.IsFailure)
            {
                _bindingSourceWarning = $"Could not flatten this instance ({flat.Error}); showing the template's direct attributes only.";
                source = directAttrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).Select(a => (a.Name, a.DataSourceReference));
            }
            else
            {
                _flattenedForBindings = flat.Value.Configuration;
                source = flat.Value.Configuration.Attributes
                    .Where(a => !string.IsNullOrEmpty(a.DefaultDataSourceReference) || !string.IsNullOrEmpty(a.DataSourceReference))
                    .Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference));
            }
        }
        catch (Exception ex)
        {
            _bindingSourceWarning = $"Could not flatten this instance ({ex.Message}); showing the template's direct attributes only.";
            source = directAttrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).Select(a => (a.Name, a.DataSourceReference));
        }

        _bindingDefaults.Clear();
        _bindingRows = source.Select(s =>
        {
            _bindingDefaults[s.Name] = s.Default;
            return new BindingRow(s.Name, s.Default,
                _bindingSelections.TryGetValue(s.Name, out var c) ? c : null,
                _bindingOverrides.GetValueOrDefault(s.Name));
        }).ToList();
        RebuildBindingGroups();
    }

    private void RebuildBindingGroups()
    {
        // Re-project current selections/overrides into rows, then regroup.
        _bindingRows = _bindingRows
            .Select(r => r with
            {
                ConnectionId = _bindingSelections.TryGetValue(r.CanonicalName, out var c) ? c : null,
                Override = _bindingOverrides.GetValueOrDefault(r.CanonicalName)
            })
            .ToList();
        _bindingGroups = BindingGroupBuilder.Build(_bindingRows);
    }

    private int TotalUnbound => _bindingGroups.Sum(g => g.UnboundCount);
    private int MixedGroups => _bindingGroups.Count(g => g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed);

Update GetTemplateDefault(string attrName) to => _bindingDefaults.GetValueOrDefault(attrName);.

OnBindingChanged / OnOverrideForAttrChanged: after they mutate _bindingSelections/_bindingOverrides, call RebuildBindingGroups();.

BuildTestableRows: replace foreach (var attr in _bindingDataSourceAttrs) with

        foreach (var g in _bindingGroups)
        foreach (var m in g.Members)
        {
            var name = m.Row.CanonicalName;
            var connId = GetBindingConnectionId(name);
            if (connId <= 0) continue;
            var conn = _siteConnections.FirstOrDefault(c => c.Id == connId);
            if (conn is null) continue;
            var effectivePath = _bindingOverrides.GetValueOrDefault(name) ?? GetTemplateDefault(name);
            if (string.IsNullOrWhiteSpace(effectivePath)) continue;
            rows.Add(new TestBindingsDialog.BindingRowToTest(name, conn.Name, effectivePath));
        }

ApplyBulkBinding (existing all-rows connection select): iterate _bindingRows instead of _bindingDataSourceAttrs, then RebuildBindingGroups().

BuildFlattenedAttributesAsync (alarm picker): if _flattenedForBindings is already populated, use it instead of flattening a second time.

SaveBindings body is unchanged. Temporarily keep the page compiling by leaving the markup's @foreach (var attr in _bindingDataSourceAttrs) loop pointed at a shim: private IEnumerable<TemplateAttribute> _bindingDataSourceAttrs => Enumerable.Empty<TemplateAttribute>(); — Task 5 removes it with the old markup. (Do not commit a page that renders an empty table without the warning: Tasks 4 and 5 may be committed together if the implementer prefers; the structural tests for Task 4 must pass first.)

Step 4: Run — dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI -v q (0 errors) then the Task 4 filter → 3 passed, then --filter "FullyQualifiedName~InstanceConfigure" → all existing InstanceConfigure tests still pass.

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingRowsTests.cs
git commit -m "feat(centralui): Configure page sources connection-binding rows from the flattened config — composed module members were invisible; flatten failure falls back to direct attributes with a banner"

Task 5: Configure page — grouped markup with per-module Object apply

Classification: standard Estimated implement time: ~5 min Parallelizable with: none Depends on: Task 4

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor (markup ~lines 82-182 + code block)
  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.css (create if absent — check ls first; if the page has no scoped css, put the 6 rules in wwwroot/css/site.css under a /* instance-configure bindings */ comment)
  • Test: tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingGroupsTests.cs (create)

Reference mockup: artifact https://claude.ai/code/artifact/6af84fbc-5714-4937-a98c-6e2245984044 (file scratchpad/bindings-mockup.html in the design session). Bootstrap 5 only, no component libraries.

Step 1: Write the failing structural tests

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;

public class InstanceConfigureBindingGroupsTests
{
    private static string Markup => InstanceConfigureBindingRowsTests.ReadMarkup(); // make ReadMarkup internal static in Task 4's test file

    [Fact]
    public void GroupHeader_HasConnectionObjectAndApply()
    {
        var m = Markup;
        Assert.Contains("data-test=\"binding-group-header\"", m);
        Assert.Contains("data-test=\"binding-group-connection\"", m);
        Assert.Contains("data-test=\"binding-group-object\"", m);
        Assert.Contains("data-test=\"binding-group-apply\"", m);
        Assert.Contains("ApplyGroupObject(", m);
        Assert.Contains("BindingGroupBuilder.ApplyObject(", m);
    }

    [Fact]
    public void GroupHeader_ShowsUnboundAndMixedPills()
    {
        var m = Markup;
        Assert.Contains("data-test=\"binding-group-unbound\"", m);
        Assert.Contains("data-test=\"binding-group-mixed\"", m);
        Assert.Contains("data-test=\"binding-total-unbound\"", m);
    }

    [Fact]
    public void Toolbar_HasUnboundFilterAndExpandAll()
    {
        var m = Markup;
        Assert.Contains("_showOnlyUnbound", m);
        Assert.Contains("data-test=\"binding-expand-all\"", m);
        Assert.Contains("data-test=\"binding-bulk-select\"", m); // existing all-groups connection select retained
    }

    [Fact]
    public void ComposedBareDefault_IsFlagged()
    {
        Assert.Contains("data-test=\"binding-bare-default\"", Markup);
    }

    [Fact]
    public void OldFlatTable_IsGone()
    {
        Assert.DoesNotContain("_bindingDataSourceAttrs", Markup);
    }
}

Step 2: Run to verify failure — filter InstanceConfigureBindingGroupsTests → 5 failed.

Step 3: Implement

Code-block additions:

    private readonly HashSet<string> _expandedGroups = new(StringComparer.Ordinal);
    private readonly Dictionary<string, string?> _groupObjectInput = new(StringComparer.Ordinal);
    private readonly Dictionary<string, int?> _groupConnectionInput = new(StringComparer.Ordinal);
    private readonly Dictionary<string, int> _groupChangedCounts = new(StringComparer.Ordinal);
    private bool _showOnlyUnbound;

    private void ToggleGroup(string key) { if (!_expandedGroups.Remove(key)) _expandedGroups.Add(key); }
    private void ExpandAll() { foreach (var g in _bindingGroups) _expandedGroups.Add(g.Key); }

    private string? GroupObjectInput(BindingGroup g)
        => _groupObjectInput.TryGetValue(g.Key, out var v) ? v : g.InferredObject;
    private int GroupConnectionInput(BindingGroup g)
        => _groupConnectionInput.TryGetValue(g.Key, out var v) ? v ?? 0 : g.CommonConnectionId ?? 0;

    private void ApplyGroupObject(BindingGroup g)
    {
        var connId = GroupConnectionInput(g);
        var changes = BindingGroupBuilder.ApplyObject(g, connId > 0 ? connId : null, GroupObjectInput(g));
        var changed = 0;
        foreach (var c in changes)
        {
            var before = (_bindingSelections.GetValueOrDefault(c.CanonicalName), _bindingOverrides.GetValueOrDefault(c.CanonicalName));
            if (c.ConnectionId is > 0) _bindingSelections[c.CanonicalName] = c.ConnectionId.Value; else _bindingSelections.Remove(c.CanonicalName);
            if (!string.IsNullOrEmpty(c.Override)) _bindingOverrides[c.CanonicalName] = c.Override; else _bindingOverrides.Remove(c.CanonicalName);
            if (before != (_bindingSelections.GetValueOrDefault(c.CanonicalName), _bindingOverrides.GetValueOrDefault(c.CanonicalName))) changed++;
        }
        _groupChangedCounts[g.Key] = _groupChangedCounts.GetValueOrDefault(g.Key) + changed;
        _groupObjectInput.Remove(g.Key); _groupConnectionInput.Remove(g.Key);
        RebuildBindingGroups();
    }

    private IEnumerable<BindingGroupMember> VisibleMembers(BindingGroup g)
        => _showOnlyUnbound ? g.Members.Where(m => m.IsUnbound) : g.Members;
    private IEnumerable<BindingGroup> VisibleGroups
        => _showOnlyUnbound ? _bindingGroups.Where(g => g.UnboundCount > 0) : _bindingGroups;

In SaveBindings, on success: _groupChangedCounts.Clear();.

Markup — replace the Connection Bindings card body (from the @* Connection Bindings *@ comment through its closing </div>) with (abbreviated to the essential structure; keep Bootstrap classes, keep existing data-test="binding-bulk-select"):

@* Connection Bindings — grouped by module (design 2026-08-20) *@
<div class="card mb-3">
    <div class="card-header d-flex align-items-center gap-3">
        <strong>Connection Bindings</strong>
        <span class="text-muted small">@_bindingRows.Count data-sourced attributes in @_bindingGroups.Count groups</span>
        <span class="ms-auto"></span>
        @if (TotalUnbound > 0) { <span class="badge text-bg-warning" data-test="binding-total-unbound">@TotalUnbound unbound</span> }
        @if (MixedGroups > 0) { <span class="badge text-bg-secondary">@MixedGroups group(s) mixed</span> }
    </div>
    @if (_bindingSourceWarning is not null)
    {
        <div class="alert alert-warning rounded-0 mb-0 py-2 small" data-test="binding-source-warning">@_bindingSourceWarning</div>
    }
    <div class="card-body border-bottom py-2 d-flex align-items-center gap-3 flex-wrap small">
        @if (_siteConnections.Count > 0)
        {
            <label class="d-flex align-items-center gap-2">All groups
                <select class="form-select form-select-sm" style="width:auto" data-test="binding-bulk-select" @bind="_bulkConnectionId">
                    <option value="0">— none —</option>
                    @foreach (var c in _siteConnections) { <option value="@c.Id">@c.Name</option> }
                </select>
                <button class="btn btn-outline-primary btn-sm" @onclick="ApplyBulkBinding" disabled="@(_bulkConnectionId <= 0)">Apply connection</button>
            </label>
        }
        <span class="ms-auto"></span>
        <label class="form-check form-check-inline mb-0"><input class="form-check-input" type="checkbox" @bind="_showOnlyUnbound" /> Show only unbound</label>
        <button class="btn btn-outline-secondary btn-sm" data-test="binding-expand-all" @onclick="ExpandAll">Expand all</button>
    </div>

    @if (_bindingRows.Count == 0)
    {
        <div class="card-body text-muted small">This template has no data-sourced attributes.</div>
    }
    else
    {
        @foreach (var g in VisibleGroups)
        {
            var open = _expandedGroups.Contains(g.Key);
            <div class="binding-group border-bottom">
                <div class="binding-group-header d-flex align-items-center gap-3 px-3 py-2 @(open ? "bg-light" : "")" data-test="binding-group-header">
                    <button class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="() => ToggleGroup(g.Key)" aria-expanded="@open">@(open ? "▼" : "▶")</button>
                    <span class="fw-semibold">@g.Key
                        @if (g.IsInstanceGroup) { <small class="text-muted fw-normal ms-1">direct attributes</small> }
                        else if (g.NestedModules.Count > 0) { <small class="text-muted fw-normal ms-1">incl. @string.Join(", ", g.NestedModules)</small> }
                    </span>
                    <span class="text-muted small">@g.Members.Count</span>
                    <select class="form-select form-select-sm" style="width:150px" data-test="binding-group-connection"
                            value="@GroupConnectionInput(g)" @onchange="e => _groupConnectionInput[g.Key] = int.TryParse(e.Value?.ToString(), out var v) ? v : 0">
                        <option value="0">@(g.ConnectionState == BindingConnectionState.Mixed ? "— mixed —" : "— none —")</option>
                        @foreach (var c in _siteConnections) { <option value="@c.Id">@c.Name</option> }
                    </select>
                    <input class="form-control form-control-sm font-monospace" style="max-width:260px" data-test="binding-group-object"
                           value="@GroupObjectInput(g)" placeholder="@(g.IsObjectMixed ? "mixed — enter object to unify" : "object, e.g. MESReceiver_022")"
                           @onchange="e => _groupObjectInput[g.Key] = e.Value?.ToString()" />
                    <button class="btn btn-outline-primary btn-sm" data-test="binding-group-apply" @onclick="() => ApplyGroupObject(g)" disabled="@_saving">Apply to @g.Members.Count</button>
                    @if (g.UnboundCount > 0) { <span class="badge text-bg-danger" data-test="binding-group-unbound">@g.UnboundCount unbound</span> }
                    else { <span class="badge text-bg-success">bound</span> }
                    @if (g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed) { <span class="badge text-bg-secondary" data-test="binding-group-mixed">mixed</span> }
                    @if (_groupChangedCounts.TryGetValue(g.Key, out var changed) && changed > 0) { <span class="badge border text-muted">@changed changed · unsaved</span> }
                </div>
                @if (open)
                {
                    <div class="table-responsive">
                        <table class="table table-sm table-bordered mb-0 align-middle">
                            <thead class="table-light"><tr><th>Member</th><th>Template default</th><th style="width:150px">Connection</th><th>Reference override</th></tr></thead>
                            <tbody>
                            @foreach (var m in VisibleMembers(g))
                            {
                                var name = m.Row.CanonicalName;
                                var bare = !g.IsInstanceGroup && string.Equals(m.Row.DefaultReference, m.MemberTail, StringComparison.Ordinal);
                                <tr class="@(m.IsUnbound ? "table-danger" : "")">
                                    <td class="font-monospace ps-4">@m.MemberTail</td>
                                    <td class="font-monospace text-muted">@m.Row.DefaultReference
                                        @if (bare) { <span data-test="binding-bare-default" title="Composed members share a bare default name; set an override or the deploy gate rejects the instance.">⚠</span> }
                                    </td>
                                    <td>
                                        <select class="form-select form-select-sm" value="@GetBindingConnectionId(name)" @onchange="e => OnBindingChanged(name, e)">
                                            <option value="0">— none —</option>
                                            @foreach (var c in _siteConnections) { <option value="@c.Id">@c.Name</option> }
                                        </select>
                                    </td>
                                    <td><input class="form-control form-control-sm font-monospace" value="@GetOverrideForAttr(name)" placeholder="@(m.Row.DefaultReference ?? "(no default)")" @onchange="e => OnOverrideForAttrChanged(name, e)" /></td>
                                </tr>
                            }
                            </tbody>
                        </table>
                    </div>
                }
            </div>
        }
        <div class="card-footer d-flex align-items-center gap-2">
            <button class="btn btn-success btn-sm" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
            <button class="btn btn-outline-secondary btn-sm" @onclick="OpenTestBindings" disabled="@(!HasTestableBindings())">Test Bindings (@BuildTestableRows().Count)</button>
            <span class="text-muted small ms-2">Saved bindings reach the site on the next Deploy.@(TotalUnbound > 0 ? $" {TotalUnbound} row(s) are unbound — Deploy will be rejected until they are bound." : "")</span>
        </div>
    }
</div>

Keep the TestBindingsDialog component reference that already exists below the card. Remove the _bindingDataSourceAttrs shim from Task 4. Add minimal CSS (.binding-group-header .form-select, .binding-group-header .form-control { min-width: 0 } plus .binding-group-header { flex-wrap: wrap } for narrow widths).

Step 4: Run — dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI -v q (0 errors/0 warnings introduced), then dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~InstanceConfigure" -v q → all pass (Task 4 + Task 5 + pre-existing).

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.css tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingGroupsTests.cs
git commit -m "feat(centralui): grouped Connection Bindings panel — per-module connection + Object apply, unbound/mixed pills, show-only-unbound filter, Test Bindings covers composed rows"

Task 6: Live check on the docker rig

Classification: small Estimated implement time: ~5 min (mostly build time) Parallelizable with: Task 7, Task 8 Depends on: Task 5

Files: none edited. Uses docker/deploy.sh and the scadabridge-cluster-ops skill (URL http://localhost:9000, multi-role/password).

Steps

  1. bash docker/deploy.sh (rebuilds scadabridge:latest, recreates the 8 nodes). Wait for curl -s localhost:9000/health/ready → 200.
  2. Pick a composed instance: curl -s -u multi-role:password -H 'Content-Type: application/json' localhost:9000/management -d '{"command":"ListInstances","payload":{}}' | python3 -c 'import sys,json;[print(i["id"],i["uniqueName"],i["templateId"]) for i in json.load(sys.stdin)]'. If none has a composed template, create one from the rig's existing feature-module template (see docker/README.md seed data) or accept the (instance) group only and note it.
  3. Open http://localhost:9000/deployment/instances/<id>/configure with the Chrome tools (claude-in-chrome skill) and screenshot: groups render, expanding shows members, typing an Object + Apply rewrites the rows and shows n changed · unsaved, Save succeeds, reload shows the inferred Object pre-filled, Test Bindings reads the composed rows.
  4. Record the screenshot path and any defect in the commit message of the next task or fix inline (small fixes → amend into Task 5's scope with their own commit).

No commit unless a fix was needed.


Task 7: CLI instance bind-module

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2, Task 3, Task 6

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs (add CreateBindModuleCommand, register it next to set-bindings in the instance command's Add(...) list; add the pure internal static helper BuildModuleRewrite)
  • Test: tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/InstanceArgumentParsingTests.cs (append) — pure helper tests; and the frozen command-surface/authz tests if they enumerate CLI commands (grep -rn "set-bindings" tests/ZB.MOM.WW.ScadaBridge.CLI.Tests | head — if a lock-in list exists, add bind-module to it).

Step 1: Write the failing tests

    [Fact]
    public void BuildModuleRewrite_RewritesOnlyTheModule_AndKeepsConnection()
    {
        var existing = new[]
        {
            new ConnectionBinding("RightMESReceiver.MoveInFlag", 1, "MESReceiver_024.MoveInFlag"),
            new ConnectionBinding("RightMESReceiver.ScanStateCmd", 1, "MESReceiver_024.ScanStateCmd"),
            new ConnectionBinding("LeftMESReceiver.MoveInFlag", 1, "MESReceiver_023.MoveInFlag"),
        };
        var ok = InstanceCommands.BuildModuleRewrite(existing, "RightMESReceiver", connectionId: 1, objectPath: "MESReceiver_022", out var rewritten, out var error);
        Assert.True(ok, error);
        Assert.Equal(2, rewritten!.Count);
        Assert.All(rewritten, b => Assert.StartsWith("MESReceiver_022.", b.DataSourceReferenceOverride!));
        Assert.DoesNotContain(rewritten, b => b.AttributeName.StartsWith("Left"));
    }

    [Fact]
    public void BuildModuleRewrite_InstanceGroup_MatchesDotlessNames()
    {
        var existing = new[] { new ConnectionBinding("ChlorineFlow", 1, "Z28061.ChlorineFlow"), new ConnectionBinding("M.A", 1, "O.A") };
        Assert.True(InstanceCommands.BuildModuleRewrite(existing, "(instance)", 1, "Z28063", out var r, out _));
        var b = Assert.Single(r!);
        Assert.Equal("Z28063.ChlorineFlow", b.DataSourceReferenceOverride);
    }

    [Fact]
    public void BuildModuleRewrite_EmptyObject_ClearsOverride()
    {
        var existing = new[] { new ConnectionBinding("M.A", 1, "O.A") };
        Assert.True(InstanceCommands.BuildModuleRewrite(existing, "M", 2, "", out var r, out _));
        var b = Assert.Single(r!);
        Assert.Null(b.DataSourceReferenceOverride);
        Assert.Equal(2, b.DataConnectionId);
    }

    [Fact]
    public void BuildModuleRewrite_UnknownModule_ListsAvailable()
    {
        var existing = new[] { new ConnectionBinding("M.A", 1, "O.A"), new ConnectionBinding("N.B", 1, "O.B") };
        Assert.False(InstanceCommands.BuildModuleRewrite(existing, "Q", 1, "X", out _, out var error));
        Assert.Contains("M", error);
        Assert.Contains("N", error);
    }

    [Fact]
    public void BindModule_ParsesOptions()
    {
        var root = Program.BuildRootCommand(); // or however the existing parsing tests obtain the root — mirror InstanceArgumentParsingTests
        var result = root.Parse("instance bind-module --id 1 --module RightMESReceiver --connection 1 --object MESReceiver_022 --dry-run");
        Assert.Empty(result.Errors);
    }

(Adjust the last test to the root-command accessor the existing file already uses.)

Step 2: Run to verify failure — dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter "FullyQualifiedName~InstanceArgumentParsingTests" -v q → build error.

Step 3: Implement

    /// <summary>
    /// Pure core of <c>instance bind-module</c>: selects the existing bindings that belong
    /// to <paramref name="module"/> (canonical names starting with <c>{module}.</c>, or
    /// dot-less names for <c>(instance)</c>) and rewrites each override to
    /// <c>{objectPath}.{memberTail}</c> on <paramref name="connectionId"/>. An empty
    /// <paramref name="objectPath"/> clears the override. Only the module's rows are
    /// returned, so the upsert leaves every other module untouched.
    /// </summary>
    internal static bool BuildModuleRewrite(
        IReadOnlyList<ConnectionBinding> existing, string module, int connectionId, string? objectPath,
        out List<ConnectionBinding>? rewritten, out string? error)
    {
        rewritten = null; error = null;
        var isInstance = module is "(instance)" or ".";
        var prefix = module + ".";
        var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.');

        var rows = existing.Where(b => isInstance
                ? !b.AttributeName.Contains('.')
                : b.AttributeName.StartsWith(prefix, StringComparison.Ordinal))
            .Select(b =>
            {
                var tail = isInstance ? b.AttributeName : b.AttributeName[prefix.Length..];
                return new ConnectionBinding(b.AttributeName, connectionId, obj.Length == 0 ? null : obj + "." + tail);
            })
            .ToList();

        if (rows.Count == 0)
        {
            var available = existing.Select(b => b.AttributeName.Contains('.') ? b.AttributeName[..b.AttributeName.IndexOf('.')] : "(instance)")
                .Distinct().OrderBy(s => s, StringComparer.Ordinal);
            error = $"Instance has no bindings for module '{module}'. Available: {string.Join(", ", available)}. " +
                    "A module with no bindings yet must be seeded with 'instance set-bindings' (or bound once in the Central UI).";
            return false;
        }
        rewritten = rows;
        return true;
    }

    private static Command CreateBindModuleCommand(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
    {
        var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
        var moduleOption = new Option<string>("--module") { Description = "Module (canonical-name prefix), e.g. RightMESReceiver; use '(instance)' for direct attributes", Required = true };
        var connOption = new Option<int>("--connection") { Description = "Data connection ID", Required = true };
        var objectOption = new Option<string>("--object") { Description = "MxAccess/OPC object path the module's members hang off, e.g. MESReceiver_022 or Left_002.DelmiaReceiver; empty clears overrides", Required = true };
        var dryRunOption = new Option<bool>("--dry-run") { Description = "Print the resulting bindings without saving" };

        var cmd = new Command("bind-module") { Description = "Rebind every member of one composed module to a new object path (upserts only that module's bindings)" };
        cmd.Add(idOption); cmd.Add(moduleOption); cmd.Add(connOption); cmd.Add(objectOption); cmd.Add(dryRunOption);
        cmd.SetAction(async (ParseResult result) =>
        {
            var id = result.GetValue(idOption);
            var module = result.GetValue(moduleOption)!;
            var conn = result.GetValue(connOption);
            var obj = result.GetValue(objectOption);
            var dryRun = result.GetValue(dryRunOption);

            return await CommandHelpers.ExecuteCommandAsync(
                result, urlOption, formatOption, usernameOption, passwordOption,
                new GetInstanceCommand(id),
                onSuccess: json =>
                {
                    var existing = ParseExistingBindings(json); // System.Text.Json over "connectionBindings": [{attributeName,dataConnectionId,dataSourceReferenceOverride}]
                    if (!BuildModuleRewrite(existing, module, conn, obj, out var rewritten, out var error))
                    {
                        OutputFormatter.WriteError(error!, "UNKNOWN_MODULE");
                        return 1;
                    }
                    if (dryRun)
                    {
                        OutputFormatter.WriteJson(rewritten!); // or the formatter's equivalent success writer
                        return 0;
                    }
                    return CommandHelpers.ExecuteCommandAsync(
                        result, urlOption, formatOption, usernameOption, passwordOption,
                        new SetConnectionBindingsCommand(id, rewritten!)).GetAwaiter().GetResult();
                });
        });
        return cmd;
    }

Implement ParseExistingBindings(string json) with JsonDocument (camelCase property names as the management API emits them). Register: find where CreateSetBindingsCommand(...) is added to the instance command and add CreateBindModuleCommand(...) right after it. Check OutputFormatter for the success-writer name (grep -n "public static" src/ZB.MOM.WW.ScadaBridge.CLI/OutputFormatter.cs).

Step 4: Run — dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests -v q (whole CLI suite — it has frozen command-surface tests that may need the new command added).

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/
git commit -m "feat(cli): instance bind-module — rebind one composed module's members to a new object path via GetInstance → SetConnectionBindings upsert; --dry-run"

Task 8: Docs

Classification: trivial Estimated implement time: ~4 min Parallelizable with: Task 6, Task 7 Depends on: Task 5

Files:

  • Modify: docs/requirements/Component-CentralUI.md (Configure Instance / connection bindings section — grep -n "Connection Bindings\|Test Bindings" docs/requirements/Component-CentralUI.md)
  • Modify: docs/requirements/Component-TemplateEngine.md (flattened configuration / ResolvedAttribute — grep -n "ResolvedAttribute\|DataSourceReference" docs/requirements/Component-TemplateEngine.md)
  • Modify: src/ZB.MOM.WW.ScadaBridge.CLI/README.md (instance commands table — add bind-module after set-bindings)
  • Modify: docs/plans/2026-08-20-grouped-connection-bindings-design.md (Status → implemented, commit range)

Content

  • CentralUI: rows come from the flattened configuration (composed members included); grouping by first canonical segment with nested modules folded; group header = connection + Object + Apply (<object>.<memberTail>, blank clears); pills n unbound / mixed / n changed · unsaved; show-only-unbound; Save posts every row; Test Bindings covers composed rows; flatten failure → direct-attribute fallback with banner. State the gap it closed (264/280 rows invisible on QA, 2026-08-20).
  • TemplateEngine: ResolvedAttribute.DefaultDataSourceReference (pre-override default, central-side presentation, excluded from the revision hash, pinned by DefaultDataSourceReferenceTests).
  • CLI README: syntax, the (instance) module key, --dry-run, the "module must already have bindings; seed with set-bindings" rule, and an example mirroring QA: scadabridge instance bind-module --id 1 --module RightMESReceiver --connection 1 --object MESReceiver_022.

Commit:

git add docs/requirements/Component-CentralUI.md docs/requirements/Component-TemplateEngine.md src/ZB.MOM.WW.ScadaBridge.CLI/README.md docs/plans/2026-08-20-grouped-connection-bindings-design.md
git commit -m "docs: grouped connection bindings — Central UI Configure page, ResolvedAttribute.DefaultDataSourceReference, CLI instance bind-module"

Task 9: Solution build + impacted suites

Classification: small Estimated implement time: ~5 min (build time) Parallelizable with: none Depends on: all

Run, in the worktree:

dotnet build ZB.MOM.WW.ScadaBridge.slnx -v q 2>&1 | tail -3          # expect 0 errors; no new warnings in touched projects
dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests -v q
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests -v q
dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests -v q
dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests -v q   # consumes FlattenedConfiguration

All green → git log --oneline main..HEAD and hand off to superpowers-extended-cc:finishing-a-development-branch (fast-forward onto main; no EF migrations in this plan). Remind the user that QA (vq03) only sees the page after a host redeploy.