Commit Graph

476 Commits

Author SHA1 Message Date
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
Joseph Doherty cd4b29a368 fix(sql-browser): close two races around the registry reaper (Driver.Sql.Browser-002)
(1) DisposeAsync disposed _gate unconditionally. SemaphoreSlim.Dispose does not
complete pending async waiters, and the in-flight call's Release() then threw
ObjectDisposedException from inside the semaphore (swallowed by the finally), so
a caller parked in WaitAsync was never released and its call never returned. The
gate is now left alive - the pattern SqlPollReader documents - so the waiter is
admitted and meets the session's own guarded ThrowIf(_disposed, this).

(2) LastUsedUtc advanced only on completion, so a slow catalog call (the AdminUI
allows 20 s) was invisible to the TTL reaper for its whole duration and a session
at the edge of its TTL could be disposed mid-call. It is now stamped on entry
too.
2026-08-10 08:01:30 -04:00
Joseph Doherty ee1f1b28e8 fix(driver.sql): a post-open query failure is not a reachability verdict (Driver.Sql-005) 2026-08-10 08:00:51 -04:00
Joseph Doherty a8cfc886a3 fix(sql-browser): rank the row-selector candidate so a date-only column is a last resort (Driver.Sql.Browser-001)
PreferredTimestampColumn nominated the first column mapping to DriverDataType
.DateTime, and that set includes date-only types (SQL Server / PostgreSQL
'date'). A table whose first date/time column is a 'date' committed
rowSelector.topByTimestamp = <date>, making the wide-row read ORDER BY <date>
DESC LIMIT 1 - which ties for every row written that day, so 'the newest row'
became storage order among the ties.

Time-bearing candidates now outrank date-only ones; a date-only column is still
stated when it is all the table has, so such a table stays bindable. Oracle's
DATE carries a time of day and is exempt - pinned by a control dialect that
differs from the SQLite one only in the Provider it reports.
2026-08-10 07:59:35 -04:00
Joseph Doherty 90f3016a3e fix(driver.sql): defer HandlePollError on the exception ReadAsync classified, not on its type (Driver.Sql-004) 2026-08-10 07:59:09 -04:00
Joseph Doherty 3f85ba985e fix(mqtt): stop disposing the primary-host gate a live reconcile still holds
Driver.Mqtt-006 (Low, concurrency). DisposeAsync disposed _gate while StopAsync
cancels the reconcile loop WITHOUT awaiting it, so a ReconcileAsync already in
flight could be inside _gate.WaitAsync, or on its way to the finally-release,
when the semaphore went away. The release then threw ObjectDisposedException
naming SemaphoreSlim, logged as a reconcile failure — telling an operator
nothing about what happened and hiding whatever the reconcile was really
failing on.

Left undisposed, matching MqttConnection's documented rule for its own lifecycle
primitives and its stated rationale: a late callback racing teardown should be a
benign no-op rather than an ObjectDisposedException naming the wrong type. Two
lifecycle types in one module disagreeing about this is what gets copied into
the third. Nothing is leaked — the gate holds no timer and no registration — and
ClaimAsync still re-checks _disposed under it, so no claim can be created after
dispose either way.

Test is deterministic rather than timing-dependent: the gate is taken before the
connect starts, so a MiniBroker in blackhole mode (TCP accepted, CONNECT never
answered) accepting the socket proves the claim is inside the gate when dispose
runs. RED: log.Captured contained "ObjectDisposedException".
2026-08-10 07:58:38 -04:00
Joseph Doherty 2298485bda fix(mqtt): a reconnect that re-establishes nothing tears the session down
Driver.Mqtt-007 (Low, error handling). OnReconnectedAsync throws when a
re-subscribe is granted zero filters — MqttConnection's contract is that a
throwing Reconnected subscriber tears the fresh session down and retries under
backoff, because a connected-but-deaf driver is the one outcome this path must
never serve. But when the live subscribed topics resolved to zero filters in the
first place it logged a warning and returned, reaching the same "nothing was
re-established" state by a different route and giving it the opposite answer.

Throw there too, and say so in the remarks so the type states ONE rule. It costs
no other tag — every live topic is in the set that resolved to nothing, so there
is nothing left flowing to protect, which is the whole reason the partial-
rejection case is exempt — and it is not permanent: the retry runs against
whatever Register has published by then.

Test reaches the state the only way production can: Register prunes
_subscribedTopics of the topics its new table no longer carries, but a SUBSCRIBE
already in flight records its own topic AFTER that prune ran, so a deploy landing
inside the round-trip leaves a live topic no authored tag resolves to. Paired
with the control that a reconnect with no live subscriptions at all stays a
no-op — throwing there would tear down the session of every driver whose tags
have not been subscribed yet.
2026-08-10 07:58:29 -04:00
Joseph Doherty 5d4dd17d7f fix(mqtt-transport): warn when a configured password is dropped for want of a username
Driver.Mqtt.Transport-002 (Low, correctness). Credentials were attached only
when Username was non-empty, so a config carrying a Password and no Username
connected anonymously with the secret silently discarded — the deployment reads
as authenticated and is not, which is the direction an operator cannot see from
the config.

Warned rather than sent. MQTT 3.1.1 may not carry a password without a
username, so the password-only CONNECT is not available on every protocol
version this factory serves, and synthesizing one under V500 only would change
the wire behaviour of deployments that connect happily today. The configured
values stay authoritative; the drop just stops being silent. The secret is
never formatted into the message.

Tests: the warning fires and does NOT contain the password, plus the falsifying
control that an ordinary anonymous config (neither credential set) stays silent
— without it the fix could warn on every connect and still pass.
2026-08-10 07:58:19 -04:00
Joseph Doherty 74594e6083 fix(sql-dialect): enforce Postgres/Oracle identifier ceilings in UTF-8 bytes (Driver.Sql.Dialect-001)
NAMEDATALEN-1 (63) and Oracle 12.2's 128 are byte limits in the database
encoding, not character counts, so a 32-character Cyrillic name passed
ident.Length and was then silently truncated (Postgres) or rejected by the
server (Oracle). Both dialects now measure Encoding.UTF8.GetByteCount and say
'bytes' in the rejection. SqlServerDialect deliberately keeps ident.Length —
sysname is nvarchar(128), a real character limit — pinned by a control test.
2026-08-10 07:58:04 -04:00
Joseph Doherty 073930e023 fix(sql): withhold the JSON parser's message from the probe's red result (Driver.Sql-009)
SqlDriverProbe surfaced 'Config JSON is invalid: ' + ex.Message, where
SqlDriverBrowser.ParseDocument deliberately withholds the identical message
because the malformed text may itself carry a connection string. The two read
the same blob shape - the AdminUI Sql picker composes a document with a
top-level connectionString literal - so a malformed paste reaches both. The
probe now reports the failure class only, matching the browser.
2026-08-10 07:56:58 -04:00
Joseph Doherty d68009106b merge: Mqtt runtime + Transport code review — deaf-reconnect subscription reset (High), teardown-before-reinit session leak (High), timeout normalization, dead CA-pin warning; 12 findings 2026-08-10 07:02:15 -04:00
Joseph Doherty 147b624437 merge: Calculation + OpcUaClient.Session code review — warm-up gate before evaluation (spurious script-throw warnings), evaluator logger wiring, probe JSON-leniency parity; 7 findings 2026-08-10 07:00:33 -04:00
Joseph Doherty c58e833aee merge: Mqtt Contracts+Browser code review — wildcard PUBLISH refusal (whole-driver outage path), Sparkplug segment shape rules, rebirth-scope wildcard gate; 12 findings 2026-08-10 07:00:22 -04:00
Joseph Doherty 978eeda753 merge: MTConnect family code review — sample-pump hot-loop backoff, probe deviceName path, stale-premise docs; 9 findings (High browse-commit String coercion recorded cross-module) 2026-08-10 07:00:09 -04:00
Joseph Doherty 9ccc0d5678 merge: Sql family code review — default-schema fallback (whole-driver outage path), rowSelector doc fix, dead SqlClient ref; 18 findings recorded across 4 modules 2026-08-10 06:59:53 -04:00
Joseph Doherty b4a03b4baa fix(calculation): probe JSON options stricter than the real spawn/reinit path (review)
CalculationDriverProbe.Opts (Test-Connect) omitted AllowTrailingCommas and
ReadCommentHandling that CalculationDriverFactoryExtensions.JsonOptions
(spawn) and CalculationDriver.ReinitJsonOptions (in-place ApplyDelta) both
set, so a config blob the real deploy path tolerates could fail Test-Connect
as "invalid JSON" even though deploying it would succeed. Aligned the three
option sets. Also adds CalculationDriverProbeTests.cs, which previously did
not exist at all for this driver type's Test-Connect surface.

Part of code-reviews/Driver.Calculation/findings.md (-003, -004).
2026-08-10 06:59:29 -04:00
Joseph Doherty a836e0d983 fix(calculation): dead logger cast + warm-up evaluated before change-gate (review)
Code review of Driver.Calculation found two real defects, both TDD-fixed:

- CalculationDriver's ctor tried to reuse its ILogger<CalculationDriver> as the
  evaluator's ILogger<CalculationEvaluator> via an `is` check that can never
  succeed (unrelated closed generics), so CalculationEvaluator always ran with
  a NullLogger regardless of what the driver was constructed with. Fixed by
  having CalculationEvaluator accept the non-generic ILogger instead.

- EvaluateTag called the evaluator BEFORE checking whether every declared
  dependency had arrived, so a multi-dependency change-triggered calc tag
  evaluated its script against a partial snapshot on every warm-up tick.
  VirtualTagContext.GetTag returns a Bad snapshot (.Value == null) for a
  missing dep, so the idiomatic `(double)ctx.GetTag("x").Value` cast throws
  NullReferenceException — caught and logged as a spurious "script threw"
  script-log Warning on ordinary deploy/restart warm-up, for the standard
  multi-input calc-tag shape. Fixed by gating evaluation itself on
  "all deps arrived", not just the publish.

See code-reviews/Driver.Calculation/findings.md for full detail (5 findings:
001-004 Resolved, 005 Open/cross-module).
2026-08-10 06:59:23 -04:00
Joseph Doherty 2b4b3fcbbf fix(mqtt): session-rebind subscriptions, re-init teardown, timeout clamp
Findings from the per-module review of Driver.Mqtt at b990adae
(code-reviews/Driver.Mqtt/findings.md records all ten; these are the fixed
ones).

Driver.Mqtt-001 (High) — MqttSubscriptionManager._subscribedTopics is a claim
about the broker session it established filters on, and AttachTo never cleared
it, but the manager OUTLIVES a session rebuild (AdoptOptions keeps it whenever
the ingest identity is unchanged). After a driver-host reconnect
(ShutdownAsync + InitializeAsync, the #527 watchdog's retry) or a
ReinitializeAsync that only changed the endpoint/credentials, the host's
re-subscribe resolved an empty pending set, sent no SUBSCRIBE at all, and left
the driver Connected/Healthy on a session carrying ZERO subscriptions — no
error, no Bad status, values frozen until the new session happened to drop.
AttachTo now forgets them, mirroring SparkplugIngestor.AttachTo's reset.

Driver.Mqtt-002 (High) — InitializeCoreAsync published _connection and
_primaryHost over whatever the driver already held, so a second initialize
stranded a connected, self-supervising broker session (plus a second Sparkplug
primary-host claim) that no later dispose could reach. Reachable without any
teardown of ours via DriverInstanceActor.AdoptConfigDuringInit. Now tears down
first, under the gate both callers already hold.

Driver.Mqtt-003 (Medium) — connectTimeoutSeconds is [Range]-annotated but
unvalidated on the runtime path; 0 makes every deadline fire immediately and a
negative value throws ArgumentOutOfRangeException out of ShutdownAsync and
DisposeAsync. Normalized once, at the seam every options instance passes.

Also: ShutdownAsync keeps the live last-message instant (-004), three usings
left behind by #512 removed (-005), stale remarks corrected (-009, -010), and
docs/drivers/Mqtt.md now distinguishes the two kinds of reconnect.

Tests 708 -> 713. All three regression tests were confirmed to fail against
the un-fixed code, with two controls that pass in either state.
2026-08-10 06:59:18 -04:00
Joseph Doherty 69ad376f0d review(Driver.Sql): findings + fix the missing default-schema fallback, the rowSelector doc, and a dead package ref
Driver.Sql-001 (Medium): SqlCatalogLoader.ReadDefaultSchemaAsync documented a
fallback to the sole visible schema when the server reports no default schema and
did not implement it, so a login with no DEFAULT_SCHEMA (or a Postgres search_path
that resolves to nothing) rejected EVERY unqualified authored table at the catalog
gate — a whole-driver BadNodeIdUnknown outage against a healthy database. Fixed;
SqlCatalogDefaultSchemaTests written red first, with a control proving the
ambiguous multi-schema case still refuses to guess.

Driver.Sql-003 (Medium): docs/drivers/Sql.md spelled the WideRow row selector at
the top level, but the parser reads those keys only inside a nested rowSelector —
a tag authored from the doc is refused and reads BadNodeIdUnknown (the #552 class,
via the documentation). Also removed the documented-but-nonexistent pollIntervalMs
per-tag field.

Driver.Sql-007 (Low): dropped the unused Microsoft.Data.SqlClient PackageReference
left behind by the .Dialect extraction (#526).

Open: 002 (no IN-list chunking vs the 2100/1000 provider bind caps), 004, 005, 006,
008, 009, 010.
2026-08-10 06:58:08 -04:00
Joseph Doherty a86a09767c test(historian-gateway): cover GatewayHistorianValueWriter + HistorizationOutboxEntrySerializer offline
Driver.Historian.Gateway-003 (Medium): both were exercised only by the
env-gated Category=LiveIntegration suite (skips cleanly offline) or only
indirectly through FasterLogHistorizationOutboxTests with single-char
ASCII tags. Adds direct offline unit coverage for the value writer's
ack-to-bool mapping, timestamp presence/absence, and non-throwing
contract (via the existing FakeHistorianGatewayClient double), and for
the serializer's Serialize/Deserialize round trip incl. a multi-byte
UTF-8 tag, an empty tag, and ushort/double boundaries.
2026-08-10 06:53:47 -04:00
Joseph Doherty 55b12dc217 fix(historian-gateway): map alarm severity numerically; dispose provisioner's gRPC channel
Driver.Historian.Gateway-001 (High): AlarmEventMapper wrote the alarm's
Severity wire property as the .NET enum name (e.g. "High"), but
EventMapper's read side int.TryParse()s that same property and silently
falls back to the minimum severity on any unparseable value - so every
alarm event's severity round-tripped through this module's own mappers
collapsed to 1 (Low), contradicting AlarmHistorianEvent.Severity's own
documented contract ("mapped to Historian's numeric priority"). Now
mapped through a numeric priority mirroring the existing
NativeAlarmProjector.MapSeverity convention (200/500/700/900).

Driver.Historian.Gateway-002 (Low): GatewayTagProvisioner never disposed
its IHistorianGatewayClient, unlike its GatewayHistorianDataSource /
GatewayAlarmHistorianWriter siblings. Now implements IAsyncDisposable so
the DI-owned singleton's gRPC channel closes gracefully at shutdown.

Both TDD: new/corrected tests confirmed RED against the pre-fix code.
2026-08-10 06:53:41 -04:00
Joseph Doherty 3118213772 fix(mqtt-browser): reject a wildcard rebirth scope before any NCMD leaves
SparkplugRebirthScope (the AdminUI's typed input) rejects '+'/'#' and its own
remarks say it is 'advisory, not the gate' because MqttBrowseSession
.ResolveRebirthTargets 'refuses an unusable one BEFORE any message leaves'. That
claim was false: the bare {group}/{edgeNode} branch — honoured on purpose, since a
node that has not birthed is the prime rebirth target — gated on IsUsableLabel,
which checks length and control characters only. A scope of 'Plant1/#' therefore
published spBv1.0/Plant1/NCMD/# , a PUBLISH the MQTT spec forbids and a conformant
broker answers by closing the browse connection; '+' would have fanned one
operator click across every matching edge node.

The check is a separate IsUsableRebirthSegment rather than a tightening of
IsUsableLabel: that helper also judges Sparkplug METRIC names, which are arbitrary
publisher-chosen UTF-8 and never part of a topic, so tightening it there would drop
legitimate metrics from the tree. Cases 1 and 2 cannot reach this shape — their ids
come off SparkplugTopic.TryParse, which already rejects both characters.

Driver.Mqtt.Tests 724 (was 708).
2026-08-10 06:51:59 -04:00
Joseph Doherty 6c63bf8fab fix(mtconnect): back off a /sample re-baseline that resolved nothing; scope Test Connect to the configured device
Driver.MTConnect-001 (Medium): StreamVerdict.Reopen reset the reconnect ladder
unconditionally. The InvalidDataException catch that routes an Agent OUT_OF_RANGE
to a /current re-baseline also catches an unparseable chunk and an over-sized
multipart part - neither of which a re-baseline can resolve - so the pump reopened
and failed with no delay at any point: two HTTP requests and one Warning per
iteration, forever. The Reopen arm now uses the same discriminator the transient
arm does (did THIS connection deliver a chunk?), so a buffer roll behind a working
stream still reopens instantly while a re-baseline that resolved nothing advances
BackoffFor.

Driver.MTConnect-002 (Medium): MTConnectDriverProbe requested {AgentUri}/probe
while MTConnectAgentClient requests {AgentUri}/{DeviceName}/probe, so a mistyped or
since-renamed deviceName showed GREEN on Test Connect and then Faulted the driver
at InitializeAsync (an unknown device path answers MTConnectError under HTTP 200).
The probe's minimal DTO now reads deviceName - the rule is 'which keys compose the
URL', not 'which keys are cheap to read'.

Driver.MTConnect-008 (Low): corrected two stale claims that a since-changed
RawBrowseCommitMapper invalidated (see Driver.MTConnect-003, which remains open and
belongs in AdminUI), and made DiscoverAsync log the options snapshot it
deliberately read once instead of the field.

Tests: 538 pass (534 base + 4 new, each red before its fix); AdminUI.Tests 1466 pass.
2026-08-10 06:51:39 -04:00
Joseph Doherty 6eace4bbba fix(mqtt-contracts): refuse unpublishable write destinations and advise on them at deploy
A wildcard writeTopic was published verbatim. MQTT-3.3.2-2 forbids a wildcard in a
PUBLISH topic and a conformant broker answers a protocol violation by closing the
connection — which for this driver is the one connection every subscription lives
on, so a single write to a mis-authored tag took the whole driver dark. The
Sparkplug side had the same hole plus a worse one: the binding tuple IS the
NCMD/DCMD topic, so a '/' in groupId/edgeNodeId/deviceId silently addressed a
different edge node than the tag names.

MqttWriteEncoder now refuses both as NoWriteDestination (-> BadNotWritable); the
read path is untouched. metricName is exempt on purpose — it rides in the payload
and Sparkplug's own canonical names use '/'.

MqttTagDefinitionFactory.Inspect gained the matching ADVISORIES (never rejections
— the mappers accept these blobs, and the gate must never be stricter than the
runtime). Both rules were already hard errors in the AdminUI's typed editor, but
that is one authoring path of several: the raw-JSON textarea, CSV import and the
API all reach the deploy gate directly, so the authoring surface was stricter than
the gate — the drift class the #549-#559 arc exists to close.

Shared shape rules single-sourced in the new internal MqttAddressRules.

Driver.Mqtt.Tests 724 (was 708); AdminUI.Tests 1466; ControlPlane.Tests 261.
2026-08-10 06:51:39 -04:00
Joseph Doherty 621a9e5dc2 fix(mqtt): runtime mappers no longer refuse leading-whitespace TagConfig — JsonDocument.Parse + ValueKind is the single framing authority (#559)
FromTagConfig and FromSparkplugTagConfig each carried a framing pre-check
(`tagConfig[0] != '{'`) evaluated on the UNTRIMMED string, so a TagConfig blob
with leading whitespace was refused by MQTT alone and the tag read
BadNodeIdUnknown forever — while the other six structural-parse drivers
(Modbus, S7, AbCip, AbLegacy, TwinCAT, FOCAS) hand the identical blob to
JsonDocument.Parse, which skips leading whitespace per the JSON spec.

The clause is dropped (the blank guard stays). JsonDocument.Parse plus the
root-ValueKind check now decide framing, exactly as for the six siblings: an
array/number/string root fails ValueKind, non-JSON is a JsonException. The gate
was a validator, not a discriminator — no caller (MqttSubscriptionManager,
SparkplugIngestor, MqttTagConfigModel, Inspect) used the first byte to pick a
format — so verdicts are identical for every input except whitespace-padded
JSON objects, which now get their content's verdict.

Inspect needs no logic change (it has trimmed since #556 and hands the RAW
reference to the mappers), but its comments claimed the padded-blob refusal was
production-reachable; rewritten. That shape was also MQTT's only vehicle for the
catch-all backstop, which is now unreachable-today like FOCAS's — so its test is
replaced by the same count-relation pin, and the backstop code stays as
defence-in-depth.

Tests: both mappers pinned by padded-vs-unpadded DEFINITION equality (a record,
so field-wise), padded non-object/malformed negative controls, an Inspect
verdict+inspection parity theory over the accepted AND rejected corpus with an
anti-vacuity control, and a padded MQTT corpus entry in the AdminUI
authoring/runtime parity suite.
2026-08-10 03:36:46 -04:00
Joseph Doherty f5475868f7 fix(inspect): trim before the framing check; stop advisories swallowing the backstop (#556); pin the MQTT payloadFormat mode contract (#557 item 2)
#556 item 1 — leading whitespace defeated every Inspect's pre-filter. Seven
factories (Modbus/S7/AbCip/AbLegacy/TwinCAT/FOCAS/Mqtt) tested
`reference[0] != '{'`, so a padded blob returned EMPTY and a tag the driver
refuses deployed clean. The check now trims first, matching the idiom
Sql/Calculation/MTConnect/OpcUaClient already used. The raw reference is still
what is handed to the mapper — the driver reads the stored blob, not a trimmed
copy — so the gate keeps mirroring the runtime rather than becoming lenient.
Both directions are pinned: a padded blob the mapper accepts still draws
nothing, and one that is not object-framed AFTER trimming still draws nothing
(it is not an equipment-tag TagConfig at all).

Six of the seven mappers hand the blob to a whitespace-tolerant
JsonDocument.Parse, so their verdict is unchanged by padding. MQTT is the
exception: both its readers carry the same framing gate, so a padded blob is
genuinely refused — which is why the untrimmed pre-filter was a real defect
there and not a cosmetic mismatch.

#556 item 2 — FOCAS and MQTT appended advisories unconditionally and then gated
the catch-all rejection message on `warnings.Count == 0`, so an advisory (which
fires on ACCEPTED blobs too, and says nothing about a refusal) silently
swallowed the only message naming a refusal nothing else can. The two classes
now accumulate in SEPARATE lists and the backstop gates on the rejection count
alone — structural, not string matching. MQTT's swallow was reachable in
production (a padded blob its readers refuse while every describe branch finds
the parsed document well-formed); FOCAS's backstop is currently unreachable by
audit, so its guard is protective.

#557 item 2 — decided and pinned: KEEP the union verdict. Evidence:
FromSparkplugTagConfig hard-codes PayloadFormat=Json and never consults the key;
only MqttSubscriptionManager (the Plain ingest path) decodes by it; and
MqttTagConfigModel.ToJson deliberately omits the key entirely in Sparkplug mode
(#513). So a Sparkplug driver genuinely serves the tag and rejecting it would be
stricter than the runtime. The cost — served behaviour depends on the driver's
MqttMode, which no tag row records — is paid by an ADVISORY naming the key and
the mode dependency, not by changing the verdict, so both gates keep agreeing.
The same invalid value on a blob the union REFUSES stays a rejection; the class
is decided by the accept gate, never by the key.

Falsified: reverting Modbus's trim reddens the padded-rejection theory;
neutering MQTT's backstop guard to the pre-#556 shape reddens the
advisory-plus-unnameable-rejection test; disabling the #557 advisory reddens the
contract pin; and for FOCAS, removing a describe branch alone stays green (the
backstop covers it) while additionally neutering the guard goes red.
2026-08-10 03:07:18 -04:00
Joseph Doherty 7498d39e5b fix(sql): browse-commit states a topByTimestamp row selector (#552)
`RawBrowseCommitMapper.BuildSqlTagConfig` emitted a WideRow blob carrying only
table + columnName. A wide-row tag must also say WHICH row it reads, so both
`SqlEquipmentTagParser` (the runtime authority) and `SqlTagConfigModel.Validate`
(the authoring gate) refused it — a browse-committed Sql tag was never readable,
first silently as BadNodeIdUnknown and, since #551 put the gate on the import,
loudly at commit.

`SqlBrowseSession` now states a fourth address field: the table's preferred
date/time-typed column, computed from the columns its attributes call ALREADY
read (no extra catalog round-trip). Candidacy is decided by
`ISqlDialect.MapColumnType` reporting `DriverDataType.DateTime` — never by column
name, which would order a wide-row read by a string column that merely looks like
a timestamp. Multiple candidates tie-break on FIRST IN CATALOG ORDER (every
dialect's column SQL orders by ordinal position), so the pick is stable across
calls and servers; it is a visible, editable default the typed editor round-trips.

No candidate (a table with no date/time column) is an operator-actionable refusal,
not an invented selector: the only remaining selector is a whereColumn/whereValue
pair whose value is data, not schema, so `DescribeUncommittableLeaf` refuses that
leaf by name, names the table, and points at the tag editor.

The session and the mapper now agree via `SqlBrowseAddressFields` constants rather
than two independently spelled string literals — a disagreement there is silent,
and is exactly the shape this defect had.

`RawTagConfigGateTests`' Sql leg flips from "records the gap" to "browse-commit
output passes the gate", with the no-candidate case pinned as its own test.
2026-08-10 01:20:12 -04:00
Joseph Doherty 05b424788f fix(tagconfig): close the four #551 residuals — accept-gate-first Inspect, three authoring rules, CSV/service gate, doc sweep
Gitea #551.

1. CSV import ran no driver-typed config gate. Its typed columns were always
   strictly parsed, but the free-form TagConfigJson column reached the database
   unread, so a CSV could author a tag the driver rejects — one that deploys
   green and reads BadNodeIdUnknown forever. RawTagCsvMapper.Parse now names the
   offending row in the staged review grid, and RawTreeService enforces the same
   gate on CreateTag/UpdateTag/ImportTags: a form cannot be the guarantee (the
   API and any future caller reach none of them, and this repo has no bUnit).
   A rejected import row is an ERROR — the batch inserts nothing and the row is
   named — never a skip.

   Note the brief's premise that manual entry rides ImportTagsAsync is wrong:
   RawManualTagEntryModal commits row-by-row via CreateTagAsync, which is why
   the gate had to land on all three write paths.

2/3. The seven pre-#549 Inspect methods ran a hand-list of DescribeInvalidEnum
   checks, so every rejection cause not on the list was invisible. They are now
   ACCEPT-GATE-FIRST (the MTConnect/Sql/OpcUaClient/Calculation shape): ask the
   driver's own reader for the verdict, then explain the refusal, with a
   catch-all so "the parse rejects but Inspect said nothing" is unreachable.
   That closes S7's absent dataType, AbCip members[], Modbus stringByteOrder and
   the type-dependent guards, plus causes the issue did not name (absent
   region/address/tagPath/symbolPath/topic, TwinCAT Structure, MQTT's Sparkplug
   tuple). FOCAS and MQTT keep advisories about ACCEPTED blobs, so they
   diagnose rather than return empty.

   Authoring half: ModbusTagConfigModel gains stringByteOrder;
   AbCipTagConfigModel asks the new AbCipTagDefinitionFactory.DescribeInvalidMember;
   S7TagConfigModel captures whether the SOURCE blob declared dataType and asks
   S7TagDefinitionFactory.DataTypeKey/DeclaresDataType. One authority per rule.

4. Doc sweep: the six factory Inspect summaries, EquipmentTagConfigInspector's
   class remarks and AdminOperationsActor's comment all claimed invalid enums are
   "silently defaulted by the lenient runtime" — false since R2-11 Phase C made
   the runtime reads strict. They describe rejections now.
2026-08-10 00:08:06 -04:00
Joseph Doherty b4f8cb8de7 refactor(opcuaclient): move NamespaceMap to a .Session leaf so Contracts is dependency-light
G-7 wired OpcUaClientTagDefinitionFactory.Inspect into the deploy-time TagConfig
gate, which gave ControlPlane a ProjectReference to
Driver.OpcUaClient.Contracts — and with it, transitively, the whole OPC UA client
stack (5 OPCFoundation packages) into every host that loads the control plane.

NamespaceMap was the only reason Contracts carried
OPCFoundation.NetStandard.Opc.Ua.Client: its entire public surface is
ISession/NamespaceTable/NodeId-typed. It moves to a new leaf project,
ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session, referenced by the runtime .Driver
and by .Browser. Contracts is left with Core.Abstractions and NOTHING else —
the same shape as Calculation.Contracts.

The namespace stays ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient, so no consumer's
`using` changed. The .Session -> .Contracts direction is deliberate: the blob
read NamespaceMap needs is OpcUaClientTagDefinitionFactory.TryReadNodeId, the
one authority it shares with the deploy gate, and that must stay in the
dependency-light half.

Why a new project rather than folding NamespaceMap into the runtime .Driver and
having .Browser reference it (the Driver.Mqtt.Browser #512-style deliberate
exception): the AdminUI project-references .Browser and NOT .Driver, so that
shape would pull the OpcUaClient driver runtime — plus Core,
Opc.Ua.Configuration and Secrets.Abstractions — into the AdminUI's build/deploy
graph. That is exactly the violation #512 spent two extractions removing from
Mqtt.Browser; re-introducing it to save one csproj is a bad trade.

Guard: ContractsIsDependencyLightTests (Driver.OpcUaClient.Tests) asserts the
Contracts manifest names no Opc.Ua* assembly, plus a counterpart asserting
.Session does. An ABSENCE assertion is sound on the manifest where a presence
one is not — the compiler elides an unused reference, but it MUST emit one the
moment Opc.Ua-typed code is added. Falsified by temporarily re-adding the
package plus an Opc.Ua.NodeId-typed member: red on Opc.Ua.Types, green again on
removal.

Evidence — `dotnet list ControlPlane package --include-transitive | grep -i opc`:
  before: OPCFoundation.NetStandard.Opc.Ua.{Client,Configuration,Core,
          Security.Certificates,Types} 1.5.378.106
  after:  none (and 0 Opc.Ua*.dll in ControlPlane's output directory)
2026-08-09 21:56:29 -04:00
Joseph Doherty 205cc4a4a3 feat(galaxy): per-code OPC UA mapping for the MxStatusDetail vocabulary
Follow-up to 20a816db: mxaccessgw published the full MxStatusDetail enum
(gateway.md @ 75c71ad, "MxStatus Detail Vocabulary", lifted from
Interop.aaMxDataConsumer.MxStatusDetail), confirming the live-captured codes
(1007 = MX_E_NotWriteable, 1008 = MX_E_WriteAccessDenied). FromCategory now
maps the write-relevant band per code before falling back to the category
bucket: 1004 -> BadOutOfRange, 1005 -> BadTypeMismatch, 1006 -> BadNotReadable,
1007 -> BadNotWritable, 1008/1012/1013/1016/1017 -> BadUserAccessDenied,
1010/1011 (initializing, transient) -> BadOutOfService. 8000
(MX_E_AutomationObjectSpecificError) deliberately stays category-level — the
object supplies its own semantics. Codes <= 0xFF are value-ambiguous with the
OPC DA quality-byte space and keep the quality path (still Bad-family).

Four new named constants, all validated by StatusCodeParityTests against the
pinned SDK. Live refusal smoke re-run green (read-only refusal now surfaces
BadNotWritable instead of generic Bad).
2026-08-09 20:16:29 -04:00