Commit Graph

505 Commits

Author SHA1 Message Date
Joseph Doherty 1765e30063 fix(s7): worst-severity error message + OCE flushes recorded pass evidence (review round) 2026-08-18 07:10:51 -04:00
Joseph Doherty b35e423c1d fix(s7): a read pass that observes nothing reports nothing — empty pass no longer clears Faulted 2026-08-18 07:00:33 -04:00
Joseph Doherty 4c16118105 fix(s7): read-pass health is worst-state-wins — a mixed pass can no longer report Healthy 2026-08-17 07:14:32 -04:00
Joseph Doherty f797df5187 docs(s7-fixture): recapture the TM/CT addressing probe against the shipped profile; note CountingPlcFactory thread-safety 2026-08-17 05:47:43 -04:00
Joseph Doherty fd202d81ce test(s7): live-pin the Timer/Counter multi-var exclusion against a TM/CT-seeded s7_1500 fixture 2026-08-17 05:39:58 -04:00
Joseph Doherty 120e885b31 test(s7): assert the stale handle is disposed, not just replaced, in the cancellation-reopen pins 2026-08-17 05:39:47 -04:00
Joseph Doherty 4f86073333 test(s7): pin the span/multi-var cancellation reopen contract, one-bad-decode-in-chunk, and multi-var shape mismatch 2026-08-17 05:33:45 -04:00
Joseph Doherty 57c66416d8 test(s7): live-gate span reads + multi-var leftovers against the s7_1500 fixture 2026-08-17 04:24:39 -04:00
Joseph Doherty 6b46c485ef test(historian): bounded-wait durability read; honest nondeterminism notes on the #491 controls 2026-08-17 04:07:08 -04:00
Joseph Doherty 20022083df perf(s7): span leftovers batch through multi-var read PDUs with per-chunk fallback 2026-08-17 04:05:42 -04:00
Joseph Doherty cda82c074e perf(mtconnect): stream sample-chunk parsing through XmlReader; no per-chunk document tree 2026-08-17 03:57:16 -04:00
Joseph Doherty 6ee553cf8f feat(s7): expose ReadMultipleVarsAsync through the IS7Plc seam 2026-08-17 03:48:15 -04:00
Joseph Doherty acb3772426 fix(s7): validate span-read options at parse time; document the over-PDU degradation 2026-08-17 03:45:14 -04:00
Joseph Doherty d5f573b019 fix(abcip): single-flight cold-connect per device — backoff survives the parallel read window 2026-08-16 04:24:59 -04:00
Joseph Doherty 7320586512 fix(historian): incremental batch-append bookkeeping; genuine group-commit durability pin 2026-08-16 04:18:38 -04:00
Joseph Doherty 50035b36bb test(mqtt): pin status-code half of republish suppression; note Sparkplug bypass 2026-08-16 04:16:35 -04:00
Joseph Doherty 39f67a0fce perf(s7): span-grouped reads — one PDU per contiguous block instead of one per tag 2026-08-16 04:15:37 -04:00
Joseph Doherty 409a58b624 fix(modbus): lifecycle gate + non-vacuous reprobe regression test 2026-08-16 04:07:27 -04:00
Joseph Doherty 34f836e03c perf(mtconnect): sample chunks parse from bytes, no per-chunk string copies 2026-08-16 04:05:15 -04:00
Joseph Doherty d6a4fbd648 perf(abcip): bounded-parallel tag reads (MaxConcurrentReads, default 8) 2026-08-16 04:02:40 -04:00
Joseph Doherty 2c708f5560 perf(mqtt): suppress unchanged-value republish fan-out 2026-08-15 21:42:00 -04:00
Joseph Doherty 8d91833fed feat(s7): pure narrow-scalar decode from byte spans 2026-08-15 21:41:46 -04:00
Joseph Doherty 6ff64ca067 perf(historian): group-commit batch append on the historization outbox 2026-08-15 21:40:04 -04:00
Joseph Doherty c3970a2e5d fix(modbus): dispose the previous transport and probe loops on re-initialize 2026-08-15 21:37:28 -04:00
Joseph Doherty 5c61d0b133 fix(modbus): exception-safe batch wrapper; driver batch wiring consistency 2026-08-15 12:27:19 -04:00
Joseph Doherty cd4ce305a2 fix(modbus): batch event honors deadband — raise from the driver-filtered buffer 2026-08-15 12:17:54 -04:00
Joseph Doherty ba55a5b48e feat(drivers): Modbus + S7 implement IBatchSubscribable via PollGroupEngine batch callback 2026-08-15 12:14:43 -04:00
Joseph Doherty c51c59987b fix(galaxy): publish an unrepresentable value as BadTypeMismatch, not a good null
v2-ci / build (push) Successful in 6m23s
v2-ci / unit-tests (push) Failing after 20m2s
Reverses the conservative call from the previous commit at the user's direction,
and they were right: "surface it on a counter first" left the wire lying. An
MXAccess event can carry Good quality while its payload is a kind MxValueDecoder
has no mapping for, so the old behaviour published null-under-Good — which a
client cannot tell apart from a genuine null reading. A value the gateway DID
send would be consumed as a value it did not, with the only evidence in a log
nobody is tailing. Bad data is the honest wire representation of lost data.

BadTypeMismatch is an existing StatusCodeMap constant, deliberately reused rather
than written inline: StatusCodeParityTests finds status constants by reflecting
over Good/Uncertain/Bad-prefixed uint fields and checking them against the pinned
SDK, so an inline literal would be invisible to that guard (Gitea #497, where 16
wrong constants had accumulated across 6 drivers exactly this way).

Logging is now once per DISTINCT value kind rather than once per pump. The set is
bounded by the MxValue oneof enum so a broken producer still cannot flood, but a
second unrepresentable kind appearing later gets its own line — warn-once-per-pump
would have silently swallowed it, which is the same class of bug as the null this
commit fixes.

The test's fixture matters more than its assertion: the undecodable event carries
Quality = 192 (OPC DA Good), so a decoder that gave up silently would publish Good
and fail. Paired with a positive control — a decodable value at the same quality
must still publish Good — so it cannot be satisfied by a pump that marks
everything Bad. Falsified by reverting the status to Good: the test reds, and only
that test.

Verified: 331 Galaxy tests green, Core.Abstractions 298 (the parity guard),
solution builds 0 errors. Docs corrected in the two places that recorded the old
decision as deliberate.
2026-08-12 03:03:51 -04:00
Joseph Doherty 4769044f88 feat(galaxy): handle the replay-gap sentinel and sparse-array values
v2-ci / build (push) Successful in 4m39s
v2-ci / unit-tests (push) Failing after 19m35s
Closes the two follow-ups left open by the MxGateway 0.2.0 bump. Reading the
gateway's own contract first changed what each of them is, and one of my earlier
claims was simply wrong -- both corrections are recorded below.

Replay gap
----------
A sentinel MxEvent carries family = UNSPECIFIED, so it fell through EventPump's
family switch into the default drop: we silently ignored a documented MUST ("you
missed events -- discard local state and re-snapshot"). It is now intercepted and
answered by re-issuing SubscribeBulk for every tracked subscription, since
MXAccess delivers an initial value per item on subscribe -- re-subscribing IS the
re-snapshot. It does not restart the event pump (the stream carrying the sentinel
is healthy, and the handler runs on that stream's own read loop, so disposing the
pump from there would deadlock on itself), it does not block the read loop, and
re-entry is gated so a burst collapses to one pass instead of a subscribe storm
per sentinel.

Handled on the READ loop, not in the dispatch loop, because everything past that
point is subject to newest-dropped channel pressure -- and "you missed events" is
the one signal whose loss is unrecoverable. Pinned by a test that fills the
channel and still expects the sentinel.

⚠️ This cannot fire in the shipped configuration, and I did not pretend otherwise
or paper over it by enabling resume. The gateway emits the sentinel only for a
stream opened with after_worker_sequence > 0; GatewayGalaxySubscriber passes 0,
which the gateway treats as "fresh stream, not a resume -- plain attach, no
replay, no sentinel". Enabling resume is a larger and genuinely delicate change:
the sequence is only meaningful within ONE gateway session, our reconnect
recreates the session, and replaying a remembered sequence against a fresh
session sets the server's live watermark ABOVE every event it is about to send --
silencing the stream instead of recovering it. That deserves its own change with
a live gate, which I cannot run from here.

Sparse arrays -- correcting the last commit message
---------------------------------------------------
I wrote that the bump "makes handling it possible" on the decode path. That is
wrong: MxSparseArray is documented write-only, the gateway expands it before
forwarding, and "the worker never receives or produces it". So the decode path
is unreachable by contract and the write path has no use for it either -- it is a
wire compaction for a WHOLE-array write, not a partial update (unmentioned
indices are RESET, deliberately, because the gateway cannot read-modify-write
without racing the provider). It is therefore the wrong primitive for an OPC UA
IndexRange write, which expects untouched elements preserved -- and WriteRequest
carries no IndexRange in the first place.

It is decoded anyway, as contract defence: the alternative if a future gateway
ever emits one is a silent null published under Good quality. Fill semantics
mirror the gateway's own SparseArrayExpander exactly (epoch for Time, empty
string, Integer widening to long[] when any element is int64) so a value that
round-trips through a write reads back as the same CLR array.

Also: the decoder is now total by contract, since it runs inside the dispatch
loop where an escaping exception kills dispatch for the life of the pump. A
malformed payload degrades instead of throwing.

A real defect found in the same code
------------------------------------
A stream the GATEWAY completes -- a closed or evicted session -- ends the RPC
without an error, so nothing threw, the supervisor was never notified, and the
pump exited silently. GetHealth() kept reporting Healthy, which also means the
#527 comms-loss watchdog could never trip, because it requires a non-Healthy
state. The driver went permanently deaf with no signal anywhere. This one is
live, unlike the two above. Now reported as a fault; the falsification control is
the existing CleanShutdown_DoesNotInvokeOnStreamFault, since our own disposal
also ends the stream and must NOT be reported.

Value kinds we cannot represent are now counted and warned once rather than
published as an indistinguishable null. The status is deliberately NOT downgraded
to Bad: nothing can reach that branch today, and changing what an OPC UA client
sees for a live shape is not a change to make without a gate. The counter exists
so the next person has evidence instead of a guess.

Verified: 330 Galaxy tests green (12 new), plus Galaxy.Browser 21, Runtime 558,
Core 351; full solution builds 0 errors with the warning census unchanged. Every
new test was falsified first -- disabling the gap interception reds 3 of 4 (the
throwing-callback test passes vacuously when the callback never runs, by
construction), disabling sparse decode plus the representability signal reds all
7 decoder tests, and both were restored and re-verified green.

Docs: the Galaxy.Performance invariant was stated as received = dispatched +
dropped + in-flight, which this breaks -- a sentinel is received but never enters
the channel -- so it now reads + replay_gaps. Galaxy.md gains the three
operational notes; DriverLifecycle.md records that "reaches a non-Healthy state"
is an obligation a driver can silently fail to meet, which is the general shape
of the clean-end bug.
2026-08-12 02:56:17 -04:00
Joseph Doherty 1882df4924 test(sql-browser): OracleFlavouredSqliteDialect implements the new ISqlDialect.MaxBindParameters (cross-branch composition) 2026-08-10 08:27:48 -04:00
Joseph Doherty acea65eb96 merge: MQTT browser+contracts review findings resolved (Driver.Mqtt.Browser-002..-005, Driver.Mqtt.Contracts-004..-007) 2026-08-10 08:23:16 -04:00
Joseph Doherty 93f70504bd fix(mqtt-contracts): strict retainSeed, real bounds instead of dead [Range]s, DateTime edges, closed topic formatting
Driver.Mqtt.Contracts-004/-005/-006/-007.

-004: retainSeed was the last behavioural key read leniently, so "retainSeed":"false"
— what a CSV import produces — silently became the default TRUE, the INVERSE of the
authored intent. Both mappers now read it through TryReadBoolStrict (the qos
absent/valid/present-but-invalid split) and DescribeRejection names it. The authoring
side needed no hand-mirrored rule: MqttTagConfigModel.Validate already consults this
same parse through TagConfigParseGate, so the save is blocked with the inspector's
wording — pinned by a new AdminUI parity theory pair.

-005: the seven [Range] attributes on MqttDriverOptions/MqttPlainOptions never ran —
the options are bound by JsonSerializer, and nothing in the four MQTT projects calls
Validator.TryValidateObject. Two of them CONTRADICTED the real authority (KeepAlive's
[Range(1,…)] forbade the zero Normalize admits as legal MQTT; MaxPayloadBytes' forbade
the non-positive value its own remarks document as falling back to 1 MiB), so wiring
them up would have broken deliberate behaviour. Deleted, with each property naming
where its bound actually lives. The one escape they papered over is closed: an
out-of-range defaultQos rode into the SUBSCRIBE verbatim, and MqttSubscriptionManager's
constructor now clamps it exactly as the AdminUI driver form already persists.

-006: the DateTime write arm cast a negative long to ulong, so 1969-12-31 published a
timestamp ~584 million years in the future; it is now refused as ValueNotRepresentable.
An Unspecified kind was handed to ToUniversalTime(), which reads it as LOCAL — the same
authored instant encoded differently on the two nodes of a redundant pair; it is now
read as UTC.

-007: Format/FormatState enforced only null/empty, and nothing at all on deviceId, so
the type was not round-trip-closed. They now apply the parser's IsValidSegment (plus
'/', which only the format side can see). MqttWriteEncoder's own guards stay — TryEncode
returns an error rather than throwing, and that contract does not change.
2026-08-10 08:18:30 -04:00
Joseph Doherty b8acabfa5f merge: SQL driver health review findings resolved (Driver.Sql-004/-005/-006/-008/-010)
# Conflicts:
#	docs/drivers/Sql.md
2026-08-10 08:11:40 -04:00
Joseph Doherty ead8a97511 merge: MTConnect review findings resolved (Driver.MTConnect-004 write-back authoring, -005/-006/-007)
# Conflicts:
#	code-reviews/Driver.MTConnect/findings.md
2026-08-10 08:11:03 -04:00
Joseph Doherty 38dee82e29 merge: SQL contracts review findings resolved (Driver.Sql.Contracts-001 scalar keyValue, -002 fleet-wide numeric-enum rejection) 2026-08-10 08:10:36 -04:00
Joseph Doherty bb83099300 docs(review): close Driver.Sql-004/005/006/008/010 with resolutions and RED evidence 2026-08-10 08:09:51 -04:00
Joseph Doherty 55f4354117 merge: Calculation review findings resolved (Driver.Calculation-005 pump split, Driver.Calculation.Contracts-001 single-parse Inspect) 2026-08-10 08:09:21 -04:00
Joseph Doherty 298ad04fba feat(adminui): author MTConnect write-back on the driver form (Driver.MTConnect-004)
IWritable shipped behind write.enabled and docs/drivers/MTConnect.md told the operator to
set it, but MTConnectDriverForm — the only editor DriverFormMaps maps for this type, with
no raw-JSON escape hatch and no driver-instance API — exposed no write section, so the
documented enablement path was a hand edit of DriverInstance.DriverConfig in the config DB.

- Write-back section: switch + form text stating it is observation injection, that the
  Agent's AllowPut is an independent second gate, and that it does not by itself make a
  node writable over OPC UA (Tag.AccessLevel still composes CurrentWrite).
- Write added to the mirrored MTConnectConfigDto and to the form model, written in BOTH
  states — an omitted block would leave a previously-authored true standing, so the switch
  could never turn writing off again.
- The false 'MTConnect is read-only' header comment is gone; the doc names the new section.

No bUnit here, so the model/serialization seam is unit-tested both sides: the AdminUI test
pins the emitted bytes, and MTConnectFactoryTests parses that same document and asserts
MTConnectDriverOptions.Write.Enabled. Live page verification remains a rig step.
2026-08-10 08:07:36 -04:00
Joseph Doherty 5c8d6f6f98 test(calc): make the not-evaluated-inline assertion exact, not thread-id based
The first form compared the publish's managed thread id with the caller's, which flaked
(1 in 6): the evaluation pump may reuse the pooled thread the test released while awaiting
the drain, so an id match proves nothing. It now sets a ThreadLocal flag for exactly the
OnDependencyValue window, so 'published inline' is answered on the stack rather than by
thread identity. Re-falsified by making SchedulePump run the pump synchronously (red
immediately, reverted); 5 consecutive clean suite runs after.
2026-08-10 08:07:27 -04:00
Joseph Doherty 678aa3aa53 fix(sql): read keyValue with the same scalar read as whereValue (Driver.Sql.Contracts-001)
The two value-bearing fields were read asymmetrically and nothing said why.
whereValue went through ReadScalar (string/number/boolean — the design doc's own
wide-row example authors it as the number 7); keyValue went through the string-only
ReadString, so a numerically-authored key refused the tag outright. Nothing downstream
justified the split: both are BOUND as DbParameters and both are matched back as text —
SqlPollReader.IndexByKey stringifies the key cell precisely so an authored key can face
an INTEGER key column.

The refusal was also mis-explained. Inspect branched on ReadString(root, "keyValue") is
null, so a numerically-authored key drew "Sql KeyValue tag has no 'keyValue'" — telling
the operator the field is missing when it is right there in the blob, the #549 shape of
an authoring surface disagreeing with the runtime about WHY.

keyValue now uses ReadScalar; Inspect mirrors it with HasScalar and says what a usable
key is; SqlTagConfigModel loads it with the shared TagConfigJson.GetScalarText (which
also replaces SqlRowSelectorModel's private copy), so the editor is not left stricter
than the parser. An object/array/null key is still refused. docs/drivers/Sql.md updated.

RED first: 4 failing in Driver.Sql.Tests.
2026-08-10 08:07:15 -04:00
Joseph Doherty d7568ac342 merge: SQL dialect+chunking review findings resolved (Driver.Sql-002, Driver.Sql.Dialect-001/-002/-003) 2026-08-10 08:06:13 -04:00
Joseph Doherty 300abde4bb merge: SQL browser/probe review findings resolved (Driver.Sql-009, Driver.Sql.Browser-001/-002/-003) 2026-08-10 08:06:02 -04:00
Joseph Doherty 336e79ea9a fix(driver.sql): compare-and-swap the health field so a concurrent transition cannot be lost (Driver.Sql-010) 2026-08-10 08:05:23 -04:00
Joseph Doherty eddf5f57a5 fix(mqtt-browser): converge the plain-mode label guard, make the node cap atomic, total the sibling sort
Driver.Mqtt.Browser-002/-003/-004/-005.

-002: ObservePlainTopic's pre-pass judged segment LENGTH only, so a plain topic
segment carrying a newline or an ANSI escape became the picker's displayed label —
a rule ObserveSparkplugBirth already applied via IsUsableLabel. The control-character
half is now single-sourced (HasControlCharacter) and the plain marker text converges
with the Sparkplug one. An EMPTY topic level stays accepted on purpose: it is legal
MQTT and its terminal leaf is a real committable address.

-003: GetOrCreate read _nodeCount and incremented it later, so N observers could each
see cap-1 and all insert. The slot is now reserved with one Interlocked.Increment and
given back on both non-consuming paths.

-004: Project ordered a level by Segment alone, but the level is keyed by Path
precisely because two Sparkplug siblings CAN share a Segment — leaving the tie to
ConcurrentDictionary enumeration order, which .NET's randomised string hashing makes
differ per process. .ThenBy(Path) makes the sort total.

-005: no behaviour change — DefaultNodeCap's doc now states the real per-session
ceiling and records that the aggregate across sessions is bounded by the reaper's
idle TTL, not by any session cap.
2026-08-10 08:03:47 -04:00
Joseph Doherty 73230a42c5 perf(calc-contracts): single-parse Inspect (Driver.Calculation.Contracts-001)
Inspect parsed the TagConfig blob once itself (root kind + the present-but-discarded
changeTriggered/timerIntervalMs checks) and then called TryReadTagConfig(reference, out _),
which parsed the same string from scratch again for the scriptId rule. The field-by-field
read is now TryReadRoot(JsonElement), which both entry points share, so Inspect consults
the one authority over the root it already has. The public string-based TryReadTagConfig
signature other modules consume is unchanged.

Pure refactor: added CalculationTagDefinitionFactoryInspectTests (17 cases pinning the
exact wording, the rejection-vs-advisory split and their order, and the
never-stricter-than-the-runtime-read invariant). They pass unchanged against the
pre-refactor code — verified by stashing the production change.
2026-08-10 08:02:51 -04:00
Joseph Doherty a0a49d3395 fix(driver.sql): advance LastSuccessfulRead on evidence the database answered (Driver.Sql-006) 2026-08-10 08:02:49 -04:00
Joseph Doherty 886ce1b87c fix(sql): chunk the key-value IN list at the dialect's bind ceiling (Driver.Sql-002, Driver.Sql.Dialect-002)
ISqlDialect gains MaxBindParameters — the per-command ceiling the seam was
missing while modelling everything else that differs per backend: 2098 usable
of SQL Server's 2100 (sp_executesql spends two), 1000 for Oracle's IN-list
limit (ORA-01795, tighter than its 64k bind count), 65535 for the Postgres
Bind message.

SqlGroupPlanner.AddKeyValuePlans now emits several plans for one group when
its distinct keys exceed that ceiling, instead of one statement the provider
refuses after the connection opened — which left every tag in an EAV group
publishing BadCommunicationError on every poll, permanently, and reported the
database endpoint as down. Chunks share the group key; members follow their
own key so a shared keyValue never splits. SqlPollReader is unchanged: it runs
one command per plan and slices each plan's own Members back, proven by a
fixture read at a ceiling of 1.
2026-08-10 08:02:44 -04:00
Joseph Doherty 235e06a875 fix(calc): evaluate off the mux callback thread and out from under the state lock (Driver.Calculation-005)
OnDependencyValue ran every change-triggered dependent's script INLINE, under _lock,
via TimedScriptEvaluator.RunAsync(...).GetAwaiter().GetResult() — on the Akka dispatcher
thread of Runtime's DependencyConsumerMuxAdapter, for up to RunTimeout (2 s default) per
arrival, with the state lock held for the whole run.

The callback now records the value, PREPARES the affected tags under the lock (definition
+ warm-up gate + dependency snapshot taken at trigger time) and queues them for a single
serialized evaluation pump. _lock is a short-held state lock again; a new _evalGate
serializes the pump against the timer ticks and is taken only by evaluation workers, so
no host thread can wait on a script. Lock order is always _evalGate -> _lock.

Semantics preserved: per-tag ordering (one FIFO pump), the warm-up gate BEFORE the
evaluator runs, dedup/Bad-transition/recovery publishes, and — because the dependency
snapshot is taken when the trigger fires rather than when the pump catches up — a burst
of arrivals still evaluates against the values that triggered it.
2026-08-10 08:02:44 -04:00
Joseph Doherty 8d4ca5489a fix(tagconfig): a numerically-spelled enum names no member — reject it fleet-wide (Driver.Sql.Contracts-002)
TagConfigJson.ClassifyEnumText classified via Enum.TryParse(text, ignoreCase: true),
which accepts a numeric STRING for any enum: "1" returns the member at that ordinal
and an out-of-range "99" parses just as happily. Every driver contract's strict enum
read inherits that, so a Sql tag authored "type":"99" was ACCEPTED, carried
(DriverDataType)99, and SqlPollReader.TryCoerce fell through its switch to the String
arm — a stringified cell published Good under a type nothing can map.

Refuse a digit- or sign-leading spelling before the parse (judged on the TRIMMED text,
because Enum.TryParse trims), and require Enum.IsDefined — which covers what the
leading-character check cannot, since TryParse also ORs a comma-separated list for a
non-[Flags] enum. Mirrors RawBrowseCommitMapper.ParseNativeType and
MTConnectTagDefinitionFactory.TryParseDataType, which already guard their own parses.

Three AdminUI restatements of the same rule now ask ClassifyEnumText instead of
Enum.TryParse (TagConfigJson.GetEnum, SqlTagConfigModel.ReadEnumStrict,
MqttTagConfigModel.DescribeInvalidEnum/GetEnumNullable) — leaving them would have opened
the #549 direction the moment the runtime tightened: a blob the modal accepts and the
driver skips.

RED first: 10 failing in Core.Abstractions.Tests, 4 in Driver.Sql.Tests, and 10 of the
11 new fleet-wide parity cases (re-confirmed by reverting the Core fix).
2026-08-10 08:02:18 -04:00
Joseph Doherty 2d7dffcc42 fix(mtconnect): volatile _options publication, IConvertible write guard, idempotent client dispose
Driver.MTConnect-005/-006/-007, each TDD'd red-first.

- -005: _options was the one field at the StartCoreAsync commit point published by a
  plain write and read lock-free from ReadAsync/WriteAsync/DiscoverAsync/HostName/health.
  Now Volatile.Write/Volatile.Read like _session/_binding/_announcedInstanceId, pinned by
  a source scan (MTConnectOptionsPublicationTests) that fails on any unfenced touch.
- -006: TryFormatValue guarded null and Array, but Convert.ToString renders ANY
  non-IConvertible value as its type name under a Good status. Guard is now the property
  the remark relies on; the accepted set (string/bool/numeric/DateTime) is unchanged.
- -007: MTConnectAgentClient.Dispose was a check-then-set on a non-volatile bool, so two
  racing disposals both passed and the loser cancelled an already-disposed CTS. Now an
  Interlocked.Exchange claim over an int, with the request seams reading it volatile.
2026-08-10 08:02:15 -04:00