# Code Review - Core.VirtualTags

| Field | Value |
|---|---|
| Module | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags` |
| Reviewer | Claude Code |
| Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` |
| Status | Reviewed |
| Open findings | 0 |

## Checklist coverage

A comprehensive review completes every category, recording "No issues found" where
a category produced nothing rather than leaving it blank.

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Core.VirtualTags-001, Core.VirtualTags-002, Core.VirtualTags-003, Core.VirtualTags-004 |
| 2 | OtOpcUa conventions | No issues found |
| 3 | Concurrency & thread safety | Core.VirtualTags-005, Core.VirtualTags-006 |
| 4 | Error handling & resilience | Core.VirtualTags-007 |
| 5 | Security | No issues found |
| 6 | Performance & resource management | Core.VirtualTags-008, Core.VirtualTags-009 |
| 7 | Design-document adherence | Core.VirtualTags-001, Core.VirtualTags-010 |
| 8 | Code organization & conventions | Core.VirtualTags-011 |
| 9 | Testing coverage | Core.VirtualTags-012 |
| 10 | Documentation & comments | Core.VirtualTags-010, Core.VirtualTags-013 |

## Findings

### Core.VirtualTags-001

| Field | Value |
|---|---|
| Severity | High |
| Category | Correctness & logic bugs |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:306` |
| Status | Resolved |

**Description:** `OnScriptSetVirtualTag` updates `_valueCache`, notifies observers, and
records history for the written path, but it does not schedule a cascade for tags that
depend on the written path. `docs/VirtualTags.md` (VirtualTagContext section) explicitly
states `SetVirtualTag(path, value)` "routes through the engine's `OnScriptSetVirtualTag`
callback so cross-tag writes still participate in change-trigger cascades." They do not.
A script that writes `ctx.SetVirtualTag("Target", x)` updates Target's cached value, but
any virtual tag whose script reads Target via `ctx.GetTag("Target")` and is
`ChangeTriggered = true` is never re-evaluated. Downstream virtual tags go stale until
some unrelated trigger fires. The existing test
`SetVirtualTag_within_script_updates_target_and_triggers_observers` only asserts the
target itself updates and never exercises a tag depending on the target, so the gap is
not caught.

**Recommendation:** Either (a) launch a fire-and-forget `CascadeAsync(path, ...)` from
`OnScriptSetVirtualTag` (note `EvaluateInternalAsync` acquires the non-reentrant
`_evalGate`, so the cascade must be scheduled, not invoked inline while the gate is
held), or (b) if cascading from a script write is intentionally unsupported, correct the
documentation and `VirtualTagContext` XML doc to say so. Decide deliberately and make
code and docs agree.

**Resolution:** Resolved 2026-05-22 — `OnScriptSetVirtualTag` now launches a fire-and-forget `CascadeAsync(path, ...)` after updating the cache, mirroring `OnUpstreamChange`, so change-triggered dependents of a script-written tag are re-evaluated; added a regression test.

### Core.VirtualTags-002

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:237` |
| Status | Resolved |

**Description:** The cold-start guard `if (!AreInputsReady(ctxCache)) return;` silently
abandons the evaluation when any input is null or Bad-quality. For a chained virtual tag
(C depends on B depends on driver tag A), if A is still Bad at startup, B is skipped --
leaving B's `_valueCache` entry absent. When C evaluates, `BuildReadCache` falls through
to `_upstream.ReadTag("B")` for the missing virtual path, which returns BadNodeIdUnknown
quality, so C is also skipped. That is acceptable for cold start, but the same guard
means a virtual tag that legitimately consumes a Bad-quality upstream (e.g. a script
written to detect comms loss and emit a fallback) can never run -- it is permanently
frozen at its prior value with no diagnostic. The tag also never transitions to a Bad
quality of its own, so an OPC UA client cannot distinguish "not yet computed" from
"computing fine."

**Recommendation:** Make the cold-start behaviour explicit: when inputs are not ready,
publish a Bad-quality snapshot (e.g. BadWaitingForInitialData, 0x80320000) for the tag
rather than returning with no state change, so clients see a defined quality. If
operators need scripts that handle Bad upstreams, consider a per-definition opt-out of
the readiness guard.

**Resolution:** Resolved 2026-05-22 — cold-start guard now publishes `BadWaitingForInitialData` (0x80320000) and notifies observers instead of silently returning, so OPC UA clients see a defined quality rather than a stale prior value.

### Core.VirtualTags-003

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:117-120` |
| Status | Resolved |

**Description:** The upstream-subscription loop in `Load` iterates
`definitions.SelectMany(d => _tags[d.Path].Reads)`. If `definitions` contains two rows
with the same Path, the first registers `_tags[Path]` and the second overwrites it, but
`definitions` still has two entries -- `_tags[d.Path]` is indexed by the second row for
both iterations, so the first row's distinct upstream reads are silently dropped. More
importantly, a duplicate Path in the input list is never rejected at all:
`_tags[def.Path] = ...` and `_graph.Add(def.Path, ...)` both overwrite without warning,
so one of two operator-authored tags with a colliding UNS path vanishes with no error.
`Load` is documented as throwing an aggregated error for every problem; a duplicate path
should be in that set.

**Recommendation:** Detect duplicate Path values while iterating `definitions` and add
them to `compileFailures` (or a dedicated rejection list) so the aggregated
`InvalidOperationException` reports them. Separately, iterate `_tags.Values` rather than
`definitions.SelectMany(d => _tags[d.Path]...)` when collecting upstream paths so the
collection is keyed off the registered set, not the raw input list.

**Resolution:** Resolved 2026-05-22 — `Load` now tracks seen paths and adds a duplicate-path entry to `compileFailures`; the upstream-subscription loop iterates `_tags.Values` instead of the raw `definitions` list so it is keyed off the registered set.

### Core.VirtualTags-004

| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:349` |
| Status | Resolved |

**Description:** `CoerceResult`'s switch has a default arm (`_ => raw`) that returns the
script's raw return value uncoerced for any `DriverDataType` not in the explicit list
(e.g. an array type, Byte, or a future enum member). The resulting `DataValueSnapshot`
then carries a value whose CLR type does not match the node's declared OPC UA data type,
which the node manager will surface as a wire-level type mismatch or a silently wrong
value. The doc claims a mismatch surfaces as BadTypeMismatch, but an unhandled
`DriverDataType` bypasses coercion entirely.

**Recommendation:** Make the default arm explicit -- either throw / return null (which
the outer pipeline maps to BadInternalError) for an unsupported `DriverDataType`, or
document precisely which `DriverDataType` values `CoerceResult` supports and validate at
`Load` time that no definition declares an unsupported type.

**Resolution:** Resolved 2026-05-23 — extended `CoerceResult` to cover every scalar `DriverDataType` (`Int16`, `UInt16`, `UInt32`, `UInt64` added); the default arm now throws (mapped to `BadInternalError`) instead of returning the uncoerced raw value, and a new `IsSupportedDataType` validation in `Load` rejects definitions declaring an unsupported type (currently `Reference`) so the typo is caught at publish time. Added regression tests for both Int16/UInt16/UInt32/UInt64 round-trip and the publish-time rejection.

### Core.VirtualTags-005

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Concurrency & thread safety |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs:50-64` |
| Status | Resolved |

**Description:** `SubscribeAsync` registers the per-path engine observers first (lines
52-56), then in a second loop reads the current value and fires the initial-data
callback (lines 60-64). Between those two loops an upstream change can cascade and the
engine can invoke the just-registered observer with a new value. The OPC UA client then
receives the real change event followed by the initial-data event carrying the older
`engine.Read(path)` snapshot -- out-of-order delivery, and the client's last-known value
ends up stale.

**Recommendation:** Capture the current snapshot and fire the initial-data callback for
each path before registering the change observer for that path (or hold a per-handle
lock spanning both so no engine callback interleaves). The initial value must be
delivered before any subsequent change for that path.

**Resolution:** Resolved 2026-05-22 — `SubscribeAsync` now fires the initial-data callback per path before registering the change observer for that path, eliminating the out-of-order delivery race.

### Core.VirtualTags-006

| Field | Value |
|---|---|
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:177-182`, `:395-401` |
| Status | Resolved |

**Description:** `Subscribe` does `_observers.GetOrAdd(path, _ => [])` then
`lock (list) { list.Add(observer); }`. When `Unsub.Dispose` removes the last observer,
the now-empty List is left in `_observers` and the dictionary entry is never removed.
For a long-running server with churning OPC UA subscriptions this is an unbounded (if
slow) growth of empty lists. There is also a benign-but-real race: a thread can call
`GetOrAdd` and obtain a list reference that another thread's `Dispose` is about to leave
empty in the map -- not a correctness bug today because the list object is still valid,
but it makes any future "prune empty entries" logic racy.

**Recommendation:** Either accept the unbounded map and document it, or have
`Unsub.Dispose` remove the dictionary entry when the list becomes empty under the same
lock, re-checking emptiness inside the lock to avoid dropping a concurrently-added
observer.

**Resolution:** Resolved 2026-05-23 — `Unsub.Dispose` now removes the dictionary entry under the same lock when the observer list becomes empty, using the `ICollection<KeyValuePair>.Remove(key,value)` overload so a racing Subscribe's brand-new list is not collateral damage. `Subscribe` retries via the GetOrAdd / lock-and-reconfirm pattern so it cannot deposit an observer into a list that has already been pruned. Added a regression test that subscribes twice + disposes both and asserts the dictionary entry is gone.

### Core.VirtualTags-007

| Field | Value |
|---|---|
| Severity | Low |
| Category | Error handling & resilience |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs:58` |
| Status | Resolved |

**Description:** `Tick` calls
`_engine.EvaluateOneAsync(p, _cts.Token).GetAwaiter().GetResult()`, blocking the
`System.Threading.Timer` callback thread (a thread-pool thread) for the full duration of
the evaluation. Because `EvaluateInternalAsync` serialises all tags through `_evalGate`,
a timer tick that races a long change-trigger cascade blocks until the cascade drains.
With multiple interval groups, several timer callbacks can each pin a thread-pool thread
waiting on the same gate. A group of N tags can take N times the script timeout while
holding a pool thread, and under timer re-entrancy (a tick firing again before the prior
finished) this compounds.

**Recommendation:** Make `Tick` async-aware -- store the returned Task and skip a tick
if the previous one for that group is still running (a per-group "in flight" flag),
rather than blocking synchronously. At minimum, document the blocking behaviour and the
expected upper bound on group evaluation time relative to the interval.

**Resolution:** Resolved 2026-05-23 — rewrote `TimerTriggerScheduler` to use a per-`TickGroup` `InFlight` flag (`Interlocked.CompareExchange`-guarded). The timer callback no longer blocks on `GetAwaiter().GetResult()`; instead it kicks off an async `RunTickAsync` and skips the tick (incrementing the new `SkippedTickCount` diagnostic counter) when the prior tick for that group is still running. Added a regression test that runs a 250ms evaluation against a 50ms cadence and asserts `SkippedTickCount > 2`.

### Core.VirtualTags-008

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Performance & resource management |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs:81-115` |
| Status | Resolved |

**Description:** `TransitiveDependentsInOrder` calls `TopologicalSort()` (a full O(V+E)
Kahn pass plus a Dictionary rank build) on every invocation, and it is invoked from
`CascadeAsync` on every upstream change event (`OnUpstreamChange`). On a large graph with
high-rate upstream tags this re-sorts the entire dependency graph on every protocol-rate
delta -- pure waste, since the topological order is immutable between `Load` calls. The
DFS that collects dependents is itself fine; only the repeated sort is the cost.

**Recommendation:** Compute the topological order (and the rank dictionary) once at the
end of `Load` and cache it on `DependencyGraph` (invalidated by `Add` / `Clear`).
`TransitiveDependentsInOrder` then reuses the cached rank map. This turns a per-event
O(V+E) cost into an O(closure) cost.

**Resolution:** Resolved 2026-05-22 — `DependencyGraph` now caches the topological rank dictionary (invalidated by `Add`/`Clear`) via `GetOrBuildRank()`; `TransitiveDependentsInOrder` reuses it, reducing per-change-event cost from O(V+E) to O(closure).

### Core.VirtualTags-009

| Field | Value |
|---|---|
| Severity | Low |
| Category | Performance & resource management |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs:64-65`, `:72-73` |
| Status | Resolved |

**Description:** `DirectDependencies` and `DirectDependents` allocate a fresh empty
`HashSet<string>` on every call for an unregistered node. `DirectDependents` is called
inside the `TopologicalSort` Kahn loop and the `CascadeAsync` DFS, so for a graph with
many leaf driver tags this allocates a throwaway set per leaf per sort. Minor, but it is
on the change-cascade path.

**Recommendation:** Return a shared static empty set for the miss case instead of
allocating each time.

**Resolution:** Resolved 2026-05-23 — `DependencyGraph` now exposes a shared static `EmptySet` instance and `DirectDependencies` / `DirectDependents` return it on a miss instead of allocating a fresh `HashSet<string>` every call. Added regression tests asserting `ReferenceEquals` across two miss calls.

### Core.VirtualTags-010

| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs:18`, `VirtualTagContext.cs:30`, `VirtualTagDefinition.cs:28` |
| Status | Resolved |

**Description:** Several XML docs reference component names that do not exist in the
codebase. `ITagUpstreamSource` XML doc says the subscription path "feeds the engine's
ChangeTriggerDispatcher" -- there is no ChangeTriggerDispatcher; the actual path is
`OnUpstreamChange` then `CascadeAsync`. `VirtualTagDefinition`'s TimerInterval and
`VirtualTagContext` docs reference an EvaluationPipeline that likewise does not exist;
the real type is `EvaluateInternalAsync` inside `VirtualTagEngine`. Stale type names in
XML docs mislead maintainers searching for the named component.

**Recommendation:** Update the XML docs to name the real types (`VirtualTagEngine`,
`CascadeAsync`, `EvaluateInternalAsync`) or drop the specific name in favour of a
behavioural description.

**Resolution:** Resolved 2026-05-23 — replaced the stale type names: `ITagUpstreamSource` now references `VirtualTagEngine.OnUpstreamChange` + `CascadeAsync`; `VirtualTagContext` references `VirtualTagEngine.OnScriptSetVirtualTag` + `CascadeAsync`; `VirtualTagDefinition.TimerInterval` references `VirtualTagEngine.EvaluateInternalAsync`.

### Core.VirtualTags-011

| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:404-409` |
| Status | Resolved |

**Description:** `VirtualTagState` records a Writes set (the `ctx.SetVirtualTag` targets
extracted by `DependencyExtractor`), but nothing in the engine reads it -- it is captured
at `Load` and never used. Declared write targets are not validated against the registered
tag set at publish time (a script writing to a non-existent virtual path is only caught
at runtime by `OnScriptSetVirtualTag`'s warning-and-drop), and they do not contribute to
the dependency graph. Either the field is dead state or an intended publish-time
validation is missing.

**Recommendation:** Use Writes to validate at `Load` that every `ctx.SetVirtualTag`
target resolves to a registered virtual tag (adding an entry to `compileFailures` on a
miss), so an operator typo is caught at publish rather than silently dropped at runtime.
If validation is deliberately deferred, remove the unused field or comment why it is
retained.

**Resolution:** Resolved 2026-05-23 — `Load` now iterates every registered tag's `Writes` set and adds a `compileFailures` entry for any write target that does not resolve to a registered virtual tag. Updated the pre-existing Core.VirtualTags-012 "warning on non-registered path" test to assert publish-time rejection (the runtime warning branch remains as a defensive guard but the static `DependencyExtractor` enforces literal-string paths, so it is unreachable for any operator-authored script). Added a positive companion test confirming a write to a registered path still loads cleanly.

### Core.VirtualTags-012

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Testing coverage |
| Location | `tests/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests/` |
| Status | Resolved |

**Description:** Several behaviours of the engine have no test coverage:
(1) the cold-start `AreInputsReady` guard -- no test exercises an upstream that is
null/Bad at evaluation time and asserts the resulting tag state (see
Core.VirtualTags-002);
(2) `ctx.SetVirtualTag` cascading to a dependent of the written tag -- the existing test
only checks the written tag itself, so the gap in Core.VirtualTags-001 is invisible to
the suite;
(3) the `OnScriptSetVirtualTag` warning path for a write to a non-registered path;
(4) `EvaluateOneAsync` throwing `ArgumentException` for an unregistered path;
(5) `CoerceResult` failure mapping to BadInternalError (only the success coercion
double-to-int32 is tested);
(6) duplicate Path values in a `Load` definition list (see Core.VirtualTags-003);
(7) `Read`/`Subscribe`/`EvaluateOneAsync` calls before `Load` (the `EnsureLoaded` guard).

**Recommendation:** Add unit tests for each path above. Items (1), (2), and (6) directly
correspond to open correctness findings and would have caught them.

**Resolution:** Resolved 2026-05-22 — added 9 unit tests covering all 7 gaps: `AreInputsReady` guard publishes `BadWaitingForInitialData` and recovers; `SetVirtualTag` cascade to dependent; write to non-registered path; `EvaluateOneAsync` before `Load` and for unregistered path; `CoerceResult` failure maps to `BadInternalError`; duplicate-path rejection; `Read`/`Subscribe` before `Load`.

### Core.VirtualTags-013

| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs:266-270` |
| Status | Resolved |

**Description:** `DependencyCycleException.BuildMessage` renders each cycle as
`string.Join(" -> ", c) + " -> " + c[0]`, presenting the SCC member list as a traversable
edge path that loops back to its first element. Tarjan's algorithm returns the members of
a strongly-connected component in stack-pop order, which is not guaranteed to be a valid
edge sequence -- for an SCC larger than 2 nodes the printed "A -> B -> C -> A" may list
edges that do not exist. The message can therefore mislead an operator debugging a cycle
into looking for an edge that is not in their config.

**Recommendation:** Either label the output as "cycle members" (a set, not an ordered
path) rather than rendering arrows, or reconstruct an actual cycle path within the SCC
(a single DFS back-edge walk) before formatting.

**Resolution:** Resolved 2026-05-23 — `DependencyCycleException.BuildMessage` now formats each cycle as `cycle members: A, B, C` (comma-separated set) rather than the misleading `A -> B -> C -> A` arrow form. Added a regression test asserting the message contains the word "member" and does not fabricate an edge sequence.
