diff --git a/docs/v2/implementation/phase-7-scripting-and-alarming.md b/docs/v2/implementation/phase-7-scripting-and-alarming.md new file mode 100644 index 0000000..defe069 --- /dev/null +++ b/docs/v2/implementation/phase-7-scripting-and-alarming.md @@ -0,0 +1,190 @@ +# Phase 7 — Scripting Runtime, Virtual Tags, and Scripted Alarms + +> **Status**: DRAFT — planning output from the 2026-04-20 interactive planning session. Pending review before work begins. Task #230 tracks the draft; #231–#238 are the stream placeholders. +> +> **Branch**: `v2/phase-7-scripting-and-alarming` +> **Estimated duration**: 10–12 weeks (scope-comparable to Phase 6; largest single phase outside Phase 2 Galaxy split) +> **Predecessor**: Phase 6.4 (Admin UI completion) — reuses the tab-plugin pattern + draft/publish flow +> **Successor**: v2 release-readiness capstone + +## Phase Objective + +Add two **additive** runtime capabilities on top of the existing driver + Equipment address-space foundation: + +1. **Virtual (calculated) tags** — OPC UA variables whose values are computed by user-authored C# scripts against other tags (driver or virtual), evaluated on change and/or timer. They live in the existing Equipment/UNS tree alongside driver tags and behave identically to clients (browse, subscribe, historize). +2. **Scripted alarms** — OPC UA Part 9 alarms whose condition is a user-authored C# predicate. Full state machine (EnabledState / ActiveState / AckedState / ConfirmedState / ShelvingState) with persistent operator-supplied state across restarts. Complement the existing Galaxy-native and AB CIP ALMD alarm sources — they do not replace them. + +Tie-in capability — **historian alarm sink**: + +3. **Aveva Historian as alarm system of record** — every qualifying alarm transition (activation, ack, confirm, clear, shelve, disable, comment) from **any `IAlarmSource`** (scripted + Galaxy + ALMD) routes through a new local SQLite store-and-forward queue to Galaxy.Host, which uses its already-loaded `aahClientManaged` DLLs to write to the Historian's alarm schema. Per-alarm `HistorizeToAveva` toggle gates which sources flow (default off for Galaxy-native since Galaxy itself already historizes them). Plant operators query one uniform historical alarm timeline. + +**Why it's additive, not a rewrite**: every `IAlarmSource` implementation shipped in Phase 6.x stays unchanged; scripted alarms register as an additional source in the existing fan-out. The Equipment node walker built in ADR-001 gains a "virtual" source kind alongside "driver" without removing anything. Operator-facing semantics for existing driver tags and alarms are unchanged. + +## Design Decisions (locked in the 2026-04-20 planning session) + +| # | Decision | Rationale | +|---|---------|-----------| +| 1 | Script language = **C# via Roslyn scripting** | Developer audience, strong typing, AST walkable for dependency inference, existing .NET 10 runtime in main server. | +| 2 | Virtual tags live in the **Equipment tree** alongside driver tags (not a separate `/Virtual/...` namespace) | Operator mental model stays unified; calculated `LineRate` shows up under the Line1 folder next to the driver-sourced `SpeedSetpoint` it's derived from. | +| 3 | Evaluation trigger = **change-driven + timer-driven**; operator chooses per-tag | Change-driven is cheap at steady state; timer is the escape hatch for polling derivations that don't have a discrete "input changed" signal. | +| 4 | Script shape = **Shape A — one script per virtual tag/alarm**; `return` produces the value (or `bool` for alarm condition) | Minimal surface; no predicate/action split. Alarm side-effects (severity, message) configured out-of-band, not in the script. | +| 5 | Alarm fidelity = **full OPC UA Part 9** | Uniform with Galaxy + ALMD on the wire; client-side tooling (HMIs, historians, event pipelines) gets one shape. | +| 6 | Sandbox = **read-only context**; scripts can only read any tag + write to virtual tags | Strict Roslyn `ScriptOptions` allow-list. No HttpClient / File / Process / reflection. | +| 7 | Dependency declaration = **AST inference**; operator doesn't maintain a separate dependency list | `CSharpSyntaxWalker` extracts `ctx.GetTag("path")` string-literal calls at compile time; dynamic paths rejected at publish. | +| 8 | Config storage = **config DB with generation-sealed cache** (same as driver instances) | Virtual tags + alarms publish atomically in the same generation as the driver instance config they may depend on. | +| 9 | Script return value shape (`ctx.GetTag`) = **`DataValue { Value, StatusCode, Timestamp }`** | Scripts branch on quality naturally without separate `ctx.GetQuality(...)` calls. | +| 10 | Historize virtual tags = **per-tag checkbox** | Writes flow through the same history-write path as driver tags. Consumed by existing `IHistoryProvider`. | +| 11 | Per-tag error isolation — a throwing script sets that tag's quality to `BadInternalError`; engine keeps running for every other tag | Mirrors Phase 6.1 Stream B's per-surface error handling. | +| 12 | Dedicated Serilog sink = `scripts-*.log` rolling file; structured-property `ScriptName` for filtering | Keeps noisy script logs out of the main `opcua-*.log`. `ctx.Logger.Info/Warning/Error/Debug` bound in the script context. | +| 13 | Alarm message = **template with substitution** (`"Reactor temp {Reactor/Temp} exceeded {Limit}"`) | Middle ground between static and separate message-script; engine resolves `{path}` tokens at event emission. | +| 14 | Alarm state persistence — `ActiveState` recomputed from tag values on startup; `EnabledState / AckedState / ConfirmedState / ShelvingState` + audit trail persist to config DB | Operators don't re-ack after restart; ack history survives for compliance (GxP / 21 CFR Part 11). | +| 15 | Historian sink scope = **all `IAlarmSource` implementations**, not just scripted; per-alarm `HistorizeToAveva` toggle | Plant gets one consolidated alarm timeline; Galaxy-native alarms default off to avoid duplication. | +| 16 | Historian failure mode = **SQLite store-and-forward queue on the node**; config DB is source of truth, Historian is best-effort projection | Operators never blocked by Historian downtime; failed writes queue + retry when Historian recovers. | +| 17 | Historian ingestion path = **IPC to Galaxy.Host**, which calls the already-loaded `aahClientManaged` DLLs | Reuses existing bitness / licensing / Tier-C isolation. No new 32-bit DLL load in the main server. | +| 18 | Admin UI code editor = **Monaco** via the Admin project's asset pipeline | Industry default for C# editing in a browser; ~3 MB bundle acceptable given Admin is operator-facing only, not public. Revisitable if bundle size becomes a deployment constraint. | +| 19 | Cascade evaluation order = **serial** for v1; parallel promoted to a Phase 7 follow-up | Deterministic, easier to reason about, simplifies cycle + ordering bugs in the rollout. Parallel becomes a tuning knob when real 1000+ virtual-tag deployments measure contention. | +| 20 | Shelving UX = **OPC UA method calls only** (`OneShotShelve` / `TimedShelve` / `Unshelve` on the `AlarmConditionType` node); **no Admin UI shelve controls** | Plant HMIs + OPC UA clients already speak these methods by spec; reinventing the UI adds surface without operator value. Admin still renders current shelve state + audit trail read-only on the alarm detail page. | +| 21 | Dead-lettered historian events retained for **30 days** in the SQLite queue; Admin `/alarms/historian` exposes a "Retry dead-lettered" button | Long enough for a Historian outage or licensing glitch to be resolved + operator to investigate; short enough that the SQLite file doesn't grow unbounded. Configurable via `AlarmHistorian:DeadLetterRetentionDays` for deployments with stricter compliance windows. | +| 22 | Test harness synthetic inputs = **declared inputs only** (from the AST walker's extracted dependency set) | Enforces the dependency declaration — if a path can't be supplied to the harness, the AST walker didn't see it and the script can't reference it at runtime. Catches dependency-inference drift at test time, not publish time. | + +## Scope — What Changes + +| Concern | Change | +|---------|--------| +| **New project `OtOpcUa.Core.Scripting`** (.NET 10) | Roslyn-based script engine. Compiles user C# scripts with a sandboxed `ScriptOptions` allow-list (numeric / string / datetime / `ScriptContext` API only — no reflection / File / Process / HttpClient). `DependencyExtractor` uses `CSharpSyntaxWalker` to enumerate `ctx.GetTag("...")` literal-string calls; rejects non-literal paths at publish time. Per-script compile cache keyed by source hash. Per-evaluation timeout. Exception in script → tag goes `BadInternalError`; engine unaffected for other tags. `ctx.Logger` is a Serilog `ILogger` bound to the `scripts-*.log` rolling sink with structured property `ScriptName`. | +| **New project `OtOpcUa.Core.VirtualTags`** (.NET 10) | `VirtualTagEngine` consumes the `DependencyExtractor` output, builds a topological dependency graph spanning driver tags + other virtual tags (cycle detection at publish time), schedules re-evaluation on change + on timer, propagates results through an `IVirtualTagSource` that implements `IReadable` + `ISubscribable` so `DriverNodeManager` routes reads / subscriptions uniformly. Per-tag `Historize` flag routes to the same history-write path driver tags use. | +| **New project `OtOpcUa.Core.ScriptedAlarms`** (.NET 10) | `ScriptedAlarmEngine` materializes each configured alarm as an OPC UA `AlarmConditionType` (or `LimitAlarmType` / `OffNormalAlarmType`). On startup, re-evaluates every predicate against current tag values to rebuild `ActiveState` — no persistence needed for the active flag. Persistent state: `EnabledState`, `AckedState`, `ConfirmedState`, `ShelvingState`, branch stack, ack audit (user/time/comment). Template message substitution resolves `{TagPath}` tokens at event emission. Ack / Confirm / Shelve method nodes bound to the engine; transitions audit-logged via the existing `IAuditLogger` (Phase 6.2). Registers as an additional `IAlarmSource` — no change to the existing fan-out. | +| **New project `OtOpcUa.Core.AlarmHistorian`** (.NET 10) | `IAlarmHistorianSink` abstraction + `SqliteStoreAndForwardSink` default implementation. Every qualifying `IAlarmSource` emission (per-alarm `HistorizeToAveva` toggle) persists to a local SQLite queue (`%ProgramData%\OtOpcUa\alarm-historian-queue.db`). Background drain worker reads unsent rows + forwards over IPC to Galaxy.Host. Failed writes keep the row pending with exponential backoff. Queue capacity bounded (default 1M events, oldest-dropped with a structured warning log). | +| **`Driver.Galaxy.Shared`** — new IPC contracts | `HistorianAlarmEventRequest` (activation / ack / confirm / clear / shelve / disable / comment payloads matching the Aveva Historian alarm schema) + `HistorianAlarmEventResponse` (ack / retry-please / permanent-fail). `HistorianConnectivityStatusNotification` so the main server can surface "Historian disconnected" on the Admin `/hosts` page. | +| **`Driver.Galaxy.Host`** — new frame handler for alarm writes | Reuses the already-loaded `aahClientManaged.dll` + `aahClientCommon.dll`. Maps the IPC request DTOs to the historian SDK's alarm-event API (exact method TBD during Stream D.2 — needs a live-historian smoke to confirm the right SDK entry point). Errors map to structured response codes so the main server's backoff logic can distinguish "transient" from "permanent". | +| **Config DB schema** — new tables | `VirtualTag (Id, EquipmentPath, Name, DataType, IntervalMs?, ChangeTriggerEnabled, Historize, ScriptId)`; `Script (Id, SourceCode, CompiledHash, Language='CSharp')`; `ScriptedAlarm (Id, EquipmentPath, Name, AlarmType, Severity, MessageTemplate, HistorizeToAveva, PredicateScriptId)`; `ScriptedAlarmState (AlarmId, EnabledState, AckedState, ConfirmedState, ShelvingState, ShelvingExpiresUtc?, LastAckUser, LastAckComment, LastAckUtc, BranchStack_JSON)`. Every write goes through `sp_PublishGeneration` + `IAuditLogger`. | +| **Address-space build** — Phase 6 `EquipmentNodeWalker` extension | Emits virtual-tag nodes alongside driver-sourced nodes under the same Equipment folder. `NodeScopeResolver` gains a `Virtual` source kind alongside `Driver`. `DriverNodeManager` dispatch routes reads / writes / subscriptions to the `VirtualTagEngine` when the source is virtual. | +| **Admin UI** — new tabs | `/virtual-tags` and `/scripted-alarms` tabs under the existing draft/publish flow. Monaco-based C# code editor (syntax highlighting, IntelliSense against a hand-written type stub for `ScriptContext`). Dependency preview panel shows the inferred input list from the AST walker. Test-harness lets operator supply synthetic `DataValue` inputs + see script output + logger emissions without publishing. Per-alarm controls: `AlarmType`, `Severity`, `MessageTemplate`, `HistorizeToAveva`. New `/alarms/historian` diagnostics view: queue depth, drain rate, last-successful-write, per-alarm "last routed to historian" timestamp. | +| **`DriverTypeRegistry`** — no change | Scripting is not a driver — it doesn't register as a `DriverType`. The engine hangs off the same `SealedBootstrap` as drivers but through a different composition root. | + +## Scope — What Does NOT Change + +| Item | Reason | +|------|--------| +| Existing `IAlarmSource` implementations (Galaxy, AB CIP ALMD) | Scripted alarms register as an *additional* source; existing sources pass through unchanged. Default `HistorizeToAveva=false` for Galaxy alarms avoids duplicating records the Galaxy historian wiring already captures. | +| Driver capability surface (`IReadable` / `IWritable` / `ISubscribable` / etc.) | Virtual tags implement the same interfaces — drivers and virtual tags are interchangeable from the node manager's perspective. No new capability. | +| Config DB publication flow (`sp_PublishGeneration` + sealed cache) | Virtual tag + alarm tables plug in as additional rows. Atomic publish semantics unchanged. | +| Authorization trie (Phase 6.2) | Virtual-tag nodes inherit the Equipment scope's grants — same treatment as the Phase 6.4 Identification sub-folder. No new scope level. | +| Tier-C isolation topology | Scripting engine runs in the main .NET 10 server process. Roslyn scripts are already sandboxed via `ScriptOptions`; no need for process isolation because they have no unmanaged reach. Galaxy.Host's existing Tier-C boundary already owns the historian SDK writes. | +| Galaxy alarm ingestion path into the historian | Galaxy writes alarms directly via `aahClientManaged` today; Phase 7 Stream D gives it a *second* path (via the new sink) when a Galaxy alarm has `HistorizeToAveva=true`, but the direct path stays for the default case. | +| OPC UA wire protocol / AddressSpace schema | Clients see new nodes under existing folders + new alarm conditions. No new namespaces, no new ObjectTypes beyond what Part 9 already defines. | + +## Entry Gate Checklist + +- [ ] All Phase 6.x exit gates cleared (#133, #142, #151, #158) +- [ ] Equipment node walker wired into `DriverNodeManager` (task #212 — done) +- [ ] `IAuditLogger` surface live (Phase 6.2 Stream A) +- [ ] `sp_PublishGeneration` + sealed-cache flow verified on the existing driver-config tables +- [ ] Dev Aveva Historian reachable from the dev box (for Stream D.2 smoke) +- [ ] `v2` branch clean + baseline tests green +- [ ] Blazor editor component library picked (Monaco confirmed vs alternatives — see decision to log) +- [ ] Review this plan — decisions #1–#17 signed off, no open questions + +## Task Breakdown + +### Stream A — `Core.Scripting` (Roslyn engine + sandbox + AST inference + logger) — **2 weeks** + +1. **A.1** Project scaffold + NuGet `Microsoft.CodeAnalysis.CSharp.Scripting`. `ScriptOptions` allow-list (`typeof(object).Assembly`, `typeof(Enumerable).Assembly`, the Core.Scripting assembly itself — nothing else). Hand-written `ScriptContext` base class with `GetTag(string)` / `SetVirtualTag(string, object)` / `Logger` / `Now` / `Deadband(double, double, double)` helpers. +2. **A.2** `DependencyExtractor : CSharpSyntaxWalker`. Visits every `InvocationExpressionSyntax` targeting `ctx.GetTag` / `ctx.SetVirtualTag`; accepts only a `LiteralExpressionSyntax` argument. Non-literal arguments (concat, variable, method call) → publish-time rejection with an actionable error pointing the operator at the exact span. Outputs `IReadOnlySet Inputs` + `IReadOnlySet Outputs`. +3. **A.3** Compile cache. `(source_hash) → compiled Script`. Recompile only when source changes. Warm on `SealedBootstrap`. +4. **A.4** Per-evaluation timeout wrapper (default 250ms; configurable per tag). Timeout = tag quality `BadInternalError` + structured warning log. Keeps a single runaway script from starving the engine. +5. **A.5** Serilog sink wiring. New `scripts-*.log` rolling file enricher; `ctx.Logger` returns an `ILogger` with `ForContext("ScriptName", ...)`. Main `opcua-*.log` gets a companion entry at WARN level if a script logs ERROR, so the operator sees it in the primary log. +6. **A.6** Tests: AST extraction unit tests (30+ cases covering literal / concat / variable / null / method-returned paths); sandbox escape tests (attempt `typeof`, `Assembly.Load`, `File.OpenRead` — all must fail at compile); exception isolation (throwing script doesn't kill the engine); timeout behavior; logger structured-property binding. + +### Stream B — Virtual tag engine (dependency graph + change/timer schedulers + historize) — **1.5 weeks** + +1. **B.1** `VirtualTagEngine`. Ingests the set of compiled scripts + their inputs/outputs; builds a directed dependency graph (driver tag ID → virtual tag ID → virtual tag ID). Cycle detection at publish-time via Tarjan; publish rejects with a clear error message listing the cycle. +2. **B.2** `ChangeTriggerDispatcher`. Subscribes to every referenced driver tag via the existing `ISubscribable` fan-out. On a `DataValueSnapshot` delta (value / status / timestamp — any of the three), enqueues affected virtual tags for re-evaluation in topological order. +3. **B.3** `TimerTriggerDispatcher`. Per-tag `IntervalMs` scheduled via a shared timer-wheel. Independent of change triggers — a tag can have both, either, or neither. +4. **B.4** `EvaluationPipeline`. Serial evaluation per cascade (parallel promoted to a follow-up — avoids cross-tag ordering bugs on first rollout). Exception handling per A.4; propagates results via `IVirtualTagSource`. +5. **B.5** `IVirtualTagSource` implementation. Implements `IReadable` + `ISubscribable`. Reads return the most recent evaluated value; subscriptions receive `OnDataChange` events on each re-evaluation. +6. **B.6** History routing. Per-tag `Historize` flag emits the value + timestamp to the existing history-write path used by drivers. +7. **B.7** Tests: dependency graph (happy + cycle); change cascade through two levels of virtual tags; timer-only tag ignores input changes; change + timer both configured; error propagation; historize on/off. + +### Stream C — Scripted alarm engine + Part 9 state machine + template messages — **2.5 weeks** + +1. **C.1** Alarm config model + `ScriptedAlarmEngine` skeleton. Alarms materialize as `AlarmConditionType` (or subtype — `LimitAlarm`, `OffNormal`) nodes under their configured Equipment path. Severity loaded from config. +2. **C.2** `Part9StateMachine`. Tracks `EnabledState`, `ActiveState`, `AckedState`, `ConfirmedState`, `ShelvingState` per condition ID. Shelving has `OneShotShelving` + `TimedShelving` variants + an `UnshelveTime` timer. +3. **C.3** Predicate evaluation. On any input change (same trigger mechanism as Stream B), run the `bool` predicate. On `false → true` transition, activate (increment branch stack if prior Ack-but-not-Confirmed state exists). On `true → false`, clear (but keep condition visible if retain flag set). +4. **C.4** Startup recovery. For every configured alarm, run the predicate against current tag values to rebuild `ActiveState` *only*. Load `EnabledState` / `AckedState` / `ConfirmedState` / `ShelvingState` + audit from the `ScriptedAlarmState` table. No re-acknowledgment required for conditions that were acked before restart. +5. **C.5** Template substitution. Engine resolves `{TagPath}` tokens in `MessageTemplate` at event emission time using current tag values. Unresolvable tokens (bad path, missing tag) emit a structured error log + substitute `{?}` so the event still fires. +6. **C.6** OPC UA method binding. `Acknowledge`, `Confirm`, `AddComment`, `OneShotShelve`, `TimedShelve`, `Unshelve` methods on each condition node route to the engine + persist via audit-logged writes to `ScriptedAlarmState`. +7. **C.7** `IAlarmSource` implementation. Emits Part 9-shaped events through the existing fan-out the `AlarmTracker` composes. +8. **C.8** Tests: every transition (all 32 state combinations the state machine can produce); startup recovery (seed table with varied ack/confirm/shelve state, restart, verify correct recovery); template substitution (literal path, nested path, bad path); shelving timer expiry; OPC UA method calls via Client.CLI. + +### Stream D — Historian alarm sink (SQLite store-and-forward + Galaxy.Host IPC) — **2 weeks** + +1. **D.1** `Core.AlarmHistorian` project. `IAlarmHistorianSink` interface; `SqliteStoreAndForwardSink` default implementation using Microsoft.Data.Sqlite. Schema: `Queue (RowId, AlarmId, EventType, PayloadJson, EnqueuedUtc, LastAttemptUtc?, AttemptCount, DeadLettered)`. Queue capacity bounded; oldest-dropped on overflow with structured warning. +2. **D.2** **Live-historian smoke** against the dev box's Aveva Historian. Identify the exact `aahClientManaged` alarm-write API entry point (likely `IAlarmsDatabase.WriteAlarmEvent` or equivalent — verify with a throwaway Galaxy.Host test hook). Document in a short `docs/v2/historian-alarm-api.md` artifact. +3. **D.3** `Driver.Galaxy.Shared` contract additions. `HistorianAlarmEventRequest` / `HistorianAlarmEventResponse` / `HistorianConnectivityStatusNotification`. Round-trip tests in `Driver.Galaxy.Shared.Tests`. +4. **D.4** `Driver.Galaxy.Host` handler. Translates incoming `HistorianAlarmEventRequest` to the SDK call identified in D.2. Returns structured response (Ack / RetryPlease / PermanentFail). Connectivity notifications sent proactively when the SDK's session drops. +5. **D.5** Drain worker in the main server. Polls the SQLite queue; batches up to 100 events per IPC round-trip; exponential backoff on `RetryPlease` (1s → 2s → 5s → 15s → 60s cap); `PermanentFail` dead-letters the row + structured error log. +6. **D.6** Per-alarm toggle wired through: `HistorizeToAveva` column on both `ScriptedAlarm` + a new `AlarmHistorizationPolicy` projection the Galaxy / ALMD alarm sources consult (default `false` for Galaxy, `true` for scripted, operator-adjustable per-alarm). +7. **D.7** `/alarms/historian` diagnostics view in Admin. Queue depth, drain rate, last-successful-write, last-error, per-alarm last-routed timestamp. +8. **D.8** Tests: SQLite queue round-trip; drain worker with fake IPC (success / retry / perm-fail); overflow eviction; Galaxy.Host handler against a stub historian API; end-to-end with the live historian on the dev box (non-CI — operator-invoked). + +### Stream E — Config DB schema + generation-sealed cache extensions — **1 week** + +1. **E.1** EF migration for new tables. Foreign keys from `VirtualTag.ScriptId` / `ScriptedAlarm.PredicateScriptId` to `Script.Id`. +2. **E.2** `sp_PublishGeneration` extension. Sealed-cache snapshot includes virtual tags + scripted alarms + their scripts. Atomic publish guarantees the address-space build sees a consistent view. +3. **E.3** CRUD services. `VirtualTagService`, `ScriptedAlarmService`, `ScriptService`. Each audit-logged; Ack / Confirm / Shelve persist through `ScriptedAlarmStateService` with full audit trail (who / when / comment / previous state). +4. **E.4** Tests: migration up / down; publish atomicity (concurrent writes to different alarm rows don't leak into an in-flight publish); audit trail on every mutation. + +### Stream F — Admin UI scripting tab — **2 weeks** + +1. **F.1** Monaco editor Razor component. CSS-isolated; loads Monaco via NPM + the Admin project's existing asset pipeline. C# syntax highlighting (Monaco ships it). IntelliSense via a hand-written `ScriptContext.cs` type stub delivered with the editor (not the compiled Core.Scripting DLL — keeps the browser bundle small). +2. **F.2** `/virtual-tags` tab. List view (Equipment path / Name / DataType / inputs-summary / Historize / actions). Edit pane splits: Monaco editor left, dependency preview panel right (live-updates from a debounced `/api/scripting/analyze` endpoint that runs the `DependencyExtractor`). Publish button gated by Phase 6.2 `WriteConfigure` permission. +3. **F.3** `/scripted-alarms` tab. Same editor shape + extra controls: AlarmType dropdown, Severity slider, MessageTemplate textbox with live-preview showing `{path}` token resolution against latest tag values, `HistorizeToAveva` checkbox. **Alarm detail page displays current `ShelvingState` + `LastAckUser / LastAckUtc / LastAckComment` read-only** — no shelve/unshelve / ack / confirm buttons per decision #20. Operators drive state transitions via OPC UA method calls from plant HMIs or the Client.CLI. +4. **F.4** Test harness. Modal that lets the operator supply synthetic `DataValue` inputs for the dependency set + see script output + logger emissions (rendered in a virtual terminal). Enables testing without publishing. +5. **F.5** Script log viewer. SignalR stream of the `scripts-*.log` sink filtered by the script under edit (using the structured `ScriptName` property). Tail-last-200 + "load more". +6. **F.6** `/alarms/historian` diagnostics view per Stream D.7. +7. **F.7** Playwright smoke. Author a calc tag, publish, verify it appears in the equipment tree via a probe OPC UA read. Author an alarm, verify it appears in `AlarmsAndConditions`. + +### Stream G — Address-space integration — **1 week** + +1. **G.1** `EquipmentNodeWalker` extension. Current walker iterates driver tags per equipment; extend to also iterate virtual tags + alarms. `NodeScopeResolver` returns `NodeSource.Virtual` for virtual nodes and `NodeSource.Driver` for existing. +2. **G.2** `DriverNodeManager` dispatch. Read / Write / Subscribe operations check the resolved source and route to `VirtualTagEngine` or the driver as appropriate. Writes to virtual tags allowed only from scripts (per decision #6) — OPC UA client writes to a virtual node return `BadUserAccessDenied`. +3. **G.3** `AlarmTracker` composition. The `ScriptedAlarmEngine` registers as an additional `IAlarmSource` — no new composition code, the existing fan-out already accepts multiple sources. +4. **G.4** Tests: mixed equipment folder (driver tag + virtual tag + driver-native alarm + scripted alarm) browsable via Client.CLI; read / subscribe round-trip for the virtual tag; scripted alarm transitions visible in the alarm event stream. + +### Stream H — Exit gate — **1 week** + +1. **H.1** Compliance script real-checks: schema migrations applied; new tables populated from a draft→publish cycle; sealed-generation snapshot includes virtual tags + alarms; SQLite alarm queue initialized; `scripts-*.log` sink emitting; `AlarmConditionType` nodes materialize in the address space; per-alarm `HistorizeToAveva` toggle enforced end-to-end. +2. **H.2** Full-solution `dotnet test` baseline. Target: Phase 6 baseline + ~300 new tests across Streams A–G. +3. **H.3** `docs/v2/plan.md` Migration Strategy §6 update — add Phase 7. +4. **H.4** Phase-status memory update. +5. **H.5** Merge `v2/phase-7-scripting-and-alarming` → `v2`. + +## Compliance Checks (run at exit gate) + +- [ ] **Sandbox escape**: attempts to reference `System.IO.File`, `System.Net.Http.HttpClient`, `System.Diagnostics.Process`, or `typeof(X).Assembly.Load` fail at script compile with an actionable error. +- [ ] **Dependency inference**: `ctx.GetTag(myStringVar)` (non-literal path) is rejected at publish with a span-pointed error; `ctx.GetTag("Line1/Speed")` is accepted + appears in the inferred input set. +- [ ] **Change cascade**: tag A → virtual tag B → virtual tag C. When A changes, B recomputes, then C recomputes. Single change event triggers the full cascade in topological order within one evaluation pass. +- [ ] **Cycle rejection**: publish a config where virtual tag B depends on A and A depends on B. Publish fails pre-commit with a clear cycle message. +- [ ] **Startup recovery**: seed `ScriptedAlarmState` with one acked+confirmed alarm + one shelved alarm + one clean alarm, restart, verify operator does NOT see ack prompts for the first two, shelving remains in effect, clean alarm is clear. +- [ ] **Ack audit**: acknowledge an alarm; `IAuditLogger` captures user / timestamp / comment / prior state; row persists through restart. +- [ ] **Historian queue durability**: take Galaxy.Host offline, fire 10 alarm transitions, bring Galaxy.Host back; queue drains all 10 in order. +- [ ] **Per-alarm historian toggle**: Galaxy-native alarm with `HistorizeToAveva=false` does NOT enqueue; scripted alarm with `HistorizeToAveva=true` DOES enqueue. +- [ ] **Script timeout**: infinite-loop script times out at 250ms; tag quality `BadInternalError`; other tags unaffected. +- [ ] **Log isolation**: `ctx.Logger.Error("test")` lands in `scripts-*.log` with structured property `ScriptName=`; main `opcua-*.log` gets a WARN companion entry. +- [ ] **ACL binding**: virtual tag under an Equipment scope inherits the Equipment's grants. User without the Equipment grant reads the virtual tag and gets `BadUserAccessDenied`. + +## Decisions Resolved in Plan Review + +Every open question from the initial draft was resolved in the 2026-04-20 plan review — see decisions #18–#22 in the decisions table above. No pending questions block Stream A. + +## References + +- [`docs/v2/plan.md`](../plan.md) §6 Migration Strategy — add Phase 7 as the final additive phase before v2 release readiness. +- [`docs/v2/implementation/overview.md`](overview.md) — phase gate conventions. +- [`docs/v2/implementation/phase-6-2-authorization-runtime.md`](phase-6-2-authorization-runtime.md) — `IAuditLogger` surface reused for Ack/Confirm/Shelve + script edits. +- [`docs/v2/implementation/phase-6-4-admin-ui-completion.md`](phase-6-4-admin-ui-completion.md) — draft/publish flow, diff viewer, tab-plugin pattern reused. +- [`docs/v2/implementation/phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md) — Galaxy.Host IPC shape + shared-contract conventions reused for Stream D. +- [`docs/v2/driver-specs.md`](../driver-specs.md) §Alarm semantics — Part 9 fidelity requirements. +- [`docs/v2/driver-stability.md`](../driver-stability.md) — per-surface error handling, crash-loop breaker patterns Stream A.4 mirrors. +- [`docs/v2/config-db-schema.md`](../config-db-schema.md) — add a Phase 7 §§ for `VirtualTag`, `Script`, `ScriptedAlarm`, `ScriptedAlarmState`.