docs(plans): grouped connection bindings design — Configure page sources binding rows from the flattened config (composed members were invisible), per-module Object bulk apply, CLI instance bind-module parity

This commit is contained in:
Joseph Doherty
2026-08-20 15:44:40 -04:00
parent 4388fa41e4
commit eef77b6654
@@ -0,0 +1,128 @@
# Grouped Connection Bindings — design
**Date:** 2026-08-20 · **Status:** approved, implementation pending · **Components:** Central UI (#15), Template Engine (flattening), CLI
## Problem
The Configure Instance page (`/deployment/instances/{id}/configure`) builds its Connection
Bindings table from `GetAttributesByTemplateIdAsync(templateId)` — the template's **own**
attributes only. Members contributed by composed feature modules (`LeftMESReceiver.*`,
`LeftReactorSide.LeakTest.*`, …) never appear, yet the deploy gate
(`ValidateConnectionBindingCompleteness`) requires every *flattened* data-sourced attribute to be
bound. For a composed template the UI therefore cannot make an instance deployable at all; on QA
(wonder-app-vq03) 264 of Z28061's 280 bindings are invisible on the page. The only working route
is the management `SetConnectionBindings` upsert (CLI `instance set-bindings` or a raw POST),
which today means hand-editing 26 rows per receiver module.
Observed 2026-08-20 while remapping Z28061/Z28062/Z28063 MES and Delmia receivers on QA.
Saving from the page is already safe: `_bindingSelections` is seeded from *all* existing bindings
and `SaveBindings` posts the whole dictionary, so the hidden rows survive a UI save. The gap is
visibility and editing, not data loss.
## Decision
1. **Source the bindings table from the flattened configuration.** The page already runs
`IFlatteningPipeline.FlattenAndValidateAsync(id, validateScripts: false)` for the alarm picker;
use the same result for bindings. One walk, the same walk the deploy gate uses, so the UI can
never disagree with the gate.
2. **Group rows by module** and give each group a connection picker + an **Object** box whose
Apply rewrites every member's override to `<object>.<member-tail>`. This is exactly the bulk
operation performed by hand on QA.
3. **CLI parity:** `scadabridge instance bind-module` performs the same per-module rewrite through
the existing `SetConnectionBindings` upsert. No new server command.
Alternatives rejected: walking the composition graph inside the page (duplicates the flattener's
inheritance/nesting/lock rules and drifts from it); a new "binding rows" management query (the
page already injects the pipeline; adds an RPC for nothing).
## Template Engine (additive)
`ResolvedAttribute` gains `string? DefaultDataSourceReference` — the data-source reference
**before** the instance binding is applied (the template/composed default).
`FlatteningService.ApplyConnectionBindings` sets it from `existing.DataSourceReference`; for
attributes with no binding it equals `DataSourceReference`. Central-side only — the flattened
config is not a wire contract here, and the field is **excluded from the revision hash** (the
hash input is unchanged, otherwise every deployed instance would read as stale after upgrade).
Pinned by a test that hashes a config with and without the field populated.
## Central UI — Connection Bindings panel
**Row source.** Flattened attributes where `DataSourceReference` (post-binding) or
`DefaultDataSourceReference` is non-empty. Row key = `CanonicalName` (path-qualified), which is
what `InstanceConnectionBinding.AttributeName` already stores for composed members.
If the flatten itself fails (not validation — a thrown/failed pipeline result) the panel falls back
to today's direct-attribute list and shows a warning banner naming the failure, so the page never
goes blank.
**Grouping.** A pure helper, `BindingGroupBuilder` (CentralUI, no page dependencies), maps rows to
groups:
- module key = canonical name minus its last segment; a nested module (`LeftReactorSide.LeakTest`)
folds into its **first** segment's group (`LeftReactorSide`) and its member tail keeps the nested
part (`LeakTest.DeltaVac`). Direct attributes (no dot) form the `(instance)` group, listed first.
- per group: member count, unbound count (rows with no connection), connection state
(`single` / `mixed` / `none`), and the **inferred object prefix** — the longest common prefix of
the members' effective references ending at a `.` boundary after stripping each member tail;
`null` when members disagree (`mixed`) or no row has an override.
- `ApplyObject(group, connectionId, object)` returns the new `(connectionId, override)` for every
member: `object + "." + memberTail`; an empty object clears the overrides (back to default) and
leaves connections as chosen. Whitespace trimmed; a trailing `.` on the object is tolerated.
**Layout.** Groups collapsed by default, each header: caret · module name (with `incl. LeakTest`
style note when nested modules were folded) · count · connection `<select>` · Object text box ·
Apply · state pill (`bound` / `n unbound` / `mixed`) · `n changed · unsaved` pill once edited.
Expanded group = table `Member | Template default | Connection | Reference override`; the
template default for composed members is the bare member name, marked ⚠ with a tooltip
("composed members need an override or the deploy gate rejects the instance"). Unbound rows are
tinted. Panel header shows total rows/groups plus the aggregate `n unbound` and `n groups mixed`
pills. Toolbar keeps the existing all-groups connection select ("Apply connection"), adds
**Show only unbound** and **Expand all**. Footer hint states the unbound count and that Deploy is
rejected while it is non-zero.
**Save / Test.** `SaveBindings` unchanged in shape (full dictionary → `SetConnectionBindingsAsync`),
now covering every row. `Test Bindings` builds its rows from the same list, so composed members are
live-read too. Bindings still reach the site only on Deploy; the hint says so.
**Authorization / mutation.** No new commands; `RequireDeployment` policy unchanged.
## CLI
```
scadabridge instance bind-module --id <instanceId> --module <ModuleName> --connection <dataConnectionId> --object <ObjectPath> [--dry-run]
```
Client-side: `GetInstance` → select bindings whose `attributeName` starts with `<ModuleName>.`
(for `--module "(instance)"` or `--module .` select the dot-less names) → rewrite each override to
`<ObjectPath>.<tail>` → `SetConnectionBindings` (upsert; untouched modules are not sent). `--object ""`
clears overrides. `--dry-run` prints the resulting rows and exits 0 without saving. Errors: unknown
module (no matching attribute) → non-zero with the list of available modules. Because the CLI
reads *existing bindings* rather than the flattened template, a module with **no** bindings yet
(Z28063's case) needs the attribute list — so the CLI also falls back to the flattened
configuration through the existing `PreviewFlattenedConfig`-style query if one exists, otherwise
documents that `set-bindings` seeds the first binding set. *(Plan task decides which query is
available; prefer reuse over a new command.)*
## Testing
- `BindingGroupBuilderTests`: grouping incl. nested fold and `(instance)` group; prefix inference
(single / mixed / none / nested object paths like `Left_002.DelmiaReceiver`); `ApplyObject`
incl. clear, trim, trailing-dot; unbound counts.
- `InstanceConfigure` bUnit tests: composed rows render; group Apply rewrites rows and marks
unsaved; Save posts every row (regression for the hidden-row survival property); flatten
failure shows the fallback banner with the direct-attribute table.
- Flattener: `DefaultDataSourceReference` populated pre-binding; revision hash unchanged with the
field present.
- CLI: `bind-module` rewrite, dry-run, unknown module, clear.
## Docs to update
`Component-CentralUI.md` (Configure Instance → Connection Bindings), `Component-TemplateEngine.md`
(`ResolvedAttribute` field + hash exclusion), `src/ZB.MOM.WW.ScadaBridge.CLI/README.md`
(`instance bind-module`). Mockup used for approval: artifact
`https://claude.ai/code/artifact/6af84fbc-5714-4937-a98c-6e2245984044`.
## Rollout
Central UI code — reaches wonder-app-vq03/vd03 only on their next host redeploy (config changes
there are live, UI code is not). No migration, no wire change, no site impact.