docs(m7): implementation plan + task graph — 22 tasks across waves A-E (T13-T17)

This commit is contained in:
Joseph Doherty
2026-06-18 01:57:18 -04:00
parent 254e0e729f
commit a87bf8a459
2 changed files with 584 additions and 0 deletions
@@ -0,0 +1,546 @@
# M7 — OPC UA / MxGateway UX Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.
**Goal:** Ship all five M7 features (T13T17): operator Alarm Summary page, MxGateway two-person secured writes, OPC UA `BrowseNext` paging + bounded recursive search, browse type-info surfacing + attribute-override CSV import, and a Verify-endpoint button with site-local cert trust.
**Architecture:** Features layer on infrastructure already in `main`. New cross-cluster verbs ride the existing `CommunicationService → SiteEnvelope → ClusterClient Ask → SiteCommunicationActor → DeploymentManagerActor → DataConnectionManagerActor → DataConnectionActor → adapter` path (the same path `BrowseNodeCommand` uses today). T14 adds a central `PendingSecuredWrite` table + ManagementActor handlers + an approve→site write relay (reusing the existing `WriteTagRequest`). T13 fans out the existing per-instance debug snapshot. All decisions are pinned in `docs/plans/2026-06-18-m7-opcua-mxgateway-ux-design.md` (D1D7).
**Tech Stack:** C#/.NET 10, Akka.NET (cluster, ClusterClient), EF Core 10 (central MS SQL), Blazor Server (Bootstrap, no third-party component libs), OPC Foundation SDK, xUnit + bUnit + NSubstitute + Playwright. `TreatWarningsAsErrors=true` everywhere; central package management (`Directory.Packages.props`).
**Execution conventions (per CLAUDE.md + standing constraints):**
- Implementers do **NOT** create worktrees — this session already runs in `.claude/worktrees/m7-opcua-mxgateway-ux` (branch `worktree-m7-opcua-mxgateway-ux`, off `origin/main` 241a792).
- Commit **pathspec form**: `git commit -m "<msg>" -- <paths>` (the `-m` BEFORE the `--`). Never `git add -A`/`-a`. Retry on `index.lock`.
- Keep ≤23 concurrent committers per wave; after each wave verify every commit is on HEAD (`git merge-base --is-ancestor <sha> HEAD`).
- **Targeted** builds/tests per task (the task's project + filtered tests). Full-solution build + `bash docker/deploy.sh` + Playwright only in the final integration task (M7-E1).
- The `Files:` block on each task is the authoritative edit list. If an implementer needs a file not listed, that's a plan defect — surface it.
**Reference (verbatim signatures gathered during planning):** see the design doc; key anchors —
`BrowseNode`/`BrowseChildrenResult`/`IBrowsableDataConnection` (`Commons/Interfaces/Protocol/IBrowsableDataConnection.cs:9-51`), `BrowseCommands.cs` (`Commons/Messages/Management/BrowseCommands.cs:1-39`), `RealOpcUaClient.BrowseChildrenAsync` (`DataConnectionLayer/Adapters/RealOpcUaClient.cs:705-765`), `RealOpcUaClient.ConnectAsync`/`RealOpcUaClientFactory` (`:62-156`/`:779-816`), `StubOpcUaClient` (`DataConnectionLayer/Adapters/IOpcUaClient.cs:173-248`, browse throws at `:237-239`), `DataConnectionActor.HandleBrowse` (`:1148-1202`, dispatch `:332-354`), `DataConnectionManagerActor` route-by-name (`:174-190`), `CommunicationService.BrowseNodeAsync`/`SiteEnvelope` (`Communication/CommunicationService.cs:364-372`/`:678`), `BrowseService` (`CentralUI/Services/BrowseService.cs:20-88`), `NodeBrowserDialog.razor` (`CentralUI/Components/Dialogs/`), `OpcUaEndpointConfig` (`Commons/Types/DataConnections/OpcUaEndpointConfig.cs:6-91`), `OpcUaGlobalOptions` (`DataConnectionLayer/OpcUaGlobalOptions.cs:9-19`), `OpcUaEndpointEditor.razor`/`DataConnectionForm.razor` (`CentralUI/Components/Forms/` + `.../Pages/Design/`), `Roles.cs` (`Security/Roles.cs:35-45`), `AuthorizationPolicies.cs` (`Security/AuthorizationPolicies.cs:105-149`), `ManagementActor` dispatch + `GetRequiredRole` (`ManagementService/ManagementActor.cs:93-221,367-368,701-740,845-861`), `SiteCall` entity/config/repo/migration pattern (`Commons/Entities/Audit/SiteCall.cs`, `ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs`, `ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs`, `ScadaBridgeDbContext.cs:132,159`, `ServiceCollectionExtensions.cs:56`, latest migration `20260617234323_AddKpiSampleTable.cs` + `ScadaBridgeDbContextModelSnapshot.cs`), `AuditKind`/`AuditChannel`/`IAuditLogRepository.InsertIfNotExistsAsync` (`Commons/Types/Enums/AuditKind.cs`, `.../AuditChannel.cs`, `Commons/Interfaces/Repositories/IAuditLogRepository.cs:24-35`), MxGateway write (`MxGatewayDataConnection.cs:214-225`, `IMxGatewayClient.cs:17-64`, `WriteTagRequest`/`WriteTagResponse` handled at `DataConnectionActor.cs:332-337`), parked-relay precedent (`RemoteQueryCommands.cs:5-6`, `ManagementActor.cs:845-861`), `DebugViewSnapshot` (`Commons/Messages/DebugView/DebugViewSnapshot.cs:31-39`), `AlarmStateChanged`/`AlarmConditionState`/`AlarmKind` (`Commons/Messages/Streaming/AlarmStateChanged.cs:6-88` + `Commons/Types/...`), debug snapshot Ask (`DebugSnapshotCommand` is Deployer-gated in `GetRequiredRole`; `ITemplateEngineRepository.GetInstancesBySiteIdAsync` `:217`), `NavMenu.razor:86-98`, `Health.razor` poll pattern (`CentralUI/Components/Pages/Monitoring/Health.razor`), DebugView alarm badge markup (`DebugView.razor:202-238,580-611`), `InstanceAttributeOverride`/`AttributeValueCodec`/`SetInstanceOverridesCommand` handler (`Commons/Entities/Instances/InstanceAttributeOverride.cs`, `Commons/Types/AttributeValueCodec.cs:15-107`, `ManagementActor.cs:701-740`), CLI `set-overrides` (`CLI/Commands/InstanceCommands.cs:275-297`), `InputFile` precedent (`CentralUI/Components/Pages/Design/TransportImport.razor.cs`), test harnesses (bUnit `tests/...CentralUI.Tests/Auth/SessionExpiryComponentTests.cs`, Playwright `tests/...CentralUI.PlaywrightTests/NavigationTests.cs` + `PlaywrightFixture.cs`, MSSQL `SkippableFact` `tests/...ConfigurationDatabase.Tests/Migrations/...`).
> **No CSV library** is referenced in `Directory.Packages.props` — T16 uses a small hand-written quote-aware parser in Commons (pure + unit-tested). Do not add CsvHelper.
---
## Waves & dependency overview
- **Wave A (foundations, parallel-safe):** A1 (AlarmStateBadges) → A2 (Alarm Summary page); A3 (roles) ∥ A1.
- **Wave B (OPC UA / DCL — serialized on shared DCL files):** B1 → B2 → {B3, B4} → B5 → B6; B7 → {B8, B9} → B10. B-stream is largely sequential because `RealOpcUaClient.cs` / `IBrowsableDataConnection.cs` / `DataConnectionActor.cs` / `BrowseService.cs` recur.
- **Wave C (T14b secured writes):** C1 (entity) ∥ Wave B; C2 (handlers, needs C1 + A3) → C3 (relay) → C4 (audit); C5 (UI, needs C2/C3 + A3).
- **Wave D (T16 CSV):** D1 (parser) ∥ anything; D2 (UI, needs D1) ; D3 (CLI, needs D1).
- **Integration:** E1 (needs everything).
Disjoint streams that may run concurrently: A-stream, B-stream, C1+C-stream, D-stream. Keep ≤23 implementers committing at once.
---
## Wave A — Foundations
### Task A1: Extract `AlarmStateBadges` shared component (T13 prep)
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** A3, B1, C1, D1
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmStateBadges.razor`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor` (replace the inline alarm-badge markup `:202-238` with `<AlarmStateBadges Alarm="node.Alarm" />`; move helpers `GetAlarmStateBadge`/`GetAlarmLevelBadge`/`GetKindBadge`/`FormatKind`/`FormatLevel` `:580-611` into the component)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/AlarmStateBadgesTests.cs`
**Step 1 — Write failing bUnit test.** Render `AlarmStateBadges` with an `AlarmStateChanged` that is `State=Active, Kind=NativeOpcUa, Condition{Active:true,Acknowledged:false,Shelve:Unshelved,Suppressed:false,Severity:700}, Level=High`. Assert the rendered markup contains an "Active" badge (`bg-danger`), a kind badge ("OpcUa"/`bg-info`), an "Unacked" badge, "sev 700", and a "High" level badge. Mirror the harness in `SessionExpiryComponentTests.cs` (`BunitContext`, `Render<T>(...)`).
**Step 2 — Run, expect FAIL** (component doesn't exist): `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ --filter AlarmStateBadges`
**Step 3 — Implement.** Create `AlarmStateBadges.razor` with one `[Parameter] public AlarmStateChanged Alarm { get; set; }`. Lift the verbatim markup from `DebugView.razor:202-238` (the `State`/`Kind`/`Unacked`/`Shelved`/`Suppressed`/`sev`/`Level` badges, gating the native-only badges on `Alarm.Kind != AlarmKind.Computed`) and the `@code` helpers from `:580-611`. Then edit `DebugView.razor` to call `<AlarmStateBadges Alarm="node.Alarm" />` where the inline block was, and delete the now-duplicated helpers (keep `BuildAlarmTooltip` and the `💬` message indicator in DebugView — only the badge cluster moves).
**Step 4 — Run, expect PASS.** Same filter. Also build the project: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`.
**Step 5 — Commit.** `git commit -m "feat(centralui): extract AlarmStateBadges shared component from DebugView (T13)" -- src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmStateBadges.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/AlarmStateBadgesTests.cs`
---
### Task A2: Operator Alarm Summary page + fan-out service (T13)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** A3, C1, D1 (NOT A1 — depends on it)
**Blocked by:** A1
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAlarmSummaryService.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor` (add inside the `RequireDeployment` block at `:90-95`: `<NavRailItem Href="/monitoring/alarms" Text="Alarm Summary" />`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Program.cs` (or the CentralUI DI extension where services like `IBrowseService` are registered) — `services.AddScoped<IAlarmSummaryService, AlarmSummaryService>();`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs`
**Design notes.** `AlarmSummaryService.GetSiteAlarmsAsync(int siteId, CancellationToken)` returns `AlarmSummaryResult(IReadOnlyList<AlarmStateChanged> Alarms, IReadOnlyList<string> NotReportingInstances)`. It enumerates Enabled instances via `ITemplateEngineRepository.GetInstancesBySiteIdAsync(siteId)` (filter `State == InstanceState.Enabled`), then fans out the **existing per-instance debug snapshot Ask** (the same `DebugViewSnapshot` DebugView uses for its initial snapshot — issue `DebugSnapshotCommand` per instance through `CommunicationService`/management seam) with a `SemaphoreSlim(maxConcurrency: 8)`. Snapshots that throw/time out add the instance name to `NotReportingInstances`; the rest contribute their `AlarmStates`. Inject an abstraction (`Func<int,CancellationToken,Task<DebugViewSnapshot>>` or a thin `IDebugSnapshotClient`) so the service is unit-testable with NSubstitute. **Confirm the exact snapshot entrypoint** against `DebugStreamService`/`DebugSnapshotCommand` — if no single-shot snapshot Ask is exposed to CentralUI, add a thin `CommunicationService.GetDebugSnapshotAsync(siteId, instanceUniqueName)` mirroring `BrowseNodeAsync`.
**Step 1 — Failing service unit test.** Mock the snapshot client to return alarms for 2 of 3 instances and throw for the 3rd. Assert aggregated alarm count + that the 3rd instance is in `NotReportingInstances`. Also assert a roll-up helper computes worst severity + active count.
**Step 2 — Run, expect FAIL.** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ --filter AlarmSummaryService`
**Step 3 — Implement** the interface + service (capped fan-out, partial-results) + the page. Page skeleton mirrors `Health.razor`: `@page "/monitoring/alarms"`, `@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]`, inject `IAlarmSummaryService` + `ISiteRepository`, a site `<select>`, roll-up tiles (total active / worst severity / unacked / per-`AlarmKind` counts), and a filterable table whose rows render `<AlarmStateBadges Alarm="a" />` (from A1) plus instance + name columns. Filters: instance, kind, state, acked/unacked, severity threshold, name search (client-side over the loaded list). Manual "Refresh" button + a `Timer` poll (default 15 s, dispose in `IDisposable`). Show the "not reporting" instances as a muted note. Add `data-test="alarm-summary"` on the root and `data-test="alarm-summary-row"` on rows.
**Step 4 — Run, expect PASS** + build CentralUI project.
**Step 5 — Commit.** Pathspec all 6 paths. Message: `feat(centralui): operator Alarm Summary page + per-instance snapshot fan-out (T13)`
---
### Task A3: `Operator` + `Verifier` roles + policies + LDAP mapping (T14a)
**Classification:** high-risk
**Estimated implement time:** ~3 min
**Parallelizable with:** A1, A2, B1, C1, D1
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs:35-45` (add `Operator`/`Verifier` consts; add both to `Roles.All`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs` (add `public const string RequireOperator = "RequireOperator";` + `RequireVerifier`; register both policies mirroring `:129-136`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappingForm.razor:31-37` (add `<option value="@Roles.Operator">Operator</option>` and `<option value="@Roles.Verifier">Verifier</option>`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesTests.cs` (create if absent) or extend the nearest existing Security test.
**Step 1 — Failing test.** Assert `Roles.All` contains `Operator` and `Verifier`, and (if a policy-registration test harness exists) that `RequireOperator`/`RequireVerifier` resolve. If no Security test project exists, add a Commons-level constant test asserting the two new strings exist and are distinct.
**Step 2 — Run, expect FAIL.**
**Step 3 — Implement** the three edits. `Operator = "Operator"`, `Verifier = "Verifier"`; `All = [Administrator, Designer, Deployer, Viewer, Operator, Verifier]`. Policies: `options.AddPolicy(RequireOperator, p => p.RequireClaim(JwtTokenService.RoleClaimType, Roles.Operator));` and likewise for Verifier.
**Step 4 — Run, expect PASS** + build Security + CentralUI projects.
**Step 5 — Commit.** `feat(security): add Operator + Verifier roles + policies + LDAP mapping options (T14a)`
> **Dev note for reviewers/integration:** with `DisableLogin` on (docker), `AutoLoginAuthenticationHandler` grants `Roles.All` to one identity — so the two-person flow can't be exercised end-to-end via the dev UI with a single user. No-self-approval is covered by handler tests (C2). Real two-person use needs two real identities.
---
## Wave B — OPC UA / DCL stream
### Task B1: Browse type-info fields (T16 type-info)
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** A1, A3, C1, D1
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs:33-37` (add optional fields to `BrowseNode`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs:705-765` (populate type info for Variable nodes; add a built-in-type name map)
- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataTypeNameMapTests.cs`
**Design.** Extend the record additively:
```csharp
public record BrowseNode(
string NodeId,
string DisplayName,
BrowseNodeClass NodeClass,
bool HasChildren,
string? DataType = null, // friendly built-in name, e.g. "Double"
int? ValueRank = null, // -1 scalar, 0/1 array
bool? Writable = null); // from UserAccessLevel CurrentWrite bit
```
In `BrowseChildrenAsync`, after building the references, for `NodeClass.Variable` nodes batch-`ReadAsync` the `DataType`, `ValueRank`, and `UserAccessLevel` attributes (one `ReadValueId` list, one read call), then map the `DataType` NodeId → friendly name via a static `OpcUaBuiltInTypeNames` lookup (DataTypeIds.Double→"Double", Int32→"Int32", Boolean→"Boolean", String→"String", etc.; fall back to the NodeId string). Keep it best-effort: if the read fails, leave the fields null (don't fail the browse).
**Step 1 — Failing unit test** on the pure `OpcUaBuiltInTypeNames.Resolve(NodeId)` helper: known built-ins map to friendly names; unknown → the NodeId string.
**Step 2 — Run, expect FAIL.** `dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ --filter OpcUaDataTypeName`
**Step 3 — Implement** the record extension + the helper + the batched read in `BrowseChildrenAsync`.
**Step 4 — Run, expect PASS** + build `ZB.MOM.WW.ScadaBridge.Commons` and `...DataConnectionLayer`.
**Step 5 — Commit.** `feat(dcl): surface OPC UA DataType/ValueRank/Writable on BrowseNode (T16 type-info)`
---
### Task B2: `BrowseNext` continuation through the browse contract (T15)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** C1, D1
**Blocked by:** B1 (shares `IBrowsableDataConnection.cs` + `RealOpcUaClient.cs`)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs` (add optional `continuationToken` param + `ContinuationToken` on `BrowseChildrenResult`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs:705-765` (emit base64 CP; call `Session.BrowseNextAsync` when a token is supplied; on invalid CP → fresh browse)
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs:173-248` (give `StubOpcUaClient.BrowseChildrenAsync` a canned impl instead of `throw new NotImplementedException()`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientBrowseTests.cs`
**Design.** Additive contract:
```csharp
Task<BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId, string? continuationToken = null,
CancellationToken cancellationToken = default);
public record BrowseChildrenResult(
IReadOnlyList<BrowseNode> Children, bool Truncated, string? ContinuationToken = null);
```
`RealOpcUaClient`: when `continuationToken` is null → `session.BrowseAsync(...)` (as today) and return `ContinuationToken = Convert.ToBase64String(cp)` when `cp` is non-empty (and `Truncated = cp != null`). When `continuationToken` is non-null → `session.BrowseNextAsync(null, releaseContinuationPoints:false, new ByteStringCollection { Convert.FromBase64String(token) }, ct)`, map the same way. Wrap `BrowseNextAsync` in try/catch for `ServiceResultException` with `BadContinuationPointInvalid`/`BadInvalidArgument` → fall back to a fresh `BrowseAsync` of the parent (return its first page). `StubOpcUaClient`: return a small fixed two-level tree (e.g. root → `Folder1`/`Folder2`; `Folder1``Tag1`(Variable)/`Tag2`(Variable)); honor a fake continuation by returning a 2nd canned page when a sentinel token is passed, so paging is unit-testable without a server.
**Step 1 — Failing test** against `StubOpcUaClient`: browsing root returns the canned folders; browsing with the sentinel continuation token returns the 2nd page; browsing a leaf returns empty. (Today it throws.)
**Step 2 — Run, expect FAIL.**
**Step 3 — Implement** contract + RealOpcUaClient BrowseNext + Stub canned impl.
**Step 4 — Run, expect PASS** + build Commons + DataConnectionLayer. (Note: the `IBrowsableDataConnection` sig change is source-compatible via the optional param; confirm `OpcUaDataConnection`/any other implementor compiles.)
**Step 5 — Commit.** `feat(dcl): BrowseNext continuation paging + StubOpcUaClient canned browse (T15)`
---
### Task B3: Thread continuation token through browse plumbing (T15)
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** B4 (B4 touches RealOpcUaClient/Stub; B3 touches actor/comm/service — disjoint)
**Blocked by:** B2
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs` (add `string? ContinuationToken = null` to `BrowseNodeCommand`; add `string? ContinuationToken = null` to `BrowseNodeResult`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:1148-1202` (pass `command.ContinuationToken` to `BrowseChildrenAsync`; propagate `result.ContinuationToken` into `BrowseNodeResult`; preserve `CapBrowseChildren`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs:39-88` + `IBrowseService` (add optional `continuationToken` to `BrowseChildrenAsync`)
- Test: extend `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/` with a `DataConnectionActor` browse test that asserts the token round-trips (use a fake `IBrowsableDataConnection`).
**Steps:** TDD as above — failing actor test that a supplied token reaches the adapter and the returned token is surfaced; implement additive fields; targeted build of Commons + DataConnectionLayer + CentralUI; commit `feat: thread BrowseNext continuation token through actor + BrowseService (T15)`.
---
### Task B4: Bounded recursive address-space search — adapter (T15)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** B3
**Blocked by:** B2 (shares `RealOpcUaClient.cs`/`IOpcUaClient.cs`)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IAddressSpaceSearchable.cs` (`Task<AddressSpaceSearchResult> SearchAddressSpaceAsync(string query, int maxDepth, int maxResults, CancellationToken ct = default)` + `AddressSpaceSearchResult(IReadOnlyList<BrowseNode> Matches, bool CapReached)`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs` (implement bounded BFS from ObjectsFolder, substring match on DisplayName/path, caps)
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs` (`StubOpcUaClient` canned search over its fixed tree)
- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientSearchTests.cs`
**Design.** OPC UA has no generic search RPC → implement a **bounded BFS**: queue of (nodeId, pathPrefix) starting at ObjectsFolder; for each, `BrowseChildrenAsync`; for each child, case-insensitive substring match on `DisplayName` (and accumulated path) → add to matches with full path; enqueue Object children until `depth == maxDepth` or `matches.Count == maxResults` (set `CapReached`). Make `RealOpcUaClient` implement `IAddressSpaceSearchable`. `StubOpcUaClient` searches its canned tree.
**Steps:** failing Stub search test (query "Tag" finds Tag1/Tag2; tiny `maxResults` sets `CapReached`); implement; build DCL + Commons; commit `feat(dcl): bounded recursive OPC UA address-space search (T15)`.
---
### Task B5: Search plumbing — message + actor + comm + service (T15)
**Classification:** standard
**Estimated implement time:** ~5 min
**Blocked by:** B3, B4 (touches `DataConnectionActor`/`CommunicationService`/`BrowseService`)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs` (add `SearchAddressSpaceCommand(string ConnectionName, string Query, int MaxDepth, int MaxResults)` + `SearchAddressSpaceResult(IReadOnlyList<BrowseNode> Matches, bool CapReached, BrowseFailure? Failure)`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs` (dispatch + `HandleSearch`, mirroring `HandleBrowse`; `_adapter is IAddressSpaceSearchable` capability check → `NotBrowsable` failure otherwise)
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs:174-190` (route `SearchAddressSpaceCommand` by connection name, like browse)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs:364-372` (add `SearchAddressSpaceAsync(siteId, cmd)` mirroring `BrowseNodeAsync`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs` + `IBrowseService` (add `SearchAsync(siteId, connectionName, query, ...)` with the Designer-role guard + timeout mapping, mirroring `BrowseChildrenAsync`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/` actor search test (fake searchable adapter).
**Steps:** failing actor search test (routes to adapter; non-searchable adapter → `NotBrowsable`); implement all five edits additively; targeted build of the four projects; commit `feat: OPC UA address-space search plumbing — actor + comm + BrowseService (T15)`.
---
### Task B6: NodeBrowserDialog — Load-more + search box + type column (T15/T16)
**Classification:** standard
**Estimated implement time:** ~5 min
**Blocked by:** B3, B5
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSearchTests.cs`
**Design.** Replace the "type node id manually" dead-end on truncated nodes with a **"Load more"** button that re-calls `BrowseService.BrowseChildrenAsync(..., node.ContinuationToken)` and appends children (keep the manual field as a secondary affordance). Add a **search box** above the tree → calls `BrowseService.SearchAsync(...)`, renders matches as a flat selectable list (each shows DisplayName + path + the `DataType` from B1); selecting a match selects that node. Show the new type column/badge in tree leaves (`node.DataType`). Surface `CapReached` ("showing first N — refine your search"). Add `data-test="node-search-input"`, `data-test="node-search-result"`, `data-test="node-load-more"`.
**Steps:** failing bUnit test (mock `IBrowseService.SearchAsync` → 2 matches; typing + submit renders 2 result rows; clicking one raises the select callback); implement; build CentralUI; commit `feat(centralui): NodeBrowserDialog search + load-more + type column (T15/T16)`.
---
### Task B7: Verify-endpoint — message + site probe handler (T17)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** C-stream, D-stream
**Blocked by:** B5 (touches `DataConnectionManagerActor`)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/VerifyEndpointCommands.cs` (`VerifyEndpointCommand(string ConnectionName, string Protocol, string ConfigJson)`, `VerifyEndpointResult(bool Success, VerifyFailureKind? FailureKind, string? Error, ServerCertInfo? Cert)`, `ServerCertInfo(string Thumbprint, string Subject, string Issuer, DateTime NotBefore, DateTime NotAfter, string DerBase64)`, `enum VerifyFailureKind { Unreachable, AuthFailed, UntrustedCertificate, Timeout, ServerError }`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs` (handle `VerifyEndpointCommand`: spin a temporary `RealOpcUaClient` via `RealOpcUaClientFactory`, deserialize config via `OpcUaEndpointConfigSerializer`, connect with `AutoAcceptUntrustedCerts=false` + a validation hook that captures the rejected cert, short timeout ~6 s, disconnect; map outcome). Use `PipeTo(sender)`.
- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs` (pure mapping: exception/cert → `VerifyEndpointResult`)
**Design.** The probe path is OPC-UA-only in v1 (MxGateway verify is out of scope; for `Protocol != "OpcUa"` return `VerifyFailureKind.ServerError` "verify not supported for protocol"). Capturing the cert: hook `appConfig.CertificateValidator.CertificateValidation += (s,e) => { capture e.Certificate; e.Accept = false; }`. Keep the temporary client fully disposed in a `finally`. Factor the exception→result mapping into a static method so it's unit-testable without a live server.
**Steps:** failing mapping test (a captured-cert exception → `UntrustedCertificate` + `ServerCertInfo`; timeout → `Timeout`; generic → `ServerError`); implement command + handler + mapping; build Commons + DataConnectionLayer; commit `feat(dcl): OPC UA verify-endpoint probe with untrusted-cert capture (T17)`.
---
### Task B8: Verify-endpoint plumbing + UI (T17)
**Classification:** standard
**Estimated implement time:** ~4 min
**Blocked by:** B7
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs` (`VerifyEndpointAsync(siteId, cmd)` mirroring `BrowseNodeAsync`)
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IEndpointVerificationService.cs` + `EndpointVerificationService.cs` (Designer-role guard — D7; serialize the in-editor `OpcUaEndpointConfig` via `OpcUaEndpointConfigSerializer`, call comm)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Forms/OpcUaEndpointEditor.razor` (a "Verify endpoint" button near the endpoint URL `:16-40`; show spinner → success/failure; on `UntrustedCertificate` show the `ServerCertInfo` panel with a "Trust" button — Trust wired in B10)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Program.cs` (register the service)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/OpcUaEndpointVerifyTests.cs` (mock service → success and untrusted-cert renders)
**Steps:** failing bUnit test; implement; build Communication + CentralUI; commit `feat(centralui): Verify-endpoint button + result/cert panel (T17)`.
> The editor needs the owning `siteId` + `connectionName` to verify. `DataConnectionForm` already knows both (`_formSiteId`, `_formName`); pass them into `OpcUaEndpointEditor` as parameters (new `[Parameter]`s). For a not-yet-saved connection, verify uses the in-memory config against the chosen site.
---
### Task B9: Cert trust — per-node CertStore actor + broadcast (T17, D6)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Blocked by:** B7
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs` (`TrustServerCertCommand(string ConnectionName, string DerBase64, string Thumbprint)`, `ListServerCertsCommand()`, `RemoveServerCertCommand(string Thumbprint)`, `TrustedCertInfo(...)`, `CertTrustResult(bool Success, string? Error, IReadOnlyList<TrustedCertInfo>? Certs)`)
- Create: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs` (per-node actor at a well-known path; writes/reads/removes `.der` files in the OPC UA trusted-peer store resolved from `OpcUaGlobalOptions.TrustedPeerStorePath` via the same `ResolveStorePath` logic; lists rejected store too)
- Modify: the site actor bootstrap (where `DataConnectionManagerActor`/site actors are created on **each node**, e.g. `Host/Actors/AkkaHostedService.cs` site path) to start `CertStoreActor` on **every** site node (NOT a singleton).
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs` (singleton handles `TrustServerCertCommand`/`RemoveServerCertCommand` by fanning out to the per-node `CertStoreActor` on each site cluster member via the member list + `Context.ActorSelection`, awaiting acks; `ListServerCertsCommand` reads the local store)
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/CertStoreActorTests.cs` (write a der to a temp dir, list it back, remove it — point `TrustedPeerStorePath` at a temp folder)
**Design.** Per-node delivery (D6): the singleton enumerates `Cluster.Get(system).State.Members` filtered to the site role, and for each member sends to `Context.ActorSelection($"{member.Address}/user/{CertStoreActor.Path}")` a `WriteCertToLocalStore(der)` and awaits acks (with a short timeout; report partial success). This guarantees both node-a and node-b stores get the cert so failover doesn't lose trust. **This broadcast mechanism is the riskiest part** — the high-risk review must scrutinize the member-selection + ack aggregation; if Akka member-address selection proves unreliable in the docker cluster, the integration task (E1) is the gate to validate it live.
**Steps:** failing `CertStoreActor` test (TestKit: tell write → expect ack → list returns it → remove → list empty, all against a temp store path); implement actor + bootstrap + singleton broadcast; build SiteRuntime + Commons + Host; commit `feat(siteruntime): per-node CertStore actor + trust broadcast to both site nodes (T17)`.
---
### Task B10: Cert trust plumbing + cert-management UI (T17)
**Classification:** standard
**Estimated implement time:** ~5 min
**Blocked by:** B8, B9
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs` (`TrustServerCertAsync`/`ListServerCertsAsync`/`RemoveServerCertAsync`)
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ICertManagementService.cs` + impl (**Administrator-role guard** for trust/remove — D7; list may be Designer)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Forms/OpcUaEndpointEditor.razor` (wire the B8 "Trust" button → `ICertManagementService.TrustServerCertAsync`; on success re-run verify)
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ConnectionCertificates.razor` (`@page "/design/connections/{Id:int}/certificates"`, `[Authorize(Policy=RequireAdmin)]`; list trusted/rejected certs with Remove)
- Modify: `DataConnectionForm.razor` (a "Manage certificates" link for OPC UA connections)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/ConnectionCertificatesTests.cs`
**Steps:** failing bUnit test (mock service → list renders 1 trusted cert + Remove calls service); implement; build Communication + CentralUI; commit `feat(centralui): cert-management UI + Trust action (T17)`.
---
## Wave C — Secured writes (T14b)
### Task C1: `PendingSecuredWrite` entity + persistence + migration
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Wave B, D1
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/SecuredWrites/PendingSecuredWrite.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/PendingSecuredWriteEntityTypeConfiguration.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs:132,159` (`DbSet<PendingSecuredWrite> PendingSecuredWrites`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs:56` (register repo)
- Create: migration `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/<ts>_AddPendingSecuredWriteTable.cs` (+ `.Designer.cs`) and regenerate `ScadaBridgeDbContextModelSnapshot.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs` (`SkippableFact`, MSSQL fixture)
**Entity (mirror `SiteCall`):**
```csharp
public sealed class PendingSecuredWrite
{
public long Id { get; set; }
public required string SiteId { get; set; }
public required string ConnectionName { get; set; }
public required string TagPath { get; set; }
public required string ValueJson { get; set; }
public required string ValueType { get; set; } // DataType name
public required string Status { get; set; } // Pending|Approved|Rejected|Executed|Failed|Expired
public required string OperatorUser { get; set; }
public string? OperatorComment { get; set; }
public required DateTime SubmittedAtUtc { get; set; }
public string? VerifierUser { get; set; }
public string? VerifierComment { get; set; }
public DateTime? DecidedAtUtc { get; set; }
public DateTime? ExecutedAtUtc { get; set; }
public string? ExecutionError { get; set; }
}
```
Config mirrors `SiteCallEntityTypeConfiguration` (table `PendingSecuredWrites`, `varchar`/`IsUnicode(false)` columns, `HasMaxLength`, PK on `Id` identity, index `IX_PendingSecuredWrites_Status_Submitted (Status, SubmittedAtUtc)` + `IX_PendingSecuredWrites_Site (SiteId)`). Repository: `AddAsync`, `GetAsync(long id)`, `QueryAsync(status?, siteId?, paging)`, `UpdateAsync`. **Generate the migration with the same dotnet-ef invocation the repo uses** and include the regenerated model snapshot (avoids `PendingModelChangesWarning` — see the M2-pre lesson).
**Steps:** failing repo round-trip `SkippableFact`; implement entity/config/repo/DbSet/DI; `dotnet ef migrations add AddPendingSecuredWriteTable -p src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` (confirm it includes the snapshot delta); run the SkippableFact (skips if no MSSQL) + `dotnet build` the ConfigurationDatabase project; commit `feat(db): PendingSecuredWrite entity + migration + repository (T14b)`.
---
### Task C2: Secured-write commands + submit/reject/list handlers
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Blocked by:** C1, A3
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SecuredWriteCommands.cs` (`SubmitSecuredWriteCommand(string SiteId, string ConnectionName, string TagPath, string ValueJson, string ValueType, string? Comment)`, `ApproveSecuredWriteCommand(long Id, string? Comment)`, `RejectSecuredWriteCommand(long Id, string? Comment)`, `ListSecuredWritesCommand(string? Status, string? SiteId)` + result DTOs)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`GetRequiredRole` `:156-221``SubmitSecuredWriteCommand => Roles.Operator`, `ApproveSecuredWriteCommand or RejectSecuredWriteCommand => Roles.Verifier`, `ListSecuredWritesCommand => null`/any-auth; dispatch entries `:367`; handlers `HandleSubmitSecuredWrite`/`HandleRejectSecuredWrite`/`HandleListSecuredWrites`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs` (create the test project reference if needed; else nearest)
**Handler rules.** Submit: validate the connection exists at the site **and is MxGateway protocol** (look up via `ISiteRepository`), insert `Pending` row stamped with `user.Name` as `OperatorUser`. Reject: load row, require `Status==Pending`, set `Rejected` + `VerifierUser=user.Name` + `DecidedAtUtc`; **enforce `user.Name != OperatorUser`** (no self-approval) — return a `ManagementUnauthorized`-style failure otherwise. List: query by status/site. Approve is added in C3 (it also executes).
**Steps:** failing handler tests (submit on non-MxGateway connection → error; reject by the submitter → no-self-approval error; reject by a different verifier → status flips); implement; build Commons + ManagementService; commit `feat(mgmt): secured-write submit/reject/list handlers + Operator/Verifier gating (T14b)`.
---
### Task C3: Approve → site write relay
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Blocked by:** C2
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs` (`WriteTagAsync(siteId, WriteTagRequest)` — Ask returning `WriteTagResponse`, mirroring `BrowseNodeAsync`; reuses the existing site-side `WriteTagRequest` handler at `DataConnectionActor.cs:332-337`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleApproveSecuredWrite`: load `Pending` row, enforce no-self-approval, set `Approved`+verifier, decode value via `AttributeValueCodec.Decode(ValueJson, ValueType,...)`, call `commService.WriteTagAsync(SiteId, new WriteTagRequest(...ConnectionName, TagPath, value...))`, record `Executed`/`Failed` + `ExecutionError` from `WriteTagResponse`; `ApproveSecuredWriteCommand => Roles.Verifier` already in C2)
- Test: extend `SecuredWriteHandlerTests.cs` (approve by a different verifier → `WriteTagAsync` invoked + row `Executed`; failed write → `Failed`+error; self-approval still blocked)
**Steps:** failing approve test (NSubstitute `CommunicationService`/comm seam); implement relay + handler; build Communication + ManagementService; commit `feat(mgmt): secured-write approve relays to site MxGateway write (T14b)`.
---
### Task C4: `AuditKind.SecuredWrite` + audit wiring
**Classification:** high-risk
**Estimated implement time:** ~4 min
**Blocked by:** C2, C3
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs` (add `SecuredWriteSubmit`, `SecuredWriteApprove`, `SecuredWriteReject`, `SecuredWriteExecute`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditChannel.cs` (add `SecuredWrite`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (in the C2/C3 handlers, after each state change, central direct-write an `AuditEvent` via `IAuditLogRepository.InsertIfNotExistsAsync``CorrelationId = PendingSecuredWrite.Id`, capture `OperatorUser`+`VerifierUser`, target = `SiteId/ConnectionName/TagPath`, `SourceNode = central-a/central-b`)
- Test: extend `SecuredWriteHandlerTests.cs` (each lifecycle step inserts one audit row with the right kind + both users)
**Design.** Audit is best-effort — wrap each `InsertIfNotExistsAsync` so a failure NEVER aborts the secured-write action (the action's own success/failure path is authoritative — the standing audit invariant). Reuse the central direct-write path used by Notification Outbox dispatch / Inbound API. Confirm the `AuditEvent` constructor shape from a current direct-write call site.
**Steps:** failing audit test; implement (best-effort try/catch); build Commons + ManagementService; commit `feat(audit): SecuredWrite audit kinds + per-lifecycle central direct-write (T14b)`.
---
### Task C5: Secured Writes Central UI page
**Classification:** standard
**Estimated implement time:** ~5 min
**Blocked by:** C2, C3, A3
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ISecuredWriteService.cs` + impl (calls the management API: submit/approve/reject/list)
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor` (`@page "/operations/secured-writes"`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor` (new section or under Monitoring; submit form gated `RequireOperator`, queue gated `RequireVerifier`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Program.cs` (register service)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs`
**Design.** Three regions on one page, each in an `AuthorizeView`:
- **Operator** (`RequireOperator`): submit form — site `<select>` → MxGateway connection `<select>` → tag path (optionally a "Browse" button reusing `NodeBrowserDialog`) → typed value + `DataType` → comment → Submit.
- **Verifier** (`RequireVerifier`): pending queue table with Approve/Reject (+comment). Disable Approve/Reject on rows whose `OperatorUser == currentUser` (UI mirror of the server guard). Approve shows a confirm dialog with exact site/connection/tag/value.
- **History**: terminal rows with who/when/outcome.
Add `data-test="secured-writes"`, `data-test="secured-write-submit"`, `data-test="secured-write-approve"`.
**Steps:** failing bUnit test (mock service; submit calls service; approve disabled for own row); implement; build CentralUI; commit `feat(centralui): Secured Writes page — operator submit + verifier queue + history (T14b)`.
---
## Wave D — CSV override import (T16)
### Task D1: `OverrideCsvParser` pure helper
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Wave B, C1
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/OverrideCsvParser.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/OverrideCsvParserTests.cs`
**Design.** Pure, allocation-light, quote-aware CSV → `OverrideCsvParseResult(IReadOnlyList<OverrideCsvRow> Rows, IReadOnlyList<string> Errors)`. Header `AttributeName,Value,ElementType` (ElementType optional). `OverrideCsvRow(string AttributeName, string? Value, string? ElementType, int LineNumber)`. Handle quoted fields (`"a,b"`), empty `Value` → null override, per-line error collection (wrong column count, blank attribute name). No file I/O (callers pass the text). No external lib.
**Steps:** failing tests (simple rows; quoted comma; blank value→null; bad line→error with line number); implement; build + test Commons; commit `feat(commons): quote-aware OverrideCsvParser (T16 CSV)`.
---
### Task D2: InstanceConfigure CSV import UI
**Classification:** standard
**Estimated implement time:** ~5 min
**Blocked by:** D1
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor` (+ `.cs` if present) — an `InputFile` "Import overrides (CSV)" control (mirror `TransportImport.razor.cs` `OnFileSelectedAsync`): read text → `OverrideCsvParser.Parse` → validate names against the instance's flattened attributes + types (reuse the existing override-validation used by the list editor / `AttributeValueCodec`) → build the dict → call the existing `SetInstanceOverridesCommand` path → show a result summary (imported N, M errors with line numbers).
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureCsvImportTests.cs`
**Steps:** failing bUnit test (feed a small CSV via the InputFile test double → asserts the override-set call carries the parsed dict; a bad row surfaces an error); implement; build CentralUI; commit `feat(centralui): InstanceConfigure CSV bulk override import (T16)`.
---
### Task D3: CLI `instance import-overrides --file`
**Classification:** small
**Estimated implement time:** ~3 min
**Blocked by:** D1
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs:275-297` (add `BuildImportOverrides` mirroring `BuildSetOverrides`: `--id` + `--file <path>`; read file, `OverrideCsvParser.Parse`, on parse errors print them + return 1, else call `SetInstanceOverridesCommand`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (document the new subcommand)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/` if a CLI test project exists (else covered by D1 parser tests + manual)
**Steps:** implement; build CLI; (run any CLI tests); commit `feat(cli): instance import-overrides --file (T16)`.
---
## Integration
### Task E1: Integration — docs, full build, docker rebuild, Playwright, smoke
**Classification:** high-risk (final integration reviewer)
**Estimated implement time:** ~5 min implement + verification time
**Blocked by:** ALL prior tasks
**Files:**
- Modify docs: `docs/requirements/Component-CentralUI.md` (Alarm Summary, Secured Writes, cert-mgmt, node-search pages), `Component-DataConnectionLayer.md` (BrowseNext/search/verify/cert-trust), `Component-Security.md` (Operator/Verifier roles), `Component-SiteRuntime.md` (CertStore actor), `Component-ManagementService.md` (secured-write handlers), `Component-AuditLog.md` (SecuredWrite kinds).
- Modify: `CLAUDE.md` (note Operator/Verifier roles + secured writes in Security/Key Decisions; component count stays **26** — M7 adds features, not a new component).
- Modify: `stillpending.md:94-98` (mark T13T17 delivered) and `docs/plans/2026-06-15-stillpending-completion-design.md` (M7 status: delivered; note site-local cert trust + CSV-attribute-override scope; deferred follow-ups).
- Modify: `README.md` if any feature table references need it.
- Add follow-up tasks (TaskCreate): native-alarm-source-override CSV import; aggregated live alarm stream; central-persisted cert trust.
**Verification steps (run, capture output):**
1. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — expect 0 warnings / 0 errors.
2. Run the M7 test filters across the touched test projects — expect green.
3. `bash docker/deploy.sh` — rebuild the cluster image; wait for `/health/ready` 200; confirm the `AddPendingSecuredWriteTable` migration applied.
4. Live smoke (CLI + Chrome): Alarm Summary renders for a site with alarms; submit a secured write as a user with Operator + approve as a different user with Verifier → MxGateway write relayed + audited; node browser search returns matches + "Load more" pages; Verify-endpoint button reports success/untrusted on a configured OPC UA connection; trust a cert → re-verify succeeds on both nodes.
5. `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/ --filter "AlarmSummary|SecuredWrites|NodeBrowserSearch|VerifyEndpoint"` (SkippableFacts; need the docker cluster up).
**Commit** docs + status updates (pathspec). Then run the final integration reviewer over `git diff 254e0e7..HEAD`.
---
## Testing strategy (summary)
- **Pure/unit:** `OverrideCsvParser`, OPC-UA type-name map, verify-result mapping, alarm aggregation/roll-up, secured-write status transitions + no-self-approval + MxGateway-only validation, CertStore write/list/remove.
- **Actor (TestKit):** `DataConnectionActor` browse/search token + capability routing; `CertStoreActor`.
- **bUnit:** AlarmStateBadges, AlarmSummary, NodeBrowserDialog search/load-more, Verify panel, ConnectionCertificates, SecuredWrites, InstanceConfigure CSV import.
- **MSSQL `SkippableFact`:** `SecuredWriteRepository` round-trip + migration.
- **Playwright (E1):** the four end-to-end flows above.
## Risks
- **T14 secured writes** — writes to live equipment; two-person gate + no-self-approval + confirm dialog + audit. High-risk reviews + final integration reviewer.
- **B9 cert broadcast** — Akka per-node member-address selection is the trickiest mechanism; validated live in E1.
- **B2 BrowseNext** — continuation points are session-bound/expirable; fallback-to-fresh-browse on invalid CP.
- **StubOpcUaClient** must gain canned browse/search (B2/B4) or DCL tests can't run serverless.
- **DisableLogin dev caveat** — single identity gets all roles; two-person flow needs two real identities (handler tests cover the guard).
## Follow-ups (logged at E1, not in M7)
- Native-alarm-source-override CSV import (`InstanceNativeAlarmSourceOverride`).
- Aggregated **live** alarm stream for the summary page (vs snapshot+poll).
- Central-persisted, auditable server-cert trust (supersede site-local).
@@ -0,0 +1,38 @@
{
"planPath": "docs/plans/2026-06-18-m7-opcua-mxgateway-ux.md",
"designPath": "docs/plans/2026-06-18-m7-opcua-mxgateway-ux-design.md",
"branch": "worktree-m7-opcua-mxgateway-ux",
"baseSha": "254e0e7",
"tasks": [
{"id": 184, "subject": "M7-A1: Extract AlarmStateBadges shared component", "classification": "standard", "status": "pending"},
{"id": 185, "subject": "M7-A2: Operator Alarm Summary page + fan-out service", "classification": "standard", "status": "pending", "blockedBy": [184]},
{"id": 186, "subject": "M7-A3: Operator + Verifier roles + policies + LDAP mapping", "classification": "high-risk", "status": "pending"},
{"id": 187, "subject": "M7-B1: Browse type-info fields on BrowseNode", "classification": "standard", "status": "pending"},
{"id": 188, "subject": "M7-B2: BrowseNext continuation through browse contract", "classification": "high-risk", "status": "pending", "blockedBy": [187]},
{"id": 189, "subject": "M7-B3: Thread continuation token through browse plumbing", "classification": "standard", "status": "pending", "blockedBy": [188]},
{"id": 190, "subject": "M7-B4: Bounded recursive address-space search — adapter", "classification": "high-risk", "status": "pending", "blockedBy": [188]},
{"id": 191, "subject": "M7-B5: Search plumbing — message + actor + comm + service", "classification": "standard", "status": "pending", "blockedBy": [189, 190]},
{"id": 192, "subject": "M7-B6: NodeBrowserDialog — load-more + search box + type column", "classification": "standard", "status": "pending", "blockedBy": [189, 191]},
{"id": 193, "subject": "M7-B7: Verify-endpoint — message + site probe handler", "classification": "high-risk", "status": "pending", "blockedBy": [191]},
{"id": 194, "subject": "M7-B8: Verify-endpoint plumbing + UI", "classification": "standard", "status": "pending", "blockedBy": [193]},
{"id": 195, "subject": "M7-B9: Cert trust — per-node CertStore actor + broadcast", "classification": "high-risk", "status": "pending", "blockedBy": [193]},
{"id": 196, "subject": "M7-B10: Cert trust plumbing + cert-management UI", "classification": "standard", "status": "pending", "blockedBy": [194, 195]},
{"id": 197, "subject": "M7-C1: PendingSecuredWrite entity + persistence + migration", "classification": "high-risk", "status": "pending"},
{"id": 198, "subject": "M7-C2: Secured-write commands + submit/reject/list handlers", "classification": "high-risk", "status": "pending", "blockedBy": [197, 186]},
{"id": 199, "subject": "M7-C3: Approve → site write relay", "classification": "high-risk", "status": "pending", "blockedBy": [198]},
{"id": 200, "subject": "M7-C4: AuditKind.SecuredWrite + audit wiring", "classification": "high-risk", "status": "pending", "blockedBy": [198, 199]},
{"id": 201, "subject": "M7-C5: Secured Writes Central UI page", "classification": "standard", "status": "pending", "blockedBy": [198, 199, 186]},
{"id": 202, "subject": "M7-D1: OverrideCsvParser pure helper", "classification": "standard", "status": "pending"},
{"id": 203, "subject": "M7-D2: InstanceConfigure CSV import UI", "classification": "standard", "status": "pending", "blockedBy": [202]},
{"id": 204, "subject": "M7-D3: CLI instance import-overrides --file", "classification": "small", "status": "pending", "blockedBy": [202]},
{"id": 205, "subject": "M7-E1: Integration — docs, full build, docker rebuild, Playwright, smoke", "classification": "high-risk", "status": "pending", "blockedBy": [184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204]}
],
"waves": {
"A": [184, 185, 186],
"B": [187, 188, 189, 190, 191, 192, 193, 194, 195, 196],
"C": [197, 198, 199, 200, 201],
"D": [202, 203, 204],
"E": [205]
},
"lastUpdated": "2026-06-18"
}