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.
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.
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.
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.
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.
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.
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.
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.
#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.
`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.
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.
#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.
RawBrowseCommitMapper.BuildTagConfig's unknown-driver fallback claimed to be
guarded by RawBrowseCommitMapperParityTests, which did not exist anywhere under
tests/. The claim it made — "every BROWSABLE driver is handled above" — is the
exact claim that stopped being true when SqlDriverBrowser registered (deferment.md
G-6), so the comment was asserting the invariant instead of pinning it.
The guard now exists and enumerates DriverTypeNames.All:
- Browsability's bespoke arm is DERIVED from production, not hand-listed: the test
runs AddAdminUI() and reads back every registered IDriverBrowser's DriverType —
the same set BrowserSessionService.CanBrowse indexes. A driver that gains or
loses a bespoke browser moves the guard with it.
- The universal arm (DiscoveryDriverBrowser -> ITagDiscovery) is NOT derivable from
the AdminUI assembly: it references driver Contracts/Browser projects, never the
runtime drivers, and IDriverFactory is composed in the Host. Covered instead by
requiring every declared driver type to be typed or explicitly exempted with a
reason (Modbus, Calculation), with a third fact mechanically refuting any
exemption the derived arm contradicts — the Sql failure mode.
Falsified three ways, each red naming the offender: disabling the Sql branch trips
the derived-arm fact; disabling the MTConnect branch trips the enumeration fact
(the universal-arm half, which no bespoke registration would have caught); adding
Sql to the exemption list trips the contradiction fact.
Comment corrected to state what the test actually asserts, including the residual
trust the universal arm leaves.
`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.
ModelBackedCsvDriverTagMap.ApplyTypedColumns round-tripped the row's free-form
TagConfigJson cell through a driver model's FromJson/ToJson to fold the typed
columns in. That load is deliberately lenient — it has to be, so a poisoned blob
can be opened and repaired — so a typo'd value under a key the model owns
("dataType":"Intt16") came back out as the model's DEFAULT, before the #551
import gate or any later gate could judge it. The row imported clean and the tag
the operator described was gone. Only keys the model did not own survived to be
judged, which is why RawTagConfigGateTests looked like it covered this.
MTConnect carried the same defect from the export end. It is the one driver whose
blob accepts several spellings per field, and the runtime PREFERS driverDataType
over the dataType key the MtDataType column writes. The export residual carried
that alias (the map owned only the canonical key), so a re-imported row let it
out-rank the very column the operator had just edited — accepted, then silently
ignored at the driver — while the exported cell printed the spelling the driver
was ignoring, or nothing at all.
The fix preserves evidence; it does not move the verdict:
- ApplyTypedColumns writes the base cell's own tokens back over the model's
serialisation for every key no non-blank typed column overrode. The model still
stamps defaults for keys the row declares nowhere, so fragment cells compose
exactly as before.
- CsvTypedColumn gains ReadKeys / OwnedJsonKeys: the full ordered set of spellings
the RUNTIME reads for that column, mirroring TryFromTagConfig's FirstNonBlank
pairs. A firing column clears the out-ranking aliases; the export residual drops
them; the export cell shows the EFFECTIVE value.
- The review grid's override provenance reads the whole owned set, so an
overridden driverDataType is announced as an overridden dataType would be.
Nothing here decides which values are legal. The verdict stays with
TagConfigValidator on the assembled blob and with IRawTreeService.ImportTagsAsync
behind it — the deliberate reason NOT to follow the issue's own "run the validator
over the pre-merge cell" direction, which would have imposed completeness on a
cell that is legitimately a fragment and false-rejected every typed-column row.
Semantics chosen for the override contradiction: a cell token that a non-blank
column overrides is ACCEPTED with the column winning, and reported by name in
TypedOverrodeFallback. Typed-column-wins is the map's documented contract, the
override is announced rather than silent, and erroring would remove the only way
to repair a poisoned tag through the CSV surface. Every token that reaches the
stored blob is judged; a token the operator overrode is discarded openly.
Falsification: neutering the restore loop + alias handling turns exactly 7 of the
15 new tests red, while RawTagConfigGateTests (#551), RawTagCsvRoundTripTests and
CsvColumnMapReflectionTests all stay green — layering, not replacement.
AdminUI 1248, MTConnect 534, Modbus 373, S7 285, TwinCAT 205 — all green.
Verified against the live mapper: every browsable driver's browse-committed
TagConfig passes the gate the commit now runs, EXCEPT Sql — its branch emits a
WideRow blob with no rowSelector, which SqlTagConfigModel and
SqlEquipmentTagParser both refuse. That tag was never readable; the gate only
moves the failure from the deploy gate to the commit. Reported for its own fix
rather than special-cased here.
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.
MTConnectTagDefinitionFactory.TryFromTagConfig resolves the coercion type as
FirstNonBlank(blob.DriverDataType, blob.DataType) — driverDataType OUT-RANKS
dataType — while MTConnectTagConfigModel read and wrote dataType alone. That
broke in both directions at once:
* a blob carrying "driverDataType": "Strng" was accepted at authoring while
the runtime rejected the tag (the residual slice of #549), and
* a VALID driverDataType silently overrode whatever type the editor displayed
and saved, so the editor showed a lie and a save RETYPED the tag.
Both halves land together, mirroring the existing IdKeys pattern:
* FromJson reads DataTypeKeys = [driverDataType, dataType] in the runtime's
precedence, with the runtime's own per-key semantics — absent/JSON-null and
present-but-blank fall through, a non-string token stops the walk (the
runtime deserialise throws JsonException on it and rejects the whole tag),
and the raw TOKEN is captured so Validate() judges what the driver judges.
* ToJson drops every non-canonical DataTypeKeys entry and writes dataType as
the enum NAME, so the alias can never out-rank the key the editor writes on
the next load.
* Validate() now names the offending spelling rather than always saying
"dataType".
Doing only one half silently retypes existing tags on their next save; the
falsification runs recorded in the PR confirm each half-fix reddens a distinct
test.
MTConnectTagConfigModel read `dataType` with TagConfigJson.GetEnum, which falls
back to String on ANY unrecognised value. A misspelled name ("Strng") from a
hand-authored blob, a CSV import or the API was therefore accepted at authoring
and silently rewritten, while the driver's strict parse rejected it and the tag
died with BadNodeIdUnknown. Found by the G-7 parity tests' falsifiability
control, which had to use the digits-only shape to have a case that failed.
The rule is not restated in the AdminUI. MTConnectTagDefinitionFactory exposes
its parse as `IsAcceptedDataTypeName`, documented as the one authority the
driver, the deploy gate and the authoring model all consult; the model's
Validate() asks it about the raw value the blob carries. The old digits-only
message is NOT retained as a special case: the runtime draws no distinction
between a bare number and a typo — one predicate, one Inspect warning — so a
second message here would be a second statement of a rule this model does not
own. The raw value is captured as the JSON TOKEN, because the runtime
deserialises the key into a `string?` DTO field and an unquoted number throws
there too.
The survey found the same asymmetry at six more editors, contrary to the
assumption that those runtimes silently default: R2-11 Phase C moved every
driver's TagConfig enum read to TagConfigJson.TryReadEnumStrict, which REJECTS
the tag on a present-but-invalid string, while the editors kept loading them
with a silent fallback. Modbus (region/dataType/byteOrder), S7, AbCip, AbLegacy,
TwinCAT and FOCAS now check their enums in Validate() through the runtime's own
TagConfigJson.DescribeInvalidEnumText — added alongside a public
ClassifyEnumText that TryReadEnum now delegates to, so the JSON-token door and
the string door reach one implementation. Mqtt and Sql already carried
equivalent strict checks and are untouched.
Care taken NOT to become stricter than the runtime: a present-but-non-string
token stays accepted for those six (TryReadEnum calls it absent), as does an
absent key, each pinned by a test.
Also adds a direct ProjectReference to Core.Abstractions from the AdminUI — the
save gate now leans on a type it was consuming only transitively.
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)
Adds the Sql / MTConnect / Calculation / OpcUaClient Contracts ProjectReferences to
ControlPlane and their entries to EquipmentTagConfigInspector, closing the G-7 asymmetry: the
deploy gate now checks every driver the AdminUI checks. Galaxy is the sole remaining unmapped
type and is so by design — its dotted tag_name.AttributeName reference has no typed shape, and
the AdminUI has no typed editor for it either, so the two gates agree.
Adding the ProjectReference is what makes EquipmentTagConfigInspectorCoverageTests demand a map
entry (its sweep scans the output directory, so an unreferenced Contracts assembly is invisible
to it). Falsified: deleting the Sql entry fails
Every_driver_shipping_an_Inspect_is_wired_into_the_dispatch_map with
["SqlTagDefinitionFactory"]; restored.
Tests: three cases per driver in EquipmentTagConfigInspectorTests (canonical valid config warns
nothing / present-but-invalid field draws a warning naming it / broken JSON warns without
throwing), plus a Calculation control pinning that the two shapes the runtime accepts and the
editor refuses draw nothing. OpcUaClient moved out of the unmapped-driver theory.
New TagConfigAuthoringDeployParityTests (in AdminUI.Tests, the only suite that sees both gates)
pins the direction that matters: a config the AdminUI model's own ToJson produces and Validate
accepts draws no deploy warning. Its falsifiability control — the same driver types warning
about a blob the editor refuses — caught a pre-existing, unrelated authoring-side gap:
MTConnectTagConfigModel reads dataType leniently, so a misspelled name loads as String and
saves clean while the strict runtime rejects it. Left alone (rewriting the editors is out of
scope); the control uses the digits-only shape both gates refuse, and the case is documented
inline.
Deferment-register G-7: these four were validated by the AdminUI's TagConfigValidator at
authoring but by nothing at deploy, so config arriving by CSV import or API met no gate at
all. The deploy gate dispatches to a driver's *TagDefinitionFactory.Inspect in its Contracts
assembly, and none of the four had one.
Rather than mirror the AdminUI models (a mirror of an accept/reject boundary drifts silently,
with the gate describing a shape the driver no longer reads), each Inspect is built on the
driver's own runtime parse:
- Sql wraps the existing SqlEquipmentTagParser, which already lived in Sql.Contracts. The
P3-deferred Query model stays refused; Inspect surfaces the refusal rather than weakening it.
- MTConnect's blob reader MOVED out of MTConnectDriver (it was private, with a private DTO)
into MTConnectTagDefinitionFactory; the driver now calls it. Behaviour unchanged.
- OpcUaClient's nodeId read MOVED off NamespaceMap into OpcUaClientTagDefinitionFactory;
NamespaceMap now calls it. Behaviour unchanged.
- Calculation gains a Contracts assembly it never had. Only the JSON reading moved there
(CalculationTagDefinitionFactory.TryReadTagConfig); building a CalculationTagDefinition
still needs EquipmentScriptPaths from Commons and stays in the driver, so the split is at
the blob boundary. The new project is deliberately reference-free — a calc blob is strings,
a bool and an int, with no Core.Abstractions type in it.
Each Inspect runs the parse FIRST and returns empty on success, so "never stricter than the
runtime" is structural rather than a promise: a warning is only reachable for a blob the
driver would have refused and then skipped silently. Calculation additionally reports two
present-but-silently-discarded fields (a non-boolean changeTriggered reads as true; a
non-positive-integer timerIntervalMs is dropped), and deliberately does NOT warn about a
trigger-less tag or a sub-50ms timer — the editor refuses both, the runtime honours both, and
the deploy gate follows the runtime.