Files
lmxopcua/archreview/02-scripting-alarms.md
T
Joseph Doherty 1891f5d6a7 docs(archreview): 2026-07-12 re-review at f6eaa267 + 12 round-2 remediation plans
Re-ran all seven domain reviews at master f6eaa267 (reports rewritten in
place, each with a prior-finding status table): all 4 round-1 Criticals
verified closed; new top findings are the S7 connect-timeout OCE regression
(05/STAB-14), the ResilienceConfig operator-authorable brick (01/S-6), and
a batch of resilience-seam Mediums. 00-OVERALL.md carries the updated
maturity matrix + 12-item action list.

Adds R2-01..R2-12 design/implementation plans (one per action item, house
format + bite-sized TDD task breakdowns + co-located .tasks.json; 193 tasks,
~18-24 dev-days). STATUS.md updated: round-1 topology marked historical
(all merged+pushed), re-review findings table + plan pointers added.
2026-07-12 23:52:23 -04:00

47 KiB
Raw Blame History

Architecture Review 02 — Scripting, Virtual Tags, Scripted Alarms, Alarm Historian

  • Date: 2026-07-12
  • Commit: f6eaa267 (master, clean tree)
  • Updates: the 2026-07-08 review at 9cad9ed0. All arch-review remediation branches have since merged to master; the delta touching this domain is exactly one commit — 7fd44f0f (Critical 2a/2b: real VT script timeout + ALC-safe compile cache). The five in-scope Core projects are byte-identical to 9cad9ed0 (git diff empty; only test-csproj NoWarn OTOPCUA0001 additions), so every prior Core-internal finding was re-verified against unchanged code and its line refs remain valid.
  • Scope:
    • src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting + Core.Scripting.Abstractions (Roslyn compile/eval engine)
    • src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags (virtual-tag runtime)
    • src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms (scripted-alarm engine + Part 9 state machine)
    • src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian (SQLite store-and-forward sink)
    • Production evaluation path: Host/Engines/RoslynVirtualTagEvaluator, Commons/Engines/IScriptCacheOwner, Runtime/VirtualTags/VirtualTagHostActor + VirtualTagActor (the surface the Critical-2 remediation changed)
    • Test projects under tests/Core/ + the new Host.IntegrationTests/Runtime.Tests coverage
  • Method: verified every prior finding against the actual code (not STATUS.md claims); full adversarial read of the remediation commit (RoslynVirtualTagEvaluator, IScriptCacheOwner, VirtualTagHostActor.OnApply, the new tests) plus the cache/evaluator internals it now leans on (CompiledScriptCache.Clear concurrency, ScriptEvaluator.Dispose/RunAsync disposed-guard, TimedScriptEvaluator semantics); fresh-eyes pass over the changed surface.

Prior-finding status (9cad9ed0f6eaa267)

ID One-liner Status @ f6eaa267
S1 VirtualTagEngine.Load mutates shared state with no gate vs in-flight evals STILL OPENVirtualTagEngine.cs unchanged (empty diff); engine still dormant (see U1)
S2 No coalescing/backpressure on upstream-change fan-out (both engines) STILL OPENScriptedAlarmEngine.cs:442-450 / VirtualTagEngine.cs:263-272 unchanged
S3 Failed reload is fail-stop, not fail-back (both engines) STILL OPEN — unchanged
S4 _alarmsReferencing plain Dictionary read from upstream threads without a gate STILL OPENScriptedAlarmEngine.cs:446 unchanged
S5 Timed-shelve expiry via the predicate path swallows the Unshelved emission STILL OPENPart9StateMachine.cs unchanged; the missing test is still missing
S6 SqliteStoreAndForwardSink.Dispose races an in-flight drain STILL OPEN — unchanged
S7 Sandbox CPU/memory unbounded; timed-out script leaks a thread; no quarantine STILL OPEN — blast radius grew: the U2 fix routes production VT evaluation through the same abandon-on-timeout design, so a wedged VT script now orphans one pool thread and blocks its actor ~2 s per dependency change (see updated S7 body)
S8 ScriptedAlarmEngine.Dispose blocks sync-over-async STILL OPENScriptedAlarmEngine.cs:761 unchanged
S9 At-least-once delivery duplicates on crash window (doc note) STILL OPEN — no docs/Historian.md note added
S10 VirtualTagSource.SubscribeAsync seed-then-subscribe can miss one update STILL OPEN — unchanged (dormant code)
S11 Capacity eviction drops oldest accepted alarm events (policy doc note) STILL OPEN — no docs/AlarmTracking.md policy note
P1 ScriptSandbox.Build rebuilds the full BCL reference set on every compile STILL OPENScriptSandbox.cs:83-87 unchanged; now amplified by the per-apply cache clear (see P7)
P2 Task.Run + WaitAsync per evaluation on the hot path STILL OPEN (accepted) — now also the production VT cost, by design of the U2 fix
P3 VT engine allocates per evaluation what the alarm engine pools STILL OPEN — moot while U1 unresolved
P4 Single global eval gate per engine STILL OPEN (accepted)
P5 SQLite per-call overheads STILL OPEN (accepted)
P6 DependencyGraph well optimized (no action) unchanged — informational
C1 ITagUpstreamSource defined twice STILL OPEN — duplicate still at ScriptedAlarmEngine.cs:860-872
C2 Abstractions assembly spans three namespaces undocumented STILL OPEN
C3 Trailing type definitions in engine files STILL OPEN
C4 Repeated raw OPC UA status-code literals STILL OPEN
C5 Core.AlarmHistorian mixes contract and implementation STILL OPEN
C6 Plan-era "Phase 7 plan Stream …" doc residue STILL OPENTimedScriptEvaluator.cs:5,22,39 etc. unchanged; the 3b5ef439 fixdocs sweep touched only S7/TwinCAT/Resilience files
C7 ApplyPredicate xmldoc claims branch handling that doesn't exist STILL OPENPart9StateMachine.cs:31-34 unchanged
U1 Entire Core.VirtualTags engine stack dormant in production STILL OPENnew VirtualTagEngine still appears only in its own tests; ScriptAnalysisService.cs:293 even documents "the cascade VirtualTagEngine is dormant". No retire/sanction decision made
U2 CRITICAL — production script timeout ineffective (TimedScriptEvaluator bypassed) FIXED7fd44f0f: RoslynVirtualTagEvaluator.Evaluate routes through TimedScriptEvaluator (RoslynVirtualTagEvaluator.cs:101-115); the orphan-thread trade-off is explicitly acknowledged in the comment; regression test Evaluate_infinite_loop_script_fails_within_timeout_and_does_not_hang is itself WaitAsync-bounded so a regression fails instead of hanging the suite. Caveats: on the node, a timeout is a dropped update, not Bad quality (new S13), and the runaway thread still leaks with no quarantine (S7)
U3 HIGH — live path bypasses CompiledScriptCache: unbounded ALC accretion FIXED7fd44f0f: raw ConcurrentDictionaryCompiledScriptCache<VirtualTagContext, object?> (RoslynVirtualTagEvaluator.cs:28); new IScriptCacheOwner seam (Commons/Engines/IScriptCacheOwner.cs); apply-boundary clear at VirtualTagHostActor.cs:92 on every ApplyVirtualTags (the only republish path — VT config only reaches children via this message); wiring-guard test asserts the clear per generation. Caveat: the clear races in-flight child evaluations (new S12)
U4 IHistoryWriter is a permanently-Null surface STILL OPENRuntime/ServiceCollectionExtensions.cs:58,242 still binds only NullHistoryWriter
U5 Part 9 surface gaps real but undeclared in one place STILL OPEN — no conformance statement added to docs/ScriptedAlarms.md
U6 No TODO/HACK/stub markers (healthy) unchanged — informational
U7 Test coverage broad with specific holes (a)(e) PARTIALLY FIXED — hole (e) closed: RoslynVirtualTagEvaluatorTests now covers the production timeout (infinite-loop + happy-path-after-wrapping + ClearCompiledScripts), and VirtualTagHostActorTests guards the apply-boundary clear wiring. Holes (a) shelve-expiry emission, (b) load-vs-cascade concurrency, (c) sink dispose-during-drain, (d) dedicated ForbiddenTypeAnalyzer suite — all still open

Architecture Overview

Script compile/eval pipeline (Core.Scripting)

User scripts are C# statement bodies ending in return …;. ScriptEvaluator<TContext,TResult>.Compile (Core.Scripting/ScriptEvaluator.cs) synthesizes a wrapper class (CompiledScript.Run(globals)), then runs a five-step gate:

  1. Parse the wrapper source with #line 1 remapping so diagnostics point at operator source.
  2. Wrapper-injection guard (EnforceSingleRunMember, ScriptEvaluator.cs:222) — rejects brace-balanced injections that declare sibling members/types (diagnostics LMX001/LMX002).
  3. Roslyn compile against the ScriptSandbox reference set (pinned OtOpcUa assemblies + System.*/netstandard TPA subset) — the allow-list is explicitly not the security boundary (type forwarding makes it porous).
  4. ForbiddenTypeAnalyzer — the real gate. Two semantic passes: (1) member/call surface via GetSymbolInfo on creations/invocations/member-access/identifiers; (2) every TypeSyntax via GetTypeInfo, recursing generic args + array element types. Deny-list = namespace prefixes (System.IO, System.Net, System.Diagnostics, System.Reflection, System.Threading.Tasks, System.Runtime.InteropServices, System.Runtime.Loader, Microsoft.Win32) plus type-granular names for dangerous residents of allowed namespaces (Environment, AppDomain, GC, Activator, Thread, ThreadPool, Timer, Unsafe).
  5. Emit to an in-memory PE, load into a per-script collectible AssemblyLoadContext, bind a static Func<ScriptGlobals<TContext>,TResult> delegate. Dispose() unloads the ALC.

Around the core evaluator sit three wrappers: CompiledScriptCache (SHA-256-source-keyed, Lazy single-compile, value-scoped TryRemove on Clear() so a concurrent re-add survives), TimedScriptEvaluator (Task.Run + WaitAsync wall-clock budget, default 250 ms, wraps timeout as ScriptTimeoutException; documents the known orphan-thread leak for CPU-bound scripts), and the script-log pipeline (ScriptRootLogger → rolling scripts-*.log + ScriptLogCompanionSink error mirror to the main log + ScriptLogTopicSink → DPS script-logs topic via IScriptLogPublisher).

Core.Scripting.Abstractions holds the Roslyn-free closure the sandbox pins: ScriptContext (the ctx API — GetTag/SetVirtualTag/Now/Logger/Deadband), ScriptGlobals<T>, PassthroughScript (regex fast-path classifier for the return ctx.GetTag("X").Value; mirror shape), and — notably — the concrete VirtualTagContext and AlarmPredicateContext, which live in this assembly but declare Core.VirtualTags / Core.ScriptedAlarms namespaces (see C2).

DependencyExtractor statically harvests ctx.GetTag("literal") / ctx.SetVirtualTag("literal", …) call sites from the AST, rejecting dynamic paths at publish so the change-trigger subscription graph is knowable up front.

Virtual-tag runtime — two implementations, one live (now with the safety wrappers on the live one)

The in-scope VirtualTagEngine is a self-contained multi-tag engine: Load() compiles all scripts through a CompiledScriptCache, validates data types and SetVirtualTag targets, builds a DependencyGraph (iterative Tarjan cycle detection + Kahn topo sort + cached rank), subscribes to upstream driver tags via ITagUpstreamSource, and serializes all evaluations under a single SemaphoreSlim gate. Change events cascade dependents in topological order; TimerTriggerScheduler adds interval-grouped timer triggers with in-flight tick skipping; VirtualTagSource adapts the engine to the driver-agnostic IReadable/ISubscribable surface.

Production still does not run this engine (U1 unchanged). The live path is VirtualTagHostActor → per-tag VirtualTagActorIVirtualTagEvaluator bound to Host/Engines/RoslynVirtualTagEvaluator (single-tag adapter; fan-out owned by DependencyMuxActor). What changed at 7fd44f0f: the adapter now (a) routes every compiled evaluation through TimedScriptEvaluator (default 2 s budget; ScriptTimeoutExceptionFailure("script timed out …")), so a CPU-bound/infinite-loop script can no longer wedge the owning VirtualTagActor's message loop; and (b) caches compiles in a CompiledScriptCache and implements the new IScriptCacheOwner capability, which VirtualTagHostActor.OnApply invokes at the top of every ApplyVirtualTags generation — stale collectible ALCs are dropped per deploy instead of accreting forever. The PassthroughScript mirror fast-path still bypasses Roslyn (and therefore both wrappers) — correctly, since it executes no user code.

Scripted-alarm flow (Core.ScriptedAlarms) — live end to end (unchanged)

ScriptedAlarmHostActor (Runtime) owns a long-lived ScriptedAlarmEngine and calls LoadAsync per config apply (generation-tagged to discard stale completions). Inside the engine:

  1. LoadAsync (under _evalGate) tears down the prior generation (shelving timer, subscriptions, alarms, scratch, compile-cache ALCs), compiles every predicate via CompiledScriptCache<AlarmPredicateContext,bool>, builds an inverse index tag-path → alarm ids (alarms are DAG leaves; no topo sort), seeds the value cache, restores persisted AlarmConditionState from IAlarmStateStore (EF-backed in prod, in-memory for dev/tests), re-derives ActiveState from the live predicate (startup recovery), then subscribes upstream and starts the 5 s shelving-check timer.
  2. Upstream changes (OnUpstreamChange, fed by DependencyMuxTagUpstreamSource) update the value cache and spawn tracked fire-and-forget ReevaluateAsync tasks that serialize on _evalGate, run the predicate through TimedScriptEvaluator against a reused per-alarm AlarmScratch (read-cache dictionary + context, refilled in place — the hot path allocates nothing), and apply the result through Part9StateMachine.ApplyPredicate.
  3. Part9StateMachine is a pure-function transition table over the immutable AlarmConditionState record (Enabled/Active/Acked/Confirmed/Shelving + audit Comments as ImmutableList). Operator verbs (Acknowledge/Confirm/OneShotShelve/TimedShelve/Unshelve/Enable/Disable/AddComment) come in via the engine's ApplyAsync (persist-before-update-in-memory; emissions built under the gate, fired after release to avoid re-entrancy deadlock).
  4. Emissions (ScriptedAlarmEvent) fan out through OnEventScriptedAlarmSource (the IAlarmSource adapter with equipment-path-prefix subscription filters) into the Part 9 condition nodes / /alerts / historian adapter, carrying the per-alarm HistorizeToAveva opt-out. MessageTemplate resolves {TagPath} tokens at emission with a stricter-than-predicate quality bar (Good only; else {?}).

Alarm-history write path (Core.AlarmHistorian) — unchanged

IAlarmHistorianSink is the fire-and-forget ingestion contract; production is SqliteStoreAndForwardSink: WAL-mode SQLite Queue table, EnqueueAsync commits a row per event (capacity fast path consults an Interlocked cached counter, falling back to COUNT(*) + oldest-row eviction at the wall, with periodic 10k-enqueue resync). A self-rescheduling one-shot timer drains batches (default 100) through IAlarmHistorianWriter (the HistorianGateway SendEvent gRPC client), applying per-event outcomes: Ack → delete, PermanentFail → dead-letter, RetryPlease → attempt-bump with a max-attempts (10) poison cap; corrupt payloads dead-letter immediately so they can't stall the queue head. Backoff ladder 1→2→5→15→60 s applied to the timer due-time; dead letters retained 30 days with operator RetryDeadLettered(); GetStatus() surfaces depth/dead-letter/last-error/evictions to the Admin UI and health checks.


Findings

Severity scale: Critical (production correctness/availability today) / High (real risk under realistic conditions) / Medium (defect or debt worth scheduling) / Low (note).

Fixed prior findings (U2, U3, U7-e) live only in the status table above. Still-open findings keep their IDs; new findings continue the per-category scheme (S12+, P7+, C8+).

1. Stability

S1 — HIGH — VirtualTagEngine.Load mutates shared state with no gate against in-flight evaluations

VirtualTagEngine.Load (Core.VirtualTags/VirtualTagEngine.cs:78-173) clears and rebuilds the plain Dictionary _tags (line 24), the DependencyGraph, and disposes the compile cache without acquiring _evalGate, while three concurrent paths read that state from arbitrary threads: EvaluateInternalAsync reads _tags before taking the gate (line 293), CascadeAsync walks _graph (line 278) whose internals are plain dictionaries plus a nullable _cachedRank, and timer ticks call EvaluateOneAsync. A Load during an in-flight cascade is a torn-read on non-thread-safe collections. The sibling ScriptedAlarmEngine solved exactly this (gate-held LoadAsync, ConcurrentDictionary with an explanatory comment at ScriptedAlarmEngine.cs:42-50); the VT engine never received the same treatment. Mitigating factor: the engine is dormant in production (U1) — but it is fully unit-tested and presented as production-ready, so anyone wiring it up inherits the race. Recommendation: either retire the engine (preferred — see U1) or port the alarm engine's discipline: gate-held load, ConcurrentDictionary for _tags, and re-check-after-gate in the eval path.

S2 — HIGH — No coalescing/backpressure on upstream-change fan-out (both engines)

Every upstream change spawns a fire-and-forget task that queues on the single eval gate: ScriptedAlarmEngine.OnUpstreamChange (ScriptedAlarmEngine.cs:442-450) spawns one ReevaluateAsync per change event, and VirtualTagEngine.OnUpstreamChange (VirtualTagEngine.cs:263-272) spawns one CascadeAsync per change (untracked). When a referenced tag changes faster than the serialized evaluations drain (a 100 Hz analog referenced by one alarm is enough), tasks accumulate without bound — memory growth plus an ever-staler processing lag, and each queued task re-evaluates against the current cache so the extra work is pure waste. The alarm engine tracks these tasks for dispose-drain (_inFlight) but does not bound them. Recommendation: replace per-event task spawning with a dirty-set + single pump: mark the alarm/tag dirty, and have one loop drain the dirty set under the gate. This makes cost proportional to distinct dirty alarms, not event rate.

S3 — MEDIUM — Failed reload is fail-stop, not fail-back (both engines)

Both engines tear down the previous generation before compiling the new one. On a compile failure, VirtualTagEngine.Load has already unsubscribed, cleared _tags/_graph, and cleared the compile cache (VirtualTagEngine.cs:84-88) before throwing (line 148) — leaving _loaded == true from the prior load, an empty tag set, and a stale _valueCache that Read/VirtualTagSource keep serving as Good values that will never update. ScriptedAlarmEngine.LoadAsync is the same shape (ScriptedAlarmEngine.cs:196-209, throw at 250-255): after a failed apply, zero alarms are loaded — no predicates evaluate until the next successful publish. The host actor explicitly accepts this ("log it and stay inert", ScriptedAlarmHostActor.cs:188, 245), and publish-time validation upstream makes a bad definition reaching the engine unlikely — but a transient IAlarmStateStore.LoadAsync/SaveAsync failure during the state-restore loop (ScriptedAlarmEngine.cs:273-281) also lands here, and that is not operator-preventable. Recommendation: build the new generation into local structures and swap under the gate only on success (compile is already side-effect-free; the subscriptions and timer are the only external effects). At minimum, distinguish store-failures from compile-failures and retry the former.

S4 — MEDIUM — _alarmsReferencing read without synchronization

ScriptedAlarmEngine._alarmsReferencing is a plain Dictionary (ScriptedAlarmEngine.cs:120-121) mutated inside gate-held LoadAsync (lines 201, 237-242) but read by OnUpstreamChange (line 446) from upstream callback threads with no gate. LoadAsync disposes the old subscriptions first, but IDisposable.Dispose on a subscription does not guarantee an already-executing callback has finished — a callback in flight during reload can race Clear()/Add() on a non-thread-safe dictionary. The class comment (lines 42-50) carefully justifies ConcurrentDictionary for _alarms for exactly this class of reader; _alarmsReferencing was missed. Recommendation: make it a ConcurrentDictionary or swap-in an immutable snapshot (Dictionary rebuilt per load, published via Volatile.Write to a field).

S5 — MEDIUM — Timed-shelve expiry via the predicate path swallows the Unshelved emission

Part9StateMachine.ApplyPredicate silently expires timed shelving (Part9StateMachine.cs:47-49 via MaybeExpireShelving, lines 318-322) and returns EmissionKind.None (or Activated/Cleared for the value transition) — the Unshelved emission only ever comes from ApplyShelvingCheck on the 5 s timer. If a predicate re-evaluation lands between expiry time and the next timer tick, the state record is persisted as unshelved but no Unshelved event is ever published — OPC UA clients and the /alerts page tracking ShelvingState from events never learn the shelve ended (the next Activated/Cleared arrives un-suppressed, which is at least confusing for an operator who believes the alarm is still shelved). Recommendation: have ApplyPredicate return a compound result (or the engine detect Shelving kind change between seed and result) so the expiry emits Unshelved before the value-transition emission. Add the missing state-machine test (Part9StateMachineTests.cs does not cover this path).

S6 — MEDIUM — SqliteStoreAndForwardSink.Dispose races an in-flight drain

Dispose (SqliteStoreAndForwardSink.cs:679-686) sets _disposed, disposes the timer, then immediately disposes _drainGate and the writer. Timer.Dispose() (no WaitHandle overload used) does not wait for a running callback, so an in-flight DrainOnceAsync can still be holding the gate and mid-WriteBatchAsync: the finally { _drainGate.Release(); } (line 467) then throws ObjectDisposedException (caught by the timer callback's catch and logged as a drain fault), and the writer can be disposed under an active call. Not a crash — but shutdown noise and an undefined writer contract. Recommendation: mirror the alarm engine's dispose-drain: _drainGate.Wait() (or an in-flight task handle) before disposing the gate/writer, or use Timer.Dispose(WaitHandle).

S7 — MEDIUM — Accepted sandbox limits: CPU/memory unbounded, timed-out scripts leak a thread — blast radius now includes the live VT path

Documented and deliberate: the sandbox cannot bound allocation or CPU (ScriptSandbox.cs:30-35), and a timed-out CPU-bound script leaves its thread-pool thread spinning forever (TimedScriptEvaluator.cs:17-26). One orphan per bad-script evaluation attempt — and since 7fd44f0f this is no longer confined to the alarm engine: production virtual-tag evaluation now runs through the same abandon-on-timeout design (RoslynVirtualTagEvaluator.cs:101-115), so a hot looping VT script referenced by a fast-changing dependency orphans one pool thread and blocks its VirtualTagActor dispatcher thread for the full 2 s budget per dependency change, with no quarantine — the actor keeps re-evaluating on every change until the pool starves. (The alarm engine holds prior state on timeout, ScriptedAlarmEngine.cs:535-538, but likewise does not quarantine the predicate.) That trade-off was the right call for U2 — a bounded-loss timeout strictly beats a wedged actor — but the compensating control the prior review recommended still does not exist. Recommendation: add a per-script circuit breaker (N consecutive timeouts → suspend evaluation + surface to Admin UI) as a cheap interim before any out-of-process runner. The three-layer compile-time sandbox itself (allow-list refs → wrapper-injection guard → semantic deny-list with the full TypeSyntax pass) remains notably strong.

S8 — LOW — ScriptedAlarmEngine.Dispose blocks sync-over-async

Task.WhenAll(toAwait).GetAwaiter().GetResult() (ScriptedAlarmEngine.cs:761) — fine under the actor host (no sync context) but a deadlock trap if ever disposed from a context-bound thread. Consider IAsyncDisposable.

S9 — LOW — At-least-once delivery duplicates on crash window

A crash between a successful WriteBatchAsync and the outcome transaction commit (SqliteStoreAndForwardSink.cs:372-441) re-delivers the batch. Correct choice for alarm history (dupes over loss) — worth one line in docs/Historian.md so the gateway side knows to tolerate replays.

S10 — LOW — VirtualTagSource.SubscribeAsync initial-value window can miss one update

Emitting the seed read before registering the observer (VirtualTagSource.cs:63-75) guarantees ordering but means a change landing between the Read and the Subscribe is missed until the next change — the inverse trade-off of the race the comment describes. Register first, then emit the seed, and let the (idempotent, newer-wins) consumer handle a possible out-of-order pair — or document that seed-then-subscribe can serve a stale value on slow-changing tags.

S11 — LOW — Capacity eviction drops the oldest accepted alarm events

Documented behavior with an EvictedCount operator counter (SqliteStoreAndForwardSink.cs:602-636). Note only: for a compliance-oriented alarm trail, dropping newest (or refusing enqueue) is sometimes preferred over silently losing the oldest; the current choice is defensible but should be a deliberate, documented policy in docs/AlarmTracking.md.

S12 — MEDIUM — NEW (in the U3 remediation) — Apply-boundary ClearCompiledScripts races in-flight child evaluations

VirtualTagHostActor.OnApply calls ClearCompiledScripts() first thing (VirtualTagHostActor.cs:92), on the host actor's thread — while unchanged child VirtualTagActors keep evaluating concurrently on other dispatcher threads (nothing gates them; a DependencyValueChanged can land at any time). The comment says this "mirrors ScriptedAlarmEngine's per-generation clear", but the mirror is inexact: the alarm engine clears under _evalGate with the old subscriptions already torn down, so no evaluation can hold a disposed evaluator; the VT host has no gate spanning child evaluations. Concrete race: a child's Evaluate obtains the evaluator from the cache (RoslynVirtualTagEvaluator.cs:69), the host's Clear() then value-scoped-removes and disposes it (CompiledScriptCache.cs:113-116ScriptEvaluator.Dispose sets _disposed + _alc.Unload()), and the child's subsequent timed.RunAsync hits the disposed guard (ScriptEvaluator.cs:180) — ObjectDisposedException → caught by the adapter's generic catch (RoslynVirtualTagEvaluator.cs:116-120) → Failure("script threw: …") → that evaluation's update is silently dropped (and per S13, quality is not degraded). A secondary wrinkle: the clear runs before the removed/changed children are stopped (lines 98-118), and Context.Stop is asynchronous — a doomed child can process one queued change after the clear and recompile its stale expression into the fresh cache, keeping its ALC alive until the next apply (bounded, one generation — not a leak, but it undercuts the "released in one sweep" intent). The cache itself is blameless: its value-scoped TryRemove correctly protects concurrent GetOrCompile re-adds; the gap is the missing coordination between clear and in-flight use of already-handed-out evaluators. Consequence: a transient dropped evaluation per unlucky deploy (self-heals on the tag's next dependency change — which for a slow-changing tag can be a long staleness window), plus one-generation stale-ALC retention. Real but bounded — Medium. Recommendation: cheapest robust fix is in the adapter: catch ObjectDisposedException around RunAsync, re-fetch via GetOrCompile, and retry once (the recompile is idempotent). Alternatively move the clear to after the stop/respawn reconciliation and have Evaluate re-validate, or route child evaluations through a shared ReaderWriterLockSlim the clear takes as writer. Add a concurrent clear-during-evaluate test either way.

S13 — MEDIUM — NEW — Script failure/timeout never degrades the OPC UA node's quality: stale value stays Good forever

VirtualTagActor.OnDependencyChanged handles a failed evaluation (including the new timeout Failure) by publishing a Warning script-log entry + a fail metric and returning without any value/quality publish (Runtime/VirtualTags/VirtualTagActor.cs:123-128; same for the evaluator-threw path at 115-121). The node therefore keeps its previous state: BadWaitingForInitialData if the script never succeeded (acceptable), but the last Good value with its old timestamp, indefinitely, if a previously-working script starts failing or timing out. Data-plane consumers (OPC UA clients, the historian recorder, downstream ScadaBridge) have no way to distinguish "healthy but unchanged" from "script broken for a week" — only the script-log page and the eval metric know. Note the remediation commit message's claim that a runaway script "returns Bad within the timeout" is true of the evaluator's return value but not of the node. The dormant VirtualTagEngine gets this right — it maps script failure to BadInternalError quality (VirtualTagEngine.cs:307,328), and ScriptEvaluator's own remarks (lines 40-43) promise that mapping; the live actor path never implemented it. Recommendation: on !result.Success, publish the existing value with Bad quality (e.g. BadInternalError / Uncertain per policy) to the publish actor — the plumbing (OpcUaPublishActor.AttributeValueUpdate carries OpcUaQuality) already exists at the host-actor bridge (VirtualTagHostActor.cs:179-180, which currently hard-codes Good). Requires EvaluationResult to carry success/quality. Pair with a regression test: succeed once, then fail — node quality must degrade.

2. Performance

P1 — HIGH — ScriptSandbox.Build rebuilds the full BCL reference set on every compile

Every ScriptEvaluator.Compile calls ScriptSandbox.Build(typeof(TContext)) (ScriptEvaluator.cs:74), which calls MetadataReference.CreateFromFile for every System.*/netstandard DLL in the trusted-platform-assemblies list (ScriptSandbox.cs:83-87, 100-130) — on the order of 100+ files, each a fresh PortableExecutableReference whose AssemblyMetadata Roslyn must load and parse per compilation. CompiledScriptCache de-dupes per source, but a publish with 200 distinct scripts pays this ~200 times, and the Admin UI ScriptAnalysis endpoints (which reuse this compiler context per keystroke-ish cadence) pay it continuously. The references are immutable per contextType for the process lifetime. Now amplified by the (correct) U3 apply-boundary clear: every deploy generation recompiles every VT script from scratch (see P7), each paying this cost. Recommendation: cache SandboxConfig (or at least the MetadataReference list) in a static ConcurrentDictionary<Assembly, …> keyed by contextType.Assembly. This is likely a 10-100× compile-latency win and directly reduces publish time and script-editor diagnostic latency.

P2 — MEDIUM — Task.Run + WaitAsync per evaluation on the hot path

TimedScriptEvaluator.RunAsync (TimedScriptEvaluator.cs:78-81) pushes every predicate/tag evaluation to the pool and registers a timeout. This now applies to the production VT path too (by design of the U2 fix): each VirtualTagActor evaluation pays a pool hop plus a synchronous block of its dispatcher thread (RoslynVirtualTagEvaluator.cs:109 GetAwaiter().GetResult()). The hop is required for the timeout to work at all, and the common case (sub-millisecond script) pays scheduling + WaitAsync machinery every time. Recommendation: acceptable at current scale; if profiling ever shows it, an inline-run-with-watchdog design (run synchronously, watchdog flags overruns for quarantine rather than aborting) trades hard timeout for zero hop.

P3 — MEDIUM — VT engine allocates per evaluation what the alarm engine pools

VirtualTagEngine.EvaluateInternalAsync builds a fresh read-cache Dictionary and a fresh VirtualTagContext per evaluation (VirtualTagEngine.cs:298, 313-317); the alarm engine got the AlarmScratch reuse optimization (ScriptedAlarmEngine.cs:52-63, 798-835) for exactly this pattern. The live RoslynVirtualTagEvaluator shares the allocate-per-evaluation shape (BuildReadCache + fresh VirtualTagContext + fresh TimedScriptEvaluator wrapper per call, RoslynVirtualTagEvaluator.cs:87-99,108). Inconsistent with the alarm engine — moot for the dormant engine (U1), but the live adapter is on the hot path.

P4 — LOW — Single global eval gate per engine

All evaluations serialize on one SemaphoreSlim (VirtualTagEngine.cs:44; ScriptedAlarmEngine.cs:124). Correct and simple; it caps throughput at single-threaded script execution per engine. Fine for operator-authored low-thousands scale (the compile cache doc states the same bound); a per-alarm or sharded gate is the escape hatch if scale demands it. Not worth changing now.

P5 — LOW — SQLite per-call overheads

EnqueueAsync opens a (pooled) connection and re-runs two PRAGMAs per event (SqliteStoreAndForwardSink.cs:244-246); GetStatus runs a synchronous COUNT(*) per call (line 479-484). Both fine at alarm-event rates; the capacity fast path (cached counter, probe only near the wall — lines 271-290) already removed the real hot-path cost, and the drain-order index (IX_Queue_Drain) is right.

P6 — LOW — DependencyGraph is well optimized

Cached topological rank invalidated on mutation, shared empty set for misses, iterative Tarjan/Kahn (DependencyGraph.cs:34-44, 141-149). The per-cascade List/HashSet/Stack allocations in TransitiveDependentsInOrder are minor. No action.

P7 — LOW — NEW — Unconditional per-apply cache flush forces full VT recompilation on every deploy, including no-op redeploys

VirtualTagHostActor.OnApply clears the compile cache on every ApplyVirtualTags (VirtualTagHostActor.cs:92) — even an identical redeploy whose plans diff equal and whose children are all left untouched. Every VT script then recompiles on its next evaluation, each paying the full P1 reference-set rebuild (5-20 ms+ per script today). The host actor already computes exactly which plans changed (the _planByVtag diff at lines 109-118); the clear could be conditional on any-change, or the cache could evict by retained-source set. The alarm engine has the same unconditional per-generation shape but its LoadAsync only runs on actual config applies. Cheap to leave as-is at current scale; fix alongside P1.

3. Conventions

C1 — MEDIUM — ITagUpstreamSource is defined twice

Core.VirtualTags/ITagUpstreamSource.cs and a second, byte-for-byte-shaped interface at the bottom of ScriptedAlarmEngine.cs:860-872 ("intentionally identical shape so Stream G can compose them behind one driver bridge"). Two distinct .NET types means DependencyMuxTagUpstreamSource and any composing bridge must implement/adapt each separately, and the "identical shape" invariant is enforced only by comment. Recommendation: hoist a single ITagUpstreamSource into Core.Scripting.Abstractions (both projects already reference it) and retire the duplicate.

C2 — MEDIUM — Core.Scripting.Abstractions declares types across three namespaces

ScriptContext/ScriptGlobals/PassthroughScript claim namespace Core.Scripting, VirtualTagContext claims Core.VirtualTags, AlarmPredicateContext claims Core.ScriptedAlarms — all physically in the Abstractions assembly. The reason is sound (the sandbox pins contextType.Assembly, so concrete contexts must live in the Roslyn-free assembly to keep Roslyn out of the script compilation closure — ScriptSandbox.cs:60-70), but it is documented only in a PassthroughScript remark and violates the repo's namespace-follows-assembly norm; find-by-namespace navigation actively misleads here. Recommendation: add a project-level README or AssemblyInfo doc comment stating the rule ("this assembly is the sandbox-pinned closure; types keep their consumer namespaces deliberately"), or bite the bullet and align namespaces with a TypeForwardedTo migration.

C3 — LOW — Trailing type definitions in engine files

ScriptedAlarmEvent and the duplicate ITagUpstreamSource live at the bottom of ScriptedAlarmEngine.cs (843-872); CompilationErrorException and ScriptAssemblyLoadContext at the bottom of ScriptEvaluator.cs (391-432); DependencyCycleException in DependencyGraph.cs. Mostly reasonable co-location, but ScriptedAlarmEvent is a public cross-project contract and deserves its own file.

C4 — LOW — Repeated raw OPC UA status-code literals

0x80340000u /* BadNodeIdUnknown */, 0x80020000u /* BadInternalError */, 0x80320000u /* BadWaitingForInitialData */, 0x80000000u severity mask are hand-rolled with comments in at least five files (VirtualTagEngine.cs:215-218, 307, 328; VirtualTagContext.cs; AlarmPredicateContext.cs; ScriptedAlarmEngine.cs:584-587; and the remediation added another — RoslynVirtualTagEvaluator.BuildReadCache hard-codes StatusCode: 0u Good, plus VirtualTagHostActor.cs:188's 0u /* StatusCodes.Good */). A small KnownStatusCodes static in Core.Abstractions would end the copy-paste drift risk (these layers deliberately avoid the OPC Foundation package, so the constants can't come from there).

C5 — LOW — Core.AlarmHistorian mixes contract and implementation

IAlarmHistorianSink/IAlarmHistorianWriter/status types share the project with SqliteStoreAndForwardSink, while scripting got a dedicated Abstractions assembly. Tolerable at three files, but the Runtime adapter and the gateway driver both reference the whole project (dragging the Microsoft.Data.Sqlite dependency) to get the interfaces.

C6 — LOW — Plan-era naming residue

XML docs throughout reference "Phase 7 plan Stream A.4/B/C/E/F/G" (TimedScriptEvaluator.cs:5, 39; ScriptSandbox.cs; IAlarmStateStore.cs:13; many more). The repo already purged the meaningless Phase7 type prefixes (master 40e8a23e), and the 3b5ef439 fixdocs sweep stripped arch-review IDs elsewhere — but these plan-stream references survived both passes and now point at plans superseded by docs/ScriptedAlarms.md/docs/ScriptEditor.md. Sweep them to current doc anchors during the next doc pass.

C7 — LOW — Doc-drift: ApplyPredicate claims branch handling that doesn't exist

Part9StateMachine.ApplyPredicate's summary promises "branch-stack increment when a new active arrives while prior active is still un-acked" (Part9StateMachine.cs:31-34), but no branch/previous-instance logic exists anywhere in the file — re-activation simply resets Acked/Confirmed. See U5; at minimum fix the comment.

C8 — LOW — NEW — VT script timeout is hard-coded and inconsistent with the alarm budget

Production registration passes no runTimeout (Host/Program.cs:217-221), so the live VT budget is the ctor default 2 s (RoslynVirtualTagEvaluator.cs:45) while alarm predicates default to TimedScriptEvaluator.DefaultTimeout 250 ms (ScriptedAlarmEngine.cs:160). Neither is operator-configurable despite the Scripting:* config section already existing for log settings (Program.cs:227-229) and TimedScriptEvaluator's doc claiming "configurable per tag so deployments with slower backends can widen it". A Scripting:EvaluationTimeoutMs (or per-engine keys) would make the budgets deliberate instead of incidental — and matters more now that the timeout is load-bearing (S7/S13).

4. Underdeveloped Areas

U1 — HIGH — The entire Core.VirtualTags engine stack is dormant in production

VirtualTagEngine (568 lines), TimerTriggerScheduler (162), VirtualTagSource (106), and DependencyGraph (324) have no production instantiation — repo-wide grep at f6eaa267 still finds new VirtualTagEngine only in tests/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests, and AdminUI/ScriptAnalysis/ScriptAnalysisService.cs:293 documents the fact in passing ("the cascade VirtualTagEngine is dormant"). The live virtual-tag runtime is Runtime/VirtualTags/VirtualTagHostActor + DependencyMuxActor + Host/Engines/RoslynVirtualTagEvaluator. The prior review's sharpest edge is now blunted — since 7fd44f0f the live adapter no longer bypasses the scope's safety wrappers (U2/U3 fixed), so the dormant engine's remaining unique value (topo-ordered cascade, cycle detection, timer triggers, IReadable adaptation) is squarely "unshipped feature", not "unshipped defense". That strengthens the case for retiring it: ~1,160 lines of engine + ~1,260 lines of tests still being maintained and carrying latent bugs (S1/S3/S10) for a component nothing runs. Recommendation: decide explicitly. Either (a) retire VirtualTagEngine/TimerTriggerScheduler/VirtualTagSource (keep DependencyGraph only if the publish-time cycle check uses it — verify) and let the actor pipeline be the one implementation, or (b) document the engine as the sanctioned embedded/non-Akka path and fix S1-S3. Option (a) is now clearly preferable.

U4 — MEDIUM — IHistoryWriter is a permanently-Null surface

VirtualTagDefinition.Historize routes to IHistoryWriter.Record, but DI binds only NullHistoryWriter (Runtime/ServiceCollectionExtensions.cs:57-58, 241-242 — "durable AVEVA sink is infra-gated; a deployment binding a real IHistoryWriter overrides"). No real implementation exists in the tree; the operator-visible Historize checkbox on virtual tags is a silent no-op (the separate ContinuousHistorization path taps the mux directly). Either wire IHistoryWriter to the gateway value-writer or hide/annotate the checkbox until it does something.

U5 — MEDIUM — Part 9 surface gaps are real but undeclared in one place

No condition branches / previous-instances (re-activation while un-acked just resets Acked — and the xmldoc overpromises, C7); Severity is static per definition ("not currently computed by the predicate", ScriptedAlarmDefinition.cs); AlarmKind affects only node typing; MessageTemplate has no brace escaping (documented as "reach for a feature request", MessageTemplate.cs:16-19); Retain is carried on the definition but not consulted anywhere in this scope. Each is a defensible v1 cut; collect them in docs/ScriptedAlarms.md as an explicit conformance statement so OPC UA client integrators know what subset to expect.

U6 — LOW — No TODO/HACK/stub markers

Grep found zero TODO|HACK|FIXME|NotImplemented across all five projects — unfinished edges are instead documented in XML remarks (a healthier pattern, though it makes gaps greppable only by reading).

U7 — LOW (was MEDIUM-leaning) — Test coverage: broad and behavior-focused, with specific holes

~6,690 test lines against ~5,760 source lines, all four impl projects have dedicated suites: SqliteStoreAndForwardSinkTests (888 — outcomes, poison cap, corrupt payloads, capacity fast path), ScriptedAlarmEngineTests (1,270), ScriptSandboxTests (614 — the escape catalog), CompiledScriptCacheTests (409 — including ALC-reclaim via WeakReference), VirtualTagEngineTests (800), state machine, extractor, graph, loggers, sources. Closed since the prior review: hole (e) — the production RoslynVirtualTagEvaluator timeout is now covered (Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs: WaitAsync-bounded infinite-loop test, happy-path-after-wrapping, ClearCompiledScripts emptying), and the apply-boundary clear has a production-caller wiring guard (Runtime.Tests/VirtualTags/VirtualTagHostActorTests.ApplyVirtualTags_clears_the_evaluator_compile_cache_each_generation — two generations, proving per-generation not one-shot). Remaining holes: (a) S5's expiry-during-predicate emission swallow — no Part9StateMachineTests case; (b) no concurrency test for load-vs-cascade (would expose S1/S4) — and now also none for clear-vs-in-flight-evaluate (would expose S12); (c) no sink Dispose-during-drain test (S6); (d) ForbiddenTypeAnalyzer has no dedicated unit suite (exercised only through end-to-end Compile in sandbox tests); (f — new) nothing asserts node quality degrades on persistent script failure (would expose S13).


Maturity Ratings

Scale: 1 (prototype) → 5 (production-hardened). Rating reflects the scoped code as it stands, including how it is (or isn't) consumed. Old rating = 2026-07-08 @ 9cad9ed0.

Dimension Old → New Justification
Stability 3.5 → 3.5 The worst live-path availability risk (a hung VirtualTagActor) is gone and the fix carries real, hang-proof regression tests — but every Core-engine stability finding (S1-S11) is untouched, and the remediation itself introduced two moderates: the apply-boundary clear races in-flight evaluations (S12), and failure/timeout never reaches node quality so stale values stay Good (S13). Net: same grade, different failure modes — bounded-loss instead of wedged.
Performance 3 → 3 P1 (per-compile BCL reference-set rebuild) still unfixed and now amplified by the unconditional per-apply cache flush (P7); the hot path additionally pays the (accepted) pool hop + actor-thread block. Right structures elsewhere unchanged.
Conventions 4 → 4 The remediation code follows repo norms well (why-comments, narrow capability seam mirroring the repo's optional-interface pattern, Null-object-safe cast). C1-C7 all still open; C8 (hard-coded, inconsistent timeout budgets) is new but minor.
Underdeveloped areas 2 → 3 The dimension's defining problem — "the live path bypasses the scope's two key defenses" — is fixed and guard-tested (U2/U3), and the production timeout finally has tests (U7-e). Still holding it below 4: the dormant 1,160-line engine remains unretired with its latent bugs (U1), Historize is still a silent Null no-op (U4), and the Part 9 conformance statement still doesn't exist (U5).

Priority Recommendations (ordered)

  1. S13 — Propagate script failure/timeout to node quality (the AttributeValueUpdate plumbing already carries OpcUaQuality; the bridge hard-codes Good). Without it, the new timeout converts a visible hang into an invisible stale-Good value — arguably a worse operator experience for a persistent fault.
  2. S12 — Close the clear-vs-in-flight-evaluate race in RoslynVirtualTagEvaluator (catch-ObjectDisposedException-recompile-retry is a 10-line fix), with a concurrency test.
  3. P1 (+P7) — Statically cache the ScriptSandbox reference set; the per-apply cache flush makes every deploy pay the full rebuild for every VT script, so this is now a deploy-latency issue, not just editor latency.
  4. U1 — Retire the dormant VirtualTagEngine stack. With U2/U3 fixed, it no longer holds any defense the live path lacks; keeping it means maintaining S1/S3/S10 in dead code.
  5. S7 — Per-script circuit breaker (N consecutive timeouts → quarantine + Admin UI surface): the timeout now protects both engines, but each timed-out attempt still orphans a thread and (VT path) blocks an actor 2 s — a hot dependency on a wedged script is a slow pool-starvation vector.
  6. S2 — Add dirty-set coalescing to ScriptedAlarmEngine upstream fan-out before a fast tag meets a popular alarm in production.
  7. S5 + C7 — Fix the timed-shelve expiry emission and the state-machine doc drift together, with a state-machine test.
  8. S3/S4/S6 — Schedule the reload-swap, _alarmsReferencing synchronization, and sink dispose-drain as one hardening pass.