Files
ScadaBridge/docs/plans/2026-06-18-m7-opcua-mxgateway-ux.md
T

547 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).