Files
scadalink-design/code-reviews/Commons/findings.md

38 KiB
Raw Blame History

Code Review — Commons

Field Value
Module src/ScadaLink.Commons
Design doc docs/requirements/Component-Commons.md
Status Reviewed
Last reviewed 2026-05-17
Reviewer claude-agent
Commit reviewed 39d737e
Open findings 0

Summary

Commons is in good overall health. It is a well-organized, dependency-light library: the architectural-constraint tests enforce the no-Akka/no-EF/no-ASP.NET rule, the POCO-entity and message-as-record conventions, and the UTC timestamp rule. The folder and namespace hierarchy closely matches REQ-COM-5b. No Critical issues were found.

The findings cluster around three themes. First, a handful of files quietly stretch the REQ-COM-6 "no business logic" boundary — StaleTagMonitor, OpcUaEndpointConfigSerializer, OpcUaEndpointConfigValidator, ScriptParameters, ValueFormatter, DynamicJsonElement and ScriptArgs all carry non-trivial behavior, and a couple have real correctness or concurrency defects (the StaleTagMonitor stale-fire race, the DynamicJsonElement JsonDocument-lifetime hazard, the silent conversion-failure swallowing in ScriptParameters.GetNullable). Second, the ManagementCommandRegistry name mapping is asymmetric and namespace-scoped in a way that does not match the broader set of *Command records elsewhere in Messages/. Third, several behavior-bearing types (ValueFormatter, DynamicJsonElement, ScriptArgs, ManagementCommandRegistry, Result<T>, the OPC UA serializer round-trip) have no unit tests despite containing the kind of edge-case logic that warrants them. Entity and message contracts otherwise look clean and additive-evolution-friendly, with the exception of one ValueTuple use in a wire command.

Re-review 2026-05-17 (commit 39d737e). All twelve prior findings (Commons-001 through Commons-012) are confirmed Resolved — the fixes are sound, well-targeted, and backed by focused regression tests (StaleTagMonitorRaceTests, DynamicJsonElementTests, ScriptParametersTests, ManagementCommandRegistryTests, OpcUaEndpointConfigSerializerTests, ResultTests, ValueFormatterTests, ConnectionBindingSerializationTests, FlatteningAndScriptScopeTests). The new files introduced since 9c60592 (TemplateAlarm lock/inherit fields, IExternalSystemRepository name-keyed lookups, DeploymentStateQueryRequest/Response, ParameterDefinition) follow the established POCO / record / additive-evolution conventions and carry round-trip compatibility tests. Two new Low-severity findings were recorded this pass: a DynamicJsonElement array indexer that rejects long indices (Commons-013) and an OpcUaEndpointConfigSerializer legacy-fallback path that can mislabel a corrupt new-shape row as Legacy (Commons-014). No Critical, High, or Medium issues were found.

Checklist coverage

# Category Examined Notes
1 Correctness & logic bugs DynamicJsonElement.TryConvert returns success for non-convertible types; Result<T> allows null error; legacy-config fallback loses data. Re-review: DynamicJsonElement.TryGetIndex rejects non-int indices (Commons-013).
2 Akka.NET conventions Commons has no actors (correct). Message contracts are records and immutable. One wire message uses ValueTuple (Commons-008). Correlation IDs present on request/response messages.
3 Concurrency & thread safety StaleTagMonitor has a check-then-act race between the timer callback and OnValueReceived (Commons-001).
4 Error handling & resilience ScriptParameters.GetNullable silently swallows conversion failures (Commons-003); OPC UA legacy deserialize discards malformed input (Commons-005). Re-review: corrupt typed OPC UA rows can fall through to the legacy path and be mislabelled Legacy (Commons-014).
5 Security No auth logic here. SmtpConfiguration.Credentials / OPC UA passwords are plain-string fields (storage/encryption is a consumer concern) — noted, not a finding. No script-trust violations: Commons defines no forbidden-API surface.
6 Performance & resource management StaleTagMonitor disposes its Timer correctly. DynamicJsonElement references a JsonElement whose backing document lifetime is not owned (Commons-002).
7 Design-document adherence Several behavior-bearing helper/validator/serializer classes push against REQ-COM-6 "no business logic" (Commons-007). Folder layout matches REQ-COM-5b.
8 Code organization & conventions ManagementCommandRegistry naming is asymmetric/namespace-scoped (Commons-004). DeployedConfigSnapshot, InstanceAlarmOverride, TemplateFolder, ISiteRepository, several service interfaces and Messages/Management exist but are not listed in Component-Commons.md (Commons-009).
9 Testing coverage ValueFormatter, DynamicJsonElement, ScriptArgs, ManagementCommandRegistry, Result<T>, ConfigurationDiff, AlarmContext, and the OPC UA serializer round-trip have no tests (Commons-010).
10 Documentation & comments OpcUaEndpointConfigSerializer.Deserialize XML doc does not mention the silent data-loss path (Commons-005). Component-Commons.md is stale relative to the actual file set (Commons-009). ValueFormatter uses current-culture formatting without documenting it (Commons-012).

Findings

Commons-001 — StaleTagMonitor stale-fire race between timer and OnValueReceived

Severity Medium
Category Concurrency & thread safety
Status Resolved
Location src/ScadaLink.Commons/Types/StaleTagMonitor.cs:42-46, :62-67

Description

OnValueReceived sets _staleFired = false then calls _timer.Change(...), while the timer callback OnTimerElapsed reads _staleFired, sets it to true, and invokes the Stale event. _staleFired is volatile, which guarantees visibility but not atomicity of the check-then-set. The two methods run on different threads (a value- arrival thread and a ThreadPool timer thread). If the timer callback has already passed the if (_staleFired) return; check when OnValueReceived runs, Stale fires even though a fresh value just arrived — a spurious staleness signal. There is also a window where OnValueReceived resets _staleFired and reschedules the timer while a callback for the previous period is mid-flight, so Stale can fire once per period as documented but at the wrong moment. For a heartbeat monitor feeding connection-health decisions, a false stale signal can trigger an unnecessary reconnect.

Recommendation

Guard the state transition with a lock, or replace the _staleFired bool with an Interlocked.CompareExchange on an int so only one of "fire" / "reset" wins. The callback should atomically test-and-set; OnValueReceived should atomically reset and only then reschedule the timer.

Resolution

Resolved 2026-05-16 (commit pending) — confirmed the race against the source. Replaced the volatile bool guard with a lock-protected monotonic generation token: Start, OnValueReceived and Stop each bump the generation under a gate, and the timer callback only raises Stale if its scheduled generation still matches. OnValueReceived now recreates the timer (rather than Change-ing it) so the rescheduled callback carries the new token. A superseded or stopped period can no longer emit a spurious staleness signal. Regression tests added in StaleTagMonitorRaceTests (deterministic via an internal CallbackEnteredHook test seam).

Commons-002 — DynamicJsonElement retains a JsonElement whose JsonDocument lifetime it does not own

Severity Medium
Category Performance & resource management
Status Resolved
Location src/ScadaLink.Commons/Types/DynamicJsonElement.cs:10-17

Description

DynamicJsonElement stores a JsonElement and exposes it for deferred, dynamic access from scripts. A JsonElement is only valid while the JsonDocument that produced it has not been disposed; accessing a JsonElement after its document is disposed throws ObjectDisposedException. Nothing in DynamicJsonElement keeps the document alive or documents that the caller must. Because the wrapper is explicitly designed for "convenient property access in scripts" — i.e. access at an arbitrary later time — a caller that wraps an element from a using var doc = JsonDocument.Parse(...) block (the exact pattern used in OpcUaEndpointConfigSerializer) will hand scripts a wrapper that faults on first member access.

Recommendation

Either clone the element on construction with JsonElement.Clone() (which detaches it from the document and makes it safe to retain), or hold a reference to the owning JsonDocument and implement IDisposable. Document the lifetime contract on the type regardless.

Resolution

Resolved 2026-05-16 (commit pending) — confirmed the hazard: ExternalCallResult.Response constructs the wrapper from JsonDocument.Parse(...).RootElement with no reference kept to the document, so deferred script-time access could fault. Fixed at the root by cloning the element with JsonElement.Clone() in the DynamicJsonElement constructor, detaching it from the owning document; the public constructor signature is unchanged. Added a remarks block documenting the lifetime contract. Regression tests added in DynamicJsonElementTests (access after the source document is disposed / GC-collected).

Commons-003 — ScriptParameters.GetNullable silently swallows conversion failures

Severity Medium
Category Error handling & resilience
Status Resolved
Location src/ScadaLink.Commons/Types/ScriptParameters.cs:72-86

Description

GetNullable<T> catches ScriptParameterException from ConvertScalar and returns default! (null) "on conversion failure for nullable". This conflates two distinct cases: a parameter that is genuinely absent/null, and a parameter that is present but holds an unconvertible value (e.g. Get<int?>("count") when count is the string "banana"). The latter is almost always a script or caller bug, and silently mapping it to null hides it — the script then proceeds with a null it interprets as "not supplied". The non-nullable Get<T> and the array/list paths correctly throw with a descriptive message for the same bad input, so the behavior is also inconsistent across the API surface. The XML doc states "returns null if missing, null, or unconvertible", so the behavior is intentional, but it remains a footgun.

Recommendation

Distinguish "absent/null" from "present but unconvertible": return null only for the former and throw ScriptParameterException for the latter, mirroring the array/list element handling. If the swallowing must stay for compatibility, at minimum surface it (e.g. an out-of-band warning) rather than failing silently.

Resolution

Resolved 2026-05-16 (commit pending) — confirmed the silent-swallow path against the source. Removed the catch (ScriptParameterException) block in GetNullable<T>: an absent or explicitly-null parameter still returns null, but a parameter that is present but holds an unconvertible value now throws ScriptParameterException with a descriptive message, consistent with Get<T>() and the array/list element paths. The Get<T> XML doc was corrected accordingly. This is a deliberate behavioral change toward correctness — the previous behavior masked caller/script bugs; the type-level public contract is unchanged. Regression tests added in ScriptParametersTests (Get_NullableInt_PresentButUnparsable_Throws and siblings).

Commons-004 — ManagementCommandRegistry name mapping is asymmetric and namespace-scoped

Severity Medium
Category Code organization & conventions
Status Resolved
Location src/ScadaLink.Commons/Messages/Management/ManagementCommandRegistry.cs:14-35

Description

BuildRegistry registers only types in the exact ScadaLink.Commons.Messages.Management namespace whose names end in Command. GetCommandName(Type), however, strips a Command suffix from any type passed to it. The two halves disagree:

  • GetCommandName will happily compute a command name for *Command records that live in other Messages/ sub-namespaces (DeployInstanceCommand in Messages.Deployment, DisableInstanceCommand in Messages.Lifecycle, SetStaticAttributeCommand in Messages.Instance, DeployArtifactsCommand in Messages.Artifacts, etc.), yet Resolve will return null for every one of those names because they were never registered.
  • Because of this gap the Management namespace carries deliberately renamed duplicates (MgmtDeployInstanceCommand, MgmtEnableInstanceCommand, MgmtDisableInstanceCommand, MgmtDeleteInstanceCommand in InstanceCommands.cs) whose Mgmt prefix exists only to dodge a collision the registry's namespace filter already prevents — a confusing, undocumented coupling.

A round-trip Resolve(GetCommandName(t)) is therefore not guaranteed to return t, which is the implicit contract of a name registry.

Recommendation

Make the two methods symmetric: either scan all of Messages/ (and detect/throw on duplicate stripped names, since ToFrozenDictionary will throw on a collision) or restrict GetCommandName to types the registry actually contains. Document the chosen scope, and reconsider whether the Mgmt* prefixed duplicates are still needed.

Resolution

Resolved 2026-05-16 (commit pending) — confirmed the asymmetry: GetCommandName stripped Command from any type while BuildRegistry only registered the Messages.Management namespace. In practice no defect was observed because every command type the CLI and ManagementService actually use is in Messages.Management (a round-trip test over all registered commands confirms no name collision). Closed the asymmetry by making GetCommandName registry-bound: it now looks up a reverse Type→name frozen dictionary built from the same registry and throws ArgumentException for any unregistered type, so Resolve(GetCommandName(t)) == t holds for every type it accepts. Added an XML remarks block documenting the registry scope and the symmetry guarantee. The Mgmt* prefixed records were left in place — they are the genuine Management-namespace command types the CLI constructs and renaming them would change wire command names (out of scope for a behavior-preserving fix; noted for a future cleanup). CLI, ManagementService, and SiteRuntime all build clean against the change. Regression tests added in ManagementCommandRegistryTests.

Commons-005 — OpcUaEndpointConfigSerializer.Deserialize discards malformed legacy input and over-reports IsLegacy

Severity Low
Category Error handling & resilience
Status Resolved
Location src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs:25-51

Description

When the typed-deserialize path fails or the JSON lacks endpointUrl, Deserialize falls through to LoadLegacy. If LoadLegacy itself throws JsonException (genuinely malformed JSON), the method returns (new OpcUaEndpointConfig(), IsLegacy: true) — a default, empty config with the legacy flag set. The original stored string is silently discarded, and the caller is told it is a recoverable "legacy" row when in fact the data was unparseable. A form built on the documented IsLegacy contract ("prompt the user to re-save") will present an empty config as if it were the user's saved configuration, inviting them to overwrite real (if malformed) data with blanks. The XML doc only describes the happy legacy path and does not mention this data-loss branch.

Recommendation

Distinguish "parsed as legacy" from "could not parse at all" — e.g. return a third state or throw for genuinely malformed input so the caller can surface an error instead of an empty form. Update the XML doc to describe the failure branch.

Resolution

Resolved 2026-05-16 (commit pending) — confirmed the over-reporting and silent data-loss branch. Deserialize now returns an OpcUaConfigParseResult (a readonly record struct) carrying an explicit OpcUaConfigParseStatus (Typed / Legacy / Malformed); genuinely unparseable input is classified Malformed with IsLegacy == false instead of being mislabelled as a recoverable legacy row. The struct keeps a custom two-element Deconstruct(out Config, out bool isLegacy) so the existing SiteRuntime caller (var (config, _) = ...) is unaffected — verified by a green SiteRuntime build. The XML doc now describes all three outcomes. Throwing was rejected because the sole consumer (DeploymentManagerActor.FlattenConnectionConfig) does not wrap the call and is out-of-scope to change. Regression tests added in OpcUaEndpointConfigSerializerTests (Deserialize_Malformed_ReportsMalformedNotLegacy, Deserialize_LegacyParsed_StatusIsLegacy, Deserialize_ObjectWithoutEndpointUrl_ParsesAsLegacy, Deserialize_TwoElementDeconstruction_StillWorks).

Commons-006 — DynamicJsonElement.TryConvert reports success for unconvertible target types

Severity Low
Category Correctness & logic bugs
Status Resolved
Location src/ScadaLink.Commons/Types/DynamicJsonElement.cs:47-51, :66-76

Description

TryConvert does result = ConvertTo(binder.Type); return result != null || binder.Type == typeof(object);. ConvertTo returns null for any type/kind pair it does not handle (e.g. requesting int from a JSON string, or DateTime from anything). For a non-object target this yields result == null and return false, which is correct. But the || binder.Type == typeof(object) clause makes (object)dynamicElement succeed with a null result even when the wrapped element is, say, a JSON object or a non-null string — the cast silently produces null instead of the element or its value. Any script doing object o = jsonThing; gets null for a present value. The conversion of a present, non-null JSON value should never yield null.

Recommendation

For the object target, return the element itself (or Wrap(_element)) rather than null. Only return null when the wrapped element is genuinely JsonValueKind.Null.

Resolution

Resolved 2026-05-16 (commit pending) — confirmed by a direct TryConvert regression test that the original code returned (result: null, true) for an object conversion of a present value. TryConvert now handles the object target explicitly: it returns Wrap(_element) (the unwrapped scalar, or the wrapper for objects/arrays) and yields null only for a genuine JsonValueKind.Null. Non-object targets keep the correct behavior — a null from ConvertTo now reports false (the dead || typeof(object) clause is removed) so the binder surfaces a real binding error for unconvertible pairs. Regression tests added in DynamicJsonElementTests (TryConvert_ObjectTarget_OnPresentValue_ReturnsNonNull, TryConvert_ObjectTarget_OnJsonNull_ReturnsNull, TryConvert_NonObjectTarget_OnUnconvertibleValue_ReportsFailure).

Commons-007 — Several Commons types carry non-trivial logic, stretching REQ-COM-6

Severity Low
Category Design-document adherence
Status Resolved
Location src/ScadaLink.Commons/Types/ScriptParameters.cs, src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs, src/ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs, src/ScadaLink.Commons/Types/StaleTagMonitor.cs, src/ScadaLink.Commons/Types/ScriptArgs.cs

Description

REQ-COM-6 states Commons "must contain only data structures, interfaces, enums, and constants" and "must not contain any business logic", with method bodies "limited to trivial data-access logic". Several files exceed that: ScriptParameters performs typed conversion with reflection and JSON-element unwrapping; OpcUaEndpointConfigSerializer implements a multi-shape (typed + legacy flat-dict) serialization strategy; OpcUaEndpointConfigValidator encodes OPC UA domain rules (e.g. LifetimeCount ≥ 3× KeepAliveCount); StaleTagMonitor runs a Timer and raises events; ScriptArgs reflects over arbitrary objects. The ArchitecturalConstraintTests "no service/actor" heuristic only counts public methods (> 3) and so does not catch these. This is design drift, not a defect — but it should be a deliberate decision: either move these helpers into the components that own the behavior (Data Connection Layer, Site Runtime, Template Engine) or amend Component-Commons.md to explicitly permit "pure stateless helpers/validators".

Recommendation

Decide and document the policy. If these are intentionally allowed in Commons, add a sentence to REQ-COM-6 carving out pure validators/serializers/parsers; otherwise relocate them. Tighten the architectural test if the rule is meant to be enforced.

Resolution

Resolved 2026-05-16 (commit pending) — design-document decision. This is drift, not a defect: the helpers are pure, dependency-light (no Akka/ASP.NET/EF, no I/O), and shared across components, so the intentional choice is to keep them in Commons rather than duplicate the logic. REQ-COM-6 in docs/requirements/Component-Commons.md was amended with an explicit "pure-helper carve-out" — stateless, side-effect-free helpers that only transform/format/parse/validate Commons' own data types are now permitted, with the qualifying conditions (no I/O dependency, no shared mutable cross-call state, no orchestration) and the current examples (Result<T>, ScriptParameters, ScriptArgs, ValueFormatter, DynamicJsonElement, StaleTagMonitor, OpcUaEndpointConfigSerializer, OpcUaEndpointConfigValidator) spelled out. No regression test — this is a documentation policy decision. The ArchitecturalConstraintTests heuristic was left as-is since the carve-out makes these types compliant by design.

Commons-008 — SetConnectionBindingsCommand uses ValueTuple in a wire message contract

Severity Low
Category Akka.NET conventions
Status Resolved
Location src/ScadaLink.Commons/Messages/Management/InstanceCommands.cs:10

Description

SetConnectionBindingsCommand declares IReadOnlyList<(string AttributeName, int DataConnectionId)> Bindings. The tuple element names are compile-time-only; System.Text.Json serializes a ValueTuple as Item1 / Item2, and the message is positional with no room for additive evolution (you cannot add a third field without changing the tuple type, which REQ-COM-5a forbids). Every other message in Messages/ uses named records. A management command travels over the ClusterClient boundary and is exactly the kind of contract REQ-COM-5a's additive-only rule targets.

Recommendation

Replace the tuple with a small named record, e.g. record ConnectionBinding(string AttributeName, int DataConnectionId), and use IReadOnlyList<ConnectionBinding>.

Resolution

Resolved 2026-05-16 (commit <pending>) — confirmed against the source: the ValueTuple was the only positional/tuple element in Messages/ and System.Text.Json serializes it as Item1/Item2, unfriendly to REQ-COM-5a additive evolution. Introduced a named record ConnectionBinding(string AttributeName, int DataConnectionId) in Messages/Management/InstanceCommands.cs (alongside the command, matching the existing record-per-file convention) and changed SetConnectionBindingsCommand.Bindings to IReadOnlyList<ConnectionBinding>. All consumers were updated in lock-step: ScadaLink.CLI (InstanceCommands.TryParseBindings now builds a List<ConnectionBinding>), ScadaLink.TemplateEngine (InstanceService.SetConnectionBindingsAsync parameter), ScadaLink.ManagementService (ManagementActor forwards cmd.Bindings unchanged — the new element type flows through), and one consumer the finding did not list, ScadaLink.CentralUI (InstanceConfigure.razor's SaveBindings built a List<(string,int)>). A repo-wide src/ scan confirms no other references remain. Regression tests added in ConnectionBindingSerializationTests (ConnectionBinding_SerializesWithNamedProperties asserts named AttributeName/DataConnectionId JSON properties and the absence of Item1/Item2; SetConnectionBindingsCommand_RoundTripsThroughJson asserts a full JSON round-trip); existing parse/forward tests (ParseBindings_ValidJson_ReturnsPairs, SetConnectionBindings_BulkAssignment_Success) were updated to the new type. Affected suites are green (Commons 226, CLI 77, ManagementService 55, TemplateEngine 287) and dotnet build ScadaLink.slnx is clean.

Commons-009 — Component-Commons.md is stale relative to the actual file set

Severity Low
Category Documentation & comments
Status Resolved
Location docs/requirements/Component-Commons.md:61-198

Description

The design doc's entity list, repository list, and folder tree no longer match the code:

  • Entities present but undocumented: DeployedConfigSnapshot, InstanceAlarmOverride, TemplateFolder.
  • Repository interface present but undocumented: ISiteRepository (the doc lists seven repositories under REQ-COM-4; the code has eight).
  • Service interfaces present but undocumented: IDatabaseGateway, IExternalSystemClient, IInstanceLocator, INotificationDeliveryService — REQ-COM-4a documents only IAuditService.
  • Whole namespaces absent from the REQ-COM-5b folder tree: Messages/Management, Messages/DataConnection, Messages/Integration, Messages/Instance, Messages/RemoteQuery, plus Types/DataConnections, Types/Scripts, Serialization/, and Validators/.

CLAUDE.md's editing rules require the design docs to stay in sync with the code; the doc is now a partial map.

Recommendation

Refresh Component-Commons.md to enumerate the current entities, repository and service interfaces, and the actual Types/, Messages/, Serialization/, and Validators/ folders.

Resolution

Resolved 2026-05-16 (commit pending) — docs/requirements/Component-Commons.md was refreshed against the actual file set. REQ-COM-3 now lists TemplateFolder, InstanceAlarmOverride and DeployedConfigSnapshot and drops the non-existent SiteDataConnectionAssignment (no such entity in the code). REQ-COM-4 adds ISiteRepository (eight repositories). REQ-COM-4a documents IDatabaseGateway, IExternalSystemClient, IInstanceLocator and INotificationDeliveryService alongside IAuditService, and corrects the IAuditService owner to the Configuration Database component. The REQ-COM-5b folder tree was rewritten to include the previously-absent Types/DataConnections, Types/Flattening, Types/Scripts, the helper types under Types/, the Messages/DataConnection|Instance|Integration|InboundApi|RemoteQuery|Management namespaces, and the Serialization/ and Validators/ folders. Documentation-only; no regression test.

Commons-010 — Behavior-bearing Commons types have no unit tests

Severity Low
Category Testing coverage
Status Resolved
Location tests/ScadaLink.Commons.Tests/

Description

ScadaLink.Commons.Tests covers Result, RetryPolicy, ScriptParameters, StaleTagMonitor, the OPC UA validator, enums, message conventions, compatibility, and entity conventions. It does not cover several types that contain exactly the kind of edge-case logic that warrants tests:

  • ValueFormatter — scalar vs collection vs null formatting.
  • DynamicJsonElement — member/index access, conversions, the issues in Commons-002 and Commons-006 would have been caught by tests.
  • ScriptArgs.Normalize — dictionary/anonymous-object/primitive-rejection paths.
  • ManagementCommandRegistryResolve / GetCommandName round-trip (would have surfaced Commons-004).
  • Result<T>Match, failure/success accessors, error-on-misuse.
  • OpcUaEndpointConfigSerializer typed↔flat round-trip and legacy fallback.
  • ConfigurationDiff / AlarmContext / ScriptScope — minor, but HasChanges / HasParent logic is untested.

Recommendation

Add focused unit tests for the helper/utility types above, prioritizing DynamicJsonElement, ScriptArgs, ManagementCommandRegistry, and the OPC UA serializer round-trip.

Resolution

Resolved 2026-05-16 (commit pending) — all the listed types now have unit tests. DynamicJsonElement, ManagementCommandRegistry, Result<T> and the OPC UA serializer were already covered by tests added in earlier finding fixes (Commons-002/004) and this pass extended them (Commons-005/006). New focused test files added this pass: ValueFormatterTests (scalar/collection/null formatting plus the Commons-012 culture-invariance regression), ScriptArgsTests (dictionary / read-only-dictionary / non-generic-IDictionary / anonymous-object / primitive-rejection paths of ScriptArgs.Normalize), and FlatteningAndScriptScopeTests (ConfigurationDiff.HasChanges and ScriptScope.HasParent computed-property logic). The module suite is green at 224 tests (up from 196).

Commons-011 — Result<T>.Failure accepts a null error string

Severity Low
Category Correctness & logic bugs
Status Resolved
Location src/ScadaLink.Commons/Types/Result.cs:15-20, :30-32, :36

Description

Result<T>.Failure(string error) and the private failure constructor do not validate error. A caller passing null produces a failed Result whose Error getter returns null via _error!, and whose Match calls onFailure(_error!) with null. Result is the system-wide error-handling type ("consistent error handling across component boundaries"); a failed result with no error message defeats its purpose and pushes a NullReferenceException risk onto every consumer that logs or displays Error.

Recommendation

Throw ArgumentNullException (or ArgumentException for empty/whitespace) in Failure/the failure constructor so a failed Result always carries a message.

Resolution

Resolved 2026-05-16 (commit pending) — the private failure constructor now calls ArgumentException.ThrowIfNullOrWhiteSpace(error), so a failed Result<T> always carries a usable message (ArgumentNullException for null, ArgumentException for empty/ whitespace). The Failure factory XML doc records both exceptions. A repo-wide scan of .Failure(...) call sites confirmed every caller passes a non-empty literal or interpolated string, so no consumer is broken. Regression tests added in ResultTests (Failure_WithNullError_ShouldThrow, Failure_WithBlankError_ShouldThrow).

Commons-012 — ValueFormatter uses current-culture formatting without documenting it

Severity Low
Category Documentation & comments
Status Resolved
Location src/ScadaLink.Commons/Types/ValueFormatter.cs:20-27

Description

FormatDisplayValue formats IFormattable values (and collection elements) with the parameterless ToString(), which uses the current thread culture. The XML doc calls this "the value's natural string representation" without noting the culture dependency. The same numeric or DateTime attribute value will render differently depending on the server/UI locale — e.g. decimal separators, date order. CLAUDE.md mandates UTC for timestamps and notes local-time conversion is "a UI display concern only"; if ValueFormatter is used outside a UI rendering context (e.g. logging, event-log entries, diff display) the culture-dependent output is inconsistent and a latent bug.

Recommendation

Decide whether ValueFormatter is a UI-only helper. If it can be used outside the UI, format with CultureInfo.InvariantCulture (using the IFormattable.ToString(null, IFormatProvider) overload). Either way, document the culture behavior on the method.

Resolution

Resolved 2026-05-16 (commit pending) — confirmed ValueFormatter is not UI-only: its sole consumer is StreamRelayActor, which formats attribute values into outbound gRPC SiteStreamEvent messages (a wire context), so culture-dependent output was a latent bug. FormatDisplayValue now formats IFormattable scalars and collection elements with IFormattable.ToString(null, CultureInfo.InvariantCulture); a new FormatElement helper applies the same invariant rule per collection element (previously elements went through the parameterless ToString()). The XML doc gained a remarks block stating the culture-invariant contract and why. Regression tests added in ValueFormatterTests (FormatDisplayValue_Double_UsesInvariantCulture_*, _DateTime_*, _CollectionOfDoubles_*, each pinned under de-DE).

Commons-013 — DynamicJsonElement.TryGetIndex rejects non-int index values

Severity Low
Category Correctness & logic bugs
Status Resolved
Location src/ScadaLink.Commons/Types/DynamicJsonElement.cs:40-54

Description

TryGetIndex accepts an index only when indexes[0] is int index. DynamicJsonElement is designed for dynamic access from scripts (obj.items[0]). In a dynamic expression the index operand's runtime type follows the script's variable type — a script that computes an index in a loop counter or reads it from another DynamicJsonElement (whose numbers are unwrapped as long by Wrap, see :105) will pass a long, not an int. The pattern match then fails, TryGetIndex returns false, and the dynamic binder throws a RuntimeBinderException for what is a perfectly valid in-range index. Because the wrapper itself surfaces JSON numbers as long, obj.items[obj.count - 1] — count being a wrapped JSON number — is the exact failing case. The int-only guard also silently rejects byte/short indices that would widen to a valid array position.

Recommendation

Accept any integral index by converting through Convert.ToInt64 (guarded for OverflowException) or by matching int, long, short, byte and normalizing to a single integer before the bounds check. Add a regression test indexing with a long.

Resolution

Resolved 2026-05-17 — confirmed the int-only guard, and during fixing found the underlying cause was wider than the finding text: Wrap's TryGetInt64 ? long : double ternary unified both branches to double, so integral JSON numbers were never actually boxed as longobj.count always surfaced as double. Fixed at the root: Wrap delegates to a new WrapNumber helper that boxes integral numbers as long and non-integral as double with separate typed returns; TryGetIndex now accepts any integral index via TryGetIntegralIndex (int/long/short/byte/sbyte/ushort/ uint/ulong, plus whole-valued double/decimal) normalized to long before the bounds check. Internal-only change — no public-API or message-contract change. Regression tests added in DynamicJsonElementTests (IndexAccess_WithLongIndex_Works, IndexAccess_WithIndexDerivedFromWrappedJsonNumber_Works, IndexAccess_WithWideningIntegralIndex_Works, IndexAccess_WithLongIndexOutOfRange_Throws).

Commons-014 — OpcUaEndpointConfigSerializer.Deserialize can mislabel a corrupt typed row as Legacy

Severity Low
Category Error handling & resilience
Status Resolved
Location src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs:107-131

Description

Deserialize tries the typed path first: it parses the document, checks for an endpointUrl property, then calls JsonSerializer.Deserialize<OpcUaEndpointConfig>. The whole block is wrapped in catch (JsonException) { /* fall through to legacy */ }. If a row is the current typed shape (it has endpointUrl) but is corrupt in a way that makes JsonSerializer.Deserialize throw a JsonException — e.g. an enum-valued field holding an unrecognised string, or a numeric field holding a non-numeric token — the exception is swallowed and control falls through to LoadLegacy. LoadLegacy only requires the root to be a JSON object, so it will usually succeed against the same input and the result is reported as OpcUaConfigParseStatus.Legacy. The Commons-005 fix added the Malformed status precisely so a caller can tell a recoverable legacy row from unparseable data; this path re-introduces a softer version of the same confusion — a genuinely broken current-shape row is presented to the user as a benign "please re-save" legacy row, and the offending field is silently dropped by FromFlatDict (which ignores keys it cannot parse) rather than surfaced. The XML doc describes the legacy fallback as being for "pre-refactor rows" only and does not mention this branch.

Recommendation

Only fall through to LoadLegacy when the typed shape is genuinely not present — i.e. the endpointUrl property is absent. When endpointUrl is present but typed deserialization throws, classify the outcome as Malformed (or a distinct status) so the caller can surface a real error instead of an empty/partial config. Tighten the XML doc to describe this branch, and add a regression test for a typed row with an invalid enum field.

Resolution

Resolved 2026-05-17 — confirmed the swallow-and-fall-through against the source. Split Deserialize into a shape-detection phase and a materialization phase: it first parses the document to decide whether the row is the current typed shape (root object carrying endpointUrl); only a non-typed-shape row falls through to LoadLegacy. A row that is the typed shape but fails JsonSerializer.Deserialize (invalid enum, wrong-typed field) is now classified Malformed instead of being mislabelled Legacy with the offending field silently dropped. The OpcUaConfigParseResult shape and (Config, IsLegacy) deconstruction are unchanged, so existing callers are unaffected. XML docs updated to describe the corrupt-typed-row branch. Regression tests added in OpcUaEndpointConfigSerializerTests (Deserialize_TypedShapeWithInvalidEnum_ReportsMalformedNotLegacy, Deserialize_TypedShapeWithWrongTypeField_ReportsMalformedNotLegacy, Deserialize_ValidTypedRow_StillReportsTyped).