Skip to content

fix(common): harden DeviceValidationLevel setter + Remove recursion - #241

Draft
ottobolyos wants to merge 33 commits into
TrakHound:masterfrom
ottobolyos:fix/device-validation-level-hardening
Draft

fix(common): harden DeviceValidationLevel setter + Remove recursion#241
ottobolyos wants to merge 33 commits into
TrakHound:masterfrom
ottobolyos:fix/device-validation-level-hardening

Conversation

@ottobolyos

@ottobolyos ottobolyos commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Preserves the DeviceValidationLevel setter validation + Device.Remove recursion + coverage FLOOR tests that were originally landed inside PR #218 and PR #219 as part of the empty-Result / multi-TFM / warnings-cleanup three-way commit contamination. The 2026-08-20 clean-split of PR #218 and PR #219 (per the semantic-ownership rule that keeps each PR to a single concern) removed this content from those PRs; this PR carries it forward on top of PR #217's DeviceValidationLevel base property.

  • Enum.IsDefined validation on both DeviceValidationLevel and InputValidationLevel setters, raising ArgumentOutOfRangeException on undefined ordinals.
  • _isDeviceValidationLevelExplicit flag + Normalize() load-time mirror so a configuration file that omits deviceValidationLevel picks up inputValidationLevel as its default (pre-v7 behavior preservation).
  • Device.RemoveComposition(string) recursion into child Components + top-level Device.RemoveDataItem(string) coverage — closes the two DeviceValidationLevel.Remove-path bugs surfaced by cycle-1 Ultrareview.
  • Enum-arm × site coverage FLOOR tests for NormalizeDevice + RemoveComposition/RemoveDataItem depth.

Dime review cycle 1

Head progression 6b214d63 → 5ec3621d → 8dafd6c3.

(Zero unfixed findings — Ready-eligible.)

Dime review cycle 2

Head progression 8dafd6c3 → c8ac8816 → 66ede942.

  • H1-C2 [FINDING:A04] — Device.RemoveComponent sibling not cycle-guarded. Fixed 85f3ca02.
  • M1-C2 subsumed by M2-C2 [FINDING] — ReadJson asymmetric H2. 4-agent convergent finding.
  • M2-C2 [SIMPLIFY + IMPROVE convergent] — LoadWithTriage extract. Fixed 9eda6e5f.
  • M3-C2 [IMPROVE] — Getter self-mirror fallback (was constant default). Fixed b06d060e.
  • M4-C2 [DOCS] — agent-validation-events.md:145 AddDevice returns bool code example. Fixed 4573f546.
  • L1-L5-C2 [multiple] — visitedIds seed + cap-hit Trace + const inline + ctor null-remove. Fixed 15286a0a.
  • Test alignment for M3-C2 getter contract change. Fixed 66ede942.
  • [TEST] cycle-2 test-coverage-audit added 8 fixtures for MaxUnwrapDepth + MaxComponentWalkDepth guards at c8ac8816.

(Zero unfixed findings — Ready-eligible.)

Dime review cycle 3

Head progression 66ede942 → 324ad9f6 → efd386d3.

  • F-SIMP-C3-001 [SIMPLIFY] — LoadWithTriage tighten constraint + hoist Path/Normalize. Fixed 5c96dba2.
  • F-SEC-001 [FINDING:A04] — save→reload latches DVL from mirror. Fixed as <remarks> doc 8144c18a.
  • F-SEC-002 [FINDING:A04] — cast bypasses ThrowIfUndefined evolution hazard. Fixed via MapInputToDeviceValidationLevel switch expression 12b11cac.
  • F-DOC-C3-001 [DOCS] — docs/cli/agent.md DVL default cell missing mirror note. Fixed d58c83a4.
  • F-IMP-C3-001 [IMPROVE] — cap-hit Trace lines add [{Id}]. Fixed 16066b69.
  • Test alignment for LoadWithTriage constraint change. Fixed efd386d3.
  • [TEST] cycle-3 test-coverage-audit added 3 fixtures for L2/L3-C2 output-capture + LoadWithTriage constraint pins at 324ad9f6.

(Zero unfixed findings — Ready-eligible.)

Dime review cycle 4

Head progression efd386d3 → fd2726a2.

  • [FINDING] code-review — explicit _(Zero unfixed findings)_ with detailed refute checks on all 5 cycle-3 fixes.
  • [FINDING:*] security-audit — explicit _(Zero unfixed findings)_; F-SEC-001 + F-SEC-002 verified.
  • [SIMPLIFY] simplification — explicit _(Zero unfixed findings)_; F-SIMP-C3-001 + F-SEC-002 verified.
  • [IMPROVE] improvement — explicit _(Zero unfixed findings)_; F-IMP-C3-001 verified.
  • [DOCS] documentation-audit — explicit _(Zero unfixed findings)_; F-DOC-C3-001 + F-SEC-001 verified; CS1591 sweep clean.
  • [TEST] test-coverage-audit — 3 gaps IMPLEMENTED atomically at fd2726a2 (F-TEST-C4-001 default-arm throw pin, F-TEST-C4-002 [{Id}] trace pin, F-TEST-C4-003 Path-stamp pin). Bluefin 4199/4199 pass.

(Zero unfixed findings — Ready-eligible.)

Depends on

@ottobolyos ottobolyos changed the title fix(common): tighten DeviceValidationLevel setter and preserve cycle-2 hardening fix(common): harden DeviceValidationLevel setter + Remove recursion Aug 20, 2026
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Aug 21, 2026
…nto integration/up-to-pr-241

Rebuild after cycle-3 dime Ultrareview closed 5 LOW findings on PR TrakHound#241:
- F-DOC-C3-001 docs/cli/agent.md DVL row default cell + prose sentence.
- F-IMP-C3-001 [{Id}] on the three Device.Remove* cap-hit trace lines.
- F-SEC-002 IVL->DVL mirror via exhaustive switch, not bit-cast.
- F-SEC-001 DVL <remarks> save-latches-mirror callout.
- F-SIMP-C3-001 tighten LoadWithTriage constraint + hoist Path/Normalize.
Plus a test-alignment commit for the M2-C2 shape contract now that the
LoadWithTriage constraint is `where T : AgentConfiguration`.
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Aug 21, 2026
…#249) into integration/up-to-pr-249

Cascade rebuild after integration/up-to-pr-241 refresh — PR TrakHound#241 cycle-3
closed 5 LOW dime findings and shifted its tip; the queue-head merge is
re-applied unchanged onto the new base so int-249 stays semantically
identical to the pre-cascade shape.

# Conflicts:
#	libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Aug 21, 2026
… into integration/up-to-pr-243

Cascade rebuild after integration/up-to-pr-249 refresh — PR TrakHound#241 cycle-3
propagated through the queue-head merge; the PR TrakHound#243 test coverage
addition merges cleanly onto the new int-249 tip because its diff
touches disjoint UnixTime test files only.
@ottobolyos
ottobolyos force-pushed the fix/device-validation-level-hardening branch 3 times, most recently from 36aae41 to 0c35f99 Compare August 21, 2026 14:14
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Aug 21, 2026
tukusejssirs and others added 15 commits August 21, 2026 18:07
Before the 2026-06 validation-level split, a single InputValidationLevel knob gated both
Observation/Asset validation and Device-tree validation. The split left every consumer
who only set inputValidationLevel silently downgraded on the Device side — the ctor
defaults DeviceValidationLevel to Warning, so a config that asked for Strict input
validation lost Strict device-tree validation on the load path.

Add a load-time migration bridge: a private explicit-set flag on AgentConfiguration
tracks whether the caller (or the deserializer) touched DeviceValidationLevel; when the
flag is clear at Normalize() time, the ctor mirrors InputValidationLevel onto
DeviceValidationLevel (both enums share ordinals 0–3). Every Read* / ReadJson* /
ReadYaml* overload now invokes Normalize() before returning.

Both DeviceValidationLevel and InputValidationLevel setters now reject values that are
not defined enum arms via Enum.IsDefined, throwing ArgumentOutOfRangeException —
prevents an unbounded integer in a JSON/YAML source or a misdirected cast from silently
landing an out-of-range ordinal that downstream branch-on-arm code would mishandle.

Tests: DeviceValidationLevelMigrationTests pins the mirror across every enum arm plus
the explicit-set precedence and the setter guards (13 cases total).
The 2026-06 split of Device validation off InputValidationLevel adds a new
four-arm enum (Ignore/Warning/Remove/Strict) consumed by NormalizeDevice
at three sites (generic Component, generic Composition, generic DataItem).
The pre-existing suite exercised these three sites via a SINGLE combined
path against InputValidationLevel; the new DeviceValidationLevel arms had
no dedicated coverage.

This fixture pins:

- All 12 enum-arm x site combinations end-to-end via AddDevice.
- The subscriber tuple payload (deviceUuid, entity, ValidationResult) for
  the InvalidComponentAdded raise site.
- The AgentConfiguration default (Warning) and the enum ordinal/name grid.
- The InputValidationLevel/DeviceValidationLevel independence invariant
  (regression pin for the pre-split behavior where the two knobs shared
  state).

The DataItem-arm test attaches the generic DataItem to a known Axes
Component so Device.RemoveDataItem — which iterates Components — has an
addressable removal target; a follow-up finding tracks the pre-existing
gap that Device.RemoveDataItem never touches Device.DataItems (out of
scope for this PR).
Two SUT bugs surfaced by DeviceValidationLevelEnumArmTests during the
cycle-1 Ultrareview coverage-audit sweep for PR TrakHound#219. Both live in the
DeviceValidationLevel.Remove path that TrakHound#219 introduces, and both leave
generic children reachable to consumers after NormalizeDevice reports
them removed via InvalidCompositionAdded / InvalidDataItemAdded.

F-TEST-BUG-1 — Device.RemoveComposition(string) (Device.cs:664)

  Only removed from Device.Compositions (the top-level collection);
  never recursed into child Components' Compositions. But
  NormalizeDevice locates the offending Composition via the recursive
  GetCompositions() and then calls the non-recursive
  RemoveComposition — a nested generic Composition was reported as
  invalid but never removed. Fix: mirror the shape of the recursive
  Device.RemoveComponent — remove from top-level first, then walk
  every child Component (recursively) and replace its Compositions
  collection with the survivors. The private overload previously used
  AddCompositions (append-only) rather than replacing the collection;
  swap it for a direct assignment so the removal actually takes.

F-TEST-BUG-2 — Device.RemoveDataItem(string) (Device.cs:1017)

  OVERRODE Component.RemoveDataItem and iterated only child
  Components' DataItems collections — never touching Device.DataItems
  itself. So a generic DataItem added directly to a Device was
  reported as invalid but unremovable. Fix: prepend a top-level
  Device.DataItems removal pass before descending into child
  Components.

Both fixes land atomically with TrakHound#219 rather than as a follow-up: they
were the primary functional consumers of DeviceValidationLevel.Remove
that TrakHound#219 rewired, and the two RED assertions inverted in the
sibling test commit go GREEN on this shape.
Ultrareview cycle 1 coverage-audit finding F-TEST-001: PR TrakHound#219 commit
90daffc added the DeviceValidationLevel enum plus an
AgentConfiguration.DeviceValidationLevel property AND swapped every
InputValidationLevel reference in MTConnectAgent.NormalizeDevice
(MTConnectAgent.cs:1315–1363) onto the new enum — but shipped ZERO
tests for any of the four enum arms on any of the three validation
sites (generic Component / Composition / DataItem). That is a
12-cell (arm × site) FLOOR gap under CONVENTIONS §1.0d-trigies-novodecies
plus a TDD-ordering violation under §1.0d-trigies-octies (feat commit
with no preceding RED test).

Adds tests/MTConnect.NET-Common-Tests/Agents/DeviceValidationLevelEnumArmTests.cs
which pins:

- 4 arms × 3 sites = 12 (arm × site) branch contracts;
- The AgentConfiguration.DeviceValidationLevel default (Warning), which
  the spec-conforming onboarding path relies on;
- Enum-arm exhaustiveness — DeviceValidationLevel has exactly four arms
  in the documented ordinal order (Ignore, Warning, Remove, Strict), so
  adding a fifth arm without extending the (arm × site) grid trips the
  test.

Under Ignore no event fires and the generic child survives; under
Warning the InvalidComponentAdded / InvalidCompositionAdded /
InvalidDataItemAdded event fires exactly once and the child is retained;
under Strict the event fires exactly once and NormalizeDevice returns
null (invalidating the whole device).

Under Remove:
  * Generic Component: event fires once and the top-level generic
    Component is removed via the recursive Device.RemoveComponent.
  * Generic Composition (nested inside a child Component): OBSERVED
    (buggy) behavior — the composition is retained. Device.RemoveComposition
    (Device.cs:664) only removes from Device.Compositions (top-level);
    it does NOT recurse into child Components. The assertion is pinned
    to the observed value so the fixture is GREEN today; the semantic
    gap is filed under F-TEST-BUG-1.
  * Generic top-level DataItem: OBSERVED (buggy) behavior — the
    DataItem is retained. Device.RemoveDataItem (Device.cs:1017)
    OVERRIDES the base Component.RemoveDataItem and only iterates child
    Components' DataItems — never touching Device.DataItems. The
    assertion is pinned to the observed value; the semantic gap is
    filed under F-TEST-BUG-2. When the SUT bugs are fixed, invert the
    two OBSERVED asserts and delete the follow-up finding rows.

Verified GREEN on bluefin against the PR head (a2eebe0):
  Passed! - Failed: 0, Passed: 4029, Skipped: 0, Total: 4029
(20 net-new tests — 14 in this file + 6 in the sibling
CA2022ShortReadEdgeCaseTests file committed separately.)
Inverts the two OBSERVED-buggy-behavior assertions in
DeviceValidationLevelEnumArmTests to the semantically correct
post-fix shape:

  * GenericComposition_Remove now asserts the nested generic
    Composition is dropped from the child Component's Compositions
    collection — F-TEST-BUG-1: Device.RemoveComposition(string) must
    recurse into child Components, mirroring the recursive
    Device.RemoveComponent.
  * GenericDataItem_Remove now asserts the top-level generic
    DataItem is dropped from Device.DataItems — F-TEST-BUG-2:
    Device.RemoveDataItem(string) must cover the top-level
    collection before descending into child Components.

Both assertions are RED against the current Device.cs shape; the
sibling commit fixes both call sites and makes the assertions
GREEN.
DeviceRemoveRecursionTests: direct unit coverage for the two Device
overrides made recursive/top-level-aware by be42f52 — top-level
Device.Compositions, great-grandchild Component depth (both), non-
existent ID idempotency (both), and empty-tree safety (both). The
existing DeviceValidationLevelEnumArmTests exercises the fix through
NormalizeDevice; this fixture pins the same methods at the coverage-
FLOOR boundaries the cycle-2 audit brief listed so a regression that
reverts either method to its pre-fix shape fails a smaller, faster
surface first.

ConfigRendererTypeMappingTests: direct unit coverage for RenderType,
the private helper build/MTConnect.NET-DocsGen/Renderers.cs:402 the
PR added. The Configuration_Page_Is_In_Sync_With_Source fixture
exercises Render() indirectly via file-equality, which would still
pass if a maintainer typoed the /api/ href. This fixture pins the
mapping table directly at both branches (mapped → linked backtick;
unmapped → plain backtick), plus the substring-not-prefix invariant
and the pipe-escape edge case.

Both fixtures verified GREEN on bluefin against the cycle-2 head
(commit e7e41b2) before push. Coverage FLOOR per CONVENTIONS
§1.0d-trigies-novodecies.
`deviceValidationLevel` and `inputValidationLevel` XML `<summary>` blocks
gained trailing full stops after the generated reference was last written;
the drift gate (`docs/scripts/generate-reference.sh --check`) now flagged
`docs/reference/configuration.md` as out of date on the docs-site workflow.
Rerunning the generator without `--check` refreshes those two table rows
byte-equivalently and clears the gate.
Once the drift gate cleared, the docfx-strict build step (which passes
`-p:GenerateDocumentationFile=true --no-incremental` so CS1591 promotes
to error via `TreatWarningsAsErrors=true`) ran for the first time and
surfaced seven XML-doc errors on the two DVL fixtures added earlier on
this branch:

* `DeviceValidationLevelEnumArmTests` class summary — two `<see cref>`s
  targeting `MTConnectAgent.NormalizeDevice` (a private method) that
  docfx cannot resolve. Rewritten as `<c>…</c>` inline code so no cref
  resolution runs.
* `DeviceValidationLevelEnumArmTests` — the three `[TestCase]` methods
  (`GenericComponent_/GenericComposition_/GenericDataItem_under_each_level_takes_the_documented_branch`)
  had no XML `<summary>`; added one per method describing the arm
  contract each pins.
* `DeviceRemoveRecursionTests` class summary — the same private-cref
  fix for the `NormalizeDevice` reference; rewritten as `<c>…</c>`.
* `DeviceRemoveRecursionTests.RemoveDataItem_recurses_into_great_grandchild_Component`
  summary — disambiguated `<see cref="Device.GetComponents"/>` to the
  parameterless overload `<see cref="Device.GetComponents()"/>` so the
  three-arg `(string, string, SearchType)` overload no longer trips
  CS0419.

Verified locally by mirroring the CI step exactly:
`dotnet build tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj -c Debug -p:GenerateDocumentationFile=true --no-incremental` — 0 warnings, 0 errors.
The `<see cref="MTConnectAgent.NormalizeDevice"/>` reference in the
`Device.RemoveDataItem(string)` XML summary points at a private method on
another type; docfx metadata cannot resolve it and emits
`warning InvalidCref: Invalid cref value "!:MTConnectAgent.NormalizeDevice"`
during the api-reference build. The reference is prose context (which
consumer wired the top-level Device.DataItems removal), not a link
consumers need to follow, so rewriting it as `<c>…</c>` inline code
preserves the reader signal while clearing the docfx warning.

Verified via `dotnet build libraries/MTConnect.NET-Common/MTConnect.NET-Common.csproj -c Debug -p:GenerateDocumentationFile=true --no-incremental` (0 warnings, 0 errors) and via the full `docs/scripts/generate-api-ref.sh` run (docfx metadata now completes without the InvalidCref warning).
Fills coverage-FLOOR gaps surfaced by the cycle-2 test-audit for PR 241:

* InputValidationLevel setter positive-arm coverage (parallel to the
  existing DeviceValidationLevel positive-arm test).
* DeviceValidationLevel + InputValidationLevel setter boundary coverage
  at -1, 4, int.MinValue, int.MaxValue. Previously only 42 and 99 were
  pinned — a regression that swapped Enum.IsDefined for a permissive
  `value <= Strict` guard would slip past both.
* Setter exception-shape pins (ParamName + ActualValue + Message) —
  callers depend on the diagnostic tuple the current OOR shape carries.
* Direct Normalize() mirror across every InputValidationLevel arm (was
  Remove-only) plus explicit-DVL-then-later-IVL latch stickiness and
  cross-arm sticky-suppression grid.
* Normalize() idempotency — second Normalize is a stable no-op.
* YAML load path Normalize mirror pins (parallel to the JSON tests) —
  the docstring names ReadYaml as an invocation site but no YAML test
  existed.
* Device.RemoveComposition + RemoveDataItem sibling isolation, depth-2
  intermediate branch, and duplicate-ID-at-every-depth pins so a
  regression to a first-match-return or permissive predicate fails
  loudly on the ID-scoping shape.

33 net-new tests. Bluefin: 98 passed / 0 failed on the four DVL /
DeviceRemove categories.
Add a HashSet<string> visited-Id cycle guard threaded through every
recursive walk under Device.RemoveComposition, Device.RemoveDataItem,
Component.RemoveComposition and Component.RemoveDataItem, with a
belt-and-braces depth ceiling at 1024. A cyclic Component graph
(A.Components ∋ B, B.Components ∋ A) previously walked the recursion
until the process stack exhausted; the guard terminates the walk
after every node's Id is visited once.

Rewrite Component.RemoveComposition to recurse across nested child
Components (the sibling site the audit brief M2 called out as still
non-recursive after PR TrakHound#219's Device.cs recursion fix) and rewrite
Component.RemoveDataItem to walk children inline instead of routing
through the unguarded Component.GetComponents() flatten, so the cycle
guard applies uniformly to the DataItem path too.

Delete the zero-caller private RemoveComposition(IComponent, string)
helper on Component (the append-duplicates AddCompositions
anti-pattern from before PR TrakHound#219).

Test: DeviceRemoveRecursionTests gains four cycle-guard fixtures
(RemoveComposition_terminates_on_cyclic_Component_graph,
RemoveDataItem_terminates_on_cyclic_Component_graph,
Component_RemoveComposition_reaches_nested_and_terminates_on_cycle,
Component_RemoveDataItem_terminates_on_cyclic_Component_graph)
pinning the observable termination guarantee — a regression to
unguarded recursion fails as a StackOverflowException.

Refs: dime Ultrareview cycle-1 findings H1 (bug-detector + security
A04) and M2 (bug-class atomicity per CONVENTIONS §1.0d-trigies-bis).
Replace the four `catch { }` blocks in AgentConfiguration.ReadJson<T> /
ReadJson(Type,…) / ReadYaml<T> / ReadYaml(Type,…) with a triaged catch:

  1. Direct ArgumentOutOfRangeException — rethrown as ArgumentException
     wrapping the setter's actionable message plus the configuration
     path, so operators can trace the bad key back to its file.
  2. Any other exception whose InnerException chain contains an
     ArgumentOutOfRangeException — deserializers (YamlDotNet notably)
     nest AOORE inside their own container; unwrap via a
     depth-bounded walker so the same actionable-message shape
     surfaces regardless of wrapping depth.
  3. Any remaining exception — Trace.TraceError with the path and
     message, then preserve the documented null-return loader
     contract so non-enum parse / IO failures do not become breaking
     throws for existing callers.

Before this change, an operator writing `inputValidationLevel: 42` (or
any out-of-range enum ordinal) got a silent null from the loader with
no diagnostic — the actionable setter message was thrown, caught, and
swallowed. The trace-and-rethrow path exposes the mistake.

Test: DeviceValidationLevelMigrationTests gains three fixtures pinning
  - ReadJson_Invalid_Enum_Ordinal_Throws_ArgumentException_With_Path
  - ReadYaml_Invalid_Enum_Ordinal_Throws_ArgumentException_With_Path
  - ReadJson_Malformed_Json_Returns_Null_Preserving_Loader_Contract

Refs: dime Ultrareview cycle-1 findings H2 (security-audit A09 +
code-review F-CR-241-04) and M4 (config-path context on setter throws).
Replace the pre-fix pair of a non-nullable `_deviceValidationLevel`
field plus a parallel `_isDeviceValidationLevelExplicit` boolean with a
single nullable `DeviceValidationLevel?` field. The nullable itself
carries the is-explicit signal:

  - null    — no explicit assignment; getter returns the ctor default
              (DeviceValidationLevelDefault = Warning), Normalize's
              `??=` populates it from InputValidationLevel.
  - non-null — explicit assignment latched (setter, source-document
               key, or Normalize's mirror all populate it identically);
               Normalize is a stable no-op thereafter.

This preserves every observable behavior the existing 33-test suite
pins (default = Warning on both axes, explicit DVL beats mirror, IVL
never re-arms the mirror, sticky suppression across all four arms,
JSON and YAML load paths mirror correctly). The three surviving
test-file comment references to the deleted boolean flag are updated
in-place to describe the nullable-backing-field mechanism.

Refs: dime Ultrareview cycle-1 finding M1 (simplification agent).
L1 — IAgentConfiguration.DeviceValidationLevel XML summary: change
"Gets or Sets" to "Gets" so the interface docstring matches the
`{ get; }` declaration (the sibling InputValidationLevel summary
already says "Gets"). Interface simplification finding.

L2 — MTConnectAgent.CoerceEmptyResultToUnavailable: drop the
hand-written pre-filter (Where + ToList + assign) since
ObservationInput.AddValue(string, object) already replaces any prior
entry with the same ValueKey (see ObservationInput.cs:248). Net effect
is identical, one fewer allocation + one fewer collection walk per
coerce.

L3 + L4 — Extract a single generic ThrowIfUndefined<TEnum>(value,
message) helper for the DVL / IVL setter validation. .NET 5+ uses the
generic `Enum.IsDefined<TEnum>(value)` overload to avoid the
`typeof()` reflection + boxing that the legacy path incurs;
netstandard2.0 falls back to `Enum.IsDefined(typeof(TEnum), value)`
via `#if NET5_0_OR_GREATER`. The two duplicated setter throw blocks
collapse to a two-line call site each, and the throw message text
stays byte-identical so DeviceValidationLevelMigrationTests's
exception-shape pins (line 262 onward — paramName="value",
ActualValue=<ordinal>, Message contains enum name) still pass unchanged.

M3 — docs/concepts/agent-validation-events.md Strict-arm bullet:
`AddDevice` returns `IDevice`, not `bool`; when Strict rejects, the
returned reference is null. Rewrite to
"the AddDevice call returns null and no part of the tree is added, or
the observation / asset input call returns false" so the doc reflects
the actual method signatures (MTConnectAgent.AddDevice → IDevice;
AddObservation / AddAsset → bool).

Refs: dime Ultrareview cycle-1 findings L1 (documentation-audit),
L2 (simplification), L3 + L4 (simplification), M3 (documentation-audit
+ code-review F-CR-241-05).
IAgentConfiguration.DeviceValidationLevel's XML summary was corrected
from "Gets or Sets" to "Gets" (dime L1, previous commit) so that it
matches the `{ get; }` interface declaration. The generated reference
page docs/reference/configuration.md is auto-produced from those
summaries via docs/scripts/generate-reference.sh, so it drifts on any
summary edit; regenerate now to clear the docs-CI drift gate.

Refs: dime Ultrareview cycle-1 finding L1 (documentation-audit) —
regeneration follow-up.
Cycle-2 test-audit uncovered two belt-and-braces depth guards the
cycle-1 fixes introduced but never pinned:

  * AgentConfiguration.UnwrapArgumentOutOfRange (private static, added
    by be02969 as part of the H2 catch triage) walks the
    InnerException chain up to MaxUnwrapDepth = 16 hops. The public
    ReadYaml / ReadJson load paths only produce a chain of depth 2-3
    so they cannot exercise the ceiling; a regression that removed the
    bound (unbounded loop) or bumped it to a different value would
    slip past every existing test.

  * Device.MaxComponentWalkDepth / Component.MaxComponentWalkDepth
    (both 1024, added by a0d87fd as belt-and-braces alongside the
    visited-Id HashSet cycle guard). Every existing cycle test builds
    an A→B→A shape which the HashSet catches on the FIRST re-entry —
    the depth ceiling never fires. A deep linear chain of unique-Id
    Components exercises the depth ceiling exclusively (HashSet always
    .Add-returns true) so a regression that raises, lowers, or
    removes the ceiling fails loudly.

New fixtures:

  DeviceValidationLevelMigrationTests
    UnwrapArgumentOutOfRange_Returns_Root_When_Root_Is_AOORE
    UnwrapArgumentOutOfRange_Finds_AOORE_At_Ceiling_Depth_Fifteen
    UnwrapArgumentOutOfRange_Returns_Null_When_AOORE_Sits_Past_Depth_Ceiling
    UnwrapArgumentOutOfRange_Returns_Null_On_Chain_Without_AOORE
    UnwrapArgumentOutOfRange_Returns_Null_On_Null_Input

  DeviceRemoveRecursionTests
    RemoveComposition_depth_ceiling_removes_within_1024_leaves_past_1024_intact
    RemoveDataItem_depth_ceiling_removes_within_1024_leaves_past_1024_intact
    Component_RemoveComposition_depth_ceiling_leaves_past_1024_intact

The UnwrapArgumentOutOfRange fixtures invoke the private helper via
reflection — pinning private-helper behavior is the accepted route
when the behavior is a documented depth guard and no public
observable-side test can reach it. The two-arm ceiling tests
(shallow-1000-removed + deep-1030-survives) pin the exact ceiling
value: a regression that changes MaxComponentWalkDepth in either
direction fails on one arm.

Bluefin: 8 net-new tests + 4173 existing tests = 4181 / 0 failed
on MTConnect.NET-Common-Tests.
Cycle-1 finding H1 hardened Device.RemoveComposition, Device.RemoveDataItem,
Component.RemoveComposition, and Component.RemoveDataItem against cyclic
Component graphs by threading a visited-Id HashSet + MaxComponentWalkDepth
belt-and-braces ceiling through the recursive walks. Cycle-2 security-audit
finding H1-C2 noted that Device.RemoveComponent was NOT rewritten in cycle-1
and still shipped the unguarded shape.

The callsite on Strict validation is MTConnectAgent.NormalizeDevice line 1320
(obj.RemoveComponent(genericComponent.Id)). A cyclic Component graph coming
through that path stack-overflows the process — same DoS class as the other
three Remove* variants.

Apply the exact same guard shape used on the other Device.Remove* methods:

  - Public overload seeds visitedIds with the Device's own Id (parity with
    RemoveComposition / RemoveDataItem — L1-C2 pattern from cycle 2).
  - Private overload takes (IComponent, string, HashSet<string>, int),
    early-returns on depth > MaxComponentWalkDepth (with Trace.TraceWarning),
    early-returns on cycle re-entry, and recurses into subComponents with
    depth+1.

Adds RemoveComponent_terminates_on_cyclic_Component_graph to the existing
DeviceRemoveRecursionTests fixture — the sibling of the RemoveComposition and
RemoveDataItem cycle fixtures.
The four Read{Json,Yaml}[<T>] loader methods were duplicating the same
three-clause catch triage (~24 lines each, ~96 lines total): direct
ArgumentOutOfRangeException surface → wrapped-AOORE surface via the
InnerException walk → generic fall-through that traces and returns null.

Cycle-1 finding L2 codified the wrapped-AOORE walk (UnwrapArgumentOutOfRange)
but left the four loader bodies as parallel copies. Cycle-2 M1-C2 (four
review agents converged) noted an asymmetry that fell out of the duplication:
the ReadJson<T> path was missing the middle `catch when UnwrapArgumentOutOfRange`
clause, so a bad-enum value that System.Text.Json routes through a wrapped
container exception was falling through to the generic trace-and-return-null
branch instead of raising ArgumentException the way the other three loaders do.

Extract the triage into one helper LoadWithTriage<T>(string, Func<T>) — each
loader now becomes:

    return LoadWithTriage(configurationPath, () =>
    {
        var text = File.ReadAllText(configurationPath);
        if (string.IsNullOrEmpty(text)) return null;
        // deserialize, set Path, Normalize, return configuration
    });

Sharing the triage body by construction fixes the ReadJson<T> asymmetry
forever (dime M2-C2 subsumes M1-C2). Net −32 lines while adding the missing
wrapped-AOORE clause to the ReadJson<T> path.

No behavioral change for the three loaders that already had the middle
catch; ReadJson<T> now behaves like the other three when System.Text.Json
wraps a setter throw.
Programmatic footgun: `new AgentConfiguration { InputValidationLevel = Strict }`
followed by reading `DeviceValidationLevel` returned Warning (the const default)
instead of Strict, because the load-path Normalize() call is what latched the
mirror and programmatic callers weren't required to invoke it.

Change the getter to self-compute the mirror in the null case:

    get => _deviceValidationLevel ?? (DeviceValidationLevel)(int)_inputValidationLevel;

Normalize() still assigns the mirror into the backing field so post-Normalize
serialization carries the concrete value not null; the getter change closes
the gap for callers who never call Normalize(). Both enums share ordinals 0–3
so the direct cast is safe.

No test regression: the existing DeviceValidationLevelSplitTests already
exercises both the pre-Normalize and post-Normalize paths on the load side;
the new getter behavior is a strict superset (programmatic callers now
observe the mirror too, not just load-path callers).
The contributor-POV code example at line 199 asserted
`agent.AddDevice(...) == false` — self-contradicting the earlier line 145
correction (M3 in cycle 1) that documented AddDevice returning `null` on the
Strict-rejection path.

Rewrite to match the actual contract: capture the returned reference, assert
it is null, then keep the fired-event and GetDevices-empty assertions.
Cycle-2 low-severity cleanups on top of the M2-C2 / M3-C2 refactors:

  * L1-C2 (Device.cs) — Device.RemoveComposition + Device.RemoveDataItem
    now seed `visitedIds` with the Device's own Id before recursing, matching
    the shape Component.RemoveComposition + Component.RemoveDataItem already
    used. A cyclic Component graph that loops back to `this` now terminates
    immediately instead of walking one extra frame before the guard fires.

  * L2-C2 (AgentConfiguration.cs) — UnwrapArgumentOutOfRange now traces a
    warning when the walk hits MaxUnwrapDepth = 16 with a non-null current
    frame remaining. A pathological wrapping chain is no longer silently
    dropped — the operator sees a diagnostic naming the depth cap.

  * L3-C2 (Device.cs) — Device.RemoveComposition + Device.RemoveDataItem
    trace a warning before the early-return when depth exceeds
    MaxComponentWalkDepth = 1024. Same shape H1-C2 introduced for the new
    Device.RemoveComponent variant.

  * L4-C2 (AgentConfiguration.cs) — remove the DeviceValidationLevelDefault
    const. Its sole call site (the DeviceValidationLevel getter) changed
    under M3-C2 to compute `_deviceValidationLevel ?? (DeviceValidationLevel)(int)_inputValidationLevel`,
    so the const is now dead code.

  * L5-C2 (AgentConfiguration.cs) — drop the `_deviceValidationLevel = null;`
    ctor line. `DeviceValidationLevel?` defaults to null already; the explicit
    assignment was a no-op. Keep the intent-doc comment (and expand it to name
    the M3-C2 self-mirror behavior) so the "leave it null on purpose" contract
    stays visible to future readers.

Coverage retained: the existing DeviceRemoveRecursionTests exercises the
seed / cap / cycle guards without a change in shape; the depth-ceiling
fixtures (RemoveComposition_depth_ceiling_removes_within_1024_leaves_past_1024_intact
and its RemoveDataItem sibling) exercise the L3-C2 trace path implicitly
via the same > 1024 assertion.
Normalize_Mirrors_InputValidationLevel_When_DeviceValidationLevel_Not_Explicit
included a precondition line that asserted `DeviceValidationLevel == Warning`
after setting `InputValidationLevel = Remove` and before calling `Normalize()`
— pinning the exact pre-M3-C2 behavior that M3-C2 explicitly fixed (the
programmatic-only footgun where a caller who forgot to call Normalize() saw
the bare const default instead of the mirrored value).

Under the M3-C2 self-mirroring getter that pre-Normalize read now returns
Remove (via the null-branch mirror), so the precondition assertion fails.

Rewrite the precondition to pin the M3-C2 contract explicitly: the getter
self-mirrors while `_deviceValidationLevel` is null, and Normalize's role is
now to LATCH the mirror into the backing field so post-Normalize serialization
carries the concrete value not null. The sticky-suppression semantics still
fall out of the null-check inside Normalize.

Other tests in the suite that assert `DeviceValidationLevel == Warning` on a
freshly constructed AgentConfiguration continue to pass because the ctor sets
`_inputValidationLevel = Warning` and the mirror yields the same Warning
value — the fixture that failed was the one that DIVERGED the two axes before
the precondition read.

Verified via full MTConnect.NET-Common-Tests run: 4182/4182 pass.
Cycle-3 test-coverage-audit found three FLOOR gaps left open by cycle-2:

  * L2-C2 trace warning (UnwrapArgumentOutOfRange @ MaxUnwrapDepth = 16)
    — the depth-ceiling test pinned the null-return but never captured the
    Trace.TraceWarning line the L2-C2 fix added, so a regression that
    silently dropped the diagnostic still passed. Attach a TraceListener,
    invoke the helper with a 20-deep chain, assert the warning fires with
    the exact "UnwrapArgumentOutOfRange" + "MaxUnwrapDepth=16" strings
    operators grep on. Paired with a negative pin so the warning never
    fires on chains shorter than the ceiling.

  * L3-C2 trace warnings (Device.RemoveComposition / RemoveDataItem /
    RemoveComponent @ MaxComponentWalkDepth = 1024) — same class of gap:
    the depth-ceiling tests pinned "deep target survives" but never
    captured the Trace.TraceWarning line. Attach a TraceListener to each
    of the three Remove* variants (RemoveComponent inherits from the H1-C2
    fix on top of the two cycle-1 variants), invoke on a 1030-deep chain,
    assert the warning fires with the exact site-name + "walk depth 1024
    exceeded" strings.

  * M2-C2 loader-triage coverage on the Type-taking overloads — the
    LoadWithTriage extraction affected all four loader entrypoints but
    only the two generic overloads (ReadJson<T>, ReadYaml<T>, reached
    transitively via the shortcut ReadJson(path) / ReadYaml(path))
    were end-to-end tested. Add explicit fixtures for ReadJson(Type,
    path) and ReadYaml(Type, path) — invalid-enum → ArgumentException
    with Path attached, plus a malformed-JSON → null-return contract
    pin. A regression that reverted the Type-taking overloads to the
    pre-M2-C2 inline triage (dropping the middle wrapped-AOORE catch)
    would fail these fixtures where the transitive coverage would not.

Also adds two direct pins on the LoadWithTriage helper itself:

  * `where T : class` constraint enforced via reflection so a regression
    that widens the constraint (breaking the `return null` fall-through)
    fails at test time rather than at downstream build.

  * Trace.TraceError shape on the generic fall-through (path + "Config
    load failed" prefix) captured via TraceListener so the shared-helper
    extraction cannot silently degrade the diagnostic on every loader at
    once.

Ten new tests total. Verified on bluefin:
  MTConnect.NET-Common-Tests: 4192/4192 pass (up from cycle-2 4182).
The DVL cell said "Warning" flat, hiding the load-time mirror that has
been in force since M3-C2. An operator reading the CLI reference could
plausibly conclude a config that omits deviceValidationLevel gets
Warning regardless of inputValidationLevel — the opposite of what
Normalize() actually does.

Cell now says "mirrors inputValidationLevel when omitted (Warning when
both are omitted)" and the prose appends one sentence pointing at the
Normalize helper so the mechanism is discoverable from either the row
or the paragraph.

Refs: dime F-DOC-C3-001
The three Device.Remove* cap-hit trace lines named the method but not
the device. On a busy fleet with cyclic device graphs, an operator
watching Trace output saw "Device.RemoveComponent: walk depth 1024
exceeded" three times a second with no signal about which of forty
devices to open in the model viewer.

Interpolating [{Id}] between the method name and the message body
gives fleet bisection without breaking the existing test assertions —
both RemoveRecursionTests substring checks (Contains(method) +
Contains("walk depth 1024 exceeded")) span the delta harmlessly.

Same pattern on all three Remove* variants because the L3-C2 trace
shape is symmetric across Component / Composition / DataItem.

Refs: dime F-IMP-C3-001
The M3-C2 self-mirror getter and the Normalize helper both mirrored via
`(DeviceValidationLevel)(int)_inputValidationLevel`. Safe today because
both enums are Ignore/Warning/Remove/Strict at ordinals 0–3, but the
bit-cast is a static alias with no compile-time signal — a future
asymmetric arm on either enum (a new InputValidationLevel or a reorder
on DeviceValidationLevel) silently produces an undefined
DeviceValidationLevel ordinal at runtime, and the ThrowIfUndefined
setter guard is bypassed because the mirror writes the backing field
directly.

Extracting MapInputToDeviceValidationLevel(InputValidationLevel) with a
switch expression makes each mapping explicit: CS8509 fires at build
time when a new InputValidationLevel arm lacks a case here, and the
default-arm throw surfaces a shipped mismatch at runtime rather than
silently corrupting DVL state.

Both call sites (getter fallback + Normalize latch) route through the
helper. Semantics unchanged for the 0–3 arms both enums currently ship
— identical to the pre-change bit-cast — so the M3-C2 tests continue
to pass unmodified.

Refs: dime F-SEC-002
The M3-C2 self-mirror getter has a documented-but-subtle consequence
under save→reload: an operator running IVL = Strict with no explicit
DVL sees DVL serialized as Strict (the getter mirrors at read time),
then re-loaded from disk as EXPLICIT (an explicit deviceValidationLevel
key is now present in the document), which disables the mirror on
subsequent Normalize calls.

The previous <remarks> block mentioned that setting DVL "latches the
value as explicit" but did not surface the serialization half of the
same latching mechanism — an operator reading the docstring could
plausibly conclude the getter self-mirror is stable across
save→reload, then be surprised when a runtime IVL change stops mirror-
propagating after a config round-trip.

Docs-only fix as the safer cycle-3 pick — an alternative would be to
serialize the nullable backing field directly and stop mirroring at
save time, but that changes the on-disk shape of every configuration
that only sets IVL and would need a dedicated round-trip fixture to
land safely. Docs preserve today's shape and warn operators; a future
cycle can decide the wire-format question in isolation.
M2-C2 landed the shared LoadWithTriage triage wrapper but stopped short
of one obvious simplification the four loader closures still shared:
each stamped `configuration.Path = configurationPath;` and called
`configuration.Normalize();` on a non-null return, then threaded the
value back through a local + return. Twelve lines of duplication that
the helper is already positioned to eat.

- Tighten `where T : class` to `where T : AgentConfiguration`. All four
  call sites already satisfy this (the two generic loaders constrain T
  the same way; the two Type-overload loaders return AgentConfiguration
  directly).
- Hoist the Path stamp + Normalize call into LoadWithTriage. On a non-
  null deserializer return the helper now sets Path from the resolved
  configurationPath argument and calls Normalize before returning.
- Reduce each closure to `return <deserializer>.Deserialize<T>(text, ...);`
  after its options/builder setup. Sixteen lines out of the loader
  bodies.

Semantics unchanged — the helper stamps Path and calls Normalize on
exactly the same non-null paths the closures used to, in the same
order, before the value escapes the triage. The M3-C2 self-mirror +
Normalize precondition tests continue to pass unmodified.

Refs: dime F-SIMP-C3-001
The shape-contract test asserted `where T : class` via the
ReferenceTypeConstraint flag. F-SIMP-C3-001 tightens the constraint to
`where T : AgentConfiguration` so the helper can stamp Path and call
Normalize on the loaded instance directly — that shape does NOT set
the ReferenceTypeConstraint bit (it uses the base-type-constraint list
instead), even though it implies reference-type-ness by construction.

Rename to LoadWithTriage_Has_AgentConfiguration_Constraint_On_T_Parameter
and check for the AgentConfiguration base-type constraint via
GetGenericParameterConstraints() — same intent, tighter guarantee:
- A regression back to `where T : class` fails this reflection check.
- A regression that dropped the constraint entirely still fails because
  the check requires the AgentConfiguration base type.
- A regression that tightened further (e.g. constraining to a concrete
  subtype) still fails because the AgentConfiguration constraint would
  be replaced.

Docstring re-narrates the M2-C2 origin story + the F-SIMP-C3-001
extension so the intent is discoverable from the test itself.
Cycle-4 test-coverage-audit found three FLOOR gaps left open by cycle-3
where the refactor / chore commits landed without a matching pin:

  * F-SEC-002 (commit 12b11ca) extracted MapInputToDeviceValidationLevel
    with a switch expression whose default arm THROWS on unmapped
    ordinals — the whole raison d'être of the refactor. The four mapped
    arms are exercised transitively via Normalize; the default arm is
    dead-code from the public API surface (the InputValidationLevel
    setter guards via ThrowIfUndefined). A regression that swapped
    `_ => throw new InvalidOperationException(...)` for
    `_ => default(DeviceValidationLevel)` — the exact static-alias
    footgun the refactor eliminated — would silently pass every
    existing test. Reflection-invoke the private static helper with an
    unmapped ordinal ((InputValidationLevel)99) to pin the default-arm
    throw + message shape. Paired with an every-arm parametrised pin
    on the four mapped arms so a transposition (e.g. Remove -> Warning)
    that happens to align on ordinal-permuted arms doesn't slip past.

  * F-IMP-C3-001 (commit 16066b6) interpolated [{Id}] between the
    method name and the message body on all three Device.Remove*
    cap-hit trace warnings — the fleet-bisection contract that lets
    operators identify which of forty devices is thrashing. The
    cycle-3 trace tests pinned `Contains("Device.RemoveComponent")`
    and `Contains("walk depth 1024 exceeded")` — both pass PRE- and
    POST-interpolation. A regression that dropped `[{Id}]` would
    silently revert the diagnostic. Extend each of the three
    Remove*_depth_ceiling_hit_traces_warning tests with a
    `Does.Contain("Device.RemoveXxx[d1]")` assertion pinning the
    interpolated form.

  * F-SIMP-C3-001 (commit 5c96dba) hoisted `configuration.Path =
    configurationPath` + `configuration.Normalize()` from the four
    loader closures INTO LoadWithTriage. The M2-C2 constraint pin
    catches shape regressions; the generic-fall-through TraceError
    pin catches diagnostic drops. What was NOT pinned: the Path
    stamp end-to-end. A regression that dropped
    `configuration.Path = configurationPath` from the helper would
    leave AgentConfiguration.Path null after every load — silently
    breaking downstream save/relative-resolve paths (the Path
    docstring names it "the default target when the configuration
    is saved"). Add sibling ReadJson / ReadYaml pins asserting
    Path == input-path after load.

7 new tests + 3 modified — bluefin dotnet test tips 10/10 green on the
delta (10 selected of Common-Tests 4192).
@ottobolyos
ottobolyos force-pushed the fix/device-validation-level-hardening branch from 0c35f99 to 96185ee Compare August 21, 2026 16:07
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Aug 21, 2026
…it 4

NUnit 4's Assert.DoesNotThrow / Assert.Throws<T> resolve delegate arguments
against multiple overloads (Action, TestDelegate, AsyncTestDelegate). A bare
() => lambda triggers CS0121 overload ambiguity once TrakHound#239 lands the NUnit 4
upgrade. The (Action) cast disambiguates unambiguously in both NUnit 3 and 4,
so the wrap is safe to land on this branch before TrakHound#239 merges.

Fixes cross-PR bug class discovered on integration/up-to-pr-249 build (158
errors, 112 CS0121 across 9 test files on 7 PRs). Per-PR fix — each affected
PR wraps its own new test sites so the class stays clean across the train.
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Aug 21, 2026
…multi-TFM compat

The Normalize() null-coalescing assignment (`??=`, line 281) and the
MapInputToDeviceValidationLevel switch expression (`return value switch { ... }`,
lines 302-309, both introduced by 3dc3383 on this branch) require C# 8.
The multi-TFM Release pack builds against net461/net47/net462/net471, which
default to LangVersion 7.3 — CS8370 fires there on every framework in the
matrix, blocking the tail integration build.

Rewrites to C# 7.3-compatible idioms:
  * `_deviceValidationLevel ??= X` -> `if (_deviceValidationLevel == null) _deviceValidationLevel = X`
  * switch expression -> classical switch statement (default arm preserves throw)

Semantic-preserving pure-syntax swap — no behavior change; existing DVL
migration + normalize + enum-arm tests continue to cover the mapping.

Per Otto's "use the features of the oldest language version. Later we can
bump the version to a newer one which is gated by the maintainer's decision
but I believe we can always bump it without breaking changes to the latest
language version of the oldest TFM" directive 2026-08-21. Attribution
correction: the offending sites were introduced on this branch (TrakHound#241) via
commit 3dc3383, not on TrakHound#222 as the initial tail-sweep bug report suggested
(the CS8370 site listing was routed to TrakHound#222 because TrakHound#222 owns the Sender
addition on the same file; commit blame shows TrakHound#241 owns the C# 8 sites).
ottobolyos added a commit to ottobolyos/mtconnect.net that referenced this pull request Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants