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

877 lines
49 KiB
Markdown

# 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 `BindingGroup`s 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**
```csharp
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:
```csharp
/// <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
```csharp
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
```csharp
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**
```bash
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**
```csharp
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**
```csharp
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**
```bash
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")`)
```csharp
[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**
```bash
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`):
```csharp
// --- 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:
```razor
<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**
```bash
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):
```razor
@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:
```csharp
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**
```bash
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):
```csharp
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:
```razor
@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**
```bash
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:
```bash
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.
---
## Status — implemented 2026-08-21
All 9 tasks done on branch `create-instance-bindings`. **Tasks 4 and 5 landed in one
commit**: Task 4's card-state fields are write-only until Task 5's markup reads them and
the CentralUI project treats unused-field warnings as errors, so Task 4's "build → 0
errors" gate is unachievable in isolation (plan defect). Task 4's instruction to move the
existing `@code` fields into the partial was skipped — a partial class already shares
private members across files. The card also drops the `binding-panel` class the Configure
panel carries: it has no CSS rule anywhere in the repo.
### Task 7 — live check on the docker rig (PASS)
Image built with `docker/build.sh` from the worktree, rig recreated with
`docker compose -f <main>/docker/docker-compose.yml up -d --force-recreate`; central
`/health/ready` Healthy in ~20s. Driven in Chrome at `http://localhost:9000` as
`multi-role`. Template **Motor Controller** (id 4, 3 compositions) at **Test Plant A**
(site 1, `OPC PLC Simulator`, OpcUa).
1. **Card appears on template+site.** `30 data-sourced attribute(s) in 4 group(s)` —
`(instance)` 13, `CoolingTank` 7 *(incl. DrivePump, DrivePump.AlarmSensor,
DrivePump.TempSensor)*, `CoolingTank2` 7, `Pump` 3 *(incl. AlarmSensor, TempSensor)*.
Identical grouping to the Configure page, nested modules folded. `4 group(s) unbound`,
footer "saves 0 binding(s)".
2. **Object-node selection works.** Root `Browse…` opened the picker with the new
"Select an object or tag" prompt; every root node (`OpcPlc`, `Boilers`, `Locations`, …)
rendered as a *clickable* link — all Objects, plain text before this change. Picked
`nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=5`; it landed in the root Object box.
3. **Prefill.** Every group took the connection; `(instance)` took the root verbatim (no
pill); the three module groups got `{root}.{groupKey}` with a **suggested** pill. All
four → **bound**, the unbound badge cleared, footer "saves 30 binding(s)".
4. **Per-group Browse.** `Pump` → picked the `Locations` Object (`i=31915`); the value
replaced the suggestion and the **suggested** pill disappeared (a browse pick is a
typed value, per `SetGroupObject`).
5. **Create.** Landed on `/deployment/instances/3660/configure?created=1&bound=30` with
the green notice *"Instance created. 30 binding(s) saved. Test Bindings, then Deploy."*;
Configure's panel showed the same 4 groups all **bound**, Pump carrying `i=31915`, and
**Test Bindings (30)** enabled.
6. **Skip bindings.** Second instance with the box ticked → group rows collapse, footer
"Creates the instance without bindings", lands on `?created=1` (no `bound`) with
*"Instance created. Bind the groups below, then Deploy."*, `30 unbound`, Test Bindings (0).
Pre-2026-08-21 behaviour preserved exactly.
7. **Deploy gate A/B — the decisive check.**
- id 3660 (bound at creation): `4 errors — ScriptCompilation (4)`, **zero ConnectionBinding**.
- id 3661 (skipped): `34 errors — ScriptCompilation (4) + ConnectionBinding (30)`.
The create-time bindings fully satisfy `ValidateConnectionBindingCompleteness`. The 4
ScriptCompilation errors are a **pre-existing property of the rig's Motor Controller
template** (identical on both instances, unrelated to this work).
8. Both test instances deleted (`instance delete --id 3660 / 3661`).
### Task 9 — solution build + suites (PASS)
```
dotnet build ZB.MOM.WW.ScadaBridge.slnx 0 Warning(s), 0 Error(s)
dotnet test tests/…DeploymentManager.Tests Passed 148, Failed 0
dotnet test tests/…CentralUI.Tests Passed 1130, Failed 0
dotnet test tests/…TemplateEngine.Tests --filter Flatten Passed 108, Failed 0
```