Files
lmxopcua/archreview/02-scripting-alarms.md
T
Joseph Doherty 44f6408e1c docs(archreview): round-3 re-review at 12b0035a — 7 domain reports + 00-OVERALL rewritten in place, STATUS.md round-3 section
Every round-2 finding re-verified against code (73 closed / 31 partial / 97 carried);
matrix 19/28 cells up, one down. New Critical 03/S14 (AdminUI FallbackPolicy 401s every
mesh gRPC map on a login-enabled central; masked on every rig gate by DisableLogin=true).
New Highs 03/S15, 05/STAB-19, 05/STAB-18, 03/S16 — the stale-Good family at four seams.
2026-08-20 09:09:46 -04:00

192 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture Review 02 — Scripting, Virtual Tags, Scripted Alarms, Alarm Historian
- **Date:** 2026-08-19
- **Commit:** `12b0035a` (master, clean tree)
- **Updates:** the 2026-07-12 re-review at `f6eaa267` (which updated the 2026-07-08 original at `9cad9ed0`). **This is the second update (round 3).** ~1274 commits landed since `f6eaa267`; the delta in this domain is substantial: R2-03 (02/S13 + 02/S12 + 02/P7) merged; the dirty-set coalescing rework (`5df793e7` + the drain-latch fix train `445bb855`/`c8a70143`/`5ae43023`); scripted-condition quality #478 (`8c5e2be9`) + comms-loss bridging #481 (`c81cef39`) + status sub-codes #519 (`2d20aeb7`); the re-assert-after-reload fix #487 (`549c6564`); the LocalDb consolidation (`8a9cb40a`/`71379816`/`af545efd` — `SqliteStoreAndForwardSink` → `LocalDbStoreAndForwardSink` with a Primary-only drain gate) and the LocalDb condition-state store (`4f4cdd05`, `EfAlarmConditionStateStore` deleted). **`Core.Scripting`, `Core.Scripting.Abstractions`, and `Core.VirtualTags` are byte-identical to `f6eaa267`** (`git diff` empty), so prior findings in those projects were re-verified against unchanged code and their 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` (dormant cascade engine)
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms` (engine + Part 9 state machine)
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian` (`LocalDbStoreAndForwardSink`, `AlarmSfSchema`, `AlarmConditionStateSchema`)
- Production evaluation path: `Host/Engines/RoslynVirtualTagEvaluator`, `Runtime/VirtualTags/*` (`VirtualTagActor`, `VirtualTagHostActor`, mux), `Runtime/ScriptedAlarms/*` (`ScriptedAlarmHostActor`, `DependencyMuxTagUpstreamSource`, `LocalDbAlarmConditionStateStore`)
- The drain-gate wiring (`Runtime/ServiceCollectionExtensions.AddAlarmHistorian`, `IRedundancyRoleView`), `Host/Configuration/AlarmSfLegacyMigrator` + `LocalDbSetup`
- The `{{equip}}` resolution seam (`Commons/Types/EquipmentReferenceMap`)
- **Method:** re-verified every prior finding against current code (never STATUS.md's claims); full adversarial read of the coalescing rework, the LocalDb sink + migrator + condition store, the re-assert paths (#487 alarm-side, VT `ReassertValue`), the quality plumbing (#478/#481/#519), and the R2-03 fixes with their tests; fresh-eyes pass over everything landed since `f6eaa267`.
---
## Prior-finding status (f6eaa267 → 12b0035a)
| ID | One-liner | Status @ 12b0035a |
|---|---|---|
| S1 | `VirtualTagEngine.Load` mutates shared state with no gate vs in-flight evals | **STILL OPEN** — file byte-identical (`VirtualTagEngine.cs:23-24, 78+`); engine still dormant (U1). Third round carrying this in dead code |
| S2 | No coalescing/backpressure on upstream-change fan-out | **FIXED on every live path** — alarm engine: dirty-set + single drainer (`ScriptedAlarmEngine.cs:189-196, 547-677`), pinned by `A_storm_of_pushes_coalesces_to_one_drain_pass` incl. a falsification control (`ScriptedAlarmEngineTests.cs:1439-1500`); VT actors additionally get batched mux delivery — one evaluation per burst (`VirtualTagActor.cs:201-217`, `d7edc709`). Residual only in the dormant `VirtualTagEngine.OnUpstreamChange` (folds into U1) |
| S3 | Failed reload is fail-stop, not fail-back (both engines) | **STILL OPEN** — alarm engine still tears down before compiling (`ScriptedAlarmEngine.cs:256-270`, throw at 311-316); a transient `IAlarmStateStore` fault in the restore loop (336-342) still lands "zero alarms loaded". Mitigation only: the host keeps the prior mux interest on a failed reload (`ScriptedAlarmHostActor.cs:262-264`) |
| S4 | `_alarmsReferencing` plain `Dictionary` read from upstream threads without a gate | **STILL OPEN** — decl `ScriptedAlarmEngine.cs:165-166`, unguarded read in `OnUpstreamChange` at 551, gate-held Clear/Add at 261/300-302. The coalescing rework touched the surrounding code and still left this one plain |
| S5 | Timed-shelve expiry via the predicate path swallows the `Unshelved` emission | **STILL OPEN** — `Part9StateMachine.ApplyPredicate` (47-52) still silently expires via `MaybeExpireShelving` (318-322) and returns only Activated/Cleared/None; `Part9StateMachineTests` still has no expiry-during-predicate case |
| S6 | Sink `Dispose` races an in-flight drain | **STILL OPEN — carried into the rewrite.** `LocalDbStoreAndForwardSink.Dispose` (698-705) disposes the timer, then `_drainGateLock` and the writer, without waiting for a running `DrainOnceAsync` (which holds the gate, 320, and releases at 471 → `ObjectDisposedException` into the tick's catch; writer disposable under an active `WriteBatchAsync`). Same shape, new file |
| S7 | Sandbox CPU/memory unbounded; timed-out script leaks a thread; no quarantine | **STILL OPEN** — no per-script circuit breaker anywhere (grep clean). New corroborating evidence: `837d8a17`'s commit message documents that the timeout clock starts at `WaitAsync`, so under pool starvation queued evaluations time out spuriously — the exact starvation the orphaned threads cause (see updated body) |
| S8 | `ScriptedAlarmEngine.Dispose` blocks sync-over-async | **STILL OPEN** — `Task.WhenAll(...).GetAwaiter().GetResult()` now at `ScriptedAlarmEngine.cs:1180` |
| S9 | At-least-once delivery duplicates on crash window (doc note) | **CLOSED** — documented: `docs/AlarmHistorian.md:236` ("delivery is at-least-once across a failover by design"); the pair double-accept additionally converges via payload-hash row ids (`AlarmSfSchema.DeriveId`, 60-64) |
| S10 | `VirtualTagSource.SubscribeAsync` seed-then-subscribe can miss one update | **STILL OPEN** — file byte-identical; dormant code (U1) |
| S11 | Capacity eviction drops oldest accepted alarm events (policy doc note) | **CLOSED** — policy documented (`docs/AlarmHistorian.md:179, 257`; `EvictedCount` described as the data-loss signal, `IAlarmHistorianSink.cs:61-65`) |
| S12 | Apply-boundary `ClearCompiledScripts` races in-flight child evaluations | **FIXED** (R2-03) — bounded retry-once on `ObjectDisposedException` re-fetches into the current cache generation (`RoslynVirtualTagEvaluator.cs:93-97, 142-153`); fetch-time disposal correctly classified as shutdown, never retried (114-119); stress guard in `Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs`. Residual one-generation stale-ALC window documented in the class header (22-33) |
| S13 | Script failure/timeout never degrades node quality; stale value stays Good forever | **FIXED** (R2-03 + #519 reshape) — `VirtualTagActor.OnEvaluationFailed` publishes `BadInternalError` carrying the last-known value, once per Good→Bad transition, gated on inputs-ready (276-289); recovery bypasses the value dedup (254-262); host bridges `StatusCode` verbatim to the publish actor and the historian record (`VirtualTagHostActor.cs:227-241`). Tests: `VirtualTagActorTests.cs:85-110+` (degrade, once-per-transition, same-value recovery) |
| P1 | `ScriptSandbox.Build` rebuilds the full BCL reference set on every compile | **STILL OPEN** — `ScriptSandbox.cs:83-87, 100-130` byte-identical; no caller-level caching added. The P7 gate reduces how often deploys trigger mass recompiles, but each compile still pays the full rebuild |
| P2 | `Task.Run` + `WaitAsync` per evaluation on the hot path | **STILL OPEN (accepted)** — unchanged; VT path still also blocks its dispatcher thread (`RoslynVirtualTagEvaluator.cs:134`) |
| P3 | Live VT adapter allocates per evaluation what the alarm engine pools | **STILL OPEN** — fresh read-cache dict + context + `TimedScriptEvaluator` wrapper per call (`RoslynVirtualTagEvaluator.cs:79-91, 133`); the alarm engine's `AlarmScratch` reuse (1231-1255) still has no VT sibling |
| P4 | Single global eval gate per engine | **STILL OPEN (accepted)** — coalescing makes the serialized gate far cheaper in practice |
| P5 | SQLite per-call overheads | **STILL OPEN (accepted)** — new impl: `GetStatus` still runs a synchronous `COUNT(*)` per call (`LocalDbStoreAndForwardSink.cs:527-532`); enqueue fast path (253-274) preserved |
| P6 | `DependencyGraph` well optimized | unchanged — informational |
| P7 | Unconditional per-apply cache flush forces full VT recompilation on every deploy | **FIXED** (R2-03) — clear gated on a changed deployed expression set (`VirtualTagHostActor.cs:94-113`, ordinal `SetEquals`); rename/flag-toggle respawns need no recompile (cache keyed by source) |
| C1 | `ITagUpstreamSource` defined twice | **STILL OPEN** — duplicate now at `ScriptedAlarmEngine.cs:1301-1317` |
| C2 | Abstractions assembly spans three namespaces undocumented | **STILL OPEN** — project byte-identical |
| C3 | Trailing type definitions in engine files | **STILL OPEN — grew**: `ScriptedAlarmEvent`, `ScriptedAlarmProjection`, and the duplicate interface all trail `ScriptedAlarmEngine.cs` (1258-1318) |
| C4 | Repeated raw OPC UA status-code literals | **LARGELY CLOSED** — `Commons.OpcUa.OpcUaStatus` is now the named authority and the Runtime/Host paths use it (`VirtualTagActor.cs:38, 182, 288`; `DependencyMuxTagUpstreamSource.cs:43`); the remaining Core inlines are deliberate (no Commons ref), justified in-code, and pinned by a cross-project test (`ScriptedAlarmEngine.cs:58-67`). Residual accepted |
| C5 | `Core.AlarmHistorian` mixes contract and implementation | **STILL OPEN** — contract + `LocalDbStoreAndForwardSink` + schemas share the project, which now drags `Microsoft.Data.Sqlite` **and** `ZB.MOM.WW.LocalDb` (csproj:15, 28) into anything wanting just `IAlarmHistorianSink` |
| C6 | Plan-era "Phase 7 plan Stream …" doc residue | **STILL OPEN** — 20 hits across the five Core projects (e.g. `ScriptedAlarmEngine.cs:9`, `IAlarmHistorianSink.cs:14`, `TimedScriptEvaluator.cs`) |
| C7 | `ApplyPredicate` xmldoc claims branch handling that doesn't exist | **STILL OPEN** — `Part9StateMachine.cs:31-33` still promises "branch-stack increment"; no branch logic anywhere in the file |
| C8 | Script timeout budgets hard-coded and inconsistent (VT 2 s vs alarm 250 ms) | **STILL OPEN** — Host registration passes no `runTimeout` (`Program.cs:324-328` → default 2 s at `RoslynVirtualTagEvaluator.cs:58`); `DriverHostActor` builds the engine with no `scriptTimeout` (786-787 → `DefaultTimeout` 250 ms, `TimedScriptEvaluator.cs:40`); still no `Scripting:*` key for either |
| U1 | Entire Core.VirtualTags engine stack dormant in production | **STILL OPEN — third round.** `new VirtualTagEngine` still appears only under `tests/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests`; `ScriptAnalysisService.cs:350` still documents "the cascade VirtualTagEngine is dormant". No retire/sanction decision. The case for retirement is now stronger still — see body |
| U2 | (fixed @ f6eaa267) production VT timeout | **REMAINS FIXED** — routing intact (`RoslynVirtualTagEvaluator.cs:126-141`) |
| U3 | (fixed @ f6eaa267) compile-cache/ALC accretion | **REMAINS FIXED** — cache + apply-boundary clear intact (`RoslynVirtualTagEvaluator.cs:41`; `VirtualTagHostActor.cs:112`); alarm engine gained the same discipline (`ScriptedAlarmEngine.cs:122, 270, 1207`) |
| U4 | `IHistoryWriter` is a permanently-Null surface | **STILL OPEN** — DI still binds only `NullHistoryWriter` (`Runtime/ServiceCollectionExtensions.cs:67-68, 387-388`); no real implementation exists; the VT Historize flag routes to `_history.Record` (`VirtualTagHostActor.cs:238-242`) and silently no-ops. Note the separate `ContinuousHistorization` path does NOT cover VT nodes (it keys on raw-path historized refs) |
| U5 | Part 9 surface gaps real but undeclared in one place | **MOSTLY OPEN** — individual cuts are now documented where they live (`docs/ScriptedAlarms.md:135` no brace escaping; `:33` Retain semantics; `ScriptedAlarmDefinition.cs:20` static severity), but no consolidated conformance statement exists, `Retain` is still consulted nowhere (only carried: `ScriptedAlarmHostActor.cs:700`), and the C7 doc-drift stands |
| U6 | No TODO/HACK/stub markers (healthy) | unchanged — informational |
| U7 | Test coverage holes (a)–(f) | **PARTIALLY FIXED** — closed: (e) production timeout (held), (f) node-quality degradation (`VirtualTagActorTests.cs:85-110+`); plus strong new coverage: coalescing storm with falsification control, drain-latch stack/handover pins (`Sustained_synchronous_drain_failures_hand_over_without_deepening_the_stack`), S&F sink 39 tests, condition-store parity round-trips. Still open: (a) S5's expiry-during-predicate emission, (b) load-vs-cascade concurrency (dormant engine), (c) sink Dispose-**during**-drain (only disposed-then-call is tested, `LocalDbStoreAndForwardSinkTests.cs:453, 984-991`), (d) dedicated `ForbiddenTypeAnalyzer` suite (still exercised only through `Compile` in `ScriptSandboxTests`) |
---
## What changed in this domain (round-3 architecture delta)
1. **Upstream fan-out is now a dirty set + single drainer** (`ScriptedAlarmEngine.cs:189-196, 547-677`). A push marks referencing alarm ids dirty and starts at most one drainer; a storm collapses into one further pass over the latest cached values (latest-wins by construction — `RefillReadCache` reads `_valueCache`). The drain latch (`_drainScheduled`) is released on every exit path, with a queued (not called) replacement drainer on exceptional escape so sustained failures cannot deepen the stack (626-676) — pinned by a dedicated test. `TrackBackgroundTask` registers a placeholder **before** invoking the factory so `Dispose`'s drain can never miss just-started work (1000-1078). This is careful, well-reasoned concurrency code.
2. **Scripted-condition quality (#478/#481/#519).** Worst-of-input OPC UA status is computed per evaluation (`WorstInputStatus`, 923-942), bucket transitions emit an out-of-band `EmissionKind.QualityChanged` (never through the `IAlarmSource` fan-out — `ScriptedAlarmSource.cs:84-87` swallows it; the host routes it to the dedicated `AlarmQualityUpdate` node path, `ScriptedAlarmHostActor.cs:446-454`). Cold start is distinguishable from a comms-lost driver because the upstream stamps never-pushed paths `BadWaitingForInitialData` (`DependencyMuxTagUpstreamSource.cs:30-43`) and `WorstInputStatus` skips exactly that code. Sub-codes ride verbatim end-to-end since #519.
3. **Re-assert after (re)load (#487)** — `ScriptedAlarmEngine.GetProjections` (a read, deliberately a distinct type from the emission so the historian path cannot consume it) + `ScriptedAlarmHostActor.ReassertConditionNodes` (302-378) restore non-normal condition nodes after an address-space rebuild without appending historian rows. Mirrors the VT `ReassertValue` fix (`VirtualTagActor.cs:47-52, 179-185`; `VirtualTagHostActor.cs:192-208`), whose historian-skip flag (`IsReassert`) prevents fabricated samples.
4. **Alarm S&F moved to the replicated LocalDb** (`LocalDbStoreAndForwardSink`): payload-hash TEXT primary keys make the pair's boot-window double-accept converge to one row (`AlarmSfSchema.DeriveId`), acks DELETE (tombstone replicates), and the drain is gated to the Primary **only when the queue actually replicates** (`ServiceCollectionExtensions.cs:167-209` — the gate deliberately scopes to "is my queue shared?" precisely because the fleet-wide election and the pair-local queue disagree; the `NotPrimary` drain state makes a suspended Secondary diagnosable, `IAlarmHistorianSink.cs:83-93`). `AlarmSfLegacyMigrator` is a genuinely careful one-time copy: read-only open, tolerant column intersection, single transaction, rename-after-commit, re-run harmless under payload-hash ids.
5. **Part 9 condition state moved to LocalDb** (`LocalDbAlarmConditionStateStore`) — byte-parity port of the deleted EF store (Active deliberately not persisted; re-derived), unconditional PK upsert riding the replication HLC.
---
## Findings
Severity scale: **Critical** / **High** / **Medium** / **Low**. Fixed prior findings live only in the status table. New findings continue the per-category scheme (S14+, C9+).
### 1. Stability
#### S1 — HIGH — `VirtualTagEngine.Load` mutates shared state with no gate against in-flight evaluations *(carried, third round)*
Unchanged since `9cad9ed0` (`VirtualTagEngine.cs:78+` rebuilds `_tags` (24) and `_graph` (23) without `_evalGate` while `EvaluateInternalAsync`/`CascadeAsync`/timer ticks read them from arbitrary threads). Still fully mitigated only by dormancy (U1). Two review rounds have recommended retire-or-fix; neither has happened. **This finding should be resolved by resolving U1, not by fixing the code.**
#### S3 — MEDIUM — Failed reload is fail-stop, not fail-back (both engines) *(carried)*
Shape unchanged: `LoadAsync` tears down the prior generation (timer, subscriptions, `_alarms`, scratch, compile cache — `ScriptedAlarmEngine.cs:256-270`) before compiling, and throws with **zero alarms loaded** on any compile failure (311-316) or store fault during restore (336-342 — not operator-preventable). The host logs and stays inert (`ScriptedAlarmHostActor.OnLoadFailed:279-280`), keeping only the *mux interest* alive. Publish-time validation makes the compile leg unlikely; the store-fault leg got **worse odds of being transient-safe**, not better: the store is now LocalDb (local SQLite), so faults are rarer but a reload no longer has central SQL retry semantics either. Recommendation unchanged: build-then-swap under the gate, or at minimum retry store faults distinctly from compile faults.
#### S4 — MEDIUM — `_alarmsReferencing` read without synchronization *(carried)*
`Dictionary<string, HashSet<string>>` (`ScriptedAlarmEngine.cs:165-166`) mutated under the gate in `LoadAsync` (261, 300-302), read **and its `HashSet` values iterated** without the gate in `OnUpstreamChange` (551, 558) from upstream callback threads. `UnsubscribeFromUpstream` (259) does not guarantee an already-executing callback finished. The coalescing rework added a beautifully documented `_dirtyLock` discipline for the new state (189-196) and iterates `alarmIds` inside it — but the set being iterated is the one `LoadAsync` can concurrently `Clear()`. The class's own comment block (76-83) justifies `ConcurrentDictionary` for `_alarms` for exactly this reader class; `_alarmsReferencing` remains the one plain structure on a cross-thread path. Cheap fix: immutable snapshot swapped via `Volatile.Write`, or `ConcurrentDictionary<string, string[]>` rebuilt per load.
#### S5 — MEDIUM — Timed-shelve expiry via the predicate path swallows the `Unshelved` emission *(carried)*
`ApplyPredicate` (`Part9StateMachine.cs:47-52`) expires timed shelving silently (`MaybeExpireShelving`, 318-322): the state record unshelves and persists (via `ReevaluateAsync`'s `!ReferenceEquals` persist, `ScriptedAlarmEngine.cs:697-703`), but the only `Unshelved` emission source remains `ApplyShelvingCheck` on the 5 s timer — which then finds `Kind != Timed` (302) and emits nothing. Clients tracking `ShelvingState` from events never learn the shelve ended. Now slightly *more* visible in effect than before: an expiry-coincident activation/clear fires **un-suppressed** (52, 67, 84) with no preceding `Unshelved`, which is exactly the confusing sequence the prior review predicted. Still no state-machine test for the path (14 `ApplyPredicate` tests, none crossing expiry). Fix as before: compound result (Unshelved + value transition), plus the missing test.
#### S6 — MEDIUM — `LocalDbStoreAndForwardSink.Dispose` races an in-flight drain *(carried into the rewrite)*
`Dispose` (`LocalDbStoreAndForwardSink.cs:698-705`) sets `_disposed`, disposes the timer (which does not wait for a running callback), then immediately disposes `_drainGateLock` and the writer. An in-flight `DrainOnceAsync` holds the gate (320) and may be mid-`WriteBatchAsync` (373): its `finally { _drainGateLock.Release(); }` (471) then throws `ObjectDisposedException` (surfacing as a spurious "drain tick faulted" Error, 175-197), and the writer can be disposed under an active gRPC call. `DrainOnceAsync` checks `_disposed` only at entry (319). The engine sibling solved this exact problem with placeholder-tracked drains; the sink still hasn't. U7-(c): still no Dispose-during-drain test (only disposed-then-call, `LocalDbStoreAndForwardSinkTests.cs:453, 984-991`). Fix: track the in-flight drain task (or `Timer.Dispose(WaitHandle)` + `_drainGateLock.Wait()`) before disposing gate/writer.
#### S7 — MEDIUM — Sandbox CPU/memory unbounded; timed-out scripts leak a thread; no quarantine *(carried, sharpened)*
Unchanged design (`TimedScriptEvaluator.cs:10-26`; `ScriptSandbox.cs:30-35`): every timed-out CPU-bound evaluation orphans a pool thread, on both live paths, with no per-script circuit breaker (repo-wide grep clean). The round-3 delta *documents the compounding loop the prior review predicted*: `837d8a17`'s starvation test found that the budget clock starts at `WaitAsync`, so under pool starvation **legitimately queued evaluations time out spuriously** — i.e. orphaned threads → starvation → more timeouts → more orphans, with each VT timeout also blocking its actor 2 s. The alarm engine's dirty-set coalescing meaningfully caps the *rate* (one pass per push cadence, not per queued task), which lowers the practical blast radius versus round 2 — but a hot dependency on a wedged script is still a slow pool-starvation vector with no quarantine and no Admin-UI surface. Recommendation unchanged: N-consecutive-timeouts → suspend + surface. The compile-time sandbox itself remains notably strong.
#### S8 — LOW — `ScriptedAlarmEngine.Dispose` blocks sync-over-async *(carried)*
`Task.WhenAll(toAwait).GetAwaiter().GetResult()` (`ScriptedAlarmEngine.cs:1180`). Fine under the actor host; `IAsyncDisposable` remains the clean fix. The placeholder-registration rework (1024-1078) made the drain *correct*; it is still synchronous.
#### S10 — LOW — `VirtualTagSource.SubscribeAsync` initial-value window *(carried)*
Unchanged, dormant (fold into U1).
#### S14 — MEDIUM — **NEW** — VT path deliberately ignores input quality: a virtual tag computed from Bad/comms-lost inputs publishes Good
#478/#481 taught the *scripted-alarm* path to derive worst-of-input quality and to hold evaluation on Bad inputs. The VT path never received the sibling treatment, and is now explicitly documented as not having it: `DependencyValueChanged.StatusCode` "…the virtual-tag engine ignores it" (`VirtualTagActor.cs:33-38`), and `RoslynVirtualTagEvaluator.BuildReadCache` stamps **every** dependency `StatusCode: 0u` Good regardless of the source's actual status (`RoslynVirtualTagEvaluator.cs:165-177`). `VirtualTagActor.OnDependencyChanged` stores the value and evaluates unconditionally (187-191). Consequence: when a driver goes comms-lost (#527 watchdog publishes Bad for its tags), every virtual tag computed from those tags keeps evaluating against the last-known values and **keeps publishing fresh Good results with fresh timestamps** — the one signature S13 was fixed to avoid (a broken *script* now degrades the node; a broken *input* still doesn't). A script author cannot even detect it: `ctx.GetTag(path).StatusCode` reads the fabricated Good. The dormant engine's `AreInputsReady` shape and the alarm engine's hold-on-Bad show the intended semantics.
**Recommendation:** propagate `msg.StatusCode` into `_dependencies` (value + status pairs), pass real statuses through `BuildReadCache`, and pick a policy — either hold evaluation on Bad inputs (alarm parity) or publish the computed value with degraded quality. Pair with a test: driver goes Bad ⇒ dependent VT node does not stay/turn Good.
#### S15 — MEDIUM — **NEW** — The alarm drain-gate's "is my queue replicated?" predicate is hand-rolled and disagrees with `LocalDbSetup.ReplicationIsConfigured`
`AddAlarmHistorian` computes `replicated` as **`PeerAddress` OR `SyncListenPort`** (`Runtime/ServiceCollectionExtensions.cs:183-185`), while the predicate that actually decides whether the table is registered/captured for replication is **`PeerAddress` OR `ApiKey`** (`Host/Configuration/LocalDbSetup.cs:171-179` — whose remarks explicitly rule that `SyncListenPort` must *not* count, because a port without a key rejects every inbound stream, and note the health check deliberately asks a third question). The two predicates answer the *same* question for the drain gate's purposes — "does a peer hold my rows?" — and diverge on the port-without-key misconfiguration: `LocalDbSetup` deregisters the table (the queue is local-only, replicating to nobody) while the drain gate treats the queue as shared and suspends the drain whenever `ShouldDrainAlarmHistory` is false. Result: that node's alarm events accumulate in a queue **no node will ever drain**, until capacity evictions discard them — the exact "deferring to anyone means they are never delivered by anyone" failure the gate's own comment block warns about (169-172). Mitigations: the health check reports Degraded for port-without-key, and `GetStatus` shows `NotPrimary` — but the sink-status diagnosis rule documented at `IAlarmHistorianSink.cs:88-92` ("if BOTH nodes report this, no one is draining") is written for a genuinely replicating pair and reads as healthy here (only one node shows `NotPrimary`).
**Recommendation:** make `AddAlarmHistorian` call `LocalDbSetup.ReplicationIsConfigured(configuration)` (move it somewhere both can reference, or duplicate with a parity test) so the gate scopes to the predicate that governs capture. One config-shape test: SyncListenPort-without-ApiKey ⇒ the sink always drains.
#### S16 — LOW — **NEW** — `ScriptedAlarmEngine.OnUpstreamChange` writes the value cache before the disposed check
`_valueCache[path] = value` (549) runs before the `_disposed` bail (550), so a late upstream callback mutates a cleared cache after `Dispose` finished (1199-1202 clears under no gate the callback holds). Harmless today (`ConcurrentDictionary`, nothing reads it afterwards) — noted so a future Dispose-order change doesn't turn it into a real use-after-clear.
### 2. Performance
#### P1 — HIGH — `ScriptSandbox.Build` rebuilds the full BCL reference set on every compile *(carried, third round)*
Byte-identical (`ScriptSandbox.cs:83-87, 100-130`). Every distinct script compile — publish-time VT/alarm compiles and every Admin-UI `ScriptAnalysis` request — still pays ~100+ `MetadataReference.CreateFromFile` calls. The P7 gate removed the *worst amplifier* (no-op redeploys no longer force mass recompiles), and the alarm engine's per-generation `_compileCache.Clear()` (`ScriptedAlarmEngine.cs:270`) means every real alarm publish still recompiles every predicate from scratch, each paying the rebuild. Three rounds open; the fix remains a static `ConcurrentDictionary<Assembly, SandboxConfig>` — an afternoon of work for a 10-100× compile-latency win.
#### P2 — MEDIUM — Pool hop + dispatcher block per evaluation *(carried, accepted)*
Unchanged (`TimedScriptEvaluator.cs:74-81`; `RoslynVirtualTagEvaluator.cs:134`). The batched mux delivery (one evaluation per burst) has materially reduced how often this cost is paid.
#### P3 — MEDIUM — Live VT adapter allocates per evaluation what the alarm engine pools *(carried)*
`RoslynVirtualTagEvaluator.Evaluate` still builds a fresh read-cache dictionary, `VirtualTagContext`, per-eval `ForContext` logger chain, and a fresh `TimedScriptEvaluator` wrapper per call (79-91, 133); the alarm engine's `AlarmScratch` (`ScriptedAlarmEngine.cs:1231-1255`) shows the pattern. On the hot ingress path; worth doing when P1 is touched.
#### P5 — LOW — Per-call DB overheads in the sink *(carried, accepted; refs updated)*
`GetStatus` runs a synchronous `COUNT(*)` per call (`LocalDbStoreAndForwardSink.cs:527-532`); `RetryDeadLettered` likewise synchronous (559-573). Fine at Admin-UI/health cadence; the enqueue capacity fast path (253-274, with the replication-aware periodic resync) is well done.
#### P6 — LOW — `DependencyGraph` well optimized *(carried)* — no action.
### 3. Conventions
#### C1 — MEDIUM — `ITagUpstreamSource` defined twice *(carried)*
`Core.VirtualTags/ITagUpstreamSource.cs` + the byte-shaped duplicate at `ScriptedAlarmEngine.cs:1301-1317`, still enforced only by comment ("intentionally identical shape so Stream G can compose them" — a plan-stream reference that is itself C6 residue). `DependencyMuxTagUpstreamSource` implements the ScriptedAlarms one; a VT-side upstream would need the other. Hoist into `Core.Scripting.Abstractions`.
#### C2 — MEDIUM — Abstractions assembly spans three namespaces undocumented *(carried)* — unchanged; same fix (project README stating the sandbox-closure rule).
#### C3 — LOW — Trailing type definitions in engine files *(carried, grew)*
`ScriptedAlarmEvent` (1258-1279), `ScriptedAlarmProjection` (1281-1299), duplicate `ITagUpstreamSource` (1301-1317) all trail `ScriptedAlarmEngine.cs`; both records are public cross-project contracts.
#### C5 — LOW — `Core.AlarmHistorian` mixes contract and implementation *(carried, heavier)*
The project now carries `Microsoft.Data.Sqlite` **and** the `ZB.MOM.WW.LocalDb` package (csproj:15, 28); Runtime and the gateway driver still reference the whole project to get `IAlarmHistorianSink`/`IAlarmHistorianWriter`. The LocalDb move would have been the natural moment to split contract from impl; it didn't happen.
#### C6 — LOW — Plan-era naming residue *(carried)* — 20 remaining "Phase 7 plan / Stream A-G" doc references across the five Core projects (`ScriptedAlarmEngine.cs:9`, `IAlarmHistorianSink.cs:14, 20`, `TimedScriptEvaluator.cs:5, 39`, `IAlarmStateStore.cs`, etc.). Sweep to current doc anchors (`docs/ScriptedAlarms.md`, `docs/AlarmHistorian.md`).
#### C7 — LOW — `ApplyPredicate` xmldoc claims branch handling that doesn't exist *(carried)* — `Part9StateMachine.cs:31-33`. Two rounds; a one-line comment fix. Pair with U5.
#### C8 — LOW — Script timeout budgets hard-coded and inconsistent *(carried)*
VT = ctor-default 2 s (`RoslynVirtualTagEvaluator.cs:58`; nothing passed at `Program.cs:324-328`); alarm predicates = 250 ms (`TimedScriptEvaluator.cs:40`; nothing passed at `DriverHostActor.cs:786-787`). Both budgets are now load-bearing safety controls (S7/S13/#478) and neither is operator-configurable despite the `Scripting:*` section existing for log settings.
#### C9 — LOW — **NEW** — `EquipmentReferenceMap` spells the effective-name rule twice, divergently
The class exists so "the effective-name rule … live[s] ONLY here" (`EquipmentReferenceMap.cs:13-16`), but it is implemented twice with different empty-string semantics: `Build` treats an empty `DisplayNameOverride` as "use the tag's name" (`string.IsNullOrEmpty`, line 58), while `EffectiveNames` uses `??` (line 88) — an empty-string override yields `""`, fails the `IsNullOrEmpty` filter (90), and the name is **dropped from the authoring-gate/Monaco set** even though `Build` (and therefore deploy resolution) would resolve it to the tag's name. On that input the editor rejects a `{{equip}}/<Name>` path publish accepts — inverting the editor⇔publish parity the seam exists to guarantee. Only reachable if authoring persists `""` rather than null; make `EffectiveNames` reuse `Build`'s rule (or extract one private `EffectiveNameOf`).
#### C10 — LOW — **NEW** — Operator/shelving-path emissions canonicalize the worst-input status, losing the sub-code
#519's stated goal is that the status sub-code rides "UNCHANGED" to the condition node (`ScriptedAlarmEvent` remarks, 1275-1278), and the evaluation path honors that — but the operator-command and shelving-timer paths rebuild the status from the 3-state bucket via `LastWorstStatus` (`ScriptedAlarmEngine.cs:963-972`), so an ack while an input is `BadNoCommunication` projects generic `0x80000000` Bad. Documented in-code as deliberate (the paths don't re-read inputs); cheap improvement: cache the last *worst status code* (already computed per evaluation at 746) alongside the bucket.
### 4. Underdeveloped Areas
#### U1 — HIGH — The dormant Core.VirtualTags engine stack: third round, still no decision
`VirtualTagEngine` (568 lines) + `TimerTriggerScheduler` + `VirtualTagSource` + their ~1,260 test lines remain production-dead (`new VirtualTagEngine` only in its own tests; `ScriptAnalysisService.cs:350`). Every argument from round 2 got stronger: the live path now has the timeout, the ALC-safe cache, quality degradation (S13), re-assert recovery, and batched-burst evaluation — the dormant engine's remaining unique features (topo cascade, cycle detection at runtime, timer triggers) are unshipped features, and the deploy-time Tarjan cycle gate lives in the Calculation-driver deploy path, not here. Meanwhile the stack carries S1/S3/S10 as permanently-open findings and (uniquely in this domain) *mirrors the actual interface duplication* (C1). **Recommendation (unchanged, escalating): retire it this round, or write the one-paragraph sanction that makes keeping it a decision instead of a default.** Keep `DependencyGraph` only if something real consumes it.
#### U4 — MEDIUM — `IHistoryWriter` is a permanently-Null surface *(carried)*
Still only `NullHistoryWriter` (`Runtime/ServiceCollectionExtensions.cs:67-68, 387-388`; sole implementation in the tree at `Core.VirtualTags/IHistoryWriter.cs:24`). The VT bridge dutifully calls `_history.Record` for `Historize=true` plans — with correct re-assert suppression and #519 status carriage (`VirtualTagHostActor.cs:238-242`) — into a no-op. The operator-visible Historize checkbox on virtual tags remains a silent lie, and the `ContinuousHistorization` recorder does not cover VT nodes (it taps raw-path historized refs). Either wire `IHistoryWriter` to the gateway value-writer or hide/annotate the checkbox.
#### U5 — MEDIUM — Part 9 conformance statement still missing *(carried, narrowed)*
Progress: `docs/ScriptedAlarms.md` now documents the message-template escaping cut (:135) and the ack/shelve surfaces in detail; quality semantics (#478) are documented. Still missing: one consolidated "what subset of Part 9 to expect" statement (no branches/previous-instances; static severity; `Retain` carried but consulted nowhere — `ScriptedAlarmHostActor.cs:700` is its last stop; `AlarmKind` affects node typing only), and the C7 xmldoc still overpromises. Integrators still have to derive the subset from code.
#### U7 — LOW — Test coverage: strong and improving, four specific holes remain *(carried)*
Closed this round: (f) node-quality degradation, plus the coalescing falsification control, drain-latch handover/stack pins, register-before-start pin, sink outcome/poison/corrupt/capacity suite (39 tests), condition-store parity round-trips, projection tests. Still open: (a) S5's expiry-during-predicate emission swallow; (b) load-vs-cascade concurrency (moot if U1 resolves to retirement); (c) sink Dispose-during-drain (S6); (d) a dedicated `ForbiddenTypeAnalyzer` unit suite (the deny-list is the actual security boundary and is still only tested end-to-end through `Compile`).
---
## Maturity Ratings
Scale: 1 (prototype) → 5 (production-hardened). Previous scores (2026-07-12 @ `f6eaa267`) in parentheses.
| Dimension | Old → New | Justification |
|---|---|---|
| Stability | **(3.5) → 4** | Both remediation-introduced moderates (S12/S13) verifiably fixed with tests that can fail; the S2 fan-out High is closed on every live path with a falsification-controlled test; the drain-latch/background-task lifecycle work is exemplary concurrency engineering; the LocalDb move made alarm history failover-safe with a correctly *scoped* drain gate. Held below 4.5 by the untouched hardening backlog (S3/S4/S5/S6/S7 — all three rounds old), one new gate-predicate drift (S15), and the VT input-quality gap (S14). |
| Performance | **(3) → 3.5** | Dirty-set coalescing + batched mux delivery + retained scratch reuse remove the real hot-path waste; P7 fixed. P1 — the domain's standing High, now three rounds old — is the single reason this isn't 4: every compile everywhere still rebuilds the BCL reference set. |
| Conventions | **(4) → 4** | New code's why-comments are the best in the repo (the drain-latch finally block, the drain-gate scoping rationale, the migrator remarks are teaching documents). But the ledger barely moved: C1/C2/C3/C5/C6/C7/C8 all carried a third round, C3/C5 grew, and two small new inconsistencies (C9/C10) appeared. Quality of new code high; debt retirement near zero. |
| Underdeveloped areas | **(3) → 3.5** | #487 + #481 + #478 closed real operator-facing gaps; S9/S11 documentation debts paid; U7 keeps shrinking. Held down by the same two anchors as round 2: the dormant 1,100-line engine still awaits a decision (U1 — now the oldest unactioned High in the domain), and Historize on virtual tags is still a silent no-op (U4). |
---
## Priority Recommendations (ordered)
1. **U1** — Decide the dormant `VirtualTagEngine` stack's fate. Retiring it closes S1, S10, half of S3, U7-(b), and the C1 duplicate in one commit. Three rounds is enough.
2. **S14** — Propagate dependency quality into the VT path (or hold evaluation on Bad inputs). S13 fixed "broken script looks Good"; "broken input looks Good" is the same operator lie through the other door.
3. **P1** — Statically cache the `ScriptSandbox` reference set. Third round; every publish and every editor keystroke still pays it; the alarm engine's per-publish cache clear guarantees mass recompiles pay it too.
4. **S15** — Unify the drain-gate `replicated` predicate with `LocalDbSetup.ReplicationIsConfigured` (+ one config-shape test) before a port-without-key misconfiguration maroons a node's alarm queue.
5. **S7** — Per-script quarantine (N consecutive timeouts → suspend + Admin-UI chip). The starvation test (`837d8a17`) has now empirically documented the timeout↔starvation coupling the breaker would sever.
6. **S5 + C7** — Fix the predicate-path shelve-expiry emission and the branch-claim doc drift together, with the missing state-machine test.
7. **S3/S4/S6** — One hardening pass: build-then-swap reload, `_alarmsReferencing` snapshot-swap, sink dispose-drain (with the U7-(c) test).
8. **U4** — Wire `IHistoryWriter` to the gateway value-writer or remove/annotate the VT Historize checkbox.