49 KiB
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 (T13–T17): 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 (D1–D7).
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(branchworktree-m7-opcua-mxgateway-ux, offorigin/main241a792). - Commit pathspec form:
git commit -m "<msg>" -- <paths>(the-mBEFORE the--). Nevergit add -A/-a. Retry onindex.lock. - Keep ≤2–3 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.csrecur. - 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 ≤2–3 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-238with<AlarmStateBadges Alarm="node.Alarm" />; move helpersGetAlarmStateBadge/GetAlarmLevelBadge/GetKindBadge/FormatKind/FormatLevel:580-611into 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 theRequireDeploymentblock 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 likeIBrowseServiceare 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(addOperator/Verifierconsts; add both toRoles.All) - Modify:
src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs(addpublic 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
DisableLoginon (docker),AutoLoginAuthenticationHandlergrantsRoles.Allto 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 toBrowseNode) - 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 optionalcontinuationTokenparam +ContinuationTokenonBrowseChildrenResult) - Modify:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs:705-765(emit base64 CP; callSession.BrowseNextAsyncwhen a token is supplied; on invalid CP → fresh browse) - Modify:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs:173-248(giveStubOpcUaClient.BrowseChildrenAsynca canned impl instead ofthrow 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; 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(addstring? ContinuationToken = nulltoBrowseNodeCommand; addstring? ContinuationToken = nulltoBrowseNodeResult) - Modify:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:1148-1202(passcommand.ContinuationTokentoBrowseChildrenAsync; propagateresult.ContinuationTokenintoBrowseNodeResult; preserveCapBrowseChildren) - Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs:39-88+IBrowseService(add optionalcontinuationTokentoBrowseChildrenAsync) - Test: extend
tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/with aDataConnectionActorbrowse test that asserts the token round-trips (use a fakeIBrowsableDataConnection).
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(StubOpcUaClientcanned 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(addSearchAddressSpaceCommand(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, mirroringHandleBrowse;_adapter is IAddressSpaceSearchablecapability check →NotBrowsablefailure otherwise) - Modify:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs:174-190(routeSearchAddressSpaceCommandby connection name, like browse) - Modify:
src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs:364-372(addSearchAddressSpaceAsync(siteId, cmd)mirroringBrowseNodeAsync) - Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs+IBrowseService(addSearchAsync(siteId, connectionName, query, ...)with the Designer-role guard + timeout mapping, mirroringBrowseChildrenAsync) - 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(handleVerifyEndpointCommand: spin a temporaryRealOpcUaClientviaRealOpcUaClientFactory, deserialize config viaOpcUaEndpointConfigSerializer, connect withAutoAcceptUntrustedCerts=false+ a validation hook that captures the rejected cert, short timeout ~6 s, disconnect; map outcome). UsePipeTo(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)mirroringBrowseNodeAsync) - Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IEndpointVerificationService.cs+EndpointVerificationService.cs(Designer-role guard — D7; serialize the in-editorOpcUaEndpointConfigviaOpcUaEndpointConfigSerializer, 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; onUntrustedCertificateshow theServerCertInfopanel 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+connectionNameto verify.DataConnectionFormalready knows both (_formSiteId,_formName); pass them intoOpcUaEndpointEditoras 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.derfiles in the OPC UA trusted-peer store resolved fromOpcUaGlobalOptions.TrustedPeerStorePathvia the sameResolveStorePathlogic; lists rejected store too) - Modify: the site actor bootstrap (where
DataConnectionManagerActor/site actors are created on each node, e.g.Host/Actors/AkkaHostedService.cssite path) to startCertStoreActoron every site node (NOT a singleton). - Modify:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs(singleton handlesTrustServerCertCommand/RemoveServerCertCommandby fanning out to the per-nodeCertStoreActoron each site cluster member via the member list +Context.ActorSelection, awaiting acks;ListServerCertsCommandreads 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 — pointTrustedPeerStorePathat 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 regenerateScadaBridgeDbContextModelSnapshot.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-221—SubmitSecuredWriteCommand => Roles.Operator,ApproveSecuredWriteCommand or RejectSecuredWriteCommand => Roles.Verifier,ListSecuredWritesCommand => null/any-auth; dispatch entries:367; handlersHandleSubmitSecuredWrite/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 returningWriteTagResponse, mirroringBrowseNodeAsync; reuses the existing site-sideWriteTagRequesthandler atDataConnectionActor.cs:332-337) - Modify:
src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs(HandleApproveSecuredWrite: loadPendingrow, enforce no-self-approval, setApproved+verifier, decode value viaAttributeValueCodec.Decode(ValueJson, ValueType,...), callcommService.WriteTagAsync(SiteId, new WriteTagRequest(...ConnectionName, TagPath, value...)), recordExecuted/Failed+ExecutionErrorfromWriteTagResponse;ApproveSecuredWriteCommand => Roles.Verifieralready in C2) - Test: extend
SecuredWriteHandlerTests.cs(approve by a different verifier →WriteTagAsyncinvoked + rowExecuted; 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(addSecuredWriteSubmit,SecuredWriteApprove,SecuredWriteReject,SecuredWriteExecute) - Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditChannel.cs(addSecuredWrite) - Modify:
src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs(in the C2/C3 handlers, after each state change, central direct-write anAuditEventviaIAuditLogRepository.InsertIfNotExistsAsync—CorrelationId = PendingSecuredWrite.Id, captureOperatorUser+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 gatedRequireOperator, queue gatedRequireVerifier) - 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 reusingNodeBrowserDialog) → typed value +DataType→ comment → Submit. - Verifier (
RequireVerifier): pending queue table with Approve/Reject (+comment). Disable Approve/Reject on rows whoseOperatorUser == 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(+.csif present) — anInputFile"Import overrides (CSV)" control (mirrorTransportImport.razor.csOnFileSelectedAsync): 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 existingSetInstanceOverridesCommandpath → 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(addBuildImportOverridesmirroringBuildSetOverrides:--id+--file <path>; read file,OverrideCsvParser.Parse, on parse errors print them + return 1, else callSetInstanceOverridesCommand) - 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 T13–T17 delivered) anddocs/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.mdif 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):
dotnet build ZB.MOM.WW.ScadaBridge.slnx— expect 0 warnings / 0 errors.- Run the M7 test filters across the touched test projects — expect green.
bash docker/deploy.sh— rebuild the cluster image; wait for/health/ready200; confirm theAddPendingSecuredWriteTablemigration applied.- 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.
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):
DataConnectionActorbrowse/search token + capability routing;CertStoreActor. - bUnit: AlarmStateBadges, AlarmSummary, NodeBrowserDialog search/load-more, Verify panel, ConnectionCertificates, SecuredWrites, InstanceConfigure CSV import.
- MSSQL
SkippableFact:SecuredWriteRepositoryround-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).