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

38 KiB
Raw Blame History

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.