# Galaxy Phase B — Native Alarms on the Equipment-Tag Path — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task. **Goal:** A Galaxy equipment `Tag` marked as a native alarm (via its `TagConfig` JSON) materializes a real OPC UA Part 9 `AlarmConditionState` under its equipment folder, and the driver's live `IAlarmSource.OnAlarmEvent` transitions drive that condition (active/severity/message/ack) and fan out to the `alerts` topic — mirroring the scripted-alarm seam. **No EF/schema migration.** **Architecture:** Three reused layers + one new seam. (1) Alarm intent rides in the schemaless `TagConfig` blob, parsed byte-parity in `Phase7Composer` + `DeploymentArtifact` (`EquipmentTagPlan.Alarm`). (2) `Phase7Applier.MaterialiseEquipmentTags` branches: alarm tag → the existing `MaterialiseAlarmCondition` (reused verbatim), else the existing `EnsureVariable`. (3) A new driver→server alarm seam: `DriverInstanceActor` subscribes `IAlarmSource.OnAlarmEvent` (mirroring its `OnDataChange` subscription) and publishes `AttributeAlarmPublished` to `DriverHostActor`, which projects each transition into the existing `AlarmConditionSnapshot` (`NativeAlarmProjector`) and Tells the unchanged `OpcUaPublishActor.AlarmStateUpdate` → `OtOpcUaNodeManager.WriteAlarmCondition`, plus a Primary-gated `AlarmTransitionEvent` to `alerts`. A small additive contract change adds the transition `Kind` to `AlarmEventArgs` (the driver already has it; the record's surviving consumers compile via a default). **Tech Stack:** C#/.NET 10, Akka.NET (fused-host actors, Akka.TestKit.Xunit2 — **xUnit v2**, use `CancellationToken.None` not `TestContext.Current`), xUnit + Shouldly, OPC Foundation UA stack. No bUnit — Razor/live paths proven only by the user-driven docker-dev `/run` gate. **Design:** `docs/plans/2026-06-14-galaxy-phase-b-native-alarms-design.md` (master `90096e9c`). **Hard rules (every task):** stage by path, never `git add .`; never stage `sql_login.txt`, `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, `pending.md`, `current.md`, `docker-dev/docker-compose.yml`; never echo secrets; no force-push, no `--no-verify`; **NO Configuration entity / EF migration change**; no bUnit. Commit per task by path. --- ### Task 0: Feature branch **Classification:** trivial **Estimated implement time:** ~1 min **Parallelizable with:** none **Step 1:** From master at `90096e9c`: ```bash cd /Users/dohertj2/Desktop/OtOpcUa git checkout master && git rev-parse --short HEAD # expect 90096e9c git checkout -b feat/galaxy-phase-b-native-alarms ``` No code change. Do NOT touch the working-tree `docker-dev/docker-compose.yml` or `pending.md`. --- ### Task 1: Transition-kind contract (`AlarmEventArgs.Kind`) + Galaxy populates it — WS-1 **Classification:** standard **Estimated implement time:** ~4 min **Parallelizable with:** Task 2 **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs` (add enum `AlarmTransitionKind`; add trailing `Kind` param to `AlarmEventArgs`) - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:~1128-1167` (`OnAlarmFeedTransition` maps `transition.TransitionKind` → `Kind`) - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/` (new `AlarmEventArgsTests.cs`) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/` (extend the existing alarm-feed/transition test, or add `GalaxyAlarmTransitionKindTests.cs`) **Context:** `GalaxyAlarmTransition` (`Driver.Galaxy/Runtime/GalaxyAlarmTransition.cs`) carries `GalaxyAlarmTransitionKind {Unspecified=0, Raise=1, Acknowledge=2, Clear=3, Retrigger=4}` but `OnAlarmFeedTransition` drops it when building `AlarmEventArgs`. Other `IAlarmSource` implementers (FOCAS/OpcUaClient/AbCip/ScriptedAlarmSource) construct `AlarmEventArgs` without a kind — a record default keeps them compiling untouched. **Step 1 (failing test — contract):** In `AlarmEventArgsTests.cs`: ```csharp using Shouldly; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; public class AlarmEventArgsTests { private static AlarmEventArgs Make(AlarmTransitionKind? kind = null) => kind is null ? new AlarmEventArgs(new FakeHandle(), "Tank1.Hi", "c1", "LimitAlarm.Hi", "msg", AlarmSeverity.High, DateTime.UnixEpoch) : new AlarmEventArgs(new FakeHandle(), "Tank1.Hi", "c1", "LimitAlarm.Hi", "msg", AlarmSeverity.High, DateTime.UnixEpoch, Kind: kind.Value); [Fact] public void Kind_defaults_to_Unspecified_so_existing_callers_compile() => Make().Kind.ShouldBe(AlarmTransitionKind.Unspecified); [Fact] public void Kind_round_trips_when_supplied() => Make(AlarmTransitionKind.Raise).Kind.ShouldBe(AlarmTransitionKind.Raise); private sealed class FakeHandle : IAlarmSubscriptionHandle { public string DiagnosticId => "t"; } } ``` Run: `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter AlarmEventArgsTests` → FAIL (no `Kind`, no `AlarmTransitionKind`). **Step 2 (implement contract):** In `IAlarmSource.cs`, add the enum (next to `AlarmSeverity`) and the `Kind` param as the **last** parameter of `AlarmEventArgs` (after `AlarmCategory`): ```csharp /// The kind of alarm state change. Mirrors the driver-internal transition kinds so a /// consumer can derive Part 9 active/ack state without a separate value subscription. Defaults to /// so existing implementers compile unchanged. public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger } ``` ```csharp // …existing params… string? AlarmCategory = null, AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified); ``` Add a `/// …` doc line to the record's XML doc (the project sets `TreatWarningsAsErrors` — a missing `` for a documented record is a build error). **Step 3 (failing test — Galaxy mapping):** Confirm how the existing Galaxy tests feed a `GalaxyAlarmTransition` onto `OnAlarmEvent` (grep `OnAlarmFeedTransition`/`OnAlarmEvent` in `tests/Drivers/.../Driver.Galaxy.Tests`). Add a test that, for each `GalaxyAlarmTransitionKind`, the surfaced `AlarmEventArgs.Kind` equals the matching `AlarmTransitionKind`. If the test harness can't reach the private `OnAlarmFeedTransition`, extract a tiny `internal static AlarmTransitionKind MapKind(GalaxyAlarmTransitionKind)` and test that directly (mark `Driver.Galaxy` `InternalsVisibleTo` the test project if not already — grep for it first). Run → FAIL. **Step 4 (implement Galaxy mapping):** In `GalaxyDriver.OnAlarmFeedTransition`, add `Kind:` to the `new AlarmEventArgs(...)`: ```csharp Kind: transition.TransitionKind switch { GalaxyAlarmTransitionKind.Raise => AlarmTransitionKind.Raise, GalaxyAlarmTransitionKind.Acknowledge => AlarmTransitionKind.Acknowledge, GalaxyAlarmTransitionKind.Clear => AlarmTransitionKind.Clear, GalaxyAlarmTransitionKind.Retrigger => AlarmTransitionKind.Retrigger, _ => AlarmTransitionKind.Unspecified, }); ``` **Step 5:** `dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions` then run both test filters → PASS. Build the full solution (`dotnet build ZB.MOM.WW.OtOpcUa.slnx`) to confirm no other `IAlarmSource` implementer broke. **Step 6 (commit):** ```bash git add src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs \ src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs \ tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/AlarmEventArgsTests.cs \ tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ git commit -m "feat(alarms): carry transition Kind on AlarmEventArgs; Galaxy populates it (Phase B WS-1)" ``` --- ### Task 2: Alarm intent in TagConfig → `EquipmentTagPlan.Alarm` (byte-parity) — WS-2 **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** Task 1 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (`EquipmentTagPlan` record `:80-88`; new `EquipmentTagAlarmInfo`; `Select(...)` `:323-331`; new `ExtractTagAlarm` next to `ExtractTagFullName` `:432`) - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`BuildEquipmentTagPlans` `:440-448`; new `ExtractTagAlarm` mirror next to `ExtractTagFullName` `:637`) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (new `ExtractTagAlarmTests.cs`; extend the existing composer↔artifact parity test) **Context — the byte-parity invariant:** `Phase7Composer` (compose from DB rows) and `DeploymentArtifact.BuildEquipmentTagPlans` (decode the deployment artifact JSON) MUST produce identical `EquipmentTagPlan`s for the same input — there is an existing parity test (grep `EquipmentTagPlan` in `OpcUaServer.Tests` for the parity fixture). The `alarm` object rides in the same `TagConfig` blob `FullName` already does, so both sides parse it the same way. Absent/malformed `alarm` ⇒ `null` (plain variable). TagConfig shape: ```json { "FullName": "TestMachine_002.HiAlarm", "alarm": { "alarmType": "OffNormalAlarm", "severity": 700 } } ``` `alarmType` default `"AlarmCondition"`, `severity` default `500` (mirrors `ScriptedAlarm` defaults). Valid `alarmType` strings are whatever `OtOpcUaNodeManager.CreateAlarmConditionOfType` accepts (`OffNormalAlarm`/`DiscreteAlarm`/`LimitAlarm`/`AlarmCondition`); an unknown string falls back to the base type downstream — do not validate here. **Step 1 (failing test — `ExtractTagAlarm`):** In `ExtractTagAlarmTests.cs` (test the composer's private helper via `[InternalsVisibleTo]` if present, else assert through `ComposeAsync`/the plan; prefer a thin `internal static` so both helpers can be unit-tested): ```csharp [Theory] [InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)] // no alarm ⇒ null [InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)] // defaults [InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)] [InlineData("not json", false, null, 0)] // malformed ⇒ null [InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)] // wrong kind ⇒ null public void ExtractTagAlarm_parses_or_returns_null(string cfg, bool present, string? type, int sev) { var info = Phase7Composer.ExtractTagAlarm(cfg); // make it internal static if (!present) { info.ShouldBeNull(); return; } info!.AlarmType.ShouldBe(type); info.Severity.ShouldBe(sev); } ``` Run → FAIL. **Step 2 (implement `EquipmentTagAlarmInfo` + `EquipmentTagPlan.Alarm`):** In `Phase7Composer.cs`, next to `EquipmentTagPlan`: ```csharp /// Native-alarm intent parsed from an equipment tag's TagConfig.alarm object. Null ⇒ /// the tag is a plain value variable. is an OPC UA Part 9 subtype string /// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); is the 1..1000 scale. public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity); ``` Add `EquipmentTagAlarmInfo? Alarm` as the **last** field of `EquipmentTagPlan` (after `Writable`). Update the record's XML doc with a `` sentence. **Step 3 (implement `ExtractTagAlarm` — composer):** Next to `ExtractTagFullName`: ```csharp /// Parses the optional alarm object from a tag's TagConfig JSON. Returns null /// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The /// artifact-decode side (DeploymentArtifact.ExtractTagAlarm) MUST parse identically (byte-parity). internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig) { if (string.IsNullOrWhiteSpace(tagConfig)) return null; try { using var doc = JsonDocument.Parse(tagConfig); if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null; var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String ? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition"; var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number ? sEl.GetInt32() : 500; return new EquipmentTagAlarmInfo(type, sev); } catch (JsonException) { return null; } } ``` Wire it into the `Select(...)` at `:331` (add after `Writable:`): ```csharp Writable: t.AccessLevel == TagAccessLevel.ReadWrite, Alarm: ExtractTagAlarm(t.TagConfig))) ``` **Step 4 (implement `ExtractTagAlarm` — artifact mirror, byte-identical):** In `DeploymentArtifact.cs`, add a private `ExtractTagAlarm(string? tagConfig)` with the **same body** (it constructs the same `EquipmentTagAlarmInfo` — Runtime already references the assembly defining `EquipmentTagPlan`, so the type is in scope). Wire into `BuildEquipmentTagPlans` `:448`: ```csharp Writable: writable, Alarm: ExtractTagAlarm(tagConfig))); ``` **Step 5 (failing test — parity):** Extend the existing composer↔artifact parity fixture with an alarm-bearing equipment tag (TagConfig carrying the `alarm` object) and assert the composed `EquipmentTagPlan.Alarm` equals the artifact-decoded one (record equality covers it). Run the parity test → it now exercises `Alarm`. **Step 6:** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "ExtractTagAlarm|Parity"` → PASS. Full build clean. **Step 7 (commit):** ```bash git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs \ src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs \ tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs \ tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ git commit -m "feat(alarms): EquipmentTagPlan.Alarm parsed byte-parity from TagConfig (Phase B WS-2)" ``` --- ### Task 3: Materialize a condition node for an alarm tag — WS-3 **Classification:** standard **Estimated implement time:** ~3 min **Parallelizable with:** Task 4, Task 5 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:186-193` (`MaterialiseEquipmentTags` variable loop) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (extend the Phase7Applier materialise tests) **Context:** `MaterialiseEquipmentTags` currently calls `SafeEnsureVariable(...)` for every tag (`:192`). `SafeMaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity)` already exists (`:309`) — the same one scripted alarms use. An alarm tag becomes a **condition node only** (not also a variable). The condition NodeId = the tag's folder-scoped NodeId (`EquipmentNodeIds.Variable(...)`, same formula). Use the **sub-folder** as the condition's parent when `FolderPath` is set (the variable loop already computes `parent`). **Step 1 (failing test):** With the existing test sink (grep the materialise tests for the fake/recording `IOpcUaAddressSpaceSink`), feed a composition whose `EquipmentTags` contains one plain tag and one `Alarm != null` tag; assert the plain one called `EnsureVariable` and the alarm one called `MaterialiseAlarmCondition` (with the matching nodeId/type/severity) and did **not** call `EnsureVariable`. Run → FAIL. **Step 2 (implement the branch):** Replace the `SafeEnsureVariable(...)` call at `:192` with: ```csharp if (tag.Alarm is not null) { // Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path), // NOT a value variable. Parent is the sub-folder when set, else the equipment folder. SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity); } else { SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable); } ``` **Step 3:** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter MaterialiseEquipmentTags` → PASS. **Step 4 (commit):** ```bash git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs \ tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ git commit -m "feat(alarms): materialise a Part 9 condition for an alarm equipment tag (Phase B WS-3)" ``` --- ### Task 4: `NativeAlarmProjector` (transition → snapshot) — WS-4a **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 3, Task 5 **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs` - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs` **Context:** `AlarmEventArgs` is a delta (raise/clear/ack). `AlarmConditionSnapshot` (`Commons/OpcUa/AlarmConditionSnapshot.cs`: `Active, Acknowledged, Confirmed, Enabled, Shelving, Severity(ushort), Message`) is the full state `WriteAlarmCondition` wants. The projector keeps per-condition-NodeId prior `(Active, Acked, Severity, Message)` and derives the next snapshot from `Kind`. It lives in Runtime (references both `Core.Abstractions` and `Commons`). It is owned by the single-threaded `DriverHostActor` → a plain `Dictionary` is safe (no locking). **Severity map** (`AlarmSeverity` 4-bucket → ushort 1..1000): Low→200, Medium→500, High→700, Critical→900. **Kind → snapshot:** | Kind | Active | Acknowledged | Severity/Message | |---|---|---|---| | Raise / Retrigger | true | false | from event | | Acknowledge | prior | true | from event (keep prior severity if event is a pure ack — use event severity, fine) | | Clear | false | prior | from event | | Unspecified | prior | prior | from event | Constant fields: `Enabled=true`, `Confirmed=true`, `Shelving=Unshelved`. **Step 1 (failing tests):** ```csharp using Shouldly; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; public class NativeAlarmProjectorTests { private static AlarmEventArgs Evt(AlarmTransitionKind kind, AlarmSeverity sev = AlarmSeverity.High, string msg = "m") => new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind); [Fact] public void Raise_is_active_and_unacked() { var s = new NativeAlarmProjector().Project("n1", Evt(AlarmTransitionKind.Raise)); s.Active.ShouldBeTrue(); s.Acknowledged.ShouldBeFalse(); s.Severity.ShouldBe((ushort)700); s.Enabled.ShouldBeTrue(); s.Shelving.ShouldBe(AlarmShelvingKind.Unshelved); } [Fact] public void Acknowledge_sets_acked_and_keeps_prior_active() { var p = new NativeAlarmProjector(); p.Project("n1", Evt(AlarmTransitionKind.Raise)); var s = p.Project("n1", Evt(AlarmTransitionKind.Acknowledge)); s.Active.ShouldBeTrue(); s.Acknowledged.ShouldBeTrue(); } [Fact] public void Clear_deactivates_and_keeps_prior_ack() { var p = new NativeAlarmProjector(); p.Project("n1", Evt(AlarmTransitionKind.Raise)); var s = p.Project("n1", Evt(AlarmTransitionKind.Clear)); s.Active.ShouldBeFalse(); s.Acknowledged.ShouldBeFalse(); } [Fact] public void State_is_isolated_per_nodeId() { var p = new NativeAlarmProjector(); p.Project("n1", Evt(AlarmTransitionKind.Raise)); var s2 = p.Project("n2", Evt(AlarmTransitionKind.Clear)); // cold n2: clear from default-inactive s2.Active.ShouldBeFalse(); } private sealed class H : IAlarmSubscriptionHandle { public string DiagnosticId => "t"; } } ``` Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter NativeAlarmProjector` → FAIL. **Step 2 (implement):** ```csharp using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// /// Derives a full Part 9 from each native /// delta, tracking per-condition-NodeId prior state. Owned by the /// single-threaded DriverHostActor (no locking). Native alarms carry only a transition /// , not a full state machine, so this is the translation the /// scripted-alarm engine does internally. /// public sealed class NativeAlarmProjector { private readonly Dictionary _prior = new(StringComparer.Ordinal); /// Project an alarm transition onto the full condition snapshot for . public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) { var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true, Severity: (ushort)0, Message: string.Empty); var sev = MapSeverity(e.Severity); var (active, acked) = e.Kind switch { AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false), AlarmTransitionKind.Acknowledge => (prev.Active, true), AlarmTransitionKind.Clear => (false, prev.Acked), _ => (prev.Active, prev.Acked), }; _prior[nodeId] = (active, acked, sev, e.Message); return new AlarmConditionSnapshot( Active: active, Acknowledged: acked, Confirmed: true, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message); } /// Clears tracked state (call on address-space rebuild). public void Clear() => _prior.Clear(); private static ushort MapSeverity(AlarmSeverity s) => s switch { AlarmSeverity.Low => 200, AlarmSeverity.Medium => 500, AlarmSeverity.High => 700, AlarmSeverity.Critical => 900, _ => 500, }; } ``` **Step 3:** Run the filter → PASS. **Step 4 (commit):** ```bash git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs \ tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs git commit -m "feat(alarms): NativeAlarmProjector maps transitions to condition snapshots (Phase B WS-4a)" ``` --- ### Task 5: `DriverInstanceActor` subscribes `OnAlarmEvent` + publishes `AttributeAlarmPublished` — WS-4b **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** Task 3, Task 4 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (msg defs `:63-66`; new fields; `AttachAlarmSource`/`DetachAlarmSource`; `Receive`; wire attach on Connected-entry, detach in `DetachSubscription` + `PostStop`) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/` (new `DriverInstanceActorNativeAlarmTests.cs`) **Context — mirror the `OnDataChange` pattern exactly** (`:408-409` attach, `:452-460` `DetachSubscription`): the driver fires `OnAlarmEvent` on its own thread → marshal to the actor thread via `self.Tell(...)`. Galaxy's alarm feed auto-starts in `InitializeAsync` and fires `OnAlarmEvent` independent of `SubscribeAlarmsAsync`, so the actor only subscribes the C# event (no `SubscribeAlarmsAsync` call — that's a deferred follow-up for drivers that gate on it). The server filters by `SourceNodeId` downstream (unknown refs drop), so forward every transition. **Step 1 (failing test, Akka.TestKit — xUnit v2):** ```csharp // Fake driver implements IDriver + ISubscribable + IAlarmSource; exposes RaiseAlarm(args). // Spawn the actor, drive it to Connected, then fakeDriver.RaiseAlarm(...) and // ExpectMsg on a probe wired as the parent. // Use CancellationToken.None (NOT TestContext.Current — this is xUnit v2 / Akka.TestKit.Xunit2). ``` Model it on the existing `DriverInstanceActor` subscribe tests (grep `AttributeValuePublished` in `Runtime.Tests/Drivers`). Assert `AttributeAlarmPublished.DriverInstanceId` + `.Args.SourceNodeId` match. Run → FAIL. **Step 2 (implement messages):** After `AttributeValuePublished` (`:65`): ```csharp /// Published to the parent whenever the subscribed driver (an ) fires /// . The parent () projects + routes it /// to the materialised Part 9 condition. Parallels . public sealed record AttributeAlarmPublished(string DriverInstanceId, AlarmEventArgs Args); private sealed record NativeAlarmRaised(AlarmEventArgs Args); ``` Add fields near `_dataChangeHandler`: ```csharp private EventHandler? _alarmEventHandler; ``` **Step 3 (implement attach/detach + receive):** ```csharp /// Subscribe the driver's native alarm event (if it is an ), /// marshaling each transition to the actor thread. Idempotent; mirrors the OnDataChange attach. private void AttachAlarmSource() { if (_driver is not IAlarmSource src || _alarmEventHandler is not null) return; var self = Self; _alarmEventHandler = (_, e) => self.Tell(new NativeAlarmRaised(e)); src.OnAlarmEvent += _alarmEventHandler; } /// Symmetric teardown — called from and PostStop so a stale /// handler never pushes to a disconnected actor. private void DetachAlarmSource() { if (_driver is IAlarmSource src && _alarmEventHandler is not null) src.OnAlarmEvent -= _alarmEventHandler; _alarmEventHandler = null; } ``` Register in the `Connected()` behavior (next to `Receive(OnDataChangeForward)` at `:275`): ```csharp Receive(m => Context.Parent.Tell(new AttributeAlarmPublished(_driverInstanceId, m.Args))); ``` Call `AttachAlarmSource()` on every Connected entry — the cleanest single site is right where `ResubscribeDesired()` is called after `Become(Connected)` (both the first-connect `InitializeSucceeded` path **and** the `Reconnecting` `InitializeSucceeded` at `:295-302`). Add `AttachAlarmSource();` immediately after each `Become(Connected);`. Add `DetachAlarmSource();` inside `DetachSubscription()` (`:452`, so it fires on Connected→Reconnecting + Unsubscribe + PostStop, which all route through it — verify PostStop calls `DetachSubscription`; if not, add `DetachAlarmSource()` to `PostStop` too). **Step 4:** Run the filter → PASS. Confirm no double-attach (the `_alarmEventHandler is not null` guard) and that a teardown→reconnect re-attaches cleanly (add an assertion that a second Connected entry still delivers exactly one `AttributeAlarmPublished` per raise). **Step 5 (commit):** ```bash git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs \ tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs git commit -m "feat(alarms): DriverInstanceActor forwards native OnAlarmEvent to parent (Phase B WS-4b)" ``` --- ### Task 6: `DriverHostActor` alarm map + `ForwardNativeAlarm` → live condition — WS-4c **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** none **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (new `_alarmNodeIdByDriverRef` map + `_nativeAlarmProjector` field `:99`; build in `PushDesiredSubscriptions` `:748-763`, branching on `Alarm != null`; `Receive` in the two behaviors that hold `Receive`; new `ForwardNativeAlarm`) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/` (new `DriverHostActorNativeAlarmTests.cs`) **Context:** `ForwardToMux` (`:440-467`) is the value analogue. The value maps (`_nodeIdByDriverRef`, `_driverRefByNodeId`) + `refsByDriver` (value subscription) are built in `PushDesiredSubscriptions` (`:734-763`). Alarm tags must be **excluded** from the value maps + value subscription (they're conditions, not variables) and routed via a parallel alarm map. An alarm's `AlarmEventArgs.SourceNodeId` equals the tag's `FullName`, so the alarm map keys on the same `(DriverInstanceId, FullName)`. **Step 1 (failing test, Akka.TestKit):** Spawn `DriverHostActor` with a wired `_opcUaPublishActor` probe; apply a composition (or call the SubscribeBulk path) containing one alarm-bearing `EquipmentTag`; send `AttributeAlarmPublished(driverId, raiseEvent)`; `ExpectMsg` at the alarm tag's NodeId with `State.Active == true`. Second test: an `AttributeAlarmPublished` for an unknown ref produces **no** `AlarmStateUpdate`. Model on the existing `DriverHostActor` value-routing tests (grep `AttributeValueUpdate`/`_nodeIdByDriverRef` in `Runtime.Tests`). Run → FAIL. **Step 2 (fields):** At `:99` add: ```csharp /// (DriverInstanceId, FullName=alarm SourceNodeId) → folder-scoped condition NodeId(s). Built /// from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves native alarm /// transitions to their materialised Part 9 condition node(s). private readonly Dictionary<(string DriverInstanceId, string FullName), HashSet> _alarmNodeIdByDriverRef = new(); private readonly NativeAlarmProjector _nativeAlarmProjector = new(); ``` **Step 3 (build the map — `PushDesiredSubscriptions`):** In the `EquipmentTags` loop (`:755-763`), branch so alarm tags go ONLY into `_alarmNodeIdByDriverRef` and are kept OUT of the value maps; and exclude alarm refs from `refsByDriver` (the value-subscription set at `:734-741`). Concretely: - Add `_alarmNodeIdByDriverRef.Clear();` next to the other `Clear()`s. - In the `foreach (var t in composition.EquipmentTags)` loop: ```csharp var key = (t.DriverInstanceId, t.FullName); var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name); if (t.Alarm is not null) { if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset)) _alarmNodeIdByDriverRef[key] = aset = new HashSet(StringComparer.Ordinal); aset.Add(nodeId); continue; // alarm tags are conditions, not value variables } // …existing value-map population (unchanged)… ``` - For `refsByDriver` (`:734-741`), add `.Where(t => t.Alarm is null)` before the `GroupBy` so the driver doesn't value-subscribe alarm attributes. - Reset projector state on rebuild: call `_nativeAlarmProjector.Clear();` alongside the map clears (the condition nodes are torn down + rebuilt each apply, so prior state must not leak). **Step 4 (`ForwardNativeAlarm` + receives):** Add the handler (model on `ForwardToMux`): ```csharp private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg) { if (_opcUaPublishActor is null) return; if (!_alarmNodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.Args.SourceNodeId), out var nodeIds)) { _log.Debug("DriverHost {Node}: no alarm condition for ({Driver},{Ref}) — transition dropped", _localNode, msg.DriverInstanceId, msg.Args.SourceNodeId); return; } foreach (var nodeId in nodeIds) { var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args); _opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate( nodeId, snapshot, msg.Args.SourceTimestampUtc)); } } ``` Register `Receive(ForwardNativeAlarm);` immediately after **each** `Receive(ForwardToMux);` (there are two — the steady + applying behaviors; grep `ForwardToMux` to find both registration sites). **Step 5:** Run the filter → PASS. Full build clean. **Step 6 (commit):** ```bash git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \ tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs git commit -m "feat(alarms): DriverHostActor routes native alarm transitions to Part 9 conditions (Phase B WS-4c)" ``` --- ### Task 7: Primary-gated `AlarmTransitionEvent` fan-out to `alerts` — WS-5 **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** none **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (alarm-meta map; mediator publish in `ForwardNativeAlarm`, Primary-gated via `_localRole`) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs` (extend) **Context:** `ScriptedAlarmHostActor.OnEngineEmission` (`:289-315`) publishes `AlarmTransitionEvent` to the `alerts` topic (consumed by `HistorianAdapterActor` + AdminUI `/alerts`), **Primary-gated** (suppress on `Secondary`/`Detached`). DriverHostActor already holds `_localRole` (`:121`, same gate it uses for `RouteNodeWrite`) and a DistributedPubSub mediator (it subscribes `RedundancyStateTopic` at `:265`). Reuse both. The event needs `EquipmentPath`/`AlarmName`/`AlarmTypeName` per condition node → add a small meta map built in the same `PushDesiredSubscriptions` pass. Grep `AlertsTopic` + `AlarmTransitionEvent` (Commons `Messages/Alerts/`) and `ScriptedAlarmHostActor` for the exact topic constant, the mediator field name, and the `Publish` usage to copy. **Step 1 (failing test):** Drive the host to `_localRole = Secondary` (send the `RedundancyStateChanged` it consumes; grep the existing redundancy-gate test for `RouteNodeWrite`/"not primary" to copy the setup) → an `AttributeAlarmPublished` still Tells `AlarmStateUpdate` (condition stays warm) but publishes **no** `AlarmTransitionEvent`. With `_localRole = Primary` (or unset) → it publishes one. Run → FAIL. **Step 2 (implement meta map):** Add `private readonly Dictionary _alarmMetaByNodeId = new(StringComparer.Ordinal);`; populate it in the alarm branch of the `PushDesiredSubscriptions` loop (Task 6 step 3): `_alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType);` and `Clear()` it with the others. **Step 3 (implement gated publish):** In `ForwardNativeAlarm`, after the `AlarmStateUpdate` Tell (inside the `foreach`), append the Primary-gated publish, mirroring `ScriptedAlarmHostActor`: ```csharp if (_localRole is RedundancyRole.Secondary or RedundancyRole.Detached) continue; // warm-standby dedup var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m) ? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition"); _mediator.Tell(new Publish(AlertsTopic, new AlarmTransitionEvent( AlarmId: nodeId, EquipmentPath: meta.EquipmentId, AlarmName: meta.Name, TransitionKind: msg.Args.Kind.ToString(), Severity: snapshot.Severity, Message: msg.Args.Message, User: msg.Args.OperatorComment is null ? string.Empty : "device", TimestampUtc: msg.Args.SourceTimestampUtc, AlarmTypeName: meta.AlarmType, Comment: msg.Args.OperatorComment, HistorizeToAveva: true))); ``` Use the exact `AlarmTransitionEvent` constructor argument list discovered by grep (adjust names/order if they differ; the historian + `/alerts` consumers already handle this shape from scripted alarms). Resolve the mediator handle the same way the existing redundancy subscription does (the field used at `:265`). **Step 4:** Run the filter → PASS. Full build + `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests` green. **Step 5 (commit):** ```bash git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \ tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs git commit -m "feat(alarms): Primary-gated AlarmTransitionEvent fan-out for native alarms (Phase B WS-5)" ``` --- ### Task 8: Document the `TagConfig` alarm schema — Docs **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 3, Task 4, Task 5 **Files:** - Modify: `docs/ScriptedAlarms.md` (add a "Native driver alarms (equipment-tag path)" section) — or `docs/AlarmTracking.md` if that's the better home; pick whichever already covers condition materialization. **Step 1:** Add a section documenting: a native driver alarm (Galaxy) is an authored equipment `Tag` whose `TagConfig` carries an `alarm` object — `{"FullName":"tag.attr","alarm":{"alarmType":"OffNormalAlarm","severity":700}}`; absent ⇒ plain variable; `alarmType` ∈ {AlarmCondition, OffNormalAlarm, DiscreteAlarm, LimitAlarm} (default AlarmCondition), `severity` 1..1000 (default 500). Note it materializes a Part 9 condition (not a variable) under the equipment folder, fed by the driver's live `IAlarmSource.OnAlarmEvent`; transitions fan out to `/alerts` + the historian (Primary-gated). State the two deferred follow-ups: inbound device-ack (client Acknowledge → AVEVA) and the AdminUI Galaxy-picker pre-fill. **Step 2 (commit):** ```bash git add docs/ScriptedAlarms.md git commit -m "docs(alarms): native driver-alarm TagConfig schema (Phase B)" ``` --- ### Task 9: Live docker-dev `/run` verification (user-driven) **Classification:** verification **Estimated implement time:** n/a (user drives; the agent does NOT sign in) **Parallelizable with:** none **Gate (the design's live-verify):** On the live-gateway-backed `MAIN-galaxy-eq` (dev rig is LOCAL on this Mac — OrbStack; central-1 @ `localhost:4840`, deploy/AdminUI @ `localhost:9200`, sql @ `localhost:14330`; the Galaxy gateway needs the ephemeral `GALAXY_MXGW_API_KEY` re-exported on container recreate — see `pending.md` Galaxy dev-rig note): 1. Rebuild central on the branch: `docker compose -f docker-dev/docker-compose.yml up -d --build migrator central-1 central-2` (re-export `GALAXY_MXGW_API_KEY=…` on the recreate). 2. Author a Galaxy alarm equipment tag on `EQ-55297329838d` whose `TagConfig` carries the `alarm` object pointing at a real galaxy alarm reference (a `TestMachine_002` attribute with an alarm extension, or the seeded `TestMachine_001.TestAlarm00x`). Deploy: `POST http://localhost:9200/api/deployments`, header `X-Api-Key: docker-dev-deploy-key`. **Order:** deploy FIRST, then recreate central if the driver is faulted (a faulted driver ignores `ApplyDelta`). 3. Trip the Galaxy alarm. Confirm via Client.CLI `alarms`/`read` against `opc.tcp://localhost:4840` that a Part 9 `AlarmConditionState` appears **active** under the equipment NodeId; confirm the AdminUI `/alerts` row appears. Clear → condition goes inactive. 4. (Device-ack round-trip is the deferred follow-up — NOT part of this gate.) **On pass:** finish via `superpowers-extended-cc:finishing-a-development-branch` (intent: merge-to-master + push). Update `pending.md` (disk-only) marking Phase B done; refresh the memory. --- ## Execution order / dependency summary ``` T0 ─┬─ T1 (contract) ─┬─ T4 (projector) ─┐ │ └─ T5 (instance actor) ─┐ └─ T2 (plan field) ─┬─ T3 (applier) ├─ T6 (host live) ── T7 (host alerts) ── T9 (live) └─ T8 (docs) ┘ ``` - **Parallel batch A** (after T0): T1 ∥ T2. - **Parallel batch B** (T1+T2 done): T3 ∥ T4 ∥ T5 ∥ T8 (all disjoint files). - **Serial tail:** T6 (needs T2+T4+T5) → T7 (same file) → T9.