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

49 KiB
Raw Blame History

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:

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:

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; Folder1Tag1(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):

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-221SubmitSecuredWriteCommand => 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.InsertIfNotExistsAsyncCorrelationId = 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).