# Code Review - Core.VirtualTags

| Field | Value |
|---|---|
| Module | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags` |
| Reviewer | Claude Code |
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| 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.

---

## Re-review 2026-06-19 (commit 7286d320)

All 13 prior findings remain Resolved (no regressions found). Three new findings were added.

#### Re-review checklist

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Core.VirtualTags-014, Core.VirtualTags-015 |
| 2 | OtOpcUa conventions | No issues found |
| 3 | Concurrency & thread safety | No issues found |
| 4 | Error handling & resilience | No issues found |
| 5 | Security | No issues found |
| 6 | Performance & resource management | No issues found |
| 7 | Design-document adherence | No issues found |
| 8 | Code organization & conventions | No issues found |
| 9 | Testing coverage | Core.VirtualTags-015 (test added as part of fix) |
| 10 | Documentation & comments | Core.VirtualTags-016 |

### Core.VirtualTags-014

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

**Description:** `AreInputsReady` checked `kv.Value.Value is null` in addition to the `StatusCode` bad-bit test. A `DataValueSnapshot(null, 0u, ...)` — produced when a script returns `null` (legal for String and any nullable path) or when `ctx.SetVirtualTag(path, null)` is called — has Good quality (StatusCode = 0) but a null Value. The null-value check caused `AreInputsReady` to return `false` for this snapshot, so every downstream change-triggered dependent of that tag was permanently stuck at `BadWaitingForInitialData` with no diagnostic. There was no log message explaining why the tag would not advance from that state.

Readiness should be determined by `StatusCode` (OPC UA quality) alone. If the upstream is Good-quality with a null value, the downstream script should run; if the script then dereferences the null unconditionally it will throw, which the outer `catch` correctly maps to `BadInternalError` — the proper sentinel for "script ran but faulted," not "inputs not yet available."

**Recommendation:** Remove the `kv.Value.Value is null` check from `AreInputsReady`; gate only on `(kv.Value.StatusCode & 0x80000000u) != 0`.

**Resolution:** Resolved 2026-06-19 — removed the `kv.Value.Value is null` guard from `AreInputsReady`; readiness now gates on StatusCode bit 31 only. Updated the method XML doc to explain the reasoning. Added regression test `AreInputsReady_Good_quality_null_upstream_does_not_block_downstream` which confirmed the bug (Consumer stuck at 0x80320000) before the fix and passes after.

### Core.VirtualTags-015

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

**Description:** `TimerTriggerScheduler.Start` had no guard against being called more than once on the same (non-disposed) instance. A second call silently appended a duplicate set of `Timer` objects and `TickGroup` entries to `_timers` and `_groups`, doubling the evaluation frequency for every timer group without any error, log, or diagnostic. On dispose, the duplicate timers were correctly cleaned up (both instances disposed), but between Start and Dispose every group fired twice per interval — each with its own independent `InFlight` flag, so the skip-tick guard in `OnTimer` was also defeated.

In the current call sites, `Start` is called exactly once per Load cycle, so the bug is latent rather than triggered by production paths. However the test suite only verified the `ObjectDisposedException` path (Start after Dispose), not the Start-Start-on-live-instance path.

**Recommendation:** Track a `_started` bool and throw `InvalidOperationException` on a second call.

**Resolution:** Resolved 2026-06-19 — added `_started` field; `Start` now throws `InvalidOperationException("TimerTriggerScheduler.Start has already been called. Create a new instance for each Load cycle.")` on a second call. Added regression test `Start_called_twice_throws_InvalidOperationException`.

### Core.VirtualTags-016

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

**Description:** The `catch` block inside `CoerceResult` contained the comment "Caller logs + maps to BadTypeMismatch — we let null propagate so the outer evaluation path sets the Bad quality." The outer evaluation path at lines 348-350 maps a coercion failure (`raw is not null && coerced is null`) to `BadInternalError` (0x80020000), not `BadTypeMismatch` (0x80740000). The code was correct; only the comment named the wrong status code, which could mislead a maintainer reading the OPC UA status code tables.

**Recommendation:** Correct the comment to name `BadInternalError`.

**Resolution:** Resolved 2026-06-19 — comment updated to read "Caller maps null to BadInternalError (0x80020000) — we let null propagate so the outer evaluation path sets the Bad quality on the snapshot."
