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.
38 KiB
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 at9cad9ed0). This is the second update (round 3). ~1274 commits landed sincef6eaa267; 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 train445bb855/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→LocalDbStoreAndForwardSinkwith a Primary-only drain gate) and the LocalDb condition-state store (4f4cdd05,EfAlarmConditionStateStoredeleted).Core.Scripting,Core.Scripting.Abstractions, andCore.VirtualTagsare byte-identical tof6eaa267(git diffempty), 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 sincef6eaa267.
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)
- 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 —RefillReadCachereads_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.TrackBackgroundTaskregisters a placeholder before invoking the factory soDispose's drain can never miss just-started work (1000-1078). This is careful, well-reasoned concurrency code. - 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-bandEmissionKind.QualityChanged(never through theIAlarmSourcefan-out —ScriptedAlarmSource.cs:84-87swallows it; the host routes it to the dedicatedAlarmQualityUpdatenode path,ScriptedAlarmHostActor.cs:446-454). Cold start is distinguishable from a comms-lost driver because the upstream stamps never-pushed pathsBadWaitingForInitialData(DependencyMuxTagUpstreamSource.cs:30-43) andWorstInputStatusskips exactly that code. Sub-codes ride verbatim end-to-end since #519. - 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 VTReassertValuefix (VirtualTagActor.cs:47-52, 179-185;VirtualTagHostActor.cs:192-208), whose historian-skip flag (IsReassert) prevents fabricated samples. - 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; theNotPrimarydrain state makes a suspended Secondary diagnosable,IAlarmHistorianSink.cs:83-93).AlarmSfLegacyMigratoris a genuinely careful one-time copy: read-only open, tolerant column intersection, single transaction, rename-after-commit, re-run harmless under payload-hash ids. - 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)
- U1 — Decide the dormant
VirtualTagEnginestack's fate. Retiring it closes S1, S10, half of S3, U7-(b), and the C1 duplicate in one commit. Three rounds is enough. - 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.
- P1 — Statically cache the
ScriptSandboxreference 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. - S15 — Unify the drain-gate
replicatedpredicate withLocalDbSetup.ReplicationIsConfigured(+ one config-shape test) before a port-without-key misconfiguration maroons a node's alarm queue. - 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. - S5 + C7 — Fix the predicate-path shelve-expiry emission and the branch-claim doc drift together, with the missing state-machine test.
- S3/S4/S6 — One hardening pass: build-then-swap reload,
_alarmsReferencingsnapshot-swap, sink dispose-drain (with the U7-(c) test). - U4 — Wire
IHistoryWriterto the gateway value-writer or remove/annotate the VT Historize checkbox.