Files
ScadaBridge/docs/plans/2026-08-21-create-instance-bindings.md
T

46 KiB

Create Instance Bindings Implementation Plan

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

Goal: Let the operator bind every module group of a new instance to an MxAccess object / OPC UA node on the Create Instance page, so one Create yields an instance that is deployable (or lands on Configure with only the genuinely undecided groups unbound).

Architecture: A new IFlatteningPipeline.PreviewAsync(templateId, siteId) flattens an unsaved Instance so the create page can build the same BindingGroups the Configure page shows. A pure CreateBindingPlanner (CentralUI Services/) holds Prefill / tail-stripping / binding-list assembly and is unit-pinned like BindingGroupBuilder. InstanceCreate.razor gains the Connection Bindings card (group-header rows only), NodeBrowserDialog gains an additive SelectableNodeClasses parameter so an Object node can be picked, and Create = CreateInstanceAsync → SetConnectionBindingsAsync (upsert) → navigate to Configure, which shows a one-shot notice. Design: docs/plans/2026-08-21-create-instance-bindings-design.md; mockup: artifact "Create Instance Bindings".

Tech Stack: .NET 10, Blazor Server + Bootstrap (no component libs), xunit + NSubstitute + bUnit, existing BindingGroupBuilder, FlatteningService.

Worktree rule: implement in a dedicated git worktree off main, never in the primary checkout. Do NOT use git stash (shared across worktrees). docker/deploy.sh refuses to run from a worktree — for the live check use docker/build.sh from the worktree then docker compose -f <main>/docker/docker-compose.yml up -d --force-recreate.


Task 1: IFlatteningPipeline.PreviewAsync (DeploymentManager)

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

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.DeploymentManager/IFlatteningPipeline.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs (chain/composition loading lines ~84-135 factored into a helper)
  • Test: tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs (create)

FlatteningPipeline is the only implementor of the interface (verified), so the interface addition breaks nothing else; NSubstitute mocks elsewhere pick it up automatically.

Step 1: Write the failing tests

using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;

namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;

/// <summary>Pins PreviewAsync: same attribute set as a freshly created, un-overridden instance.</summary>
public class FlatteningPipelinePreviewTests
{
    private const int TemplateId = 10, ModuleTemplateId = 11, SiteId = 20, InstanceId = 30;
    private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
    private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
    private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>();
    private readonly FlatteningPipeline _sut;

    public FlatteningPipelinePreviewTests()
    {
        _sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>()).Returns([]);
        _sut = new FlatteningPipeline(_templateRepo, _siteRepo, new FlatteningService(), new ValidationService(),
            new RevisionHashService(), _sharedSchemaRepo, new TemplateGraphWatermark());

        // Parent template with one direct data-sourced attribute composing a module with one.
        var module = new Template("Receiver") { Id = ModuleTemplateId };
        module.Attributes.Add(new TemplateAttribute("MoveInFlag") { DataType = DataType.Boolean, DataSourceReference = "MoveInFlag" });
        var parent = new Template("Line") { Id = TemplateId };
        parent.Attributes.Add(new TemplateAttribute("Running") { DataType = DataType.Boolean, DataSourceReference = "Running" });
        var composition = new TemplateComposition { TemplateId = TemplateId, ComposedTemplateId = ModuleTemplateId, ModuleInstanceName = "Left" };

        _templateRepo.GetTemplateWithChildrenAsync(TemplateId, Arg.Any<CancellationToken>()).Returns(parent);
        _templateRepo.GetTemplateWithChildrenAsync(ModuleTemplateId, Arg.Any<CancellationToken>()).Returns(module);
        _templateRepo.GetCompositionsByTemplateIdAsync(TemplateId, Arg.Any<CancellationToken>()).Returns([composition]);
        _templateRepo.GetCompositionsByTemplateIdAsync(ModuleTemplateId, Arg.Any<CancellationToken>()).Returns([]);
        _templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>()).Returns([]);
        _templateRepo.GetInstanceByIdAsync(InstanceId, Arg.Any<CancellationToken>())
            .Returns(new Instance("Line-01") { Id = InstanceId, TemplateId = TemplateId, SiteId = SiteId });
        _siteRepo.GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any<CancellationToken>())
            .Returns([new DataConnection("MxGateway", "MxGateway", SiteId) { Id = 1 }]);
    }

    [Fact]
    public async Task Preview_matches_flatten_of_fresh_instance()
    {
        var preview = await _sut.PreviewAsync(TemplateId, SiteId);
        var real = await _sut.FlattenAndValidateAsync(InstanceId, validateScripts: false);

        Assert.True(preview.IsSuccess, preview.Error);
        var expected = real.Value.Configuration.Attributes.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference)).OrderBy(x => x.Item1).ToList();
        var actual = preview.Value.Attributes.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference)).OrderBy(x => x.Item1).ToList();
        Assert.Equal(expected, actual);
        Assert.Contains(actual, a => a.Item1 == "Left.MoveInFlag");
    }

    [Fact]
    public async Task Preview_unknown_template_fails()
    {
        var r = await _sut.PreviewAsync(999, SiteId);
        Assert.True(r.IsFailure);
    }

    [Fact]
    public async Task Preview_never_touches_instance_repository()
    {
        await _sut.PreviewAsync(TemplateId, SiteId);
        await _templateRepo.DidNotReceive().GetInstanceByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
    }
}

Check the TemplateComposition constructor/property names against src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateComposition.cs and adjust the object initializer (the entity may use a ctor).

Step 2: Run to verify failure

Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FlatteningPipelinePreviewTests Expected: build error — PreviewAsync not defined.

Step 3: Implement

IFlatteningPipeline.cs — add:

    /// <summary>
    /// Flattens <paramref name="templateId"/> as if an instance with no overrides and no
    /// bindings existed at <paramref name="siteId"/>. Attribute list only — no validation,
    /// no revision hash, nothing persisted. Used by the Create Instance page to show the
    /// module groups before the instance row exists.
    /// </summary>
    Task<Result<FlattenedConfiguration>> PreviewAsync(int templateId, int siteId, CancellationToken cancellationToken = default);

FlatteningPipeline.cs — extract the chain + composition walk (from var templateChain = await session.GetChainAsync(instance.TemplateId, LoadTemplate); through the end of the while (pendingChains.Count > 0) loop) into

    private async Task<(IReadOnlyList<Template> Chain,
                        Dictionary<int, IReadOnlyList<TemplateComposition>> CompositionMap,
                        Dictionary<int, IReadOnlyList<Template>> ComposedChains)?>
        LoadTemplateGraphAsync(int templateId, FlattenSession session, CancellationToken cancellationToken)

returning null when the chain is empty; call it from FlattenAndValidateAsync (keep the existing "Template chain is empty." failure), then add

    public async Task<Result<FlattenedConfiguration>> PreviewAsync(int templateId, int siteId, CancellationToken cancellationToken = default)
    {
        var session = CreateSession();
        var graph = await LoadTemplateGraphAsync(templateId, session, cancellationToken);
        if (graph is null)
            return Result<FlattenedConfiguration>.Failure($"Template {templateId} not found or its chain is empty.");

        var dataConnections = await session.GetDataConnectionsAsync(siteId, id => LoadDataConnectionsAsync(id, cancellationToken));
        var preview = new Instance("(preview)") { TemplateId = templateId, SiteId = siteId };
        return _flatteningService.Flatten(preview, graph.Value.Chain, graph.Value.CompositionMap, graph.Value.ComposedChains, dataConnections);
    }

(Use the fully-qualified Commons.Entities.Templates.* names the file already uses.)

Step 4: Run tests

Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter "FlatteningPipeline" Expected: all PASS (preview tests + the existing ConnectionBinding / NativeAlarmCapability / FlattenSessionCache suites, which exercise the refactored path).

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.DeploymentManager tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs
git commit -m "feat(deploymentmanager): IFlatteningPipeline.PreviewAsync — flatten a template+site without an instance row (create-page bindings)"

Task 2: CreateBindingPlanner — Prefill + tail stripping + binding list (pure)

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

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CreateBindingPlanner.cs
  • Test: tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.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 CreateBindingPlannerTests
{
    private static IReadOnlyList<BindingGroup> 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<string, CreateBindingPlanner.GroupInput>
        {
            ["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<string, CreateBindingPlanner.GroupInput>(), 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<string, CreateBindingPlanner.GroupInput>(), 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<string, CreateBindingPlanner.GroupInput>
        {
            [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<string, CreateBindingPlanner.GroupInput> { ["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");
    }
}

Step 2: Run to verify failure

Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter CreateBindingPlannerTests Expected: build error — CreateBindingPlanner not defined.

Step 3: Implement

using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;

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

/// <summary>
/// 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
/// <see cref="BindingGroupBuilder.Build"/> over a template+site preview flatten.
/// </summary>
public static class CreateBindingPlanner
{
    /// <summary>Per-group operator input. <c>ConnectionId</c> 0 = none.</summary>
    public sealed record GroupInput(int ConnectionId, string? Object);

    public enum ObjectSource { None, Typed, Suggested, FromTemplate }

    /// <summary>
    /// Sets <paramref name="connectionId"/> on every group and fills BLANK Objects only:
    /// the instance group gets <paramref name="rootObject"/>; a module group whose
    /// defaults already share a prefix keeps it (<see cref="BindingGroup.InferredObject"/>);
    /// otherwise, when a root is given, <c>{root}.{groupKey}</c> is suggested. A typed
    /// Object is never overwritten.
    /// </summary>
    public static IReadOnlyDictionary<string, GroupInput> Prefill(
        IReadOnlyList<BindingGroup> groups,
        IReadOnlyDictionary<string, GroupInput> current,
        int connectionId,
        string? rootObject)
    {
        var root = (rootObject ?? string.Empty).Trim().TrimEnd('.');
        var result = new Dictionary<string, GroupInput>(StringComparer.Ordinal);
        foreach (var g in groups)
        {
            current.TryGetValue(g.Key, out var existing);
            var obj = existing?.Object;
            if (string.IsNullOrWhiteSpace(obj))
            {
                obj = g.IsInstanceGroup ? (root.Length == 0 ? null : root)
                    : g.InferredObject is { Length: > 0 } inferred ? inferred
                    : root.Length == 0 ? null
                    : root + "." + g.Key;
            }
            result[g.Key] = new GroupInput(connectionId, obj);
        }
        return result;
    }

    /// <summary>Where a group's current Object value came from, for the row's provenance pill.</summary>
    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;
    }

    /// <summary>
    /// Object for a browser pick: an Object node is used as-is; a Variable whose id ends
    /// with <c>.{memberTail}</c> of any member of the group has that tail stripped.
    /// </summary>
    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;
    }

    /// <summary>
    /// The bindings to send with Create: every member of every group that has BOTH a
    /// connection and a non-blank Object, rewritten via <see cref="BindingGroupBuilder.ApplyObject"/>.
    /// Groups missing either are left unbound for Configure.
    /// </summary>
    public static IReadOnlyList<ConnectionBinding> BuildBindings(
        IReadOnlyList<BindingGroup> groups,
        IReadOnlyDictionary<string, GroupInput> inputs)
    {
        var list = new List<ConnectionBinding>();
        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;
    }
}

SourceOf needs to know whether Prefill produced the value: make GroupInput carry a third positional bool Suggested = false, and have Prefill set Suggested: true only on the root + "." + g.Key branch. Update the test expectations that construct GroupInput(7, "Z28063.Left") accordingly (new(7, "Z28063.Left", true) where the suggestion is asserted). Keep the record positional so the tests' Assert.Equal on records works.

Step 4: Run tests

Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "CreateBindingPlannerTests|BindingGroupBuilderTests" Expected: PASS.

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CreateBindingPlanner.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs
git commit -m "feat(centralui): CreateBindingPlanner — prefill/tail-strip/binding-list logic for create-time bindings"

Task 3: NodeBrowserDialog.SelectableNodeClasses (additive)

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

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor (parameters ~128-150; SelectBrowseNode/Select ~356-370; footer prompt ~119)
  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor (line 14 @if (Node.NodeClass == BrowseNodeClass.Variable); parameter block ~74)
  • Test: tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs (append)

Step 1: Write the failing tests (append to NodeBrowserDialogSelectionTests; the fixture's SearchAsync stub already returns one Variable — add an Object match to the stub's Matches array: new AddressSpaceMatch(new BrowseNode("ns=2;s=Pump1", "Pump1", BrowseNodeClass.Object, HasChildren: true), Path: "Devices/Pump1"))

    [Fact]
    public void Object_node_is_not_selectable_by_default()
    {
        var cut = Render<NodeBrowserDialog>(p => p.Add(c => c.SiteId, "plant-a").Add(c => c.ConnectionName, "PLC-OPC"));
        cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-OPC", null));
        cut.Render();
        cut.Find("[data-test=node-search-input]").Input("Pump1");
        cut.Find("[data-test=node-search-button]").Click();

        // Only the Variable renders a select button; the Object is plain text.
        Assert.Single(cut.FindAll("[data-test=node-search-result] button.btn-link"));
    }

    [Fact]
    public void Object_node_is_selectable_when_listed()
    {
        BrowseNode? captured = null;
        var cut = Render<NodeBrowserDialog>(p => p
            .Add(c => c.SiteId, "plant-a").Add(c => c.ConnectionName, "PLC-OPC")
            .Add(c => c.SelectableNodeClasses, new[] { BrowseNodeClass.Object, BrowseNodeClass.Variable })
            .Add(c => c.OnNodeSelected, EventCallback.Factory.Create<BrowseNode>(this, n => captured = n)));
        cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-OPC", null));
        cut.Render();
        cut.Find("[data-test=node-search-input]").Input("Pump1");
        cut.Find("[data-test=node-search-button]").Click();
        var buttons = cut.FindAll("[data-test=node-search-result] button.btn-link");
        Assert.Equal(2, buttons.Count);
        buttons.First(b => b.TextContent.Trim() == "Pump1").Click();
        cut.Find(".modal-footer .btn-primary").Click();

        Assert.Equal("ns=2;s=Pump1", captured!.NodeId);
        Assert.Equal(BrowseNodeClass.Object, captured.NodeClass);
    }

Step 2: Run to verify failure

Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter NodeBrowserDialogSelectionTests Expected: second test fails to build (SelectableNodeClasses missing); first test already passes (pin).

Step 3: Implement

NodeBrowserDialog.razor:

  • Parameter: [Parameter] public IReadOnlyCollection<BrowseNodeClass> SelectableNodeClasses { get; set; } = new[] { BrowseNodeClass.Variable }; with a doc comment "Additive: node classes the picker accepts. Default Variable-only (every existing caller). The create page passes Object+Variable so a module's container can be chosen."
  • Helper: private bool IsSelectable(BrowseNodeClass c) => SelectableNodeClasses.Contains(c);
  • SelectBrowseNode / Select: replace != BrowseNodeClass.Variable guards with !IsSelectable(node.NodeClass).
  • Search-result markup (~line 55-70): render the <button> when IsSelectable(node.NodeClass), else a plain <span>@node.DisplayName</span>; when the class is Object and selectable, add <span class="badge bg-body-secondary text-body-secondary border ms-1">object</span>.
  • Pass IsSelectable to the tree: <TreeRow ... Selectable="IsSelectable" /> and in the footer prompt, if SelectableNodeClasses.Contains(BrowseNodeClass.Object) show <span class="text-muted small me-auto">Select an object or tag</span>.

TreeRow.razor:

  • Add [Parameter] public Func<BrowseNodeClass, bool>? Selectable { get; set; } and private bool IsSelectable => Selectable?.Invoke(Node.NodeClass) ?? Node.NodeClass == BrowseNodeClass.Variable;
  • Line 14: @if (IsSelectable); keep the non-selectable branch as today; pass Selectable="Selectable" on the recursive <TreeRow> (line 52). For an Object node that IS selectable, the existing expand toggle must still render — keep the toggle markup outside the @if.

Step 4: Run tests

Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "NodeBrowserDialog" Expected: PASS (selection + search suites).

Step 5: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs
git commit -m "feat(centralui): NodeBrowserDialog.SelectableNodeClasses — opt-in Object node selection (default unchanged)"

Task 4: Create page — code-behind state + preview load

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (blocked by 1, 2)

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor (@code block: move fields to partial class; add @inject IFlatteningPipeline FlatteningPipeline, @inject ISiteRepository is already there)

Step 1: Implement (no pure logic here — the testable parts live in Tasks 1-2; this task is wiring)

InstanceCreate.razor.cs (public partial class InstanceCreate):

    // --- Connection Bindings card state (design 2026-08-21) ---
    private IReadOnlyList<BindingGroup> _groups = Array.Empty<BindingGroup>();
    private int _rowCount;
    private List<DataConnection> _siteConnections = new();
    private readonly Dictionary<string, CreateBindingPlanner.GroupInput> _inputs = new(StringComparer.Ordinal);
    private int _rootConnectionId;
    private string? _rootObject;
    private bool _skipBindings;
    private bool _previewLoading;
    private string? _previewError;
    private string? _siteIdentifier;

    private bool BindingsCardVisible => _createTemplateId > 0 && _createSiteId > 0;
    private int UnboundGroups => _groups.Count(g => !IsGroupBound(g));
    private bool IsGroupBound(BindingGroup g)
        => _inputs.TryGetValue(g.Key, out var i) && i.ConnectionId > 0 && !string.IsNullOrWhiteSpace(i.Object);
    private int BoundRowCount => _groups.Where(IsGroupBound).Sum(g => g.Members.Count);

    /// <summary>Called from the Template and Site selects' @onchange; re-runs the preview.</summary>
    private async Task OnTemplateOrSiteChangedAsync()
    {
        _groups = Array.Empty<BindingGroup>(); _inputs.Clear(); _previewError = null; _rowCount = 0;
        if (!BindingsCardVisible) return;
        _previewLoading = true;
        try
        {
            var site = _sites.First(s => s.Id == _createSiteId);
            _siteIdentifier = site.SiteIdentifier;
            _siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_createSiteId)).ToList();
            var preview = await FlatteningPipeline.PreviewAsync(_createTemplateId, _createSiteId);
            if (preview.IsFailure) { _previewError = preview.Error; return; }
            var rows = preview.Value.Attributes
                .Where(a => a.DefaultDataSourceReference is not null || a.DataSourceReference is not null)
                .Select(a => new BindingRow(a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference, null, null))
                .ToList();
            _rowCount = rows.Count;
            _groups = BindingGroupBuilder.Build(rows, _siteConnections.Select(c => c.Id).ToHashSet());
            // Groups whose defaults already carry an object prefix start "from template".
            foreach (var g in _groups)
                if (!g.IsInstanceGroup && g.InferredObject is { Length: > 0 })
                    _inputs[g.Key] = new CreateBindingPlanner.GroupInput(0, g.InferredObject);
        }
        catch (Exception ex) { _previewError = ex.Message; }
        finally { _previewLoading = false; }
    }

    private void PrefillGroups()
    {
        foreach (var kv in CreateBindingPlanner.Prefill(_groups, _inputs, _rootConnectionId, _rootObject))
            _inputs[kv.Key] = kv.Value;
    }

    private void SetGroupConnection(BindingGroup g, int id)
        => _inputs[g.Key] = (_inputs.GetValueOrDefault(g.Key) ?? new(0, null)) with { ConnectionId = id };
    private void SetGroupObject(BindingGroup g, string? obj)
        => _inputs[g.Key] = (_inputs.GetValueOrDefault(g.Key) ?? new(0, null)) with { Object = obj, Suggested = false };

Move the existing @code fields (_sites, _templates, _allAreas, _loading, _createName, _createTemplateId, _createSiteId, _createAreaId, _formError) into the partial class so both files see them; leave OnInitializedAsync, CreateInstance, GoBack, GetCurrentUserAsync in the .razor for now (Task 6 rewrites CreateInstance). The Template/Site <select>s switch from @bind to value=/@onchange so the preview can run on change:

<select class="form-select form-select-sm" value="@_createTemplateId"
        @onchange="async e => { _createTemplateId = int.TryParse(e.Value?.ToString(), out var v) ? v : 0; await OnTemplateOrSiteChangedAsync(); }">

(same for Site, which additionally resets _createAreaId = 0). Also run OnTemplateOrSiteChangedAsync() at the end of OnInitializedAsync when SiteId came from the query string and a template is preselected (it won't be — keep the call; it no-ops when either is 0).

Step 2: Build

Run: dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI Expected: 0 errors.

Step 3: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs
git commit -m "feat(centralui): Create Instance — preview-flatten state + group inputs behind the bindings card"

Task 5: Create page — Connection Bindings card markup + Browse

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (blocked by 3, 4)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor
  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs (browser plumbing)
  • Reference only: InstanceConfigure.razor lines 88-175 (group header markup to mirror), InstanceConfigure.razor.css (.binding-panel, .binding-group-header — reuse by copying the two rules into a new InstanceCreate.razor.css)

Step 1: Markup — insert after the form card, before the error/buttons (move the Create/Cancel button group into the new card's footer when the card is visible; keep it in the form card when it is not):

@if (BindingsCardVisible)
{
    <div class="card mb-3 binding-panel" data-test="create-bindings-card">
        <div class="card-header py-2 d-flex align-items-center gap-3 flex-wrap">
            <strong>Connection Bindings</strong>
            @if (_previewLoading) { <span class="spinner-border spinner-border-sm" role="status"></span> }
            else { <span class="text-muted small">@_rowCount data-sourced attribute(s) in @_groups.Count group(s)</span> }
            <span class="ms-auto"></span>
            @if (UnboundGroups > 0 && !_skipBindings) { <span class="badge text-bg-warning">@UnboundGroups group(s) unbound</span> }
            <span class="badge border text-muted">preview — nothing saved yet</span>
        </div>
        @if (_previewError is not null)
        {
            <div class="alert alert-warning rounded-0 mb-0 py-2 small" data-test="create-bindings-preview-error">
                Could not preview this template at the selected site (@_previewError). You can create the instance and bind on Configure.
            </div>
        }
        else if (!_previewLoading && _rowCount == 0)
        {
            <div class="card-body text-muted small">Nothing to bind in this template.</div>
        }
        else if (_siteConnections.Count == 0)
        {
            <div class="card-body text-muted small">This site has no data connections — add one under Sites, or create now and bind later.</div>
        }
        else if (!_skipBindings)
        {
            <div class="card-body border-bottom py-2 d-flex align-items-center gap-2 flex-wrap small">
                <span>Bind all to</span>
                <select class="form-select form-select-sm" style="width:auto" data-test="create-root-connection" @bind="_rootConnectionId">
                    <option value="0">Select connection...</option>
                    @foreach (var c in _siteConnections) { <option value="@c.Id">@c.Name (@c.Protocol)</option> }
                </select>
                <input class="form-control form-control-sm font-monospace" style="max-width:260px" data-test="create-root-object"
                       @bind="_rootObject" placeholder="root object, e.g. Z28063" />
                <button class="btn btn-outline-primary btn-sm" disabled="@(!IsBrowsable(_rootConnectionId))" @onclick="() => OpenBrowser(null)">Browse…</button>
                <button class="btn btn-outline-primary btn-sm" data-test="create-prefill" disabled="@(_rootConnectionId == 0)" @onclick="PrefillGroups">Prefill groups</button>
                <span class="text-muted">Fills the connection on every group and suggests an Object for blanks; never overwrites an Object you typed.</span>
            </div>
            @foreach (var g in _groups)
            {
                var input = _inputs.GetValueOrDefault(g.Key);
                var source = CreateBindingPlanner.SourceOf(g, input);
                <div class="binding-group-header d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-test="create-binding-group">
                    <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:160px" value="@(input?.ConnectionId ?? 0)"
                            @onchange="e => SetGroupConnection(g, int.TryParse(e.Value?.ToString(), out var v) ? v : 0)">
                        <option value="0">— 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" value="@input?.Object"
                           placeholder="object, e.g. @(g.IsInstanceGroup ? "Z28063" : g.Key)"
                           @onchange="e => SetGroupObject(g, e.Value?.ToString())" />
                    <button class="btn btn-outline-primary btn-sm" disabled="@(!IsBrowsable(input?.ConnectionId ?? 0))" @onclick="() => OpenBrowser(g)">Browse…</button>
                    @if (source == CreateBindingPlanner.ObjectSource.Suggested) { <span class="badge text-bg-secondary" title="Suggested from the root object; edit if the object is a sibling">suggested</span> }
                    else if (source == CreateBindingPlanner.ObjectSource.FromTemplate) { <span class="badge text-bg-secondary" title="The composed template's defaults already carry this object">from template</span> }
                    @if (IsGroupBound(g)) { <span class="badge text-bg-success">bound</span> }
                    else { <span class="badge text-bg-danger">@g.Members.Count unbound</span> }
                </div>
            }
        }
        <div class="card-footer d-flex align-items-center gap-2 flex-wrap">
            <div class="btn-group btn-group-sm" role="group">
                <button class="btn btn-success" @onclick="CreateInstance" disabled="@_previewLoading">Create instance</button>
                <button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
            </div>
            <label class="form-check form-check-inline mb-0 small ms-2"><input class="form-check-input" type="checkbox" @bind="_skipBindings" /> Skip bindings — I'll bind on Configure</label>
            <span class="text-muted small ms-auto">
                @if (_skipBindings) { <text>Creates the instance without bindings.</text> }
                else { <text>Creates the instance, saves @BoundRowCount binding(s), then opens Configure.@(UnboundGroups > 0 ? $" {UnboundGroups} group(s) stay unbound — Deploy is rejected until they are bound." : "")</text> }
            </span>
        </div>
    </div>
}
<NodeBrowserDialog @ref="_browserRef" SiteId="@(_siteIdentifier ?? "")" ConnectionName="@_browserConnectionName"
                   InitialNodeId="@_browserInitial" SelectableNodeClasses="@ObjectAndVariable" OnSelected="OnBrowserSelected" />

Code-behind additions:

    private static readonly BrowseNodeClass[] ObjectAndVariable = [BrowseNodeClass.Object, BrowseNodeClass.Variable];
    private NodeBrowserDialog? _browserRef;
    private string _browserConnectionName = "";
    private string? _browserInitial;
    private BindingGroup? _browserTarget; // null = the root picker

    private bool IsBrowsable(int connectionId)
    {
        var protocol = _siteConnections.FirstOrDefault(c => c.Id == connectionId)?.Protocol;
        return string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)
            || string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase);
    }

    private async Task OpenBrowser(BindingGroup? target)
    {
        _browserTarget = target;
        var connId = target is null ? _rootConnectionId : _inputs.GetValueOrDefault(target.Key)?.ConnectionId ?? 0;
        _browserConnectionName = _siteConnections.First(c => c.Id == connId).Name;
        _browserInitial = string.IsNullOrWhiteSpace(_rootObject) ? null : _rootObject;
        if (_browserRef is not null) await _browserRef.ShowAsync(_siteIdentifier ?? "", _browserConnectionName, _browserInitial);
    }

    private void OnBrowserSelected(string nodeId)
    {
        if (_browserTarget is null) _rootObject = nodeId;
        else SetGroupObject(_browserTarget, CreateBindingPlanner.ObjectFromPick(_browserTarget, nodeId));
    }

Check NodeBrowserDialog.ShowAsync's exact signature in the dialog (it is ShowAsync(siteId, connectionName, initialNodeId) per the Configure page / tests) and the @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol for BrowseNodeClass.

Step 2: Build + existing suites

Run: dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI && dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "NodeBrowserDialog|BindingGroupBuilder|CreateBindingPlanner" Expected: 0 errors, PASS.

Step 3: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.css
git commit -m "feat(centralui): Create Instance — Connection Bindings card (per-group connection/Object/Browse, Bind-all prefill)"

Task 6: Create → bindings → navigate to Configure; Configure one-shot notice

Classification: standard Estimated implement time: ~4 min Parallelizable with: none (blocked by 5)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor (CreateInstance)
  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor (query params + notice above the Connection Bindings panel, ~line 88)

Step 1: Rewrite CreateInstance (keep the validation + site-scope re-check as is):

        try
        {
            var user = await GetCurrentUserAsync();
            var result = await InstanceService.CreateInstanceAsync(
                _createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
            if (result.IsFailure) { _formError = result.Error; return; }

            var id = result.Value.Id;
            var bindings = _skipBindings || _previewError is not null
                ? Array.Empty<ConnectionBinding>()
                : CreateBindingPlanner.BuildBindings(_groups, _inputs);
            if (bindings.Count == 0) { NavigationManager.NavigateTo($"/deployment/instances/{id}/configure?created=1"); return; }

            // The instance now exists whatever happens next — always land on Configure,
            // carrying the binding error so the operator is not left on a stale form.
            var bind = await InstanceService.SetConnectionBindingsAsync(id, bindings, user);
            var query = bind.IsSuccess
                ? $"created=1&bound={bindings.Count}"
                : $"created=1&bindError={Uri.EscapeDataString(Truncate(bind.Error, 300))}";
            NavigationManager.NavigateTo($"/deployment/instances/{id}/configure?{query}");
        }
        catch (Exception ex) { _formError = $"Create failed: {ex.Message}"; }

with private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n] + "…";. Note SetConnectionBindingsAsync can throw (network/actor timeout) after the instance exists — wrap just that call in its own try/catch that sets bindError to the exception message, so the outer catch only covers create.

Step 2: Configure notice — add [SupplyParameterFromQuery] public int? Created { get; set; }, public int? Bound { get; set; }, public string? BindError { get; set; } and, directly above the binding-panel card:

@if (Created == 1)
{
    <div class="alert @(BindError is null ? "alert-success" : "alert-warning") py-2 small" data-test="configure-created-notice">
        Instance created.
        @if (BindError is not null) { <text>Bindings were NOT saved: @BindError — bind the groups below.</text> }
        else if (Bound is > 0) { <text>@Bound binding(s) saved. Test Bindings, then Deploy.</text> }
        else { <text>Bind the groups below, then Deploy.</text> }
    </div>
}

Step 3: Build

Run: dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI Expected: 0 errors.

Step 4: Commit

git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
git commit -m "feat(centralui): Create saves bindings (upsert) and lands on Configure with a created/bound/bindError notice"

Task 7: Live check on the docker rig

Classification: standard Estimated implement time: ~5 min (plus image build) Parallelizable with: Task 8

Files: none (verification). Uses docker/build.sh from the worktree, docker compose -f /Users/dohertj2/Desktop/ScadaBridge/docker/docker-compose.yml up -d --force-recreate, CLI per .claude/skills/scadabridge-cluster-ops.

Steps:

  1. Build + recreate the rig from the worktree (not deploy.sh).
  2. Pick a composed template on the rig (CLI template list; the SoakNotify/gcb test templates from the 2026-08-20 session, or create a 2-module composed template via CLI if none exists).
  3. In the browser (http://localhost:9000, multi-role/password): /deployment/instances/create → choose the template + site-a → the card shows N groups. Set Bind all to the site's OPC UA connection, root ns=2;s=Demo (or whatever the rig's OPC UA server exposes — check an existing bound instance's reference on Configure), Prefill, correct one module Object via Browse… selecting an Object node, Create.
  4. Expect: lands on /deployment/instances/{id}/configure?created=1&bound=N with the green notice; the Connection Bindings panel shows the same groups bound; Test Bindings (N) enabled; deploy instance via CLI passes the binding-completeness gate (or reports only the groups deliberately left unbound).
  5. Also create one instance with Skip bindings ticked → Configure shows "Bind the groups below" and every row unbound (today's behaviour preserved).
  6. Delete the test instances (instance delete). Record findings in the plan's status section.

Task 8: Docs

Classification: trivial Estimated implement time: ~4 min Parallelizable with: Task 7

Files:

  • Modify: docs/requirements/Component-CentralUI.md (Create Instance / Connection Bindings section — add the create-time card, Prefill semantics, Browse object selection, navigate-to-Configure)
  • Modify: docs/requirements/Component-DeploymentManager.md (IFlatteningPipeline.PreviewAsync, one paragraph: template+site preview, no validation, no persistence)
  • Modify: src/ZB.MOM.WW.ScadaBridge.CLI/README.md (one line under instance create: bindings can be set at creation in the UI; CLI equivalent is instance set-bindings / bind-module)
  • Modify: docs/plans/2026-08-21-create-instance-bindings-design.md (status → implemented, link to this plan)
  • Modify: CLAUDE.md Templates & Deployment bullet list — one sentence after the grouped-bindings material: "Create Instance page binds module groups at creation via IFlatteningPipeline.PreviewAsync (template+site flatten of an unsaved instance); NodeBrowserDialog.SelectableNodeClasses lets it pick Object nodes (default stays Variable-only)."

Commit: docs: create-time connection bindings (Central UI, DeploymentManager preview flatten, CLI cross-ref).


Task 9: Solution build + impacted suites

Classification: small Estimated implement time: ~5 min Parallelizable with: none (blocked by all)

Run:

dotnet build ZB.MOM.WW.ScadaBridge.slnx
dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests
dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter Flatten

Expected: 0 errors, all green (the known env-only Playwright E2E project is not in this list). Then git log --oneline main..HEAD shows 7 commits; hand off via superpowers-extended-cc:finishing-a-development-branch.