# Phase 7 Status — Scripting Runtime, Virtual Tags, Scripted Alarms, Historian Sink > **Reconciliation date**: 2026-05-18 > **Based on**: `docs/v2/implementation/phase-7-scripting-and-alarming.md` (the plan) and > `docs/v2/implementation/exit-gate-phase-7.md` (the exit-gate audit) cross-checked against > the actual repository files. See "Evidence sources" at the bottom. ## Summary verdict **Phase 7 core is fully shipped and the exit gate is closed.** All eight plan streams (A–H, where H = exit gate) plus the three deferred follow-ups (tasks #239 / #240 / #241) landed before the 2026-04-23 exit-gate audit. The `v2-release-readiness.md` note "Phase 7 — out of scope for v2 GA" is a stale label: the work shipped after that doc was last updated. The four `Core.*` Phase 7 projects exist, have tests, and are wired into the running server. Five targeted gaps remain open (see section below). --- ## Work-item status by plan stream ### Stream A — `Core.Scripting` (Roslyn engine, sandbox, AST inference, logger) | Plan item | Status | Evidence | |-----------|--------|----------| | A.1 — Project scaffold + `ScriptContext` base class (`GetTag` / `SetVirtualTag` / `Logger` / `Now` / `Deadband`) | **Done** | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs`, `ScriptGlobals.cs` | | A.2 — `DependencyExtractor : CSharpSyntaxWalker` — literal-only path check, `Inputs` + `Outputs` sets | **Done** | `DependencyExtractor.cs`; literal-reject logic exercised by 7 test files in `Core.Scripting.Tests` | | A.3 — Compile cache keyed on `SHA-256(source)` | **Done** | `CompiledScriptCache.cs` (`ConcurrentDictionary>>`) | | A.4 — Per-evaluation timeout (250 ms default) | **Done** | `TimedScriptEvaluator.cs`; `TimedScriptEvaluatorTests.cs` | | A.5 — Serilog sink wiring; `scripts-*.log` companion mirror to main log at WARN on ERROR | **Done** | `ScriptLoggerFactory.cs`, `ScriptLogCompanionSink.cs`; `ScriptLogCompanionSinkTests.cs` | | A.6 — Tests (AST extraction, sandbox escape, exception isolation, timeout, logger binding) | **Done** | `ScriptSandboxTests.cs`, `DependencyExtractorTests.cs`, `CompiledScriptCacheTests.cs`, `ScriptLoggerFactoryTests.cs`, `TimedScriptEvaluatorTests.cs` — 7 test files | Shipped as PRs #177–#179 (63 tests). ### Stream B — Virtual tag engine | Plan item | Status | Evidence | |-----------|--------|----------| | B.1 — `VirtualTagEngine` + `DependencyGraph` | **Done** | `VirtualTagEngine.cs`, `DependencyGraph.cs` | | B.2 — `ChangeTriggerDispatcher` (subscribe to referenced driver tags via `ITagUpstreamSource`) | **Done** | `VirtualTagEngine.OnUpstreamChange` internal subscriber path | | B.3 — `TimerTriggerDispatcher` (per-tag `IntervalMs` via timer-wheel) | **Done** | `TimerTriggerScheduler.cs` | | B.4 — `EvaluationPipeline` (serial, per-tag isolation, `_evalGate` semaphore) | **Done** | `VirtualTagEngine.EvaluateInternalAsync`; `_evalGate SemaphoreSlim(1,1)` | | B.5 — `IVirtualTagSource` implementing `IReadable` + `ISubscribable` | **Done** | `VirtualTagSource.cs` | | B.6 — History routing (`IHistoryWriter.Record` when `Historize=true`) | **Partial** | `IHistoryWriter.cs` + `NullHistoryWriter` present; no production writer is wired into the virtual-tag path. `docs/VirtualTags.md` §"Upstream reads + history" explicitly notes: "no production writer is currently wired for virtual tags". Virtual-tag historization is functional at the engine level but has no live sink. | | B.7 — Tests: dependency graph, cascade, timer, change+timer combined, error propagation, historize | **Done** | `DependencyGraphTests.cs`, `VirtualTagEngineTests.cs`, `TimerTriggerSchedulerTests.cs`, `VirtualTagSourceTests.cs` — 5 test files | Shipped as PR #180 (36 tests). ### Stream C — Scripted alarm engine + Part 9 state machine + template messages | Plan item | Status | Evidence | |-----------|--------|----------| | C.1 — `ScriptedAlarmEngine` skeleton + alarm config model | **Done** | `ScriptedAlarmEngine.cs`, `ScriptedAlarmDefinition.cs` | | C.2 — `Part9StateMachine` (Enable/Disable/Active/Ack/Confirm/Shelve/Unshelve/Comment/ShelvingCheck) | **Done** | `Part9StateMachine.cs`; `Part9StateMachineTests.cs` | | C.3 — Predicate evaluation on input change; activate/clear transitions | **Done** | `ScriptedAlarmEngine.ReevaluateAsync`; `_alarmsReferencing` inverse index | | C.4 — Startup recovery (`ActiveState` re-derived; Enabled/Ack/Confirm/Shelve loaded from store) | **Done** | `ScriptedAlarmEngine.LoadAsync`; `IAlarmStateStore.LoadAsync` | | C.5 — Template substitution (`{TagPath}` tokens resolved at emission time) | **Done** | `MessageTemplate.cs`; `MessageTemplateTests.cs` | | C.6 — OPC UA method binding (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) | **Partial** | Engine methods exist and are tested. `ScriptedAlarmSource.AcknowledgeAsync` defaults the user to `"opcua-client"`. The plan's Stream G wiring of these methods to OPC UA `MethodCall` dispatch on the condition nodes (so OPC UA client method calls reach the engine with the authenticated principal) is noted in the e2e smoke doc as "not yet wired through `DriverNodeManager.MethodCall` dispatch." Operators acknowledge through Admin UI today; the Part 9 method-call path is a follow-up. | | C.7 — `IAlarmSource` implementation / fan-out registration | **Done** | `ScriptedAlarmSource.cs` | | C.8 — Tests: all state transitions, startup recovery, template substitution, shelving timer expiry | **Done** | `Part9StateMachineTests.cs`, `ScriptedAlarmEngineTests.cs`, `ScriptedAlarmSourceTests.cs`, `MessageTemplateTests.cs` — 5 test files | Shipped as PR #181 (47 tests). ### Stream D — Historian alarm sink (SQLite store-and-forward + Wonderware IPC) | Plan item | Status | Evidence | |-----------|--------|----------| | D.1 — `Core.AlarmHistorian` project; `IAlarmHistorianSink`; `SqliteStoreAndForwardSink` (backoff, dead-letter, capacity) | **Done** | `IAlarmHistorianSink.cs`, `SqliteStoreAndForwardSink.cs`; `SqliteStoreAndForwardSinkTests.cs` | | D.2 — Live-historian smoke against dev box Aveva Historian; document the exact SDK entry point | **Partial** | The smoke (`docs/v2/implementation/phase-7-e2e-smoke.md`) ran but the IPC path via Galaxy.Host to `aahClientManaged` was the original plan. That path changed: the production implementation uses `Driver.Historian.Wonderware.Client` (`WonderwareHistorianClient.WriteBatchAsync`) over a named-pipe sidecar, not Galaxy.Host. There is no separate `docs/v2/historian-alarm-api.md` artifact documenting the SDK entry point as the plan called for; the implementation detail is in `WonderwareHistorianClient.cs` inline. | | D.3 — `Driver.Galaxy.Shared` contract additions (`HistorianAlarmEventRequest` / `Response` / `ConnectivityStatusNotification`) | **Changed** | The plan routed alarm writes through Galaxy.Host IPC. The shipped implementation uses `Driver.Historian.Wonderware.Client` (a standalone sidecar project) instead. `HistorianAlarmEventRequest` / `HistorianAlarmEventResponse` as named protos never shipped; the equivalent contract is the `AlarmHistorianEventDto` / `WriteAlarmEventsRequest` / `WriteAlarmEventsReply` MessagePack DTOs in `Driver.Historian.Wonderware.Client/Ipc/`. Galaxy.Host still exists as the mxaccessgw entry point but does not carry historian writes. | | D.4 — `Driver.Galaxy.Host` handler for alarm writes | **Changed** | Not shipped via Galaxy.Host. The sidecar (`Driver.Historian.Wonderware.Client`) is the production path. `IAlarmHistorianWriter` is implemented by `WonderwareHistorianClient`, not by a Galaxy.Host frame handler. | | D.5 — Drain worker in main server (poll SQLite queue, batch 100 events, exponential backoff) | **Done** | `SqliteStoreAndForwardSink.StartDrainLoop`; backoff ladder 1s → 2s → 5s → 15s → 60s; `Phase7Composer.ResolveHistorianSink` starts it with a 2-second drain cadence | | D.6 — Per-alarm `HistorizeToAveva` toggle; `AlarmHistorizationPolicy` per source | **Done** | `ScriptedAlarm.HistorizeToAveva` column (default `true`); `Phase7EngineComposer.RouteToHistorianAsync` checks it; Galaxy defaults `false` | | D.7 — `/alarms/historian` diagnostics view in Admin (queue depth, drain rate, last error, retry dead-lettered) | **Done** | `AlarmsHistorian.razor`; `HistorianDiagnosticsService.cs` | | D.8 — Tests | **Done** | `SqliteStoreAndForwardSinkTests.cs`; `Phase7ComposerWriterSelectionTests.cs` covers historian-writer resolution | Shipped as PR #182 (14 tests). Architecture deviated from the plan (Wonderware sidecar instead of Galaxy.Host IPC) but the functional goals are met. ### Stream E — Config DB schema + generation-sealed cache extensions | Plan item | Status | Evidence | |-----------|--------|----------| | E.1 — EF migration for `Script` / `VirtualTag` / `ScriptedAlarm` / `ScriptedAlarmState` tables | **Done** | Migration `20260420231641_AddPhase7ScriptingTables.cs`; entities in `Configuration/Entities/` | | E.2 — `sp_PublishGeneration` extension (sealed-cache snapshot includes Phase 7 rows) | **Done** | Migration `20260420232000_ExtendComputeGenerationDiffWithPhase7.cs` | | E.3 — CRUD services: `VirtualTagService`, `ScriptedAlarmService`, `ScriptService`, `ScriptedAlarmStateService` | **Done** | All four exist in `Admin/Services/`; `GetStateAsync` on `ScriptedAlarmService` serves the state query | | E.4 — Tests: migration up/down; publish atomicity; audit trail | **Done** | `Phase7ServicesTests.cs` (13 tests covering CRUD + hash behavior + harness) | Shipped as PR #183 (12 tests in configuration; 13 more in Admin.Tests). ### Stream F — Admin UI scripting tab | Plan item | Status | Evidence | |-----------|--------|----------| | F.1 — Monaco editor Razor component (CDN bundle + textarea fallback) | **Done** | `ScriptEditor.razor` (textarea with Monaco JS interop, `otOpcUaScriptEditor.attach`) | | F.2 — `/virtual-tags` tab (list view, edit pane, dependency preview, publish gate) | **Partial** | The `ScriptsTab.razor` is the single tab covering script CRUD, dependency preview, and harness. There is no separate `/virtual-tags` tab UI — virtual tags are managed through the script service alone; no VirtualTag list/edit form exists in the Admin UI. The per-tag fields (`EquipmentId`, `DataType`, `ChangeTriggered`, `TimerIntervalMs`, `Historize`) are accessible via the `VirtualTagService` backend but have no corresponding UI form. | | F.3 — `/scripted-alarms` tab (alarm type, severity, message template, `HistorizeToAveva`, detail page with shelve/ack state read-only) | **Partial** | No dedicated scripted-alarms tab razor page exists (confirmed by Glob + Grep searches). Scripted alarm CRUD (`ScriptedAlarmService`) exists as a service but has no Admin UI page. | | F.4 — Test harness (modal, synthetic inputs, output + logger display) | **Partial** | `ScriptTestHarnessService.cs` is complete and tested. `ScriptsTab.razor` calls `Harness.RunVirtualTagAsync` with zero-value synthetic inputs derived from the extractor. A full interactive input-form modal was not shipped — the harness zeroes all inputs automatically rather than prompting the operator per-tag. | | F.5 — Script log viewer (SignalR tail of `scripts-*.log` filtered by `ScriptName`, load-more) | **Not started** | No SignalR stream of the scripts log is wired in the Admin UI. The `AlertHub` / `FleetStatusHub` exist but there is no `ScriptLogHub`. | | F.6 — `/alarms/historian` diagnostics view | **Done** | `AlarmsHistorian.razor` + `HistorianDiagnosticsService.cs` | | F.7 — Playwright smoke (author calc tag, verify in equipment tree; author alarm, verify in `AlarmsAndConditions`) | **Not started** | `tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/` exists but its `UnsTabDragDropE2ETests.cs` is the only Playwright test; no Phase 7 Admin UI playwright scenario. | Shipped as PR #185 (13 Admin service tests; UI completeness is partial — see gaps section). ### Stream G — Address-space integration | Plan item | Status | Evidence | |-----------|--------|----------| | G.1 — `EquipmentNodeWalker` extension emits `NodeSourceKind.Virtual` + `NodeSourceKind.ScriptedAlarm` variables | **Done** | PR #184; `NodeSourceKind` discriminator confirmed in exit gate | | G.2 — `DriverNodeManager` dispatch routes reads by source; writes to non-Driver rejected with `BadUserAccessDenied` | **Done** | PR #186 follow-up; `OpcUaApplicationHost.SetPhase7Sources` threads `_virtualReadable` + `_scriptedAlarmReadable` into the node manager | | G.3 — `AlarmTracker` composition (`ScriptedAlarmEngine` registers as additional `IAlarmSource`) | **Done** | `ScriptedAlarmSource` adapts engine to `IAlarmSource`; `Phase7EngineComposer.Compose` wires it | | G.4 — Tests: mixed equipment folder browsable via Client.CLI; read/subscribe round-trip; alarm transitions in event stream | **Done** | `Phase7ComposerMappingTests.cs`, `Phase7EngineComposerTests.cs`, `ScriptedAlarmReadableTests.cs`, `CachedTagUpstreamSourceTests.cs`, `DriverSubscriptionBridgeTests.cs` — 6 test files in `Server.Tests/Phase7/` | | OPC UA method binding for alarm Ack/Confirm/Shelve | **Not started** | Noted explicitly in `phase-7-e2e-smoke.md` §"Known limitations": `DriverNodeManager.MethodCall` dispatch for scripted alarm methods is not wired. Engine has the methods; the OPC UA call path does not reach them. | Shipped across PRs #184 + #186 (5 + 7 tests). ### Stream H — Exit gate | Plan item | Status | Evidence | |-----------|--------|----------| | H.1 — Compliance script real-checks | **Done** | `scripts/compliance/phase-7-compliance.ps1` | | H.2 — Full solution `dotnet test` baseline | **Done** | Exit gate records ~197 new tests + solution baseline | | H.3 — `plan.md` Migration Strategy §6 update | **Not verified** | Not explicitly confirmed; minor — the plan doc is not the primary status artifact | | H.4 — Phase-status memory update | **Done** | Memory updated (see `project_alarms_over_gateway_epic.md` + `project_server_history_alarm_subsystems.md`) | | H.5 — Merge `v2/phase-7-scripting-and-alarming` → `v2` | **Done** | All PRs (#177–#186) merged | ### Post-gate follow-ups (tasks #239 / #240 / #241) All three are verified closed in the 2026-04-23 exit-gate audit: | Task | Item | Status | |------|------|--------| | #239 | `SealedBootstrap` composition root — `Phase7Composer.PrepareAsync` + `OpcUaServerService` wiring | **Done** | | #240 | Live OPC UA e2e smoke — `scripts/e2e/test-phase7-virtualtags.ps1` | **Done** (partial pass: 3/7 stages reach `PASS`; writer/subscribe/alarm stages blocked by live Galaxy attribute activity + Historian SDK environment) | | #241 | `sp_ComputeGenerationDiff` extension for Script / VirtualTag / ScriptedAlarm diff sections | **Done** — migration `20260420232000_ExtendComputeGenerationDiffWithPhase7` | --- ## What genuinely remains These are real open items, not issues with the plan reconciliation. ### Gap 1 — OPC UA method-call dispatch for scripted alarm methods (Stream G / C.6) — CLOSED All Part 9 alarm methods now route to the `ScriptedAlarmEngine`. `Acknowledge` / `Confirm` / `AddComment` route via `DriverNodeManager.RouteScriptedAlarmMethodCalls` (task #24 + follow-up); `AddComment` gates at the `AlarmAcknowledge` tier. `OneShotShelve` / `TimedShelve` / `Unshelve` route via the native `AlarmConditionState.OnShelve` / `OnTimedUnshelve` hooks wired in `MarkAsAlarmCondition`, with the per-instance shelve method NodeIds indexed so the Call gate resolves them to `OpcUaOperation.AlarmShelve`. ### Gap 2 — Admin UI: no `/virtual-tags` tab or form (Stream F.2) `VirtualTagService` CRUD is fully tested but no razor page exposes it. Operators must author virtual tags through direct SQL or Admin API calls. `ScriptsTab.razor` covers script CRUD only; virtual-tag fields (`EquipmentId`, `DataType`, trigger config, `Historize`) have no UI form. ### Gap 3 — Admin UI: no `/scripted-alarms` tab or form (Stream F.3) `ScriptedAlarmService` CRUD is fully tested but no razor page exists. Only `ScriptsTab.razor` under the cluster detail view is present; there is no `ScriptedAlarmsTab.razor` or equivalent. ### Gap 4 — Script log viewer not shipped (Stream F.5) The SignalR tail of `scripts-*.log` filtered by `ScriptName` was not implemented. `ScriptsTab.razor` shows script output from the in-process harness but has no live-log panel for production emissions. ### Gap 5 — Virtual-tag historization has no production sink (Stream B.6) `IHistoryWriter` + `NullHistoryWriter` are present; `VirtualTagEngine` calls `IHistoryWriter.Record` per evaluation when `Historize=true`. `Phase7EngineComposer.Compose` passes `NullHistoryWriter` — no live writer is wired. Virtual-tag values are computed and served correctly but never persisted to any historian. Explicitly documented in `docs/VirtualTags.md` §"Upstream reads + history". --- ## What is definitely done - All four `Core.*` projects (`Core.Scripting`, `Core.VirtualTags`, `Core.ScriptedAlarms`, `Core.AlarmHistorian`) ship with full implementation and test coverage. - Roslyn sandbox (allow-list + `ForbiddenTypeAnalyzer` defense-in-depth + 250 ms timeout + per-script Serilog sink + compile cache) is complete. - Virtual tag engine: dependency graph with iterative Tarjan SCC, topo-sort, change-trigger cascade, timer trigger, `IReadable` + `ISubscribable` adapter, per-tag error isolation. - Scripted alarm engine: full Part 9 state machine, startup recovery, template substitution, `IAlarmSource` fan-out, 5-second shelving timer, `IAlarmStateStore` (in-memory default; DB-backed via Config DB entities). - SQLite store-and-forward historian sink: drain loop with exponential backoff, dead-letter retention, bounded capacity, `RetryDeadLettered` operator action. - Config DB schema: `Script`, `VirtualTag`, `ScriptedAlarm`, `ScriptedAlarmState` tables with EF migrations and generation-diff extension. - Admin services: `ScriptService`, `VirtualTagService`, `ScriptedAlarmService`, `ScriptTestHarnessService`, `HistorianDiagnosticsService` — all backed by unit tests. - Admin UI: `ScriptsTab.razor` (Monaco-backed editor, dependency preview, test harness), `AlarmsHistorian.razor` (queue depth, drain state, retry dead-lettered). - Server-side composition: `Phase7Composer`, `Phase7EngineComposer`, `CachedTagUpstreamSource`, `DriverSubscriptionBridge`, `ScriptedAlarmReadable` — fully wired into `OpcUaServerService` startup sequence before `OpcUaApplicationHost.StartAsync`. - `EquipmentNodeWalker` emits `NodeSourceKind.Virtual` and `NodeSourceKind.ScriptedAlarm` variables; `DriverNodeManager` dispatches reads and rejects writes to virtual nodes. - `WonderwareHistorianClient.WriteBatchAsync` implements `IAlarmHistorianWriter` as the alarm-event write path (deviation from plan's Galaxy.Host route, but functionally equivalent). - Compliance script `scripts/compliance/phase-7-compliance.ps1` and e2e smoke `scripts/e2e/test-phase7-virtualtags.ps1` both present. --- ## Evidence sources | Source | Path | |--------|------| | Phase 7 plan | `docs/v2/implementation/phase-7-scripting-and-alarming.md` | | Phase 7 exit gate | `docs/v2/implementation/exit-gate-phase-7.md` | | E2E smoke runbook | `docs/v2/implementation/phase-7-e2e-smoke.md` | | Virtual tags reference doc | `docs/VirtualTags.md` | | Scripted alarms reference doc | `docs/ScriptedAlarms.md` | | `Core.Scripting` sources | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/` | | `Core.VirtualTags` sources | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/` | | `Core.ScriptedAlarms` sources | `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/` | | `Core.AlarmHistorian` sources | `src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/` | | Server Phase7 composition | `src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/` | | Admin services | `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/Script*.cs`, `VirtualTagService.cs`, `HistorianDiagnosticsService.cs` | | Admin UI pages | `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor`, `AlarmsHistorian.razor` | | Historian sidecar writer | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs` | | EF migrations | `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.cs`, `20260420232000_ExtendComputeGenerationDiffWithPhase7.cs` |