diff --git a/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md b/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md new file mode 100644 index 00000000..df49904f --- /dev/null +++ b/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md @@ -0,0 +1,417 @@ +# Phase 3 — OPC UA standards completeness (H4 + H2-bit + H6) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task. + +**Goal:** Let an OPC UA client Enable/Disable a Part 9 condition (H4); give `HistoryUpdate` its own +permission bit so a HistoryRead grant can't authorize it (H2-bit); route a *native* (Galaxy) condition's +Acknowledge to the owning driver → AVEVA, carrying the authenticated principal (H6). + +**Architecture:** All in the OPC UA node-manager + alarm command path + driver inbound-ack route. The engine +already handles Enable/Disable; only the `OnEnableDisable` node-manager delegate is missing. Native vs scripted +conditions are distinguished by a new `bool isNative` threaded through the single shared +`MaterialiseAlarmCondition` (Phase7Applier already knows which is which); native acks route through a new +`NativeAlarmAckRouter` → `DriverHostActor` (inverse condition→driver map) → `driver.AcknowledgeAsync`. + +**Tech Stack:** C# / .NET 10, OPC UA Foundation UA-.NETStandard (CustomNodeManager2 / AlarmConditionState), +Akka.NET, xUnit + Shouldly. Design: `docs/plans/2026-06-15-stillpending-phase-3-opcua-standards-design.md`. + +**Branch:** `feat/stillpending-phase-3-opcua-standards` (created off master `4af8e65a`; design committed `40b883ef`). + +--- + +## Hard rules (every task) + +- Stage **by path** — never `git add .`. Never stage `sql_login.txt`, `src/Server/.../Host/pki/`, + `pending.md`, `current.md`, `docker-dev/docker-compose.yml`, `stillpending.md`. Never echo/commit secrets + (gateway API key). No force-push, no `--no-verify`. +- **NO** Configuration entity / EF migration. The ONLY contract touches are additive: `bool isNative` on the + internal `IOpcUaAddressSpaceSink.MaterialiseAlarmCondition` seam, and `string? OperatorUser` on the + `AlarmAcknowledgeRequest` Core.Abstractions record. **`NodePermissions` is an int-backed `[Flags]` enum + (`NodeAcl.PermissionFlags` is an int column) — adding a member is NOT a schema change and needs NO migration.** +- TDD fail-then-pass. xUnit + Shouldly. **NO bUnit.** Cross-node/runtime behavior proven only by live `/run`. +- Production projects are `TreatWarningsAsErrors` — fix all warnings (incl. XML docs on new public members). + +## Verified facts the implementer needs + +- `NodePermissions` (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs`): `Browse=1<<0` … + `MethodCall=1<<11`; composites `ReadOnly/Operator/Engineer/Admin`. Highest used bit is `1<<11`. +- `OpcUaOperation` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/OpcUaOperation.cs`) ALREADY has a + `HistoryUpdate` member. +- `TriePermissionEvaluator.MapOperationToPermission` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs:78-86`) + maps `HistoryUpdate => NodePermissions.HistoryRead` with a `// TODO` — the bug. +- `MaterialiseAlarmCondition` is the SINGLE shared materializer. Interface + `IOpcUaAddressSpaceSink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity)`; + impls: `DeferredAddressSpaceSink.cs:47`, `SdkAddressSpaceSink.cs:45`, `NullOpcUaAddressSpaceSink` (in + Commons/OpcUa), `OtOpcUaNodeManager.cs:538`. Phase7Applier calls it at `:204` (native equipment-tag alarms, + `tag.Alarm`) and `:295` (scripted, `alarm.ScriptedAlarmId`). +- `OtOpcUaNodeManager.MaterialiseAlarmCondition` wires the inbound method delegates at `:594-624` + (`OnAcknowledge`/`OnConfirm`/`OnAddComment`/`OnShelve`/`OnTimedUnshelve` → `HandleAlarmCommand`). `OnEnableDisable` + is absent. `HandleAlarmCommand` (`:654-679`) AlarmAck-gates via `RoleCarryingUserIdentity` and routes via the + `Action? AlarmCommandRouter` property (`:91`). `AlarmCommand` vocab already has `Enable`/`Disable`. +- The engine already handles them: `ScriptedAlarmEngine.EnableAsync/DisableAsync`, + dispatched in `ScriptedAlarmHostActor.cs:408-412` on `AlarmCommand` `"Enable"`/`"Disable"`. +- `DriverHostActor` forward map `_alarmNodeIdByDriverRef: (DriverInstanceId, FullName) → condition NodeId(s)` + (field `:127`, cleared+rebuilt `:902-904`); `ForwardNativeAlarm` (`:537`) resolves on `msg.Args.ConditionId` + (= dotted FullName). No inbound OPC-UA-ack→driver path exists. +- `AlarmAcknowledgeRequest` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs:44`) = + `(string SourceNodeId, string ConditionId, string? Comment)` — NO principal field. +- `GalaxyDriver.AcknowledgeAsync` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:1095`) forwards + to `IGalaxyAlarmAcknowledger.AcknowledgeAsync(alarmFullReference, comment, operatorUser, ct)` (which already + takes `operatorUser`). `ScriptedAlarmSource.cs:75` hardcodes `"opcua-client"`. +- Test homes: `tests/Core/.../Authorization/TriePermissionEvaluatorTests.cs` (H2); + `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs` (H4/H6 node-manager seam); + `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/...` (DriverHostActor). Galaxy: `tests/Drivers/...Driver.Galaxy.Tests`. + +--- + +### Task 1: H2-bit — `NodePermissions.HistoryUpdate` + evaluator mapping + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 2 + +**Files:** +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs` +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs:86` +- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/TriePermissionEvaluatorTests.cs` + +**Step 1: Failing tests** (add to `TriePermissionEvaluatorTests.cs`): +- `HistoryRead_grant_does_not_authorize_HistoryUpdate`: build a scope/ACL granting only + `NodePermissions.HistoryRead`; `Authorize(session, OpcUaOperation.HistoryUpdate, scope)` → NOT Allow + (NotGranted). (Mirror an existing test's ACL/session construction.) +- `HistoryUpdate_grant_authorizes_HistoryUpdate`: a scope granting the new `NodePermissions.HistoryUpdate` bit → + `Authorize(…, HistoryUpdate, …)` → Allow. +- `HistoryUpdate_bit_does_not_collide`: `((int)NodePermissions.HistoryUpdate).ShouldBe(1 << 12)` and + `(NodePermissions.HistoryUpdate & NodePermissions.MethodCall).ShouldBe(NodePermissions.None)`. + +**Step 2: Run, expect FAIL** (the bit doesn't exist / HistoryUpdate currently maps to the HistoryRead bit so the +first test wrongly passes-as-Allow): +`dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~TriePermissionEvaluator"` + +**Step 3: Implement.** +- In `NodePermissions.cs` add `HistoryUpdate = 1 << 12,` after `MethodCall = 1 << 11,` (with an XML/`//` doc). + Do NOT add it to any composite (`ReadOnly/Operator/Engineer/Admin`) — the HistoryUpdate *service* is deferred, + so no role needs to grant it yet; the bit exists only to close the HistoryRead⇒HistoryUpdate hole. +- In `TriePermissionEvaluator.cs:86` change `OpcUaOperation.HistoryUpdate => NodePermissions.HistoryRead,` to + `OpcUaOperation.HistoryUpdate => NodePermissions.HistoryUpdate,` and delete the stale `// TODO` comment. + +**Step 4: Run, expect PASS.** Also `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean. + +**Step 5: Commit** +```bash +git add src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs \ + src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs \ + tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/TriePermissionEvaluatorTests.cs +git commit -m "fix(authz): give HistoryUpdate its own NodePermissions bit (was aliased to HistoryRead) [H2]" +``` + +--- + +### Task 2: H6a — mark native conditions (`isNative` through the sink) + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 4 + +**Files:** +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IOpcUaAddressSpaceSink.cs` (or wherever the interface + lives — `grep -rn "void MaterialiseAlarmCondition" src --include=*.cs` to confirm; the design noted the sink + interface declares it) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs:45` +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs:47` +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/NullOpcUaAddressSpaceSink.cs` (no-op impl) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs:538` (signature + `_nativeAlarmNodeIds` set) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:204,295,336-339` (`SafeMaterialiseAlarmCondition`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs` (+ `SdkAddressSpaceSinkTests` if it asserts the signature) + +**Step 1: Failing test.** In `AlarmCommandRouterTests.cs`, add a test that a condition materialised with +`isNative: true` is tracked as native (assert via behavior introduced in this task: e.g. a public/internal +`IsNativeAlarm(nodeId)` test hook on the node manager, OR defer the behavioral assertion to Task 5 and here +just assert the signature compiles + Phase7Applier passes the flag). Prefer adding an `internal bool +IsNativeAlarmNode(string alarmNodeId)` test accessor on `OtOpcUaNodeManager` and asserting +`true` after a native materialise / `false` after a scripted one. + +**Step 2: Run, expect FAIL** (param/accessor missing). + +**Step 3: Implement.** +- Add `bool isNative = false` as the LAST, defaulted param to `MaterialiseAlarmCondition` on the interface + + every impl. Defaulted so no other caller breaks. +- `OtOpcUaNodeManager`: add `private readonly HashSet _nativeAlarmNodeIds = new();`; when + `isNative`, `_nativeAlarmNodeIds.Add(alarmNodeId)`. Add `internal bool IsNativeAlarmNode(string alarmNodeId) => + _nativeAlarmNodeIds.Contains(alarmNodeId);`. (Clear the set on address-space rebuild if the manager resets + `_alarmConditions` — match that lifecycle.) +- `Phase7Applier.SafeMaterialiseAlarmCondition`: add a `bool isNative` param; pass `isNative: true` at the + native equipment-tag-alarm call (`:204`) and `isNative: false` at the scripted call (`:295`). + +**Step 4: Run, expect PASS** + `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean (all sink impls updated). + +**Step 5: Commit** (stage each modified file by path). +`git commit -m "feat(alarms): thread isNative through MaterialiseAlarmCondition; node manager tracks native conditions [H6a]"` + +--- + +### Task 3: H4 — wire `OnEnableDisable` + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (in `MaterialiseAlarmCondition`, near `:594-624`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs` + +**Step 0 — VERIFY the SDK delegate.** Confirm `AlarmConditionState`/`ConditionState` exposes +`OnEnableDisable` and its exact delegate signature (likely `ServiceResult (ISystemContext context, +ConditionState condition, bool enabling)`, matching the `OnShelve`/`OnAcknowledge` settable-handler pattern). +Use IntelliSense/decompile (`Opc.Ua.Server` 1.5.x). If the SDK instead exposes separate `OnEnable`/`OnDisable` +handlers, adapt — wire both to the same body with the right `enabling` value. If `OnEnableDisable` genuinely +does not exist on this SDK version, STOP and report (the design assumed it per `AlarmCommand.cs:37`). + +**Step 1: Failing tests** (mirror the existing `OnAcknowledge`/`OnShelve` tests in `AlarmCommandRouterTests.cs`): +- `OnEnableDisable_with_AlarmAck_routes_Disable_command`: materialise a SCRIPTED condition (`isNative:false`); + capture `AlarmCommandRouter`; invoke `OnEnableDisable(ctx-with-AlarmAck-RoleCarryingUserIdentity, condition, + enabling:false)` → result `Good`, captured `AlarmCommand.Operation == "Disable"`, `User == DisplayName`. +- `OnEnableDisable_enabling_routes_Enable_command`: same with `enabling:true` → `"Enable"`. +- `OnEnableDisable_anonymous_is_denied`: no/role-less identity → `BadUserAccessDenied`, no route. +- `OnEnableDisable_on_native_condition_is_BadNotSupported`: materialise with `isNative:true`; invoke + `OnEnableDisable` → `BadNotSupported`, no route. (Uses Task 2's `_nativeAlarmNodeIds`.) + +**Step 2: Run, expect FAIL.** + +**Step 3: Implement.** In `MaterialiseAlarmCondition`, after the `OnShelve`/`OnTimedUnshelve` wiring: +```csharp +alarm.OnEnableDisable = (context, condition, enabling) => +{ + // Native (driver-fed) conditions have no engine enable/disable surface (decision #2). + if (_nativeAlarmNodeIds.Contains(alarmNodeId)) + return new ServiceResult(StatusCodes.BadNotSupported); + return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null); +}; +``` +(Adapt the delegate shape to Step 0's finding. `_nativeAlarmNodeIds` is populated in Task 2 — this task is +ordered after Task 2.) + +**Step 4: Run, expect PASS** + full build clean. Note: `ScriptedAlarmHostActor` Enable/Disable dispatch is +already covered — no engine change needed. + +**Step 5: Commit** +`git commit -m "feat(alarms): wire OnEnableDisable over OPC UA (AlarmAck-gated; native→BadNotSupported) [H4]"` + +--- + +### Task 4: H6b — `AlarmAcknowledgeRequest.OperatorUser` + Galaxy forwarding + ScriptedAlarmSource tidy + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 1, Task 2 + +**Files:** +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs:44` (record) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:1095` (forward `OperatorUser`) +- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs:65-78` +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/...` (Galaxy ack forwarding) + a Core.Abstractions or + ScriptedAlarms test for the record/`ScriptedAlarmSource` principal. + +**Step 1: Failing tests.** +- `GalaxyDriver_AcknowledgeAsync_forwards_OperatorUser`: with a fake `IGalaxyAlarmAcknowledger`, call + `AcknowledgeAsync([ new AlarmAcknowledgeRequest("src","cond.alarm","cmt"){ OperatorUser="alice" } ])` (or + positional) → the fake received `operatorUser == "alice"`. (Find the existing Galaxy acknowledger test/fake.) +- `ScriptedAlarmSource_uses_supplied_principal_else_opcua_client`: a request with `OperatorUser="bob"` → + engine.AcknowledgeAsync called with `"bob"`; with `OperatorUser=null` → `"opcua-client"`. + +**Step 2: Run, expect FAIL** (the field doesn't exist). + +**Step 3: Implement.** +- `AlarmAcknowledgeRequest` → add `string? OperatorUser = null` as the LAST positional param (keeps existing + positional construction working). XML-doc it ("authenticated principal; null on the raw path"). +- `GalaxyDriver.AcknowledgeAsync`: pass `a.OperatorUser ?? ` as `operatorUser` to the + acknowledger (preserve whatever it currently passes when null). +- `ScriptedAlarmSource.AcknowledgeAsync` (`:75`): `await _engine.AcknowledgeAsync(a.ConditionId, + a.OperatorUser ?? "opcua-client", a.Comment, ct)`. +- AbCip/FOCAS `AcknowledgeAsync` need no change (they ignore the new field) — confirm they still compile. + +**Step 4: Run, expect PASS** + full build clean. + +**Step 5: Commit** +`git commit -m "feat(alarms): AlarmAcknowledgeRequest carries OperatorUser; Galaxy/ScriptedAlarmSource honor it [H6b]"` + +--- + +### Task 5: H6c — `NativeAlarmAckRouter` seam + native `OnAcknowledge` routing + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none (needs Task 2's `_nativeAlarmNodeIds`) + +**Files:** +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs` (small record — OpcUaServer-local, smallest scope) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (`NativeAlarmAckRouter` property + native `OnAcknowledge`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs` + +**Step 1: Failing tests.** +- `Native_OnAcknowledge_routes_to_NativeAlarmAckRouter_not_scripted`: materialise `isNative:true`; capture both + routers; invoke `OnAcknowledge(ctx-with-AlarmAck, condition, _, comment)` → `Good`; the `NativeAlarmAckRouter` + got `NativeAlarmAck(conditionNodeId, comment, OperatorUser=DisplayName)`; the scripted `AlarmCommandRouter` + got NOTHING. +- `Scripted_OnAcknowledge_still_uses_AlarmCommandRouter`: `isNative:false` → `AlarmCommandRouter` gets the + `AlarmCommand`, `NativeAlarmAckRouter` gets nothing. +- `Native_OnAcknowledge_anonymous_is_denied`: no AlarmAck role → `BadUserAccessDenied`, neither router invoked. + +**Step 2: Run, expect FAIL.** + +**Step 3: Implement.** +- `NativeAlarmAck.cs`: `public sealed record NativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser);` +- `OtOpcUaNodeManager`: add `public Action? NativeAlarmAckRouter { get; set; }`. +- In `MaterialiseAlarmCondition`, set `OnAcknowledge` based on native-ness: +```csharp +alarm.OnAcknowledge = isNative + ? (context, condition, _, comment) => HandleNativeAlarmAck(context, condition, comment) + : (context, condition, _, comment) => HandleAlarmCommand(context, condition, "Acknowledge", comment, null); +``` + (Capture `isNative` per-condition; don't rely on the set lookup inside the closure unless you prefer it — + either is fine, but the set lookup also works: `_nativeAlarmNodeIds.Contains(alarmNodeId)`.) +- Add `HandleNativeAlarmAck`: resolve the AlarmAck-gated `RoleCarryingUserIdentity` EXACTLY like + `HandleAlarmCommand` (fail-closed → `BadUserAccessDenied`); else + `NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(condition.NodeId.Identifier?.ToString() ?? "", + comment?.Text, identity.DisplayName ?? ""))`; return `Good`. (Confirm whether the native condition's NodeId + identifier is the folder-scoped condition node id the inverse map in Task 6 will key by — align with + `_alarmNodeIdByDriverRef`'s value set.) + +**Step 4: Run, expect PASS** + full build clean. + +**Step 5: Commit** +`git commit -m "feat(alarms): native condition Acknowledge routes to NativeAlarmAckRouter with principal [H6c]"` + +--- + +### Task 6: H6d — `DriverHostActor` inverse map + native-ack route to the driver + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none (needs Task 4 `OperatorUser` + Task 5 `NativeAlarmAck`) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (inverse map + `Receive`) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (a `RouteAlarmAck` receive → `driver.AcknowledgeAsync`) — only if the host routes via the instance actor; otherwise the host calls the driver via its existing handle +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/...` (mirror an existing DriverHostActor test) + +**Step 1: Failing tests.** +- `Inverse_map_resolves_condition_node_to_driver_ref`: after an `ApplyDelta`/composition with a native alarm + tag, the inverse map yields `(DriverInstanceId, FullName)` for the materialised condition node id. +- `NativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_OperatorUser`: feed the host a native-ack message + (the Runtime-side envelope the host wires the `NativeAlarmAckRouter` to) referencing a known condition node; + a stub `IAlarmSource` driver receives `AcknowledgeAsync` with `ConditionId == alarmRef`, + `OperatorUser == "alice"`, `Comment` passed through. Use the shared `StubDrivers.cs` harness. +- `NativeAlarmAck_unknown_node_is_dropped`: an ack for an unmapped node → logged + dropped, no throw. + +**Step 2: Run, expect FAIL.** + +**Step 3: Implement.** +- Build the inverse `Dictionary _driverRefByAlarmNodeId` + in the SAME clear-and-rebuild pass that builds `_alarmNodeIdByDriverRef` (`:902-904`): for each + `(driverId, fullName) → {nodeIds}`, add each `nodeId → (driverId, fullName)`. +- Decide the host's inbound envelope: the node manager's `NativeAlarmAckRouter` is `Action` + wired (in Task 7) to `Tell` a Runtime message into `DriverHostActor`. Define that Runtime message + (e.g. `DriverHostActor.RouteNativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser)`) + — do NOT reference the OpcUaServer `NativeAlarmAck` type from Runtime if it creates a bad dependency; map at + the wiring boundary (Task 7). `Receive`: look up `_driverRefByAlarmNodeId`; on miss → + `_log.Debug` + return; on hit → route to the driver via the same mechanism `ForwardNativeAlarm`/the write + path uses (Tell the `DriverInstanceActor` a `RouteAlarmAck` it turns into + `driver.AcknowledgeAsync([ new AlarmAcknowledgeRequest(SourceNodeId: , ConditionId: + fullName, Comment, OperatorUser) ], CancellationToken.None)`, fire-and-forget). Mirror the primary-gating + the write/ack paths use if applicable. + +**Step 4: Run, expect PASS** + full build clean. + +**Step 5: Commit** +`git commit -m "feat(alarms): DriverHostActor routes native-condition acks to the owning driver [H6d]"` + +--- + +### Task 7: H6e — wire `NativeAlarmAckRouter` in host DI + +**Classification:** high-risk +**Estimated implement time:** ~3 min +**Parallelizable with:** none + +**Files:** +- Modify: the host wiring that sets `AlarmCommandRouter` today (`grep -rn "AlarmCommandRouter =" src --include=*.cs` + — likely `OtOpcUaServerHostedService` / a DI/bootstrap seam). Set `NativeAlarmAckRouter` analogously to a + fire-and-forget `Tell` of the Runtime `RouteNativeAlarmAck` into the `DriverHostActor` (resolve its `IActorRef` + the same way the AlarmCommandRouter resolves the ScriptedAlarmHost / mediator). +- Test: extend the existing wiring/smoke test if one asserts `AlarmCommandRouter` is set; else none (covered by + the live `/run`). + +**Step 1: Implement** the router assignment (map `NativeAlarmAck` → the Runtime `RouteNativeAlarmAck` at this +boundary so Runtime needn't reference OpcUaServer's record). Confirm the `DriverHostActor` ref is available at +the wiring site (it's registered in the actor registry). + +**Step 2: Build + full test.** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean; `dotnet test` on the affected +projects green. + +**Step 3: Commit** +`git commit -m "feat(alarms): wire NativeAlarmAckRouter to DriverHostActor in host DI [H6e]"` + +--- + +### Task 8: Docs + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 7 + +**Files:** +- Modify: `docs/ScriptedAlarms.md`, `docs/AlarmTracking.md` +- Modify: `docs/security.md` (HistoryUpdate permission bit note, if the § on NodePermissions warrants it) + +**Step 1:** Document: H4 — OPC UA clients can now Enable/Disable Part 9 conditions (AlarmAck-gated; the engine +applies it; native conditions return `BadNotSupported`). H6 — an inbound Acknowledge on a *native* (Galaxy) +condition now routes to the owning driver → AVEVA, carrying the authenticated principal (not the generic +`opcua-client`); `AlarmAcknowledgeRequest` gained `OperatorUser`. H2 — `HistoryUpdate` has its own permission +bit (a HistoryRead grant no longer authorizes it); the HistoryUpdate *service* remains deferred (infra-gated). + +**Step 2:** No build needed. Grep for now-false claims (e.g. `AlarmCommand.cs`'s "OnEnableDisable … future +task" comment — update it; `ScriptedAlarmSource.cs`'s "opcua-client" remark). + +**Step 3: Commit** by path. +`git commit -m "docs(alarms): OPC UA Enable/Disable wired + native-ack→AVEVA + HistoryUpdate permission bit"` + +--- + +### Task 9: Full build + test + final integration review + +**Classification:** high-risk +**Estimated implement time:** ~4 min +**Parallelizable with:** none + +**Step 1:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean (TreatWarningsAsErrors). +**Step 2:** `dotnet test` on Core.Tests, OpcUaServer.Tests, Runtime.Tests, Driver.Galaxy.Tests — all green. +**Step 3:** Final integration reviewer: confirm (a) no EF/Configuration migration; the only contract touches are +the `isNative` sink param + `AlarmAcknowledgeRequest.OperatorUser` (both additive/defaulted); (b) native acks +route to the driver and scripted acks/enable-disable still route to the engine (no cross-wiring); (c) the +AlarmAck fail-closed gate holds on every new path (H4 Enable/Disable + H6 native ack); (d) the `NativeAlarmAck` +→ Runtime `RouteNativeAlarmAck` boundary doesn't create a Runtime→OpcUaServer bad dependency; (e) staging +by-path only. Apply actionable findings. +**Step 4:** Update `pending.md` (working-tree only, never staged). + +--- + +### Task 10: Live `/run` + +**Classification:** high-risk (verification) +**Estimated implement time:** ~6 min (agent-driven on docker-dev; login disabled — do NOT sign in) + +**H4 (fully local-provable):** ensure a scripted alarm exists on the dev config (or author one), deploy, then +`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- ...` — Disable the condition via the Part 9 +Disable method (Client.CLI `shelve`/`ack` style command, or a `call` on the Disable method node) **as a +user holding the AlarmAck role** (LDAP `opc-...` per the data-plane role map) → confirm the condition's +`EnabledState` goes Disabled in the server log / a follow-up read; anon → `BadUserAccessDenied`. + +**H6 (local half):** trip a native (Galaxy) alarm, Acknowledge it over OPC UA as an AlarmAck user → confirm +from the central-1 log that the ack ROUTED to the driver (`DriverHostActor` native-ack route log line) with the +authenticated principal. The **AVEVA commit** (gateway → Historian on `10.100.0.48`) is operator-gated — drive +it against the reachable gateway if feasible this session (the Galaxy live-gw smokes pattern), else record it +as operator-gated (like H5). + +**Done when:** build clean + `dotnet test` green + H4 live-proven + H6 local route proven (AVEVA commit recorded +as driven-or-operator-gated). Then `finishing-a-development-branch` → merge to master + push. diff --git a/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md.tasks.json b/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md.tasks.json new file mode 100644 index 00000000..60bf2d05 --- /dev/null +++ b/docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md.tasks.json @@ -0,0 +1,17 @@ +{ + "planPath": "docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md", + "branch": "feat/stillpending-phase-3-opcua-standards", + "tasks": [ + {"id": 423, "subject": "P3 Task 1: H2-bit — NodePermissions.HistoryUpdate + evaluator mapping", "status": "pending"}, + {"id": 424, "subject": "P3 Task 2: H6a — mark native conditions (isNative through the sink)", "status": "pending"}, + {"id": 425, "subject": "P3 Task 3: H4 — wire OnEnableDisable over OPC UA", "status": "pending", "blockedBy": [424]}, + {"id": 426, "subject": "P3 Task 4: H6b — AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource", "status": "pending"}, + {"id": 427, "subject": "P3 Task 5: H6c — NativeAlarmAckRouter seam + native OnAcknowledge routing", "status": "pending", "blockedBy": [424]}, + {"id": 428, "subject": "P3 Task 6: H6d — DriverHostActor inverse map + native-ack route to driver", "status": "pending", "blockedBy": [426, 427]}, + {"id": 429, "subject": "P3 Task 7: H6e — wire NativeAlarmAckRouter in host DI", "status": "pending", "blockedBy": [427, 428]}, + {"id": 430, "subject": "P3 Task 8: Docs — Enable/Disable + native-ack→AVEVA + HistoryUpdate bit", "status": "pending"}, + {"id": 431, "subject": "P3 Task 9: Full build + test + final integration review", "status": "pending", "blockedBy": [423, 425, 429, 430]}, + {"id": 432, "subject": "P3 Task 10: Live /run — H4 Enable/Disable + H6 native-ack route", "status": "pending", "blockedBy": [431]} + ], + "lastUpdated": "2026-06-15" +}