Commit Graph

3189 Commits

Author SHA1 Message Date
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 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 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 94b8633e68 docs(deferment): seventh round — review-coverage gap closed (14 modules, 61 findings, 26 fixed); §0.1 row struck
v2-ci / build (push) Successful in 4m32s
v2-ci / unit-tests (push) Failing after 16m43s
2026-08-10 07:09:13 -04:00
Joseph Doherty fd7d06bb12 docs(reviews): regenerate README for 14 new module reviews; fix MTConnect finding-heading level (#### invisible to the generator) 2026-08-10 07:03:02 -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 fefb240350 review(mqtt-transport): findings for Driver.Mqtt.Transport at b990adae (2 findings, 1 resolved in 74b85271, 1 open) 2026-08-10 06:59:38 -04:00
Joseph Doherty 416c471809 docs(review): add code review for Driver.OpcUaClient.Session
No defects found across all 10 checklist categories. NamespaceMap's
namespace-URI-stable NodeId encoding and v3 RawPath resolution table match
docs/v2/driver-specs.md §8 and are exercised by
OpcUaClientNamespaceTests.cs. Zero findings recorded.
2026-08-10 06:59:37 -04:00
Joseph Doherty 74b852716c fix(mqtt-transport): warn when the untrusted-cert hatch voids a pinned CA
Driver.Mqtt.Transport-001 (Medium, Security). allowUntrustedServerCertificate
and caCertificatePath are independent settings, and when both are set the hatch
wins SILENTLY: ConfigureTls installs the accept-everything validator and
returns before the pin is consulted. The combination is not exotic — the hatch
is what an operator turns on while standing a broker up and the pin is what
they add afterwards — and the failure is silent in the direction that matters:
the config reads as pinned, and nothing validates anything.

Both knobs are explicit, so the flag stays authoritative (overriding it would
break a deployment that genuinely wants the hatch, and refusing to build would
fault a driver over a config we have been accepting). Saying so once, at the
seam that resolves the conflict, is the proportionate answer. No behaviour
change; the factory's optional ILogger already exists for this class of
deferred diagnostic and never formats credentials.
2026-08-10 06:59:34 -04:00
Joseph Doherty 33f7472002 docs(review): add code review for Driver.Calculation.Contracts
No functional defects found. One Low/Open record-only finding: Inspect()
double-parses its TagConfig blob (once directly, once via TryReadTagConfig)
by deliberate design of the module's single-authority split — not pursued
as a fix since it is deploy-time-only (not a hot path) and widening
TryReadTagConfig's signature to avoid it touches consumers outside this
module (Driver.Calculation, AdminUI).
2026-08-10 06:59:33 -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 95cdb6c578 review(mqtt): findings for Driver.Mqtt at b990adae (10 findings, 8 resolved in 2b4b3fcb, 2 open) 2026-08-10 06:59:26 -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 7eba588305 docs(code-reviews): add Driver.MTConnect + Driver.MTConnect.Contracts findings (first review, b990adae)
Driver.MTConnect: 8 findings — 1 High (Driver.MTConnect-003, browse-committed tags
are typed String and defeat the probe inference; cause is in AdminUI's
RawBrowseCommitMapper, so recorded Open with a recommendation), 3 Medium
(001/002 fixed in 6c63bf8f, 004 the missing write.enabled authoring surface,
cross-module Open), 4 Low. 5 remain Open, all cross-module or record-only.

Driver.MTConnect.Contracts: 1 finding — a Low documentation defect whose false
premise concealed Driver.MTConnect-003; fixed in 6c63bf8f. No behavioural issues:
the accept-gate-first Inspect contract, the FirstNonBlank alias precedence and the
digits-only enum pre-check were all verified as deliberate and correct at HEAD.

README.md deliberately not regenerated (out of scope for this review).
2026-08-10 06:59:11 -04:00
Joseph Doherty ce5c6d093c review(Driver.Sql): stamp the fixing commit sha into the three Resolved findings 2026-08-10 06:59:05 -04:00
Joseph Doherty 256d943f1e review(Driver.Sql.Dialect): 3 Low findings, all record-only
001 the Postgres (63) and Oracle (128) identifier ceilings are BYTE limits but are
enforced as UTF-16 char counts, so a non-ASCII name passes the check and is still
truncated server-side — the exact silent truncation the check documents itself as
preventing;
002 the seam carries no max-bind-parameter member, which is why the planner cannot
chunk a key-value IN list (Driver.Sql-002);
003 Oracle's ListSchemasSql reads ALL_USERS, justified only from the catalog gate's
point of view — the browse tree shares the query and renders every database user as
an expandable schema.

Quoting, marker/bind-name split, BindByName and the type maps are all sound.
2026-08-10 06:58:41 -04:00
Joseph Doherty 368d8e9581 review(Driver.Sql.Contracts): 2 Low findings, both record-only
001 keyValue is read string-only while whereValue accepts any scalar, and Inspect
then reports a numerically-authored key as ABSENT — an authoring surface
disagreeing with the runtime about why (the #549 class);
002 cross-module: TagConfigJson's strict enum read rests on Enum.TryParse, which
accepts a numeric string, so "type":"99" is Valid and an undefined
DriverDataType reaches the address space. Fix belongs in Core.Abstractions.

The accept-gate-first Inspect and the strict enum reads are otherwise exactly the
#549-#559 arc's rules, implemented structurally.
2026-08-10 06:58:29 -04:00
Joseph Doherty 03eadae677 review(Driver.Sql.Browser): 3 Low findings, all record-only
001 the #552 topByTimestamp default admits a date-only column, so the committed
wide-row read orders by a column that ties for every row written that day;
002 the session's gate is disposed under a racing call and LastUsedUtc only
advances on completion, so a long expand is invisible to the TTL reaper;
003 the ad-hoc pasted connection string is an arbitrary outbound-connect primitive
— correctly behind the /raw ConfigEditor policy, but undocumented as such.

No code changes: the NodeId codec, the bound-parameter catalog path and the
connection-ownership handoff are all sound. 79/79 tests unchanged.
2026-08-10 06:58:20 -04:00
Joseph Doherty 9ca6042f57 docs(code-reviews): first review of Driver.Mqtt.Contracts + Driver.Mqtt.Browser at b990adae
All 10 checklist categories completed for both modules. 12 findings total:
1 High + 2 Medium fixed under TDD (6eace4bb, 31182137), 1 Medium fixed in the
browser, 8 Low recorded Open with concrete recommendations.

README.md deliberately NOT regenerated — regen-readme.py is left for the caller.
2026-08-10 06:58:08 -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 ac3a2c6c27 merge: Driver.Historian.Gateway code review — severity round-trip fix (High), provisioner dispose, value-writer/serializer offline coverage 2026-08-10 06:55:19 -04:00
Joseph Doherty 19feb1b289 docs(code-review): add Driver.Historian.Gateway findings
Per-module review at b990adae (this branch's base): all 10 checklist
categories completed, 3 findings recorded and resolved in the two prior
commits on this branch (55b12dc2, a86a0976). Not run through
regen-readme.py per the review task's instructions - the aggregated
README.md is unchanged.
2026-08-10 06:54:25 -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 959024de60 docs(reviews): mark the three Historian.Wonderware review modules as retired-module history (backend deleted in the HistorianGateway cutover); remove untracked bin/obj litter dirs 2026-08-10 06:38:08 -04:00
Joseph Doherty b990adae55 docs(plans): close F10b + SetSeverity reconsider gates as decided-against (user-confirmed 2026-08-10); open plan tasks 3 -> 1 2026-08-10 05:48:09 -04:00
Joseph Doherty 0319cf5de2 docs(plans): trim device-gated proofs bundle — Phase C T7 + Phase B T9 removed as covered (historian cutover gate / dcb0be65); branch cleanup left master only 2026-08-10 05:45:49 -04:00
Joseph Doherty d06e92c631 docs(plans): open-plan-task sweep — followups-batch T9 (Modbus-Int64) + T10 (S7/AbCip probe E2E) verified live PASS; fixedtree T11 + adminui 10.4 closed obsolete (v3 deleted their subjects); open plan tasks 7 -> 3
v2-ci / build (push) Successful in 4m33s
v2-ci / unit-tests (push) Failing after 16m28s
2026-08-10 04:29:49 -04:00
Joseph Doherty 481f4343c1 docs(deferment): register fifth round — #559 closed (MQTT framing aligned with the fleet; backstop re-pinned as count relation); drift arc complete, no residual 2026-08-10 03:41:39 -04:00
Joseph Doherty 83f2cba20d merge: #559 — MQTT runtime mappers accept leading-whitespace TagConfig; JsonDocument.Parse + ValueKind is the single framing authority, aligning MQTT with the six sibling drivers; backstop re-pinned FOCAS-style (unreachable-today count relation) 2026-08-10 03:37:38 -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 15e65c86d0 docs(deferment): register fourth round — #556/#557/#558 closed (Inspect trim + structural classification, OpcUaClient presence-shadows alias, guard consolidation); residual #559 filed 2026-08-10 03:16:15 -04:00
Joseph Doherty be15ed160a merge: #556 + #557(Mqtt) — Inspect pre-filters trim before framing; rejection vs advisory classified structurally (backstop gates on rejections only); Mqtt union verdict kept with a mode-dependency advisory 2026-08-10 03:08:08 -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 93a058287c merge: #557 (OpcUaClient half) — editor reads the legacy PascalCase NodeId at the reader's presence-shadows precedence and drops it on save; #554 stale exemption removed with the anti-vacuity guard as control 2026-08-10 03:02:12 -04:00
Joseph Doherty be5899bc7d fix(adminui): OpcUaClient editor reads the legacy PascalCase NodeId at the reader's precedence (#557 item 1)
`OpcUaClientTagDefinitionFactory.TryReadNodeId` still honours a legacy PascalCase
`NodeId` when the v3 camelCase `nodeId` is wholly absent, but
`OpcUaClientTagConfigModel` read only `nodeId` — so a legacy blob was SERVED by
the driver yet opened with an empty reference field and could not be saved
untouched. Authoring stricter than the reader, which #554 names as never
legitimate: the editor was blocking config the runtime honours.

Fixed with the #550 pattern (read both spellings, normalise on save), NOT by
retiring the spelling from the reader — that would break every legacy blob the
driver currently serves at the next deploy, a data-plane regression traded for
an editor asymmetry.

The precedence is PRESENCE-shadows, not first-non-blank. The reader's
`TryGetProperty("nodeId", out n) || TryGetProperty("NodeId", out n)`
short-circuits on the mere PRESENCE of the camelCase key and then checks
string-and-non-blank ONCE, so a blank / non-string / JSON-null `nodeId` beside a
valid legacy `NodeId` is a REJECTED blob, not a fallback. Copying MTConnect's
`FirstNonBlank` walk here would have made authoring WEAKER than the reader —
the same defect in mirror image.

- `NodeIdKeys` drives both halves, so the read and the drop cannot drift.
- `ToJson` writes `nodeId` and drops `NodeId`; the TagModal history-merge keys
  (`isHistorized` / `historianTagname`) are disjoint and untouched.
- `Validate`'s required-reference fast path is now precedence-aware by
  construction, so it agrees with the #554 parse consult across the whole
  rejection surface.
- The `TagConfigNonEnumParityTests` exemption for OpcUaClient is REMOVED — its
  anti-vacuity guard `Every_deliberately_stricter_entry_is_still_exactly_that`
  went red on the fix, which is this change's falsification control. The legacy
  shapes moved into the corpus, where both fleet theories now cover them.

Tests: a nine-row precedence theory driving the authoring verdict against the
READER's verdict per shape, on the original blob AND on the `ToJson()` output;
no-retype (legacy loads showing its reference, saves canonical, same node id
before and after); no-drift (output never carries `NodeId`). Each half was
reverted in isolation to confirm it reddens tests of its own: read-only-revert
=> 6 red incl. the fleet parity theory; drop-only-revert => 4 red.

Suites: AdminUI.Tests 1459/1459, ControlPlane.Tests 261/261,
Driver.OpcUaClient.Tests 143/143, Driver.OpcUaClient.Browser.Tests 10/10.
2026-08-10 03:01:41 -04:00
Joseph Doherty 7482cf0c3a merge: #558 — one hand-authored list for the browse-commit seam (stale-exemption direction gained a real guard); browser DriverType literals repointed to DriverTypeNames + Ordinal membership guard 2026-08-10 02:57:02 -04:00
Joseph Doherty 17130d9c82 test(parity): consolidate the browse-commit guard onto one list; key browsers off DriverTypeNames (#558)
Two guards enumerated the same RawBrowseCommitMapper.BuildTagConfig seam against
two hand-authored lists, which can only drift. The older one's list was also
misnamed: DriverDispatchMapParityTests.NonBrowsableDriverTypes really meant
"types that produce the generic key" — AbLegacy is genuinely non-browsable yet
correctly absent from it, because it has a typed branch.

Retire the older fact. RawBrowseCommitMapperParityTests.GenericKeyExemptions is
now the single hand-authored list for this seam: named for what it holds, one
written reason per entry, and checked against a DI-derived browsable set rather
than a second list. The one assertion the retired fact made that nothing here
covered — set EQUALITY, i.e. an exemption must still DESCRIBE the mapper — moves
across as Every_generic_key_exemption_still_commits_to_the_generic_key. Without
it, a driver that gained a typed branch would keep a silently inert exemption.

Item 2: OpcUaClientDriverBrowser and GalaxyDriverBrowser returned string
literals ("OpcUaClient", "GalaxyMxGateway") where Sql/Mqtt use DriverTypeNames.
The values matched, and nothing guarded that. Repoint both at the constants —
DriverTypeNames.Galaxy carries the identical value — and add
Every_registered_browser_is_keyed_by_a_declared_driver_type_name over the same
AddAdminUI() read-back. Compared ORDINALLY: BrowserSessionService indexes on the
exact persisted string, and case-only drift (TwinCat/TwinCAT) is what
DriverTypeNames exists to end. Browser projects are out of reach of
DriverTypeNamesGuardTests, which pins the driver FACTORIES' names.

Falsified, four ways, each red naming the offender and green on revert:
  - #555 re-run: disabling the Sql branch reddens the two enumeration facts
    ("...no dedicated BuildTagConfig branch...: Sql"), the same catch the
    retired fact made.
  - giving Modbus a typed branch reddens ONLY the new stale-exemption fact,
    proving it is not redundant with the four that were already there.
  - SqlDriverBrowser.DriverType => "SQL" and Galaxy => "GalaxyMXGateway" each
    redden the new browser-key guard by name — case-only typos, which an
    OrdinalIgnoreCase compare would have waved through.
2026-08-10 02:56:05 -04:00
Joseph Doherty ca3968cc31 docs(deferment): #554 (TagConfigParseGate — authoring consults the parse) + #555 (browse-commit parity guard) closed; residuals #556/#557/#558 filed 2026-08-10 02:02:10 -04:00
Joseph Doherty 7cfe7b4a24 merge: #554 — authoring models consult the driver's own runtime parse (TagConfigParseGate): verdict from the parse, wording from Inspect; five drivers' non-enum gaps closed, deliberately-stricter rules kept and pinned 2026-08-10 01:55:31 -04:00
Joseph Doherty 5ca4f8041d fix(adminui): authoring models consult the driver's own parse for non-enum TagConfig rules (#554)
#549/#551 made every typed editor judge its driver's enum KEYS against the runtime's
own reader and stopped there. Every other reason a reader refuses a tag met no gate
until deploy, or until the driver silently skipped the tag: an absent Modbus `region`,
an `"address":"notanumber"`, an S7 `stringLength` of 300, a TwinCAT `dataType:
"Structure"` (a VALID enum name the reader refuses), a Sql blob the parser cannot
recognise as Sql at all, an MQTT `"qos":null`.

Rather than hand-mirroring N more rules into N models — the drift #551 closed on the
deploy side — each `<Driver>TagConfigModel.Validate()` now asks its driver's own parse
whether it would read the AS-LOADED blob, via the new `TagConfigParseGate`. One
authority for the verdict AND the wording.

Two cautions are load-bearing and are pinned by tests:

- The gate is the PARSE VERDICT, never "the inspector said something". FOCAS, MQTT and
  Calculation inspectors emit advisories for blobs their reader ACCEPTS, so a
  non-empty-inspection gate would make the modal stricter than the runtime and leave
  rows nobody can save (#520/#535). Inspect is consulted only to word a refusal that
  already happened.
- It judges the blob the model was LOADED FROM, not the typed fields — the lenient load
  ("address":"notanumber" ⇒ 0) is what hides the defect. Same capture-the-source shape
  as S7's `_dataTypeMissingInSource`.

Deliberately-stricter editor rules were surveyed and KEPT, not aligned: AbLegacy's
arrayLength > 256 (reader clamps, but the un-clamped value still sets ArrayDimensions),
FOCAS's unparseable address (#540), MQTT wildcard topics + Sparkplug segment chars, and
Calculation's trigger/50 ms floors. They are now recorded fleet-wide in a table the
tests assert is still accurate.

Behaviour change: Modbus `{}` is now refused. Modbus was the one mapped editor with no
required field, and `{}` is exactly what the modal persists for a config nobody touched
— the driver skips it and it reads BadNodeIdUnknown forever with the deploy green. Its
pinned test asserted the old, weaker behaviour and is rewritten with the rationale.

Falsification: neutering only the Modbus consult turns 14 tests red, all Modbus-scoped;
every #549 enum test stays green.
2026-08-10 01:54:17 -04:00
Joseph Doherty 4f1c83a7cf merge: #555 — RawBrowseCommitMapperParityTests exists now: bespoke-browser types derived from the real AddAdminUI registration must be typed; generic-key fallthrough demands a documented exemption 2026-08-10 01:38:50 -04:00