Compare commits

...

45 Commits

Author SHA1 Message Date
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
Joseph Doherty 0e9d977f9a test(raw): write the browse-commit parity guard the mapper's comment cites (#555)
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.
2026-08-10 01:38:13 -04:00
Joseph Doherty 36c82b60f7 docs(deferment): #552 (session-stated topByTimestamp) + #553 (evidence-preserving CSV merge) closed; residuals #554/#555 filed 2026-08-10 01:24:59 -04:00
Joseph Doherty 1f351250ed merge: #552 — Sql browse-commit states a topByTimestamp row selector from column-type metadata (catalog-order tie-break); no-candidate leaves refused in words at the modal 2026-08-10 01:21:05 -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 62fe6c83be merge: #553 — CSV typed-column merge preserves the cell's own tokens (no pre-gate laundering); export reads effective values and strips out-ranking aliases 2026-08-10 01:19:51 -04:00
Joseph Doherty cf2af8a5a2 fix(csv): stop the typed-column merge laundering the TagConfigJson cell (#553)
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.
2026-08-10 01:18:55 -04:00
Joseph Doherty f896e0631c docs(deferment): #550 + #551 closed (accept-gate-first fleet conversion, service-enforced authoring gate); residuals #552/#553 filed 2026-08-10 00:26:56 -04:00
Joseph Doherty 333ea48805 merge: #551 — TagConfig gate residuals: accept-gate-first Inspects (7 factories), service-enforced authoring validation (create/update/import), S7 absent-dataType + AbCip members + Modbus stringByteOrder, strict-runtime doc sweep 2026-08-10 00:20:33 -04:00
Joseph Doherty 1ce6e8b107 test(#551): pin browse-commit parity for MTConnect + MQTT, and record the Sql row-selector gap
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.
2026-08-10 00:19:16 -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 b50523b152 merge: #550 — MTConnect driverDataType alias read at runtime precedence + normalised on save (editor can no longer show a lie or retype on save) 2026-08-09 23:49:59 -04:00
Joseph Doherty a486a009dc docs(mtconnect): state the driverDataType-over-dataType precedence and the editor's normalisation (#550) 2026-08-09 23:48:09 -04:00
Joseph Doherty 8971861b7c fix(adminui): MTConnect editor must read driverDataType at the runtime's precedence and drop the alias on save (#550)
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.
2026-08-09 23:44:14 -04:00
Joseph Doherty b0d2fa30e3 docs(deferment): #549 (6-model authoring strictness) + NamespaceMap split recorded; residuals #550/#551 filed 2026-08-09 22:21:05 -04:00
Joseph Doherty 3a70a4e6ea merge: #549 — authoring never accepts a TagConfig enum the runtime rejects (6 models, one shared authority) 2026-08-09 22:15:46 -04:00
Joseph Doherty a3c0ec037f fix(adminui): authoring must not accept a TagConfig enum the runtime rejects (#549)
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.
2026-08-09 22:08:42 -04:00
Joseph Doherty 10c7a0e86d merge: NamespaceMap out of OpcUaClient.Contracts (.Session leaf) — ControlPlane's dependency graph is OPC UA SDK-free 2026-08-09 21:57:08 -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 fefcd2c872 docs(deferment): G-7 + RawBrowseModal race CLOSED 2026-08-09 (§0.1, §1.2, §9 entry) 2026-08-09 21:21:17 -04:00
Joseph Doherty 0695f682c2 merge: G-7 closed — Contracts-side TagConfig Inspect for Sql/MTConnect/Calculation/OpcUaClient wired into the deploy gate 2026-08-09 21:16:16 -04:00
Joseph Doherty 1469fbb4d1 feat(deploy): wire the four G-7 inspectors into the deploy-time TagConfig gate
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.
2026-08-09 21:14:49 -04:00
Joseph Doherty 2947a2b9e2 feat(drivers): Contracts-side TagConfig Inspect for Sql, MTConnect, Calculation and OpcUaClient
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.
2026-08-09 21:14:32 -04:00
Joseph Doherty 0739f4b9a4 merge: RawBrowseModal post-await staleness guard (deferment §0.1 race ported from the 2026-08-05 sibling fix) 2026-08-09 21:08:00 -04:00
Joseph Doherty 87637e4e2c fix(adminui): guard RawBrowseModal's resolve against a superseded open
Blazor does not serialize OnParametersSetAsync against its own outstanding
task, and the modal's `_open` bool cannot tell two opens apart. ResetAndResolveAsync
awaits LoadMergedProbeConfigAsync and ResolveGroupContextAsync and then wrote
_driverType/_mergedConfig/_effectiveDeviceId/_groupPrefix/_canBrowse/_disabledReason
with no staleness re-check, so a close-reopen onto another device let the first
open's late continuation install its device's browse context under the displayed
modal — "Connect & browse" would silently dial the wrong controller and the commit
would land under the wrong tag-group.

Ports the per-open `_openGeneration` counter proven in the two sibling modals
(RawManualTagEntryModal, RawTagModal, 2026-08-05) to every post-await write here:
after both awaits, in the catch's error banner, and in the finally. The finally is
the one departure from the sibling shape and it is load-bearing — this modal resolves
inside a try/finally, so a stale `return` still runs the tail and would drop the LIVE
open's spinner mid-resolve.

Pinned by four RawBrowseModalStaleOpenTests facts, each observed red before the guard
and each re-falsified afterwards by reverting only its own guard. The modal calls
StateHasChanged on the open path, which throws without a render handle, so the test
attaches an inert Renderer subclass (file-local BL0006 pragma); [Parameter]s are set
by reflection per the BL0005 convention.

AdminUI suite 1117/1117 green.
2026-08-09 21:07:07 -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
Joseph Doherty 20a816dbf4 feat(galaxy): consume the gateway's correlated write statuses; close archreview 06/S-1
The mxaccessgw OnWriteComplete correlation is live (server b948e69, worker
53f69cd — all four unary write kinds), so every write reply's statuses[0] now
carries the real COM commit outcome. Live-gating the cutover on windev found
and fixed three defects:

- StatusCodeMap.FromMxStatus truncated the correlated failure row's MxStatus
  detail code to a byte (1007 & 0xFF = 239 -> OPC DA Good band), mapping a
  REFUSED write to Good. New invariant: a row that is not IsSuccess() never
  maps Good — details > 0xFF (and contradictory Good-band substatuses) route
  by category (SECURITY_ERROR -> BadUserAccessDenied, named const covered by
  StatusCodeParityTests; COMMUNICATION/CONFIGURATION keep their codes).

- The borrow-from-subscription-registry write optimization could never commit:
  a subscription handle is plain-advised, and MXAccess refuses both the
  no-login write through it (SECURITY_ERROR detail 1008, live-captured) and
  the supervisory advise upgrade (E_INVALIDARG) — one advise per HANDLE, not
  per attribute. Removed: writer subscribedHandleSource ctor param + borrow
  branch, GalaxyDriver wiring, SubscriptionRegistry.TryResolveItemHandle +
  its forward index and test file. The writer always AddItems its own
  supervisory-advised handle, which commits fine while the same attribute is
  subscribed (live-proven; pinned by the new write-while-subscribed smoke).

- (gateway-side, fixed there) the correlation initially covered WriteSecured
  only, while the dominant no-login path emits plain Write.

Live gate, all legs PASSED against windev: happy write Good off a confirmed
status row with galaxy.writes.unconfirmed + advise_failed both FLAT; read-only
refusal surfaces Bad-family (the #5 node-revert input Galaxy could never
produce before); WriteCompletionWaitMilliseconds=0 control -> provisional Good
+ metered unconfirmed (override removed after, restoration re-verified). New
env-gated smokes: Live_correlated_write_statuses_confirm_commit_and_surface_refusal,
Live_write_to_subscribed_tag_commits_via_own_handle (replaces the borrow
smoke), GatewayGalaxyWriteMeterLiveTests (run filtered — meters are
process-wide).
2026-08-09 20:11:55 -04:00
Joseph Doherty 1038c0e23c merge: connect-path resilience (DriverCapability.Initialize) — R2-10 T10 gate PASSED 2026-08-08 05:45:19 -04:00
Joseph Doherty dac7e1885d feat(resilience): route IDriver.InitializeAsync through the capability invoker (R2-10 T10 closed)
The connect path was the one capability call the resilience pipeline never
saw, so a driver that could not connect contributed zero breaker input and
the /hosts resilience panel read 'All targets healthy' against a dead
endpoint. InitializeAsync now routes through the invoker as
DriverCapability.Initialize, keyed on the driver-instance id.

- No pipeline retry: the actor's RetryConnect timer is the retry loop
  (parser forces retryCount overrides to 0, same funnel as write-shaped).
- Breaker threshold 2, not the Read-sized 5: the breaker samples 30 s and
  the reconnect timer fires every 10 s, so at most three attempts fit one
  window — threshold 5 could never open. Pinned by its own test.
- An open breaker fast-fails into the same InitializeFailed path as a real
  connect failure; the 15 s break window costs at most one extra tick.
  The best-effort teardown ShutdownAsync stays unwrapped.
- Companion fix: DriverCapabilityInvokerFactory.Create now invalidates the
  instance's tracker snapshots with its pipelines — a respawn during an
  outage discarded the only breaker whose OnClosed could clear the OPEN
  flag, stranding a false 'Breaker OPEN' chip forever.

Live gate PASSED on docker-dev (image symbol-verified per container):
rtu-gate against dead 10.100.0.35:5021 opened its Initialize breaker on
both centrals with the red 'BREAKER OPEN' chip on /hosts; a socat
5021→5020 forwarder recovered it (both breakers closed within 1 s, panel
back to 'All targets healthy'); healthy MAIN-modbus recorded zero breaker
events. Full suite 50/50 green. Evidence:
docs/plans/2026-08-07-adminui-followups-and-live-gates.md §7.
2026-08-08 05:45:08 -04:00
Joseph Doherty c9f182b2d7 merge: adminui-followups closed by audit + live gates (R2-03 T11/T12, reconfigure T3 PASSED; R2-10 re-diagnosed structural) 2026-08-08 05:04:07 -04:00
Joseph Doherty 1e02661598 chore(gates): close adminui-followups by audit; R2-03 T11/T12 + reconfigure Task 3 PASSED, R2-10 re-diagnosed
The two items deferment.md §0.1 named as the remaining untracked engineering.

adminui-followups (19 pending / 0 completed) — CLOSED by the overlap audit the plan
itself asks for first, with no code written. All 19 tasks are shipped, superseded by
v3, or moot. WS1's premise had also expired: it assumed driver-embedded tag lists in
DriverConfig are the runtime source of truth, which v3 ended (the artifact populates
RawTags). Its capability shipped in the v3 shape — TagConfigEditorMap + DeviceFormMap,
both verified reachable from RawTagModal/DeviceModal rather than merely present.

Live gates on docker-dev (image rebuilt: the rig was running a build 3 h older than the
last code commit; parity asserted per container plus a positive check that the symbols
under test are in the shipped DLLs):

- R2-03 T11 PASSED — build 0 errors, Runtime.Tests 555/555, RoslynVirtualTagEvaluator 20/20.
- R2-03 T12 PASSED — VT 42 -> 0x80020000 -> 42, staged WITHOUT a deployment (a Modbus
  register flipped on the fixture) so the VT child survived; a deploy-driven trip creates
  a fresh child with no last value, which is correct-by-design and masked the result twice.
  P7 confirmed: two byte-identical redeploys, zero disturbance.
- driver-reconfigure Task 3 PASSED — a faulted driver adopted a corrected config and
  connected with the node's startedAt unchanged and RestartCount=0.
- R2-10 T10 still OPEN, but the recorded cause was wrong. Not ConnectionBackoff
  throttling (the breaker opens on 2 failures / 30 s): the dead-endpoint failure mode
  never enters the resilience pipeline. IDriver.InitializeAsync is not invoker-wrapped
  (DriverInstanceActor.cs:707) and ModbusDriver swallows per-tag failures in BOTH
  directions, so even the plan's fallback trigger cannot work. Live-proven: a driver
  timing out every read against a blackhole reported ConsecutiveFailures=0 while the
  panel read "All targets healthy."

Finding from T12: the Bad publish's documented "carrying the last-known value" is
unobservable by any client — subscription and a raw Read service call both return NULL.
The actor is correct; every existing failure assertion checked StatusCode alone, so the
value half was asserted nowhere. Adds
VirtualTagActorTests.The_Bad_result_carries_the_last_known_value, which proves the actor
emits it. The documentation is what overstates.
2026-08-07 06:28:22 -04:00
119 changed files with 10224 additions and 1011 deletions
+2
View File
@@ -22,6 +22,7 @@
</Folder>
<Folder Name="/src/Drivers/">
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
@@ -47,6 +48,7 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj" />
@@ -79,7 +79,7 @@
{
"id": "R2-03-T11",
"subject": "Full-suite sweep (Runtime.Tests whole project + RoslynVirtualTagEvaluatorTests + solution build) and STATUS.md / tasks.json bookkeeping",
"status": "deferred-live",
"status": "completed",
"blockedBy": [
"R2-03-T4",
"R2-03-T5",
@@ -87,18 +87,20 @@
"R2-03-T10"
],
"note": "integration/full sweep; serialized heavy pass (build clean, Runtime.Tests 372/372, RoslynVirtualTagEvaluatorTests 20/20 verified locally; whole-solution/cross-suite sweep deferred per controller memory constraint)",
"openNote": "STILL OPEN \u2014 live-blocked on a data-less rig: every materialised node reads Bad_WaitingForInitialData because no driver data flows, so a VT cannot be staged Good->Bad. Needs a reachable driver fixture feeding the VT. Unit-verified meanwhile."
"openNote": "STILL OPEN \u2014 live-blocked on a data-less rig: every materialised node reads Bad_WaitingForInitialData because no driver data flows, so a VT cannot be staged Good->Bad. Needs a reachable driver fixture feeding the VT. Unit-verified meanwhile.",
"closureNote": "PASSED 2026-08-07. Solution build 0 errors; Runtime.Tests 555/555 (3 deliberate EquipmentDeviceBindingRetired skips); RoslynVirtualTagEvaluatorTests 20/20. Note the suite lives in Host.IntegrationTests, which a solution-wide --filter does not reach. Evidence: docs/plans/2026-08-07-adminui-followups-and-live-gates.md \u00a72."
},
{
"id": "R2-03-T12",
"subject": "Live-/run gate on docker-dev (rebuild BOTH centrals): author working VT, break it, Client.CLI read shows StatusCode Bad with last value, fix and read Good; P7 no-recompile spot-check on identical redeploy",
"status": "deferred-live",
"status": "completed",
"blockedBy": [
"R2-03-T11"
],
"note": "docker-dev live-/run; serial live pass",
"openNote": "STILL OPEN \u2014 same blocker as T11. Confirmed still open 2026-07-27 (deferment.md \u00a78.5)."
"openNote": "STILL OPEN \u2014 same blocker as T11. Confirmed still open 2026-07-27 (deferment.md \u00a78.5).",
"closureNote": "PASSED 2026-08-07 on docker-dev (both centrals rebuilt; image parity asserted per container). Good(42) -> Bad(0x80020000 BadInternalError) -> Good(42), staged WITHOUT a deployment by flipping a Modbus holding register on the fixture, so the VT child survived the transition (a deploy-driven trip creates a fresh child with no last value \u2014 correct per the inputs-ready gate). P7: two byte-identical redeploys (identical revisionHash) caused zero disturbance, Ever-went-BAD=0. FINDING: the Bad publish does NOT carry the last-known value to the client (subscription AND raw Read both return NULL), contradicting VirtualTagActor.cs:22 and this plan STATUS row. The actor is correct \u2014 new test VirtualTagActorTests.The_Bad_result_carries_the_last_known_value proves it emits 42; the value is lost in the OPC UA layer. No existing test asserted that half. Evidence: docs/plans/2026-08-07-adminui-followups-and-live-gates.md \u00a73."
}
],
"lastUpdated": "2026-07-27"
}
"lastUpdated": "2026-08-07"
}
@@ -1,6 +1,6 @@
{
"planPath": "archreview/plans/R2-10-resilience-observability-plan.md",
"lastUpdated": "2026-07-27",
"lastUpdated": "2026-08-07",
"tasks": [
{
"id": "T1",
@@ -74,13 +74,15 @@
{
"id": "T10",
"subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim",
"status": "deferred-live",
"status": "completed",
"blockedBy": [
"T6",
"T8",
"T9"
],
"openNote": "PARTIAL \u2014 the resilience PIPELINE is live-verified (Polly CircuitBreaker -> Retry -> Timeout -> CapabilityInvoker in central-1 stack traces), but breaker-OPEN state was never forced: R2-09's ConnectionBackoff throttles retries so failures don't accumulate fast enough to open the Polly breaker. The reliable trigger is a rapid idempotent write to a dead endpoint. Confirmed still open 2026-07-27 (deferment.md \u00a78.5)."
"openNote": "PARTIAL \u2014 the resilience PIPELINE is live-verified (Polly CircuitBreaker -> Retry -> Timeout -> CapabilityInvoker in central-1 stack traces), but breaker-OPEN state was never forced: R2-09's ConnectionBackoff throttles retries so failures don't accumulate fast enough to open the Polly breaker. The reliable trigger is a rapid idempotent write to a dead endpoint. Confirmed still open 2026-07-27 (deferment.md \u00a78.5).",
"passNote": "PASSED 2026-08-08 after the structural gap was FIXED (feat/wrap-driver-init-in-resilience): IDriver.InitializeAsync now routes through the invoker as DriverCapability.Initialize (no retry \u2014 the actor's RetryConnect timer is the retry loop; tier-default breaker threshold 2, because at the 10 s reconnect cadence at most three attempts fit the 30 s sampling window and a threshold of 5 could never open). Live on docker-dev (image df04c76ce06b, symbol-verified on both centrals): rtu-gate (DRV-3616122eb53d, endpoint 10.100.0.35:5021 with no listener) opened its Initialize breaker on BOTH centrals ('circuit-breaker OPENED \u2026 capability=Initialize \u2026 Connection refused'), the /hosts RESILIENCE panel showed the red 'BREAKER OPEN \u00b7 OPENED 19S AGO' chip, and after a socat 5021\u21925020 forwarder brought the endpoint alive both breakers closed within 1 s of each other ('circuit-breaker closed (recovered)' + 'reconnected') and the panel cleared to 'All targets healthy.' Control: healthy MAIN-modbus recorded zero breaker events. Two nuances: (1) 'both-replica chips' resolves as both nodes' TRACKERS tripping (proven in both logs) rendered through the single ACTIVE AdminUI \u2014 Traefik's /health/active check deliberately routes the UI to the active admin node only (standby returns 503 'Standby: active node for role admin is \u2026'), so there is never a second replica serving chips; (2) the staleness-dim leg was NOT exercised \u2014 both pair nodes publish under the same key, so staleness needs every publisher for a key to stop, which requires deliberate node-kill churn this gate did not do. Companion fix shipped in the same change: DriverCapabilityInvokerFactory.Create now Invalidates the instance's TRACKER snapshots alongside its pipelines, because a respawn during an outage discarded the only breaker whose OnClosed could clear the OPEN flag \u2014 a deploy mid-outage would have stranded a false OPEN chip forever (unreachable before this change made breaker-open reachable). Evidence: docs/plans/2026-08-07-adminui-followups-and-live-gates.md \u00a77.",
"closureNote": "ATTEMPTED 2026-08-07, NOT ACHIEVABLE AS SPECIFIED \u2014 and the reason is structural, not environmental. The breaker only sees failures that propagate out of a CapabilityInvoker-wrapped call, and the dead-endpoint path produces none: (1) IDriver.InitializeAsync is NOT wrapped (DriverInstanceActor.cs:707 calls it directly; the invoker wraps Read/Write/Subscribe/Discover/AlarmAcknowledge only), so a driver that cannot connect contributes zero breaker input \u2014 refuting this task's own premise; (2) ModbusDriver swallows per-tag failures in BOTH directions \u2014 ReadAsync -> Degraded health (:365-375,:838-855), WriteAsync -> WriteResult status \u2014 so even the fallback trigger this plan suggests (rapid idempotent write-to-dead-endpoint) cannot work. Live-proven: MAIN-modbus pointed at a TCP blackhole went DEGRADED with every read timing out (0x80050000) while the tracker held ConsecutiveFailures=0 and the panel showed \"All targets healthy.\" The breaker is configured to open on 2 failures in a 30 s window at ratio 1.0, so the previously-recorded ConnectionBackoff-throttling explanation is not the operative one. VERIFIED LIVE regardless: panel renders on /hosts (rehomed by #546; this plan's \"8 driver pages\" placement is stale), RESILIENCE block present, and the publisher emits real tracker state every 5 s. To close: wrap the connect path, stop drivers swallowing capability failures, or use a driver whose wrapped call fails AFTER a successful init. Evidence: docs/plans/2026-08-07-adminui-followups-and-live-gates.md \u00a74."
},
{
"id": "T11",
@@ -91,4 +93,4 @@
]
}
]
}
}
+34
View File
@@ -253,6 +253,40 @@ section: R2-03 T11/T12 is deferred-live pending a data-fed driver fixture, and R
breaker-OPEN state was never forced — the pipeline is live-proven but the explicit `breaker OPENED`
line + resilience chip were not triggered.)*
> **Update 2026-08-07 (live-gate run) — one of the two closed, the other re-diagnosed.**
> **R2-03 T11 + T12 ✅ PASSED.** The "data-less rig" block is gone: the Modbus fixture at
> `10.100.0.35:5020` is now reachable from inside the rig, so a VT could be staged Good and then
> tripped. Good(42) → `0x80020000` → Good(42), staged *without a deployment* (a Modbus register was
> flipped on the fixture) so the VT child survived the transition; P7 confirmed by two byte-identical
> redeploys causing zero disturbance. ⚠️ **But the R2-03 row below overstates one thing:** "carrying
> last-known value" is **not observable by any client** — subscription *and* raw Read both return
> `Value = NULL` with the Bad status. The actor is correct (new test
> `VirtualTagActorTests.The_Bad_result_carries_the_last_known_value` proves it emits the value); the
> value is lost in the OPC UA layer, and no test asserted that half.
> **R2-10 T10 ❌ still OPEN — but the recorded cause was wrong.** It is not that ConnectionBackoff
> throttles retries below the threshold (the breaker opens on 2 failures in a 30 s window). The
> dead-endpoint failure mode never enters the resilience pipeline at all: `IDriver.InitializeAsync`
> is **not** invoker-wrapped (`DriverInstanceActor.cs:707`), and `ModbusDriver` swallows per-tag
> failures in **both** directions — so even this plan's suggested fallback trigger (rapid idempotent
> write-to-dead-endpoint) cannot work. Live-proven: a driver timing out every read against a
> blackhole showed `ConsecutiveFailures = 0` and a panel reading *"All targets healthy."*
> Full evidence: [`docs/plans/2026-08-07-adminui-followups-and-live-gates.md`](../../docs/plans/2026-08-07-adminui-followups-and-live-gates.md).
> **Update 2026-08-08 — R2-10 T10 ✅ PASSED, by fixing the structural gap the entry above diagnosed.**
> `IDriver.InitializeAsync` now routes through the invoker as **`DriverCapability.Initialize`**
> (branch `feat/wrap-driver-init-in-resilience`): no pipeline retry (the actor's reconnect timer is
> the retry loop, parser-enforced), breaker threshold **2** (at the 10 s reconnect cadence at most
> three attempts fit the 30 s sampling window — a Read-sized 5 could never open; pinned by its own
> test), and `DriverCapabilityInvokerFactory.Create` now invalidates the instance's **tracker**
> snapshots with its pipelines (else a deploy mid-outage strands a false OPEN chip forever).
> Live on docker-dev: `rtu-gate` vs. dead `:5021` → breaker OPEN on **both** centrals + the red
> "BREAKER OPEN" chip on `/hosts`; socat `5021→5020` up → both breakers closed within 1 s and the
> panel cleared to "All targets healthy." (healthy `MAIN-modbus` control: zero breaker events).
> This is also the **first live sighting of the `circuit-breaker OPENED` log line**, closing
> FOLLOWUP-10's behavioral gate. Two narrowings vs. this plan's T10 wording: the chips render on the
> single **active** AdminUI (Traefik `/health/active` 503s the standby by design — "both replicas"
> means both trackers), and the staleness-dim leg was not exercised. Evidence: the same doc, §7.
### Residual follow-ups — tracked in Gitea (2026-07-15)
The remaining non-blocking items are filed as issues (none gate the remediation, which is complete):
+397 -18
View File
@@ -53,14 +53,14 @@ So everything below is **untracked work**. Nothing in it is a data-loss or outag
| What | Nature | Where |
|---|---|---|
| **4 live gates blocked on hardware/licence** — archreview R2-03 (VT Good→Bad on a data-fed rig), R2-10 (force breaker-OPEN), Galaxy `ScanStateProbeParityTests` (licence-gated), v2 GA exit criteria (~~FOCAS live-CNC smoke #54~~ *closed — live-validated on a real 31i-B 2026-06-25, see `docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md`*, OPC UA CTT pass, Ignition 8.3 cutover) | Cannot be closed from this box. Not decisions — inputs. | §5 |
| **1 deferred live-verify task** — `2026-06-14-driver-reconfigure-while-faulted` Task 3, subject literally "DEFERRED (skipped)" | Now runnable: docker-dev login is disabled, so `/run` verification needs no operator. | §5 |
| **G-7 residual** — Sql / MTConnect / Calculation / OpcUaClient have no Contracts-side `Inspect` | **The deploy gate is weaker than the authoring gate.** `TagConfigValidator` covers 11 of 12, so config arriving by **CSV import or API** — the paths that skip the UI — meets only the weaker gate. | §1.2 |
| ~~**3 carried-open archreview findings** — the failure-visibility trio~~ **Update 2026-08-07 (truth sweep): mostly CLOSED.** 01/S-1 **FIXED** — `AddressSpaceApplier.cs:95-97` tallies swallowed removal-pass failures into `AddressSpaceApplyOutcome.FailedNodes` (comment cites 01/S-1). 03/S4 **FIXED** — `PrimaryGatePolicy.cs` default-denies on unknown role when a driver peer exists (doc-comment "Fixes archreview 03/S4"), live-proven R2-04 T15. 06/S-1 **PARTIAL** — fail-closed advise path + `galaxy.writes.unconfirmed` meter shipped in `GatewayGalaxyDataWriter.cs`; residual = gateway-side WriteComplete correlation (cross-repo, mxaccessgw). | Residual is only the 06/S-1 cross-repo half. | §6.3 |
| **1 genuinely unexecuted plan** — `2026-05-29-adminui-followups`, **19 pending / 0 completed** | The only substantial engineering left. ⚠️ Its first step is an **overlap audit**, not code: several items look superseded by v3's `TagConfigEditorMap`. | §6.2 |
| **11 other open plan tasks** — ~~5 akka-hosting partials (likely v3-superseded)~~ *(verified superseded 2026-08-07 — F8/F9/F10/F13/F14 marked completed; `Phase7Composer`/`ScriptedAlarmActor` deleted, evaluators/SDK sinks/security policies all production-wired)*, 4+1 followups-batch (open *by choice*), fixedtree Task 11, 1 manual smoke checklist | Small, and two of the groups are decisions already taken. | §6.2 |
| **4 live gates blocked on hardware/licence** — ~~archreview R2-03 (VT Good→Bad on a data-fed rig)~~ ✅ *PASSED 2026-08-07*, ~~R2-10 (force breaker-OPEN)~~ ✅ *PASSED 2026-08-08 by fixing the structural gap (`DriverCapability.Initialize`)*, Galaxy `ScanStateProbeParityTests` (licence-gated), v2 GA exit criteria (~~FOCAS live-CNC smoke #54~~ *closed — live-validated on a real 31i-B 2026-06-25, see `docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md`*, OPC UA CTT pass, Ignition 8.3 cutover) | Cannot be closed from this box. Not decisions — inputs. *(The two archreview gates turned out to be closable from this box after all — §5.)* | §5 |
| ~~**1 deferred live-verify task** — `2026-06-14-driver-reconfigure-while-faulted` Task 3~~ ✅ **PASSED 2026-08-07.** A faulted driver ("initialize failed: Connection refused") adopted a corrected config and connected while faulted, with the node's `startedAt` byte-identical before and after and `RestartCount=0` — no restart. | §5 |
| ~~**G-7 residual** — Sql / MTConnect / Calculation / OpcUaClient have no Contracts-side `Inspect`~~ ✅ **CLOSED 2026-08-09** (`2947a2b9` + `1469fbb4`, merge `0695f682`). All four now ship a Contracts-side `*TagDefinitionFactory.Inspect` **wrapping the runtime parse, never mirroring the AdminUI model** — Sql wraps the pre-existing `SqlEquipmentTagParser`; the MTConnect / OpcUaClient / Calculation blob readers were **moved** into their Contracts assemblies (the drivers now call them), so never-stricter-than-runtime is structural. `Driver.Calculation.Contracts` was created (no Contracts assembly existed). The reflection coverage guard was falsified (map entry removed → red naming `SqlTagDefinitionFactory`), and `TagConfigAuthoringDeployParityTests` pins authoring-accepts ⇒ deploy-accepts. **Galaxy is now the sole unmapped type, deliberately + symmetrically** (no typed authoring editor either). ~~Found en route, left open: `MTConnectTagConfigModel` reads `dataType` leniently (misspelled name → silently `String`) while the runtime rejects it — authoring weaker than runtime, the *opposite* direction from G-7; documented inline in the parity tests.~~ ✅ Filed and FIXED same day as **#549** — and it was 6 models wide, not 1 (see the "2026-08-09 (later)" §9 entry); residuals **#550**/**#551** are ✅ **CLOSED 2026-08-10** (merges `b50523b1` + `333ea488`, see the 2026-08-10 §9 entry) — their own residuals are **#552** (Sql browse-commit emits a config the gate rejects) / **#553** (CSV typed-column merge silently rewrites typo'd enums pre-gate). | ~~The deploy gate is weaker than the authoring gate.~~ Config arriving by CSV import or API now meets the same checks the modal applies. | §1.2 |
| ~~**3 carried-open archreview findings** — the failure-visibility trio~~ **Update 2026-08-07 (truth sweep): mostly CLOSED.** 01/S-1 **FIXED** — `AddressSpaceApplier.cs:95-97` tallies swallowed removal-pass failures into `AddressSpaceApplyOutcome.FailedNodes` (comment cites 01/S-1). 03/S4 **FIXED** — `PrimaryGatePolicy.cs` default-denies on unknown role when a driver peer exists (doc-comment "Fixes archreview 03/S4"), live-proven R2-04 T15. 06/S-1 **PARTIAL** — fail-closed advise path + `galaxy.writes.unconfirmed` meter shipped in `GatewayGalaxyDataWriter.cs`; residual = gateway-side WriteComplete correlation (cross-repo, mxaccessgw). **Update 2026-08-09: the gateway half SHIPPED** — mxaccessgw main `b948e69` correlates the worker's `OnWriteComplete` onto the unary `WriteSecured` reply (`statuses[0]`; bounded wait default **1.5 s** — deliberately under OtOpcUa's 2 s Tier A Write budget, configurable via `MxGateway:Worker:WriteCompletionWaitMilliseconds`; timeout falls back to today's empty-statuses shape, still metered unconfirmed). **Update 2026-08-09 (later): 06/S-1 CLOSED — live-gated end to end, and the gate found + fixed three defects.** The user-authorized redeploy exposed (1) the correlation covered `WriteSecured` only while OtOpcUa's dominant no-login path emits plain `Write` — fixed gateway-side (worker `53f69cd`, all four unary write kinds, redeployed both hosts); (2) OtOpcUa's `StatusCodeMap.FromMxStatus` truncated the correlated failure row's MxStatus detail code to a byte (`1007 & 0xFF = 239` → OPC DA Good band), mapping a REFUSED write to Good — fixed with the not-IsSuccess-never-Good invariant + category routing (`SECURITY_ERROR → BadUserAccessDenied`); (3) the writer's borrow-from-subscription-registry optimization could never commit (a subscription handle is plain-advised; MXAccess refuses the no-login write `SECURITY_ERROR 1008` AND the advise upgrade `E_INVALIDARG` — one advise per HANDLE, not per attribute) — borrow path REMOVED, writer always owns a supervisory-advised handle. Live legs all PASSED on windev: happy write Good off confirmed `statuses[0]` + both meters flat; read-only refusal surfaces Bad-family + node-revert input; `WriteCompletionWaitMilliseconds=0` control → provisional Good + metered unconfirmed (override removed after). Pinned by `Live_correlated_write_statuses_confirm_commit_and_surface_refusal`, `Live_write_to_subscribed_tag_commits_via_own_handle`, `GatewayGalaxyWriteMeterLiveTests`, and the new `StatusCodeMapTests` rows. | ~~Residual is only the 06/S-1 live leg~~ **CLOSED 2026-08-09** — nothing remains. | §6.3 |
| ~~**1 genuinely unexecuted plan** — `2026-05-29-adminui-followups`, **19 pending / 0 completed**~~ ✅ **CLOSED 2026-08-07 by the overlap audit it asked for — it was bookkeeping drift, not backlog.** All 19 tasks are shipped, superseded by v3, or moot; **no code was needed.** The warning was right and then some: WS1's whole *premise* died with v3 (driver-embedded tag lists in `DriverConfig` are no longer the runtime source of truth — the artifact populates `RawTags`), and its capability was delivered in the v3 shape (`TagConfigEditorMap` + `DeviceFormMap`, both verified **reachable**, not merely present). WS2/WS3 shipped; WS4's target files were deleted by v3 Batch 2 + #542. Per-task evidence in the `.tasks.json`. | §6.2 |
| ~~**11 other open plan tasks**~~ **3 as of 2026-08-10** — ~~5 akka-hosting partials (likely v3-superseded)~~ *(verified superseded 2026-08-07 — F8/F9/F10/F13/F14 marked completed; `Phase7Composer`/`ScriptedAlarmActor` deleted, evaluators/SDK sinks/security policies all production-wired)*, ~~4+1 followups-batch (open *by choice*)~~ *(2026-08-10: Tasks 9 + 10 VERIFIED PASS live — Int64 authoring end-to-end on the rebuilt rig; probe E2E 12/12 with the "needs Windows-VM" premise corrected to TCP-reachability gating)*, ~~fixedtree Task 11~~ *(closed 2026-08-10: obsolete — the injection feature it gated was deleted with #507)*, ~~1 manual smoke checklist~~ *(closed 2026-08-10: obsolete — the driver pages it smokes were retired in v3 Batch 2)*. **Remaining 3: F10b + SetSeverity (reconsider gates, decisions already taken — do not build without a fresh go-ahead) and the device-gated proofs (hardware-blocked).** | Small, and two of the groups are decisions already taken. | §6.2, §9 2026-08-10 (sixth) |
| **Review-coverage gap** — 10 shipped projects never reviewed (Calculation, Historian.Gateway, Mqtt +Browser/Contracts, MTConnect +Contracts, Sql +Browser/Contracts) while **3 modules still review the retired Wonderware backend** | Re-verified 2026-08-04 against `code-reviews/`. Unchanged since the audit. | §6.3 |
| **`RawBrowseModal` stale-open race** (added 2026-08-05) — `OnParametersSetAsync` never re-checks staleness after its awaits, so a superseded open's late resolve can install the wrong device's browse config. The identical shape was found by review and **fixed** in `RawTagModal`/`RawManualTagEntryModal` (post-await staleness re-check; see `docs/plans/2026-08-05-raw-modal-picker-live-config-design.md` §6); the browse modal was deliberately left out of that branch's scope. | Lower stakes than the fixed pair (the modal resolves the device it was opened for and shows its own error banner), but the fix pattern is proven and cheap to port. | design doc §6 |
| ~~**`RawBrowseModal` stale-open race** (added 2026-08-05) — `OnParametersSetAsync` never re-checks staleness after its awaits, so a superseded open's late resolve can install the wrong device's browse config. The identical shape was found by review and **fixed** in `RawTagModal`/`RawManualTagEntryModal` (post-await staleness re-check; see `docs/plans/2026-08-05-raw-modal-picker-live-config-design.md` §6); the browse modal was deliberately left out of that branch's scope.~~ ✅ **CLOSED 2026-08-09** (`87637e4e`, merge `0739f4b9`) — the sibling `_openGeneration` guard ported to every post-await write, **plus two departures the siblings did not need**: this modal resolves inside `try/finally`, so the `catch` (a stale open's failure would paint its error over the live device) and the `finally` (a stale `return` still runs the tail, dropping the LIVE open's spinner) are generation-guarded too. Four `RawBrowseModalStaleOpenTests` facts, each observed red pre-guard and each re-falsified by reverting only its own guard (1-failed/3-passed per revert — no redundant pin). | ~~Lower stakes than the fixed pair… but the fix pattern is proven and cheap to port.~~ Ported. | design doc §6 |
⚠️ **One correction worth carrying forward.** archreview **03/S4** — "primary gate default-allows on
unknown role" — is the *same shape* as a decision deliberately taken on 2026-08-04 in
@@ -87,7 +87,7 @@ themes into tracked decisions. What remains:
| **G-7** `EquipmentTagConfigInspector` coverage | ◐ **Narrowed + guarded** (2026-07-28): MQTT wired (it shipped an `Inspect` nobody called), and a reflection guard now fails the build if a reachable driver ships one and is not mapped. **Residual:** Sql / MTConnect / Calculation / OpcUaClient have no Contracts-side `Inspect` at all, so the deploy gate stays blind for them while the AdminUI validates them — see §1.2. | §1.2 |
| ~~**13 `EquipmentTagsDarkBatch4` skipped tests**~~ | ✅ **Done** — 6 revived onto the raw/UNS shape, 3 folded into a widened live parity corpus, 4 deleted as retired. The reason constant is gone. **Skips now: `Runtime.Tests` 13 → 3, `OpcUaServer.Tests` 4 → 1** — all remaining are deliberate `EquipmentDeviceBindingRetired` tombstones. | §9 (2026-07-28, skips) |
| 17 other tracker issues | Driver features + deferred work, unchanged by this remediation. | §2 |
| Open live gates | ~~#491 continuous historization, #468 browser tree~~ ✅ **both PASSED since** (#468 `c2c3c522`, #491 `e77ea4aa` + the #537/#538 gate `4bdce910`); archreview R2-03 / R2-10 remain. *(The un-gated drift detector's gate closed by deletion — see #523.)* | §5 |
| Open live gates | ~~#491 continuous historization, #468 browser tree~~ ✅ **both PASSED since** (#468 `c2c3c522`, #491 `e77ea4aa` + the #537/#538 gate `4bdce910`); ~~archreview R2-03 / R2-10~~ ✅ **both PASSED** (R2-03 2026-08-07; R2-10 2026-08-08, closed by fixing the structural gap). *(The un-gated drift detector's gate closed by deletion — see #523.)* | §5 |
✅ **The tracker is now clean of stale-open issues** (2026-07-30). **#516**, **#518**, **#507** and
**#523** were closed, each with a resolution comment recording what was actually done — which differs
@@ -158,10 +158,10 @@ Every gap below is in the **authoring/dispatch layer, not driver runtime code**.
| G-1 | **`DriverConfigModal.razor` has no `Calculation` case** — falls to the `default` "No typed config form" warning; no `CalculationDriverForm.razor` exists | `DriverConfigModal.razor:34-72`, default arm `:69-71` | `CalculationDriverOptions.RunTimeout` (`CalculationDriverOptions.cs:19`) is **unauthorable via the UI**. Create/rename still work, so degraded — not a hard block. **This is the same class as the Sql picker defect, one modal further downstream.** | ✅ Fixed — `CalculationDriverForm.razor` |
| G-2 | **`DeviceModal.razor` is missing Sql, MTConnect, Calculation** | `DeviceModal.razor:42-74`, default arm `:71-73` | Mitigated: Sql/MTConnect hold connection at the **driver** level, and Test Connect still works via `BuildMergedProbeConfig` (`:180-184`). | ✅ Fixed — declared **single-connection** rather than given hollow device forms |
| G-3 | **`DriverConfigModal.razor:77` tells Sql/MTConnect operators the wrong thing** — the single-connection branch is hardcoded to `Galaxy or Mqtt`, so Sql/MTConnect users are told the endpoint lives on the *device* when they just authored it on the driver | `DriverConfigModal.razor:77`, `:86-88` | Actively misleading, compounds G-2. | ✅ Fixed — `DeviceFormMap.IsSingleConnection` |
| G-4 | **No parity test guards `DriverConfigModal` or `DeviceModal`** — `RawDriverTypeDialogCoverageTests`/`…ParityTests` assert `DriverTypeNames` ↔ **picker** parity only | — | **This is exactly why G-1 and G-2 survived review.** The guard pattern exists and simply was not extended to the two downstream dispatch maps. | ✅ Fixed — `DriverFormMapParityTests` + `DriverDispatchMapParityTests` |
| G-4 | **No parity test guards `DriverConfigModal` or `DeviceModal`** — `RawDriverTypeDialogCoverageTests`/`…ParityTests` assert `DriverTypeNames` ↔ **picker** parity only | — | **This is exactly why G-1 and G-2 survived review.** The guard pattern exists and simply was not extended to the two downstream dispatch maps. | ✅ Fixed — `DriverFormMapParityTests` + `DriverDispatchMapParityTests` (since #558/`7482cf0c` the browse-commit fact lives in `RawBrowseCommitMapperParityTests`; `DriverDispatchMapParityTests` guards CsvColumnMap only) |
| G-5 | **`CsvColumnMap` has no typed columns for Sql, MQTT, MTConnect** | `CsvColumnMap.cs:340-452` | CSV import/export degrades to the raw `TagConfigJson` fallback while the 7 older drivers get typed columns. | ✅ Fixed — CSV maps for Sql/Mqtt/MTConnect |
| G-6 | **`RawBrowseCommitMapper` has no Sql branch** — falls to the generic `WriteSingleKey("address", …)` whose comment claims "Browsable drivers are all handled above" | `RawBrowseCommitMapper.cs:121-141`, fallback `:145` | Untrue since `SqlDriverBrowser` was registered (`EndpointRouteBuilderExtensions.cs:83`). Same failure mode the MTConnect branch (`:135-139`) was added to avoid: typed editor opens empty, blanks the field on save. | ✅ Fixed — **and at the browser end too**, which the audit missed |
| G-7 | **`EquipmentTagConfigInspector` covers only 6 driver types** | `:24-29` | Pre-existing (OpcUaClient + Galaxy absent too); no new driver was added. **The audit understated it:** the AdminUI's `TagConfigValidator` covers **11 of 12**, so the *deploy* gate is weaker than the *authoring* gate — and config arriving by CSV import or API only meets the deploy gate. | ◐ **Narrowed + guarded** — MQTT wired, reflection guard added; 4 drivers still have no Contracts-side `Inspect`. See §9 |
| G-7 | **`EquipmentTagConfigInspector` covers only 6 driver types** | `:24-29` | Pre-existing (OpcUaClient + Galaxy absent too); no new driver was added. **The audit understated it:** the AdminUI's `TagConfigValidator` covers **11 of 12**, so the *deploy* gate is weaker than the *authoring* gate — and config arriving by CSV import or API only meets the deploy gate. | ✅ **CLOSED 2026-08-09** — MQTT wired + reflection guard (2026-07-28), then the four remaining drivers gained Contracts-side `Inspect` wrapping their runtime parses (merge `0695f682`). The gate now covers **11 of 12** — parity with the AdminUI validator; Galaxy is the sole unmapped type on both, symmetrically. See §9 |
### 1.3 Stale in-code comments (harmless at runtime, misleading to the next reader)
@@ -410,9 +410,9 @@ mere env-var presence, so it cannot pass vacuously.
| ~~**Tag-set drift detection** (shipped `e77c8a35`)~~ | ✅ **CLOSED BY DELETION** — removed under Gitea **#523** rather than gated. The gate it needed (a device whose tag set actually changes) required exactly the traffic the removal objected to. | §9, 2026-07-28 (#523) |
| **Continuous-historization value capture** (#491) | ✅ **PASSED** `e77ea4aa` (2026-08-04) — and it found a **production-fatal defect the code-complete reading missed**: `ConfigureAwait(false)` in the Akka handler destroyed `Self`, the actor restarted, and the restart discarded the (empty-seeded) ref set. One value captured, then permanent silence. Two follow-ups filed and closed the same day: **#537** (capture had no Primary gate — both pair nodes wrote duplicates) and **#538** (a poison tag pinned the FIFO head, re-writing every healthy tag forever). | `docs/plans/2026-08-01-continuous-historization-live-gate.md`; `ad744734`, `e77ea4aa`, `e7fbe977`, `4bdce910` |
| **Wave-0 universal discovery browser** full tree render (#468) | ✅ **PASSED** `c2c3c522` (2026-08-01) — unblocked by **building** the missing sim capability (an `ab_server` patch) rather than waiting for hardware. The gate then found a decoder defect **350 unit tests agreed with**. | tracking doc §Wave 0 |
| **archreview R2-03** — VT Good→Bad on a data-fed rig | **OPEN** — explicitly "live-blocked on this data-less rig" | `archreview/plans/STATUS.md` |
| **archreview R2-10** — force breaker-OPEN live | **OPEN** — breaker-OPEN state never forced live | `archreview/plans/R2-10-*.tasks.json` |
| **Driver-reconfigure-while-faulted** Task 3 | **OPEN** — task subject literally `"Task 3: Live /run verification — DEFERRED (skipped)"`, `blockedBy: [1]`. ⚠️ **The block no longer holds:** docker-dev runs the AdminUI with `Security__Auth__DisableLogin`, so `/run` verification needs no operator sign-in. This is the one gate on this list that is *runnable today*. | `2026-06-14-driver-reconfigure-while-faulted-plan.md.tasks.json` — ⚠️ **§5 cited `2026-07-14`, which does not exist**; corrected 2026-08-04 |
| **archreview R2-03** (T11 + T12) — VT Good→Bad on a data-fed rig | ✅ **PASSED 2026-08-07.** The rig is no longer data-less — the Modbus fixture is reachable from inside it — so the VT was staged Good and tripped **without a deployment** (a register was flipped on the fixture, so the VT child survived and could hold a last value): `42 → 0x80020000 → 42`, plus the P7 identical-redeploy spot-check (zero disturbance). ⚠️ **Found:** the Bad publish's "carrying the last-known value" is **unobservable by any client** — subscription *and* raw Read both return NULL. The actor is correct (new regression test pins it); the docs overstate what a client sees. | `docs/plans/2026-08-07-adminui-followups-and-live-gates.md` §2–3 |
| **archreview R2-10** — force breaker-OPEN live | ✅ **PASSED 2026-08-08 — by FIXING the structural gap** (`feat/wrap-driver-init-in-resilience`), the day after the 2026-08-07 run re-diagnosed it. `IDriver.InitializeAsync` now routes through the invoker as **`DriverCapability.Initialize`** (no retry — the actor's timer is the retry loop; **breaker threshold 2**, because at the 10 s reconnect cadence at most three attempts fit the 30 s sampling window and a Read-sized 5 could literally never open). Live: `rtu-gate` against dead `10.100.0.35:5021` opened its breaker on **both** centrals, the `/hosts` panel showed the red **"BREAKER OPEN"** chip, and a socat 5021→5020 forwarder recovered it — both breakers closed within 1 s, panel back to *"All targets healthy."* Control: healthy `MAIN-modbus`, zero breaker events. Companion fix: a respawn now `Invalidate`s the instance's **tracker** snapshots with its pipelines, or a deploy mid-outage would strand a false OPEN chip forever. Caveats recorded: the UI is served by the **active admin node only** (Traefik `/health/active` 503s the standby — "both-replica chips" means both *trackers*, one UI), and the staleness-dim leg was not exercised. | `docs/plans/2026-08-07-adminui-followups-and-live-gates.md` §7 |
| **Driver-reconfigure-while-faulted** Task 3 | ✅ **PASSED 2026-08-07** (the block indeed no longer held — docker-dev runs `DisableLogin`). A driver faulted on a bad device config adopted a corrected config **while faulted** and connected, with the node's `startedAt` byte-identical before/after and `RestartCount=0` — no restart, which is the whole point (pre-fix it retried the stale config forever). | `docs/plans/2026-08-07-adminui-followups-and-live-gates.md` §5 |
| Galaxy `ScanStateProbeParityTests` | **OPEN** — licence-gated (one `$WinPlatform` on this box) | `docs/v2/Galaxy.ParityRig.md:180` |
| **v2 GA exit criteria** — FOCAS live-CNC wire smoke (#54), OPC UA CTT/compliance pass, Ignition 8.3 redundancy cutover | **OPEN** — manual/hardware/external-tool | `docs/v2/v2-release-readiness.md:105-120` |
| mesh Phase 4 | ✅ **PASSED** `1281aebf` | CLAUDE.md said open — **corrected**, §7.2 |
@@ -421,8 +421,9 @@ mere env-var presence, so it cannot pass vacuously.
**Note on the 14 archreview `deferred-live` tasks across 8 plans:** `archreview/plans/STATUS.md:242-305`
records a 2026-07-13 live pass closing several (R2-02, R2-07, R2-11, R2-04, R2-05-partial) and
2026-07-15 closures for R2-01/R2-08/R2-06 — **but the `.tasks.json` files were never updated.** Only
R2-03 and R2-10 have no closure record anywhere.
2026-07-15 closures for R2-01/R2-08/R2-06 — **but the `.tasks.json` files were never updated.** ~~Only
R2-03 and R2-10 have no closure record anywhere.~~ *(Both now closed with full records: R2-03
2026-08-07, R2-10 2026-08-08 — their `.tasks.json` files carry the closure notes.)*
---
@@ -454,18 +455,19 @@ bookkeeping, not backlog.
> ✅ **Re-counted 2026-08-04 by parsing every `docs/plans/*.tasks.json`, not by reading this table.** The
> five rows below are confirmed unchanged, and the sweep found **one file this section missed**:
> `2026-06-14-driver-reconfigure-while-faulted-plan.md.tasks.json` (3 completed / 1 pending — the §5 live
> gate). Total open plan tasks across the tree: **31** — 19 of them in the single AdminUI file. Every
> gate). **Update 2026-08-07: the count is now 11, not 31** — the 19 AdminUI tasks closed by audit and the
> 1 reconfigure task closed by a passing live gate. Total open plan tasks across the tree: ~~**31** — 19 of them in the single AdminUI file~~. Every
> other `.tasks.json` is fully closed; `2026-07-24-mesh-phase6` carries one task marked
> `resolved-no-flip`, which is a recorded decision, not an open item.
| File | Open | Note |
|---|---|---|
| **`2026-05-29-adminui-followups.md.tasks.json`** ⬅ **now the largest open item in `docs/plans/`** | **19 pending / 0 completed** | **The largest unexecuted plan in the tree.** Generic `CollectionEditor<TRow>`; per-driver device/tag editors ×7; typed resilience form; `RoleMapper.Merge` + DB-backed LDAP→role mapping + `RoleGrants.razor` + FleetAdmin authz. **Several items look superseded by v3's `TagConfigEditorMap` — verify overlap before executing.** |
| ~~**`2026-05-29-adminui-followups.md.tasks.json`**~~ ✅ **CLOSED 2026-08-07** | ~~19 pending~~ **0 open** | **It was never backlog.** The overlap audit this row demanded found all 19 tasks shipped, superseded by v3, or moot — **no code written.** `CollectionEditor<TRow>` + the OpcUaClient endpoint editor shipped in the plan's own shape; the per-driver editors ×6 arrived as v3's `TagConfigEditorMap` + `DeviceFormMap`; WS2/WS3 (resilience form, `RoleMapper.Merge`, DB LDAP→role, `RoleGrants`, FleetAdmin) all shipped; WS4's comment targets were deleted with their files. ⚠️ WS1's premise is dead, not merely superseded: driver-embedded tag lists are no longer the runtime source of truth in v3. |
| `2026-06-19-followups-batch.md.tasks.json` | 4 pending + 1 partial | B4 F10b surgical DataType/IsArray writes and B5 alarm-severity `SetSeverity` are **OPEN-by-choice, not built** |
| `2026-06-26-otopcua-fixedtree-followups.md.tasks.json` | 1 pending | Task 11 build + regression |
| `2026-05-26-akka-hosting-alignment-plan.md.tasks.json` | 5 partial | F8/F9/F10/F13/F14 — oldest open items; **likely superseded by v3, verify** |
| `2026-05-28-adminui-driver-pages-plan.md.tasks.json` | 1 partial | 10.4 manual smoke checklist |
| `2026-06-14-driver-reconfigure-while-faulted-plan.md.tasks.json` ⬅ **added 2026-08-04** | 1 pending | Task 3 live `/run` verify, self-described "DEFERRED (skipped)". **Runnable now** — see §5. |
| ~~`2026-06-14-driver-reconfigure-while-faulted-plan.md.tasks.json`~~ ✅ **CLOSED 2026-08-07** | ~~1 pending~~ **0 open** | Task 3 live `/run` verify **PASSED** — see §5. |
`docs/plans/2026-07-23-mesh-phase4-…tasks.json` Task 9 was the only `deferred`-flagged mesh task —
**it has since landed** (`3a590a0c`), so that file is now stale too.
@@ -1278,6 +1280,383 @@ drivers), 3 carried-open archreview findings led by 01/S-1 (`AddressSpaceApplier
---
### 2026-08-10 (sixth round) — open-plan-task sweep: 2 verified live, 2 closed obsolete; 7 → 3
A freshness audit of the 7 open plan tasks (parsed from `*.tasks.json`, per the re-derive rule)
against the current tree, then one Opus agent ran the two runnable verifies. No code changed.
- **followups-batch Task 9 (Modbus-Int64 live authoring) — VERIFIED PASS.** Rig rebuilt to master
`481f4343` first (the running image was 2 days stale; parity asserted across all 6 nodes — the
round-robin trap). Authored `TASK9-INT64` (RawPath `pymodbus/plc/Task9Int64`, HoldingRegisters
address 200, Int64 BigEndian) via ConfigDb + the headless deploy API; deployment sealed. The node
advertises DataType `i=8` (Int64), scalar, Good, **identically on :4840 and :4841**, and the value
is the exact BigEndian decode of HR200–203 — four distinct non-zero words, so a byte-order or span
bug could not coincidentally match. Change propagation proven by driving HR203 via FC06 and
watching the subscribe on both centrals (registers restored after). Fixture caveat recorded: the
standard profile has **no naturally-churning 4-register span** (HR100 is isolated; any span over
it returns ILLEGAL DATA ADDRESS), so churn must be externally driven. Tag row left deployed and
serving, consistent with prior gate rows.
- **followups-batch Task 10 (S7 + AbCip Test-Connect probe E2E) — VERIFIED PASS.**
`DriverProbeHandshakeE2eTests` **12/12, 0 skipped** — S7 green, AbCip green with `"CIP session
OK"` (a real authored-tag read, not the fallback), AbCip no-authored-tags reachability, plus the
Modbus/OpcUaClient/local-rig/Galaxy legs (mxaccessgw on `10.100.0.48` happened to be up).
**Premise correction:** the skip-gating is pure TCP reachability (`DockerFixtureAvailability`),
not env vars, and the checked-in defaults already point at `10.100.0.35` — the task's
"needs Windows-VM fixtures" note had been stale since the Mac migration. S7 fixture started and
removed (net zero); AbCip was already up and left as found.
- **fixedtree Task 11 — CLOSED OBSOLETE.** The FixedTree-under-Equipment injection feature it
gated was deleted wholesale when #507 closed as superseded (`DiscoveredNodesReady`,
`DiscoveredNodeMapper`, the 18 `DiscoveryInjectionDormantV3` tests — all gone). A verification
gate for deleted code verifies nothing.
- **adminui-driver-pages 10.4 (manual smoke checklist) — CLOSED OBSOLETE.** The routed
`/clusters/{id}/drivers` flow it smokes (the 8 `*DriverPage` shells) was retired in v3 Batch 2;
no driver pages exist under `Components/Pages/`. The form bodies live on inside the `/raw`
modals, live-gated repeatedly across the 2026-07/08 rounds.
**Open plan tasks are now 3**, all deliberate: F10b surgical DataType/IsArray + alarm-severity
`SetSeverity` (reconsider gates — premises re-verified still true on the current tree:
`TagDeltaIsSurgicalEligible` still rebuild-forces DataType/IsArray; the node manager still projects
engine-computed severity) and the device-gated proofs (hardware-blocked; two sub-items possibly
part-covered by the 2026-06-27 historian gate and `dcb0be65`, worth trimming when next touched).
Side effect worth knowing: **the docker-dev rig now runs current master** (`6e7028e0856f`).
---
### 2026-08-10 (fifth round) — #559 closed: MQTT joins the fleet's framing contract; the drift arc ends
The fourth round's sole residual, one isolated-worktree Opus agent, fix `621a9e5d` merged to master.
Verification on the merged tree: clean build; 4 affected suites / 2,790 tests green (Mqtt 708,
AdminUI 1466, ControlPlane 261, FOCAS 355).
- **#559 CLOSED** — both MQTT runtime mappers (`FromTagConfig`, `FromSparkplugTagConfig`) dropped
their untrimmed `|| tagConfig[0] != '{'` clause; `JsonDocument.Parse` + the root-`ValueKind`
check are the single framing authority, as in the six sibling drivers. The verdict changes ONLY
for whitespace-padded JSON objects (now earning their content's verdict); bare names, arrays,
numbers, strings and blanks are refused by the same mechanisms as before, and a caller sweep
confirmed nothing relied on the first byte (no name-vs-blob fallback exists in v3). `Inspect`
needed **no logic change** — since #556 it trims its own pre-filter and hands the RAW blob to
the mappers — but four comment sites claiming the padded-blob refusal was real were rewritten.
- **The #556 backstop lost its only vehicle, by design.** The padded blob was the one
production-reachable shape for "refused, but no DescribeRejection branch can name why"; after
the fix no vehicle exists (a union refusal with a topic requires a named strict-field failure;
without topic and without the Sparkplug tuple the no-address branch fires). The backstop stays
as defence-in-depth, re-pinned **FOCAS-style as a count relation**
(`A_rejected_config_always_draws_more_than_its_advisories_alone`), and the old flipped test was
replaced by the stronger parity form `Leading_whitespace_changes_neither_the_verdict_nor_the_inspection`
over the accepted AND rejected corpus with an anti-vacuity control proving the corpus carries
both verdicts. New runtime tests assert padded-vs-unpadded **definition equality**, not just the
accept bool. Falsification: each mapper sabotaged separately — exactly 5 (Plain) / 4 (Sparkplug)
tests reddened, all specific to the sabotaged mapper, nothing unrelated.
- **Honest coverage note:** the three padded entries added to the AdminUI parity corpus stayed
green under BOTH sabotages — `MqttTagConfigModel` consults the same union parse via
`TagConfigParseGate`, so authoring moves with runtime by construction. They pin a future
*divergence* (unwiring the parse-gate consult), not this defect; the load-bearing coverage is
driver-side. A parity test cannot detect a defect both sides share.
**No residual filed.** The authoring-vs-deploy-gate drift arc (#549 → #550/#551 → #552/#553 →
#554/#555 → #556/#557/#558 → #559) is closed end to end.
---
### 2026-08-10 (fourth round) — #556 + #557 + #558 closed: Inspect trims and classifies; the last alias and the last hand-list fall
The three residuals of the #554/#555 round, three isolated-worktree Opus agents, merged `7482cf0c`
(#558), `93a05828` (#557 OpcUaClient half), `be15ed16` (#556 + #557 Mqtt half). Consolidated
verification on the merged tree: full build clean; 14 suites / 4,583 tests green (AdminUI 1460,
ControlPlane 261, Modbus 395, S7 302, AbCip 409, AbLegacy 243, TwinCAT 219, FOCAS 355, Mqtt 686,
OpcUaClient 143, OpcUaClient.Browser 10, Galaxy.Browser 21, Sql.Browser 79).
- **#556 CLOSED** — all seven Contracts factory `Inspect`s `TrimStart()` before the `[0] != '{'`
framing check, and each classifies its findings **structurally** into two lists — `rejections`
(the parse would refuse) and `advisories` (accepted, worth flagging) — with the "rejected blob
never draws zero warnings" backstop gating on `rejections.Count == 0`, not the combined count
(an advisory can no longer satisfy the backstop vacuously; no string matching anywhere). The fix
surfaced a discovery: **MQTT's runtime mappers uniquely also carry the framing gate**, so for
MQTT a leading-whitespace blob is genuinely refused at runtime and its trimmed Inspect reports a
real rejection — accept-gate-first symmetry holds. The other six runtimes tolerate the whitespace
(`JsonDocument.Parse` skips it), a cross-driver served-behavior inconsistency deliberately NOT
changed here (an Inspect-wording issue must not change served behavior) — **filed as #559**.
FOCAS's backstop is unreachable today (no unnamed refusal exists); pinned as a count relation so
it fails the day one appears, rather than deleted.
- **#557 CLOSED, both halves.** *OpcUaClient:* legacy `NodeId` was readable at runtime but the
editor dropped it — `OpcUaClientTagConfigModel` gained `NodeIdKeys` + `ReadNodeId` implementing
the runtime's **presence-shadows** semantics (`TryGetProperty("nodeId") || TryGetProperty("NodeId")`,
then one ValueKind/non-blank check) — deliberately NOT MTConnect's `FirstNonBlank`: a
present-but-blank `nodeId` short-circuits the `||`, blocks fallback, and the blob is REJECTED —
blank-beside-valid-legacy must refuse, and now does in both layers. `ToJson` drops aliases keyed
off the SAME array (#550 pattern). The `TagConfigNonEnumParityTests` OpcUaClient exemption is
removed — the anti-vacuity control (`Every_deliberately_stricter_entry_is_still_exactly_that`)
is what would have caught it going stale. *Mqtt:* the union parse verdict is the **decided
contract**, not a bug — `FromSparkplugTagConfig` hardcodes `PayloadFormat.Json` and never reads
the `payloadFormat` key, so a typo'd value on a sparkplug-shaped blob is genuinely servable and
refusing it would be stricter than runtime. Mitigation: a **mode-dependency advisory** on the
accepted blob (advisories ride accepted blobs by design, so the rejection backstop never trips).
- **#558 CLOSED** — one guard per seam: the browse-commit dispatch fact moved out of
`DriverDispatchMapParityTests` (now CsvColumnMap-only) into `RawBrowseCommitMapperParityTests`
(now 6 facts, the sole guard for that seam; subsumption proven under the same neuters before the
old fact was deleted). The hand-maintained `NonBrowsableDriverTypes` list is gone — the browsable
set derives from the real production `AddAdminUI()` registration, the non-derivable universal
arm covered by `DriverTypeNames.All` enumeration + `GenericKeyExemptions` + a
contradiction-refuting fact. **Ordinal (not IgnoreCase) membership compare is load-bearing** — a
case-only typo in a browser key is exactly the drift the guard exists to catch. Galaxy/OpcUaClient
browser `DriverType` literals became `DriverTypeNames` constants (Galaxy's "GalaxyMxGateway"
exact-matched before substitution), closing the last string-literal channel into the derived guard.
**Residual filed:** **#559** — align MQTT's runtime whitespace handling with the fleet (or declare
the framing gate the contract). The authoring-vs-deploy-gate drift arc (#549 → #550/#551 →
#552/#553 → #554/#555 → #556/#557/#558) is otherwise closed end to end.
---
### 2026-08-10 (third round) — #554 + #555 closed: authoring consults the parse; the cited guard now exists
The two residuals of the #552/#553 round, again two isolated-worktree Opus agents, merged `7cfe7b4a`
(#554) + `4f1c83a7` (#555). Consolidated verification on the merged tree: full build clean; 14
suites / 5,251 tests green (AdminUI 1430, ControlPlane 261, Modbus 373, S7 285, AbCip 390, AbLegacy
230, TwinCAT 205, FOCAS 341, Mqtt 673, Sql 363, MTConnect 534, OpcUaClient 143, Calculation 23).
- **#554 CLOSED** — every mapped `*TagConfigModel.Validate()` consults the driver's own runtime
parse on the AS-LOADED blob via the new `TagConfigParseGate` (AdminUI-side, no factory touched):
**verdict from the parse, wording from `Inspect` only after refusal**. The two design points are
pinned: gating on "Inspect said something" would be stricter than runtime (FOCAS/MQTT/Calculation
advisories ride ACCEPTED blobs), and judging typed fields instead of the source blob would accept
exactly what the lenient load hides. Survey closed real gaps in 5 of 11 drivers: Modbus (absent
`region`, address shapes, `stringLength<1`, `bitIndex>15`, and `{}` itself), S7 (`stringLength`
0–254), TwinCAT (`dataType:"Structure"` — valid enum NAME the reader refuses; no spelling gate
can see it), Sql (the recognition rule), Mqtt (`"qos":null`). Deliberately-stricter editor rules
KEPT and pinned by name, incl. AbLegacy's `arrayLength>256` (the runtime *clamps* but the
materialiser advertises the RAW number in `ArrayDimensions` — the authored value is silently not
honoured, so author-time refusal is the honest treatment). ⚠️ Behaviour change: **an untouched
`{}` Modbus config is now refused at save** — it was the only editor with no required field, and
a `{}` tag reads BadNodeIdUnknown forever under a green deployment. Anti-vacuity guards: the
parity corpus must exercise BOTH verdicts per driver; an exemption that stops being
deliberately-stricter FAILS instead of silently excusing.
- **#555 CLOSED** — `RawBrowseCommitMapperParityTests` now exists, holding the mapper to what its
comment merely claimed. The browsable set is DERIVED: the test runs the production `AddAdminUI()`
registration and reads back every `IDriverBrowser.DriverType` (the set `BrowserSessionService`
indexes). The universal `DiscoveryDriverBrowser` arm is NOT derivable from the AdminUI assembly
(needs runtime drivers + Host's `IDriverFactory`; the #526 cut is deliberate) — covered by
enumerating `DriverTypeNames.All` with a reasoned `GenericKeyExemptions` list (Modbus,
Calculation) plus a fact that mechanically refutes any exemption the derived arm contradicts.
Falsified three ways (disabled Sql branch → red naming Sql; disabled MTConnect branch → red via
the enumeration half that DI derivation cannot reach; bogus exemption → the contradiction fact
red quoting it). The mapper comment now states the two real claims and the residual trust.
- **Residuals filed, not folded in:** **#556** (Inspect polish — the `reference[0] != '{'`
pre-filter has no `TrimStart()` so `" {…}"` draws zero warnings in 7 factories; FOCAS/MQTT
advisories can swallow the rejection backstop via `warnings.Count == 0`), **#557** (reader
asymmetries — OpcUaClient's legacy PascalCase `NodeId` is readable but unsaveable untouched; Mqtt
Sparkplug parse accepts a `payloadFormat` the Plain parse rejects and the union admits it),
**#558** (test hygiene — the pre-existing `DriverDispatchMapParityTests` browse-commit fact
overlaps the new guard and its `NonBrowsableDriverTypes` list is hand-authored AND misnamed;
two browser classes return `DriverType` string literals instead of `DriverTypeNames` constants,
unguarded).
### 2026-08-10 (later) — #552 + #553 closed: the browse states, the merge preserves
The two residuals the #550/#551 round exposed, again two isolated-worktree Opus agents, merged
`1f351250` (#552) + `62fe6c83` (#553). Consolidated verification on the merged tree: full build
clean; 8 suites / 3,362 tests green (AdminUI 1262, ControlPlane 261, Sql 363, Sql.Browser 79,
MTConnect 534, Modbus 373, S7 285, TwinCAT 205).
- **#552 CLOSED** — Sql browse-commit emits a valid `rowSelector`. The **session states, the mapper
never infers** (the MQTT Sparkplug rule): `SqlBrowseSession` names a `topByTimestamp` candidate
from the column `DATA_TYPE` metadata it already reads (`MapColumnType == DateTime` — type only,
never a name heuristic; the fixture pins a `sample_ts TEXT` trap column), tie-break = first in
catalog order (every dialect's `ListColumnsSql` already orders by ordinal), zero extra queries
(pinned by a command-count test). New `SqlBrowseAddressFields` constants in `Driver.Sql.Contracts`
end the producer/consumer agree-by-coincidence on field names. No-candidate tables are refused
**in words at the modal** (`DescribeUncommittableLeaf` — names leaf, table, and the
whereColumn/whereValue fix, whose value is *data* no browse can supply); the emitted blob keeps
the wide-row shape so table/column survive into the typed editor. The blob's top-level
`timestampColumn` (source-timestamp) is deliberately NOT set — ordering by a column is not a
claim it timestamps the value. Emitted blobs are double-accepted (model validator AND the real
`SqlEquipmentTagParser`) with a pre-#552-bytes-refused control; the `RawTagConfigGateTests` Sql
leg flipped from records-the-gap to passes-the-gate.
- **#553 CLOSED — and characterization first shrank it.** On the current tree (post-#550/#551)
MTConnect's invalid-alias/invalid-cell legs were ALREADY caught (raw-key maps never round-trip);
the real defects were the model-backed maps' laundering (Modbus/S7 `"dataType":"Intt16"` →
silent `Int16`) and the **export side actively setting the re-import trap** (`ReadTypedColumns`
read only the canonical key → the typed column printed the *ignored* value, the residual kept the
*winning* alias). Fix is evidence preservation, not a second validator:
`ApplyTypedColumns` restores the base cell's own tokens over the model serialisation for every
key no non-blank column overrode (fragments compose byte-identically; the #551 gate stays the one
authority, now judging unlaundered evidence); `CsvTypedColumn.ReadKeys`/`OwnedJsonKeys` declare
the runtime's full spelling precedence once (MTConnect pairs), so firing columns clear
out-ranking aliases, the export residual strips them, and export cells show the effective value.
Override-contradiction semantics: column wins, OPENLY (review grid names each override) — #549's
doctrine is against *silent* rewrites. Falsification: guard neutered → exactly the 7
defect-closers red while the #551 gate suites stayed 53/53 green (a layer in front of the gate,
not a replacement).
- **The deliberate-trap discipline in the #552 fixtures is worth copying:** the multi-candidate
table's column names are asserted to DISAGREE with alphabetical order (the tie-break test cannot
pass by luck), and the no-candidate table carries the exact `sample_ts TEXT` column a name
heuristic would have bound to.
- **Residuals filed, not folded in:** **#554** — authoring models validate only enum keys; non-enum
rules (Modbus address shapes, S7 stringLength bounds…) meet no gate until deploy, where the #551
accept-gate-first Inspect names them — feedback latency, not silent death; closing it means the
models consult the same runtime parse the Inspects wrap, after a fleet survey (N-1 rule).
**#555** — `RawBrowseCommitMapper`'s fallback comment cites a `RawBrowseCommitMapperParityTests`
guard that DOES NOT EXIST; the "every browsable driver handled" invariant is unguarded (9 types
covered by hand-written InlineData) — write the `DriverTypeNames.All`-driven parity test the
comment already describes.
### 2026-08-10 — #550 + #551 closed: the accept-gate-first fleet conversion
The two #549-survey residuals, again two isolated-worktree Opus agents, merged `b50523b1` (#550) +
`333ea488` (#551). Consolidated verification on the merged tree: full build clean; 12 suites /
5,098 tests green (AdminUI 1233, ControlPlane 261, Runtime 558, Modbus 373, S7 285, AbCip 390,
AbLegacy 230, TwinCAT 205, FOCAS 341, Mqtt 673, MTConnect 534 + 15 integration).
- **#550 CLOSED** — `MTConnectTagConfigModel` reads `DataTypeKeys = ["driverDataType", "dataType"]`
with the runtime's exact `FirstNonBlank` per-key semantics (blank falls through; a NON-string
token STOPS, because the runtime's `string?` DTO deserialise throws and can never fall through)
and `ToJson` drops every non-canonical entry of the same array — read and drop are keyed off one
list so a third spelling can never be read-but-not-dropped. Both half-fixes were falsified
separately (read-only → 9 red; drop-only → 14 red incl. the Int32-blob-retypes-to-String test).
- **#551 CLOSED, structurally** — instead of three more hand-mirrored checks, all seven pre-#549
`Inspect`s (Modbus/S7/AbCip/AbLegacy/TwinCAT/FOCAS + **Mqtt**, an unnamed sibling the re-derive
found) converted to **accept-gate-first** (the G-7/MTConnect shape): parse accepts ⇒ silent,
parse rejects ⇒ a warning must name why, pinned per-factory by a structural invariant test
(rejected blob can never draw zero warnings). That closed the named gaps (S7 absent-`dataType`,
AbCip `members[]`, Modbus `stringByteOrder`) AND the unnamed ones (absent/blank address keys
fleet-wide; TwinCAT's valid-enum-name-but-refused `Structure`; Mqtt's gate is the union of both
payload mappers or every Sparkplug tag false-warns). Authoring is now enforced **in the service**:
`RawTreeService.CreateTagAsync`/`UpdateTagAsync`/`ImportTagsAsync` all run `TagConfigValidator`
(the issue's premise that manual entry rides the import was wrong — it commits via
`CreateTagAsync`), with the CSV review grid keeping the early per-row message; a rejected import
row is a named ERROR in the all-or-nothing result. S7 authoring rejects an absent `dataType` via
a source-shape capture (`_dataTypeMissingInSource`, judged on the KEY like the runtime's
`DeclaresDataType` — browse-commit-built models and load→save round-trips stay clean). Doc sweep
done: every "silently defaulted by the lenient runtime" comment now states the strict reality.
- **Never-stricter preserved deliberately where it bites:** Modbus's narrowing casts are mirrored
(`bitIndex:256` is bit 0 and accepted), AbLegacy's `arrayLength` clamp is not warned, FOCAS's two
accepted-blob advisories stay a distinct warning class (promoting warnings to errors must not
block a deploy the driver would serve).
- **Residuals filed, not folded in:** **#552** — Sql browse-commit emits a `rowSelector`-less
WideRow config both gates refuse (verified live; the tag was NEVER readable, so the #551 gate
correctly moved the failure from silent-dead-tag to a named commit error — but Sql browse-commit
now always fails and needs a `rowSelector` source in the browse UI). **#553** — the CSV
typed-column merge (`CsvDriverTagMap.ApplyTypedColumns`) round-trips the free-form cell through
the lenient model load, silently rewriting a typo'd enum to its default BEFORE any gate can see
it (the CSV analogue of #549), and MTConnect's exported `driverDataType` residual still shadows
the `MtDataType` column on re-import.
### 2026-08-09 (later) — #549 authoring strictness (6 models); NamespaceMap out of Contracts
Follow-ups from the round below, again two isolated-worktree agents, merged `3a70a4e6` + `10c7a0e8`.
Consolidated verification on the merged tree: full build clean; 12 suites / 4,119 tests green
(AdminUI 1175, ControlPlane 261, Core.Abstractions 284, MTConnect 534, AbLegacy 219, TwinCAT 192,
FOCAS 327, OpcUaClient 143 + Browser 10, Modbus 346, S7 264, AbCip 364).
- **#549 CLOSED — and the survey it demanded found the defect was 6 models wide, not 1.** The G-7
round's "MTConnect authoring accepts a dataType the runtime rejects" generalised: **R2-11 Phase C
made every runtime TagConfig enum read strict** (`TagConfigJson.TryReadEnumStrict` rejects the
tag), so every lenient `GetEnum` authoring model was authoring-weaker-than-runtime — MTConnect,
Modbus (region/dataType/byteOrder), S7, AbCip, AbLegacy, TwinCAT, FOCAS. Mqtt and Sql were already
strict on both sides. Fixed one-authority: MTConnect's `Validate` consults the factory's exposed
`IsAcceptedDataTypeName`; the other five consult `TagConfigJson.DescribeInvalidEnum`, which
delegates to the runtime's own `ClassifyEnumText` (verdict AND wording from one implementation).
Deliberately not stricter than runtime: absent keys / non-string tokens stay accepted where the
runtime accepts them, each pinned. MTConnect also captures the raw JSON token — an unquoted
`"dataType": 8` throws in the runtime DTO deserialise, so authoring now rejects it too.
- **Survey residuals filed, not folded in:** **#550** (MTConnect `driverDataType` is a second,
runtime-preferred spelling the editor neither reads nor writes — a valid one silently overrides
what the editor displays; fixing needs both read-both-spellings AND normalise-on-save together) and
**#551** (4 gate-coverage gaps: `RawCsvImportModal`'s free-form TagConfigJson column meets only the
deploy gate; S7's unique absent-`dataType` hard-requirement warns nowhere; AbCip member `dataType`
+ Modbus `stringByteOrder` checked by neither gate; the seven pre-#549 `Inspect` doc summaries
still claim "silently defaulted by the lenient runtime" — stale since R2-11).
- **NamespaceMap moved out of `OpcUaClient.Contracts`** into a new `Driver.OpcUaClient.Session` leaf
(merge `10c7a0e8`), so ControlPlane's G-7 reference no longer drags the OPC UA client SDK into its
dependency graph (before: five `OPCFoundation.*` packages transitively; after: none). The
alternative — Browser referencing the driver, the Mqtt #512 shape — lost on a measurement: AdminUI
references `.Browser`, so it would have pulled the driver runtime into the AdminUI graph, exactly
what #512 spent two extractions removing. The G-7 one-authority seam survives (`BuildRawPathTable`
still calls `OpcUaClientTagDefinitionFactory.TryReadNodeId`, now `.Session → .Contracts`).
`ContractsIsDependencyLightTests` pins it from both directions (Contracts manifest names no
`Opc.Ua*`; `.Session`'s must — so "none anywhere" cannot read as a pass), falsified red before trust.
### 2026-08-09 — G-7 closed; RawBrowseModal race ported
The two items §0.1 flagged as "highest-leverage runnable from here", executed in parallel by two
isolated-worktree agents and merged (`0739f4b9` RawBrowseModal, `0695f682` G-7). Consolidated
verification ran on the **merged** tree, not just each branch: full-solution build clean; AdminUI.Tests
1127, ControlPlane.Tests 260, MTConnect 534, Sql 363, OpcUaClient 141, Calculation 23 — all green.
- **G-7** — the load-bearing design choice is that all four new `Inspect`s **wrap the runtime parse**
rather than mirroring the AdminUI model: Sql wraps the pre-existing `SqlEquipmentTagParser`; the
MTConnect (`MTConnectDriver.TryReadRawTag`), OpcUaClient (`NamespaceMap`'s private node-id reader) and
Calculation blob readers were **moved** into their Contracts assemblies and the drivers now call them —
so "the deploy gate is never stricter than the runtime" is structural, not a convention that can
drift. Two deliberate narrowings: Calculation's `Inspect` does **not** flag a trigger-less tag or a
sub-50 ms timer even though the AdminUI editor refuses both, because the runtime accepts both and
`TagConfigValidationMode.Error` turns warnings into deploy blockers (pinned by a falsification
control); and Galaxy stays unmapped on both gates, symmetrically. `Driver.Calculation.Contracts` is a
new project. ControlPlane now pulls the OPC UA client stack transitively via OpcUaClient.Contracts —
assessed: every host loading ControlPlane already carries it.
- **Found en route, left open (the reverse of G-7):** `MTConnectTagConfigModel` reads `dataType`
leniently — a misspelled enum name is accepted at authoring and silently rewritten to `String`, while
the strict runtime parse rejects it. *Authoring weaker than runtime.* Documented inline in
`TagConfigAuthoringDeployParityTests`; worth a tracker issue when one is next filed.
- **RawBrowseModal** — the sibling `_openGeneration` port needed two extensions the siblings did not:
generation-guarding the `catch` and the `finally` (this modal resolves inside `try/finally`, so a
stale `return` still runs the tail and would clobber the LIVE open's state). Each of the four guards
is pinned by its own test — per-guard reverts produced exactly 1-failed/3-passed each time.
---
### 2026-08-08 — R2-10 closed by fixing the structural gap (`DriverCapability.Initialize`)
The 2026-08-07 entry below left R2-10 T10 "still open — structural." The user chose the fix over the
other three options (gate through a different driver / drivers reporting out-of-band / retire the gate),
and the gate then **PASSED** against the fixed build. Branch `feat/wrap-driver-init-in-resilience`;
evidence in [`docs/plans/2026-08-07-adminui-followups-and-live-gates.md`](docs/plans/2026-08-07-adminui-followups-and-live-gates.md) §7.
- **`IDriver.InitializeAsync` now routes through the invoker** as `DriverCapability.Initialize` — the
one capability call the pipeline never saw, which is why a dead endpoint produced a quiet panel. No
pipeline retry (the actor's `RetryConnect` timer is the retry loop; parser-enforced), and **breaker
threshold 2** — at the 10 s reconnect cadence at most three attempts fit the 30 s sampling window,
so the Read-sized 5 could literally never open. That threshold arithmetic is the load-bearing detail
and is pinned by its own test.
- **Live:** `rtu-gate` against dead `:5021` → breaker OPEN on both centrals + the red panel chip;
socat forwarder up → both breakers closed within 1 s, panel cleared. Healthy `MAIN-modbus`: zero
breaker events (control). Bonus: a config-invalid OpcUaClient driver also now trips — equally
invisible before.
- **Companion fix found by asking "what clears the flag":** a respawn (`Create`) now `Invalidate`s the
instance's tracker snapshots along with its pipelines — a deploy mid-outage discards the only
breaker whose `OnClosed` could clear the OPEN flag, so the old behaviour would have stranded a false
OPEN chip forever. Unreachable until this change made breaker-open reachable at all.
- **Two honest narrowings of the original T10 wording:** "both-replica chips" = both *trackers* (one
active AdminUI — Traefik's `/health/active` deliberately 503s the standby, discovered fighting the
sticky cookie); the staleness-dim leg was not exercised (needs every publisher for a key to stop).
### 2026-08-07 — adminui-followups closed by audit; three of four runnable gates PASSED
Took the two items §0.1 named as the remaining untracked engineering. Full evidence:
[`docs/plans/2026-08-07-adminui-followups-and-live-gates.md`](docs/plans/2026-08-07-adminui-followups-and-live-gates.md).
- **`2026-05-29-adminui-followups` — CLOSED, no code written.** §0.1 called it "the only substantial
engineering left"; the overlap audit it demanded shows all 19 tasks shipped, superseded, or moot.
The lesson is the one this register keeps re-learning: **a `.tasks.json` is not evidence.** The
plan's *premise* had also expired — WS1 assumed driver-embedded tag lists are the runtime source of
truth, which v3 ended. Supersession claims were checked for **reachability** (`TagConfigEditorMap`
is dispatched from `RawTagModal.razor:79`), because "superseded by X" where X is dead surface is
this repo's signature failure.
- **R2-03 T11 ✅ / T12 ✅.** Both blockers the previous pass recorded are gone — the Modbus fixture is
now reachable from inside the rig. The trip had to be staged **without a deployment** (flip a
register on the fixture) because a deploy creates a fresh VT child with no last value, which is
correct-by-design and had masked the result twice.
⚠️ **Finding:** "Bad carrying the last-known value" is **unobservable by any client** — subscription
and raw Read both return NULL. The actor is right; no test asserted that half, so one was added
(`The_Bad_result_carries_the_last_known_value`). The *documentation* is what is wrong.
- **R2-10 T10 ❌ still open — recorded cause was wrong.** Not retry throttling. The dead-endpoint
failure mode never enters the resilience pipeline: `InitializeAsync` is not invoker-wrapped and
`ModbusDriver` swallows per-tag failures in both directions. **Operator consequence:** the panel
reads *"All targets healthy."* for a driver timing out every read against a blackhole.
- **driver-reconfigure Task 3 ✅** — corrected config adopted while faulted, `RestartCount=0`.
- **Method note:** the rig image was 3 h older than the last code commit. Rebuilt, then asserted image
parity **per container** *and* that the specific symbols under test were in the shipped DLLs.
### 2026-08-05 — MTConnect write-back; the MQTT closure records reconciled
Recorded from branch `feature/mqtt-mtconnect-write`. This is exactly the drift shape the 2026-08-04
+34
View File
@@ -270,6 +270,40 @@ re-entered this path; the watchdog makes it automatic and unbounded, so a device
have leaked ~360 transports at the 10 s retry interval. The shutdown is best-effort: a driver that throws
on it still gets its initialise attempt.
### The connect path feeds the circuit breaker (R2-10 T10 closure)
`IDriver.InitializeAsync` is routed through the capability invoker as
**`DriverCapability.Initialize`**, keyed on the driver-instance id (init spans the whole driver,
like a bulk subscribe). Before this, the connect path was the ONE capability call the invoker never
saw: a driver that could not connect contributed **zero** breaker input, so the AdminUI resilience
panel read *"All targets healthy"* against a dead endpoint — live-proven during the R2-10 T10 gate,
and the reason that gate was structurally unsatisfiable for a year of attempts. Things to know
before touching it:
- **The Initialize policy never retries** — this actor's `RetryConnect` timer *is* the retry loop,
and pipeline retries would stack inside each timer tick (and stretch one attempt past the timer
interval so attempts overlap). The parser forces a `retryCount` override back to 0, same funnel
as the write-shaped rule.
- ⚠️ **The breaker threshold is 2, and raising it silently re-breaks the feature.** The breaker
samples over 30 s and the reconnect timer fires every 10 s, so at most **three** init attempts fit
one sampling window — a Read-sized threshold of 5 can literally never open on connect failures,
which reads exactly like "the panel doesn't work" with nothing logged. Pinned by
`Initialize_BreakerThreshold_IsReachableAtTheReconnectCadence` and, behaviourally, by
`Initialize_BreakerOpens_AtTierDefaults_AndFastFailsTheNextAttempt`.
- **An open breaker cannot wedge the reconnect loop.** A fast-failed attempt
(`BrokenCircuitException`) lands in the same catch as a real connect failure →
`InitializeFailed` → the timer keeps ticking. The 15 s break window is shorter than two 10 s
ticks, so recovery costs at most one extra tick. The teardown `ShutdownAsync` stays deliberately
UNWRAPPED — best-effort cleanup of an already-dead transport must not count toward the breaker.
- **A respawn resets the instance's tracker snapshots** (`DriverResilienceStatusTracker.Invalidate`,
called by `DriverCapabilityInvokerFactory.Create` alongside the pipeline invalidation). The
pipelines carry the actual breaker state; once they are discarded, a retained "Breaker OPEN"
snapshot could never self-correct — only the discarded pipeline's `OnClosed` stamps the close —
so a deploy during an outage would otherwise strand a false OPEN chip forever. This was
unreachable while breakers never opened; the Initialize wrap made it reachable.
- `ConsecutiveFailures` stays 0 during a connect outage **by design** — the tracker counts retry
attempts, and Initialize has none. The breaker-OPEN chip is the operator signal.
---
## IDriverConfigEditor — custom AdminUI config editor (plug-point)
+2 -2
View File
@@ -643,11 +643,11 @@ The OtOpcUa Client CLI at `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI` supports `-F
| `otopcua.redundancy.primary_gate_denied` | Counter (`{denial}`) | Operations denied by the Primary data-plane gate (archreview 03/S4). Tagged `site` = `write` \| `ack` \| `alarm-emit` and `reason` = `secondary` \| `detached` \| `role-unknown`. A non-zero `role-unknown` rate on a healthy multi-node cluster flags a stuck boot window (redundancy snapshot not delivering). |
| `otopcua.opcua.apply.failed` | Counter (`{apply}`) | Address-space apply/materialise passes that swallowed at least one sink failure (archreview 01/S-1). Tagged `kind` = `rebuild` (the rebuild threw) \| `nodes` (per-node materialise failures). A non-zero rate means the running server holds a stale or partial address space despite a reported-successful deploy — `OpcUaPublishActor` also logs the degraded apply at **Error**. |
| `galaxy.writes.advise_failed` | Counter (`{write}`) | Galaxy writes short-circuited to `Bad` because `AdviseSupervisory` failed — the value could not have committed, so the write **fails closed** (archreview 06/S-1) and the Bad status fires the #5 node revert instead of leaving a phantom-Good node. On the Galaxy driver meter `ZB.MOM.WW.OtOpcUa.Driver.Galaxy`. |
| `galaxy.writes.unconfirmed` | Counter (`{write}`) | Galaxy writes reported `Good` off an **empty** gateway statuses array (command accepted; the COM-side commit is unconfirmed pending the mxaccessgw `WriteComplete` correlation follow-up). The honest signal for the optimistic-Good rate until that cross-repo fix lands. On the Galaxy driver meter. |
| `galaxy.writes.unconfirmed` | Counter (`{write}`) | Galaxy writes reported `Good` off an **empty** gateway statuses array (worker-side acceptance only — the COM commit is unconfirmed). Against a correlated gateway (mxaccessgw server ≥ `b948e69`, worker ≥ `53f69cd` — every unary write reply carries the worker's `OnWriteComplete` outcome as `statuses[0]`) this stays **flat**; an increment means the bounded completion wait (`MxGateway:Worker:WriteCompletionWaitMilliseconds`, default 1500 ms, deliberately under the 2 s Tier A Write timeout) timed out, or the gateway pre-dates the correlation. On the Galaxy driver meter. |
The redundancy/apply meters are defined on `OtOpcUaTelemetry` (`src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs`); the Galaxy write meters hang off the driver's own meter (`EventPump.MeterName`). All surface through whatever OpenTelemetry exporter the host configures.
**Galaxy fail-closed write semantics (archreview 06/S-1):** a non-secured Galaxy write commits only when its item is `AdviseSupervisory`-advised first (the writer runs with no login). If the advise fails, the write is **not** issued — `GatewayGalaxyDataWriter` returns `BadCommunicationError` (a knowingly-lost write is never reported as Good). An empty statuses array on the write reply is still treated as `Good` but metered as `galaxy.writes.unconfirmed`. See [Historian.md](Historian.md) / the Galaxy driver notes for the write path.
**Galaxy fail-closed write semantics (archreview 06/S-1, closed 2026-08-09):** a non-secured Galaxy write commits only when its item is `AdviseSupervisory`-advised first (the writer runs with no login) — live-confirmed: a write through a merely plain-advised handle is refused by the automation object (`SECURITY_ERROR` detail 1008 on the correlated reply). If the advise fails, the write is **not** issued — `GatewayGalaxyDataWriter` returns `BadCommunicationError` (a knowingly-lost write is never reported as Good). With the gateway-side `OnWriteComplete` correlation live, the write reply's `statuses[0]` carries the real COM commit outcome, so a refused write surfaces as a Bad-family code and fires the #5 node revert — the writer always AddItems its **own** supervisory-advised handle (the former borrow-from-subscription-registry optimization was removed: MXAccess enforces one advise per handle, so a subscription's plain-advised handle can neither commit a no-login write nor be upgraded). An empty statuses array (completion-wait timeout / pre-correlation gateway) is still treated as `Good` but metered as `galaxy.writes.unconfirmed`. See [Historian.md](Historian.md) / the Galaxy driver notes for the write path.
## Depth reference
+7 -1
View File
@@ -180,7 +180,13 @@ into) is resolved in this order, first non-null wins:
1. The `RawTags[]` blob's `driverDataType` or `dataType` field (both
spellings accepted — the typed editor writes `dataType`, the driver's own
`tags[]` shape uses `driverDataType`).
`tags[]` shape uses `driverDataType`). **`driverDataType` wins when it is
non-blank**, and a present-but-invalid one rejects the tag rather than
falling through to `dataType`. The AdminUI's typed editor mirrors that
precedence on load and **normalises onto `dataType`, dropping the alias, on
save** (Gitea #550) — so a tag edited through the editor keeps the type the
driver read for it, and an unreadable spelling is refused at authoring
instead of dying at the driver.
2. A matching `Tags[]` entry naming the same DataItem id.
3. The Agent's own `/probe` declaration, via `MTConnectDataTypeInference.Infer`
(category/type/units/representation → `DriverDataType`).
+27
View File
@@ -169,6 +169,33 @@ Browse works against all three constructed providers; the catalog query is the d
(`INFORMATION_SCHEMA` on SQL Server and Postgres — with `pg_catalog` / `information_schema`
themselves excluded — and the `ALL_*` views on Oracle).
### The committed row selector (Gitea #552)
A browse-committed column becomes a **WideRow** tag, and a wide-row tag must say *which row* it reads
— the table and column alone are not a binding. The browse session therefore states a fourth address
field alongside schema/table/column: the table's preferred **date/time-typed** column, which the
commit writes as `rowSelector.topByTimestamp` ("read the newest row"). Before this the commit emitted
a selector-less blob that `SqlEquipmentTagParser` and the authoring gate both refuse, so no
browse-committed Sql tag was ever readable.
Two properties of the choice are deliberate:
- **Candidacy is by declared TYPE, never by column name.** The session asks
`ISqlDialect.MapColumnType` for `DriverDataType.DateTime` — the same metadata the browse tree already
renders per column. A name heuristic (`*_ts`, `*time*`) would happily order a wide-row read by a
*string* column called `timestamp`, binding the tag to whichever row sorts last lexically.
- **The tie-break is first in catalog order** (every dialect's column query orders by ordinal
position), so the pick is stable across calls, connections and servers. On a
`CreatedUtc`/`ModifiedUtc` pair it takes the earlier-declared one. It is a **visible, editable
default**: the typed editor shows the selector and the operator can change it — to the other
timestamp, or to a `whereColumn`/`whereValue` pair entirely.
**A table with no date/time column cannot be bound by browse, and the commit says so.** The remaining
selector is a `whereColumn`/`whereValue` pair whose value is *data*, not schema, so nothing in a
catalog can supply it. `RawBrowseCommitMapper.DescribeUncommittableLeaf` refuses that leaf by name,
names the table, and points at the tag editor; no selector is invented, and the mapper's blob stays
selector-less rather than dropping to a generic `{"address": …}` the typed editor cannot read.
## Authoring in the AdminUI
- **Driver config** — `/raw` → driver node → *Configure*. Sql is a **single-connection** driver: the
@@ -1,78 +1,578 @@
{
"planPath": "docs/plans/2026-05-28-adminui-driver-pages-plan.md",
"designPath": "docs/plans/2026-05-28-adminui-driver-pages-design.md",
"reconciledNote": "RECONCILED 2026-06-18: a seam-by-seam audit confirmed Phases 6-10 are ALL shipped on master (the pre-2026-06-18 'pending' states below were stale). Phase 6 (live DriverStatusPanel) + Phase 8 (Reconnect/Restart) are wired end-to-end (DriverHealthChanged contract, AkkaDriverHealthPublisher invoked at DriverInstanceActor.PublishHealthSnapshot, admin-gated DriverStatusSignalRBridge spawn, snapshot store, hub, panel in all 9 driver pages, AdminOperationsActor/DriverHostActor handlers). Phase 7 probes + Phase 9 typed pickers shipped. Phase 10 E2E tests (DriverTestConnect/DriverReconnect/DriverStatusHub) shipped at 494da22c. The genuine remnant — a deployed-driver Healthy->Reconnecting->Healthy E2E + a production-fidelity harness fix (AddOtOpcUaRuntime) — was added by docs/plans/2026-06-18-driver-pages-reconnect-e2e.md.",
"reconciledNote": "RECONCILED 2026-06-18: a seam-by-seam audit confirmed Phases 6-10 are ALL shipped on master (the pre-2026-06-18 'pending' states below were stale). Phase 6 (live DriverStatusPanel) + Phase 8 (Reconnect/Restart) are wired end-to-end (DriverHealthChanged contract, AkkaDriverHealthPublisher invoked at DriverInstanceActor.PublishHealthSnapshot, admin-gated DriverStatusSignalRBridge spawn, snapshot store, hub, panel in all 9 driver pages, AdminOperationsActor/DriverHostActor handlers). Phase 7 probes + Phase 9 typed pickers shipped. Phase 10 E2E tests (DriverTestConnect/DriverReconnect/DriverStatusHub) shipped at 494da22c. The genuine remnant \u2014 a deployed-driver Healthy->Reconnecting->Healthy E2E + a production-fidelity harness fix (AddOtOpcUaRuntime) \u2014 was added by docs/plans/2026-06-18-driver-pages-reconnect-e2e.md.",
"executionState": "COMPLETE",
"tasks": [
{"id": "0.1", "subject": "Create AdminUI test project + slnx entry + placeholder test", "status": "completed", "commit": "dc12c37"},
{"id": "1.1", "subject": "Driver.Modbus.Contracts — extract ModbusDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5058a56"},
{"id": "1.2", "subject": "Driver.AbCip.Contracts — extract AbCipDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "b474d63"},
{"id": "1.3", "subject": "Driver.AbLegacy.Contracts — extract AbLegacyDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "4902295"},
{"id": "1.4", "subject": "Driver.S7.Contracts — extract S7DriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "9f62f2c"},
{"id": "1.5", "subject": "Driver.TwinCAT.Contracts — extract TwinCATDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "a88721c"},
{"id": "1.6", "subject": "Driver.FOCAS.Contracts — extract FocasDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "d892ab9"},
{"id": "1.7", "subject": "Driver.OpcUaClient.Contracts — extract OpcUaClientDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5f0e048"},
{"id": "1.8", "subject": "Driver.Galaxy.Contracts — extract GalaxyDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5ffbc42"},
{"id": "1.9", "subject": "Driver.Historian.Wonderware.Client.Contracts — extract options", "status": "completed", "blockedBy": ["0.1"], "commit": "8c0a320"},
{"id": "1.10", "subject": "Add ProbeTimeoutSeconds to all 9 Options classes + slnx validation", "status": "completed", "blockedBy": ["1.1","1.2","1.3","1.4","1.5","1.6","1.7","1.8","1.9"], "commit": "f2f6eeb"},
{"id": "2.1", "subject": "DriverFormShell.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "85af126"},
{"id": "2.2", "subject": "DriverIdentitySection.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "1ff3875"},
{"id": "2.3", "subject": "DriverResilienceSection.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "a008530"},
{"id": "2.4", "subject": "Wire shared sections into existing DriverEdit.razor", "status": "completed", "blockedBy": ["2.1","2.2","2.3"], "commit": "a28f4cd"},
{"id": "3.1", "subject": "DriverTypePicker.razor (route: /drivers/new)", "status": "completed", "blockedBy": ["2.4"], "commit": "c0ce5d0"},
{"id": "3.2", "subject": "DriverEditRouter.razor with DynamicComponent dispatch","status": "completed", "blockedBy": ["2.4"], "commit": "55e8bf7"},
{"id": "3.3", "subject": "Hand /drivers/new from DriverEdit to DriverTypePicker","status": "completed", "blockedBy": ["3.1"], "commit": "27b3a01"},
{"id": "3.4", "subject": "Hand /drivers/{id} from DriverEdit to DriverEditRouter (fallback to DriverEdit)", "status": "completed", "blockedBy": ["3.2","3.3"], "commit": "27b3a01"},
{"id": "4.0", "subject": "AdminUI csproj references all 9 Driver.*.Contracts", "status": "completed", "blockedBy": ["1.10","3.4"], "commit": "7014c93"},
{"id": "4.1", "subject": "ModbusDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "a3073d1"},
{"id": "4.2", "subject": "AbCipDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "dc21cba"},
{"id": "4.3", "subject": "AbLegacyDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "059a621"},
{"id": "4.4", "subject": "S7DriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "5cad9b2"},
{"id": "4.5", "subject": "TwinCatDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "dfbf679"},
{"id": "4.6", "subject": "FocasDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "8149739"},
{"id": "4.7", "subject": "OpcUaClientDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "efcc231"},
{"id": "4.8", "subject": "GalaxyDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "a243cfd"},
{"id": "4.9", "subject": "HistorianWonderwareDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "2c16062"},
{"id": "4.10","subject": "Wire all 9 typed pages into DriverEditRouter._componentMap", "status": "completed", "blockedBy": ["4.1","4.2","4.3","4.4","4.5","4.6","4.7","4.8","4.9"], "commit": "5f8fa70"},
{"id": "4.11","subject": "Fixup: S7 Tags data-loss + missing FormModel tests (post-review)", "status": "completed", "blockedBy": ["4.10"], "commit": "c4086c2"},
{"id": "5.1", "subject": "Delete DriverEdit.razor + remove fallback in DriverEditRouter", "status": "completed", "blockedBy": ["4.1","4.2","4.3","4.4","4.5","4.6","4.7","4.8","4.9"], "commit": "a971db3"},
{"id": "6.1", "subject": "DriverHealthChanged DPS message contract", "status": "completed", "blockedBy": ["5.1"], "note": "shipped — Commons/Messages/Drivers/DriverHealthChanged.cs (verified 2026-06-18)"},
{"id": "6.2", "subject": "Publish DriverHealthChanged from each driver actor (IDriverHealthPublisher)", "status": "completed", "blockedBy": ["6.1"], "note": "shipped — AkkaDriverHealthPublisher (DI-bound prod) invoked at DriverInstanceActor.PublishHealthSnapshot"},
{"id": "6.3", "subject": "DriverStatusHub", "status": "completed", "blockedBy": ["6.1"], "note": "shipped — DriverStatusHub.cs, mapped via MapHub<DriverStatusHub>"},
{"id": "6.4", "subject": "DriverStatusSignalRBridge + InMemoryDriverStatusSnapshotStore", "status": "completed", "blockedBy": ["6.2","6.3"], "note": "shipped — bridge spawned admin-gated (HubServiceCollectionExtensions:69, Program.cs:196); store singleton"},
{"id": "6.5", "subject": "DriverStatusPanel.razor + wire into all 9 driver pages", "status": "completed", "blockedBy": ["6.4"], "note": "shipped — in-process snapshot-store subscription (dodges Traefik self-hub trap), wired into all 9 driver pages"},
{"id": "7.1", "subject": "IDriverProbe interface + TestDriverConnect messages", "status": "completed", "blockedBy": ["5.1"], "note": "shipped — Phase 7 probes (per design note + reconcile)"},
{"id": "7.2", "subject": "AdminOperationsActor handler for TestDriverConnect", "status": "completed", "blockedBy": ["7.1"], "note": "shipped"},
{"id": "7.3", "subject": "TCP probes (Modbus, AbCip, AbLegacy, S7)", "status": "completed", "blockedBy": ["7.1"], "note": "shipped — upgraded TCP->protocol handshake (961b2b55 & per-driver)"},
{"id": "7.4", "subject": "Specialty probes (FOCAS, TwinCAT, OPCUA, Galaxy, Historian)", "status": "completed", "blockedBy": ["7.1"], "note": "shipped"},
{"id": "7.5", "subject": "AdminProbeService + DriverTestConnectButton.razor + wire into pages", "status": "completed", "blockedBy": ["7.2","7.3","7.4"], "note": "shipped"},
{"id": "8.1", "subject": "RestartDriver + ReconnectDriver messages + AdminOperationsActor handlers", "status": "completed", "blockedBy": ["6.5","7.5"], "note": "shipped — Commons/Messages/Admin + AdminOperationsActor + DriverHostActor handlers"},
{"id": "8.2", "subject": "DriverOperator authorization policy + docs/Security.md update", "status": "completed", "blockedBy": ["6.5"], "note": "shipped — DriverOperator policy gates the panel buttons"},
{"id": "8.3", "subject": "Wire Reconnect/Restart buttons into DriverStatusPanel", "status": "completed", "blockedBy": ["8.1","8.2"], "note": "shipped — DriverOperator-gated Reconnect/Restart in DriverStatusPanel.razor"},
{"id": "9.1", "subject": "DriverTagPicker.razor modal shell", "status": "completed", "blockedBy": ["5.1"], "note": "shipped — Phase 9 typed address pickers for all 9 drivers (reconcile)"},
{"id": "9.2", "subject": "Modbus address picker body + unit test", "status": "completed", "blockedBy": ["9.1"], "note": "shipped"},
{"id": "9.3", "subject": "AbCip address picker body + unit test", "status": "completed", "blockedBy": ["9.1"], "note": "shipped"},
{"id": "9.4", "subject": "AbLegacy address picker body + unit test", "status": "completed", "blockedBy": ["9.1"], "note": "shipped"},
{"id": "9.5", "subject": "S7 address picker body + unit test", "status": "completed", "blockedBy": ["9.1"], "note": "shipped"},
{"id": "9.6", "subject": "TwinCat address picker body + unit test", "status": "completed", "blockedBy": ["9.1"], "note": "shipped"},
{"id": "9.7", "subject": "FOCAS address picker body + unit test", "status": "completed", "blockedBy": ["9.1"], "note": "shipped"},
{"id": "9.8", "subject": "OpcUaClient picker body (free-text NodeId)", "status": "completed", "blockedBy": ["9.1"], "note": "shipped"},
{"id": "9.9", "subject": "Galaxy picker body (free-text tag_name.AttributeName)", "status": "completed", "blockedBy": ["9.1"], "note": "shipped"},
{"id": "9.10","subject": "Historian.Wonderware picker body + unit test", "status": "completed", "blockedBy": ["9.1"], "note": "shipped"},
{"id": "10.1", "subject": "DriverTestConnectE2eTests (Modbus/AbCip/S7 vs Docker sims)", "status": "completed", "blockedBy": ["8.3","9.10"], "commit": "494da22c", "note": "shipped (skip-gated on sim reachability)"},
{"id": "10.2", "subject": "DriverReconnectE2eTests", "status": "completed", "blockedBy": ["8.3","9.10"], "commit": "494da22c", "note": "shipped; deferred deeper deployed-driver Healthy->Reconnecting->Healthy transition CLOSED 2026-06-18 (docs/plans/2026-06-18-driver-pages-reconnect-e2e.md)"},
{"id": "10.3", "subject": "DriverStatusHubE2eTests", "status": "completed", "blockedBy": ["8.3","9.10"], "commit": "494da22c", "note": "shipped (mock IHubContext + manual bridge spawn)"},
{"id": "10.4", "subject": "Manual smoke checklist (documented)", "status": "partial", "blockedBy": ["10.1","10.2","10.3"], "note": "design §8.3 checklist authored; automated coverage substantially supersedes it (new deployed-driver reconnect E2E + green driver E2E suite 2026-06-18). Full browser smoke recording NOT executed (deferred — login-disabled docker-dev rig drive)."}
{
"id": "0.1",
"subject": "Create AdminUI test project + slnx entry + placeholder test",
"status": "completed",
"commit": "dc12c37"
},
{
"id": "1.1",
"subject": "Driver.Modbus.Contracts \u2014 extract ModbusDriverOptions",
"status": "completed",
"blockedBy": [
"0.1"
],
"commit": "5058a56"
},
{
"id": "1.2",
"subject": "Driver.AbCip.Contracts \u2014 extract AbCipDriverOptions",
"status": "completed",
"blockedBy": [
"0.1"
],
"commit": "b474d63"
},
{
"id": "1.3",
"subject": "Driver.AbLegacy.Contracts \u2014 extract AbLegacyDriverOptions",
"status": "completed",
"blockedBy": [
"0.1"
],
"commit": "4902295"
},
{
"id": "1.4",
"subject": "Driver.S7.Contracts \u2014 extract S7DriverOptions",
"status": "completed",
"blockedBy": [
"0.1"
],
"commit": "9f62f2c"
},
{
"id": "1.5",
"subject": "Driver.TwinCAT.Contracts \u2014 extract TwinCATDriverOptions",
"status": "completed",
"blockedBy": [
"0.1"
],
"commit": "a88721c"
},
{
"id": "1.6",
"subject": "Driver.FOCAS.Contracts \u2014 extract FocasDriverOptions",
"status": "completed",
"blockedBy": [
"0.1"
],
"commit": "d892ab9"
},
{
"id": "1.7",
"subject": "Driver.OpcUaClient.Contracts \u2014 extract OpcUaClientDriverOptions",
"status": "completed",
"blockedBy": [
"0.1"
],
"commit": "5f0e048"
},
{
"id": "1.8",
"subject": "Driver.Galaxy.Contracts \u2014 extract GalaxyDriverOptions",
"status": "completed",
"blockedBy": [
"0.1"
],
"commit": "5ffbc42"
},
{
"id": "1.9",
"subject": "Driver.Historian.Wonderware.Client.Contracts \u2014 extract options",
"status": "completed",
"blockedBy": [
"0.1"
],
"commit": "8c0a320"
},
{
"id": "1.10",
"subject": "Add ProbeTimeoutSeconds to all 9 Options classes + slnx validation",
"status": "completed",
"blockedBy": [
"1.1",
"1.2",
"1.3",
"1.4",
"1.5",
"1.6",
"1.7",
"1.8",
"1.9"
],
"commit": "f2f6eeb"
},
{
"id": "2.1",
"subject": "DriverFormShell.razor",
"status": "completed",
"blockedBy": [
"0.1"
],
"commit": "85af126"
},
{
"id": "2.2",
"subject": "DriverIdentitySection.razor",
"status": "completed",
"blockedBy": [
"0.1"
],
"commit": "1ff3875"
},
{
"id": "2.3",
"subject": "DriverResilienceSection.razor",
"status": "completed",
"blockedBy": [
"0.1"
],
"commit": "a008530"
},
{
"id": "2.4",
"subject": "Wire shared sections into existing DriverEdit.razor",
"status": "completed",
"blockedBy": [
"2.1",
"2.2",
"2.3"
],
"commit": "a28f4cd"
},
{
"id": "3.1",
"subject": "DriverTypePicker.razor (route: /drivers/new)",
"status": "completed",
"blockedBy": [
"2.4"
],
"commit": "c0ce5d0"
},
{
"id": "3.2",
"subject": "DriverEditRouter.razor with DynamicComponent dispatch",
"status": "completed",
"blockedBy": [
"2.4"
],
"commit": "55e8bf7"
},
{
"id": "3.3",
"subject": "Hand /drivers/new from DriverEdit to DriverTypePicker",
"status": "completed",
"blockedBy": [
"3.1"
],
"commit": "27b3a01"
},
{
"id": "3.4",
"subject": "Hand /drivers/{id} from DriverEdit to DriverEditRouter (fallback to DriverEdit)",
"status": "completed",
"blockedBy": [
"3.2",
"3.3"
],
"commit": "27b3a01"
},
{
"id": "4.0",
"subject": "AdminUI csproj references all 9 Driver.*.Contracts",
"status": "completed",
"blockedBy": [
"1.10",
"3.4"
],
"commit": "7014c93"
},
{
"id": "4.1",
"subject": "ModbusDriverPage.razor + serialization test",
"status": "completed",
"blockedBy": [
"4.0"
],
"commit": "a3073d1"
},
{
"id": "4.2",
"subject": "AbCipDriverPage.razor + serialization test",
"status": "completed",
"blockedBy": [
"4.0"
],
"commit": "dc21cba"
},
{
"id": "4.3",
"subject": "AbLegacyDriverPage.razor + serialization test",
"status": "completed",
"blockedBy": [
"4.0"
],
"commit": "059a621"
},
{
"id": "4.4",
"subject": "S7DriverPage.razor + serialization test",
"status": "completed",
"blockedBy": [
"4.0"
],
"commit": "5cad9b2"
},
{
"id": "4.5",
"subject": "TwinCatDriverPage.razor + serialization test",
"status": "completed",
"blockedBy": [
"4.0"
],
"commit": "dfbf679"
},
{
"id": "4.6",
"subject": "FocasDriverPage.razor + serialization test",
"status": "completed",
"blockedBy": [
"4.0"
],
"commit": "8149739"
},
{
"id": "4.7",
"subject": "OpcUaClientDriverPage.razor + serialization test",
"status": "completed",
"blockedBy": [
"4.0"
],
"commit": "efcc231"
},
{
"id": "4.8",
"subject": "GalaxyDriverPage.razor + serialization test",
"status": "completed",
"blockedBy": [
"4.0"
],
"commit": "a243cfd"
},
{
"id": "4.9",
"subject": "HistorianWonderwareDriverPage.razor + serialization test",
"status": "completed",
"blockedBy": [
"4.0"
],
"commit": "2c16062"
},
{
"id": "4.10",
"subject": "Wire all 9 typed pages into DriverEditRouter._componentMap",
"status": "completed",
"blockedBy": [
"4.1",
"4.2",
"4.3",
"4.4",
"4.5",
"4.6",
"4.7",
"4.8",
"4.9"
],
"commit": "5f8fa70"
},
{
"id": "4.11",
"subject": "Fixup: S7 Tags data-loss + missing FormModel tests (post-review)",
"status": "completed",
"blockedBy": [
"4.10"
],
"commit": "c4086c2"
},
{
"id": "5.1",
"subject": "Delete DriverEdit.razor + remove fallback in DriverEditRouter",
"status": "completed",
"blockedBy": [
"4.1",
"4.2",
"4.3",
"4.4",
"4.5",
"4.6",
"4.7",
"4.8",
"4.9"
],
"commit": "a971db3"
},
{
"id": "6.1",
"subject": "DriverHealthChanged DPS message contract",
"status": "completed",
"blockedBy": [
"5.1"
],
"note": "shipped \u2014 Commons/Messages/Drivers/DriverHealthChanged.cs (verified 2026-06-18)"
},
{
"id": "6.2",
"subject": "Publish DriverHealthChanged from each driver actor (IDriverHealthPublisher)",
"status": "completed",
"blockedBy": [
"6.1"
],
"note": "shipped \u2014 AkkaDriverHealthPublisher (DI-bound prod) invoked at DriverInstanceActor.PublishHealthSnapshot"
},
{
"id": "6.3",
"subject": "DriverStatusHub",
"status": "completed",
"blockedBy": [
"6.1"
],
"note": "shipped \u2014 DriverStatusHub.cs, mapped via MapHub<DriverStatusHub>"
},
{
"id": "6.4",
"subject": "DriverStatusSignalRBridge + InMemoryDriverStatusSnapshotStore",
"status": "completed",
"blockedBy": [
"6.2",
"6.3"
],
"note": "shipped \u2014 bridge spawned admin-gated (HubServiceCollectionExtensions:69, Program.cs:196); store singleton"
},
{
"id": "6.5",
"subject": "DriverStatusPanel.razor + wire into all 9 driver pages",
"status": "completed",
"blockedBy": [
"6.4"
],
"note": "shipped \u2014 in-process snapshot-store subscription (dodges Traefik self-hub trap), wired into all 9 driver pages"
},
{
"id": "7.1",
"subject": "IDriverProbe interface + TestDriverConnect messages",
"status": "completed",
"blockedBy": [
"5.1"
],
"note": "shipped \u2014 Phase 7 probes (per design note + reconcile)"
},
{
"id": "7.2",
"subject": "AdminOperationsActor handler for TestDriverConnect",
"status": "completed",
"blockedBy": [
"7.1"
],
"note": "shipped"
},
{
"id": "7.3",
"subject": "TCP probes (Modbus, AbCip, AbLegacy, S7)",
"status": "completed",
"blockedBy": [
"7.1"
],
"note": "shipped \u2014 upgraded TCP->protocol handshake (961b2b55 & per-driver)"
},
{
"id": "7.4",
"subject": "Specialty probes (FOCAS, TwinCAT, OPCUA, Galaxy, Historian)",
"status": "completed",
"blockedBy": [
"7.1"
],
"note": "shipped"
},
{
"id": "7.5",
"subject": "AdminProbeService + DriverTestConnectButton.razor + wire into pages",
"status": "completed",
"blockedBy": [
"7.2",
"7.3",
"7.4"
],
"note": "shipped"
},
{
"id": "8.1",
"subject": "RestartDriver + ReconnectDriver messages + AdminOperationsActor handlers",
"status": "completed",
"blockedBy": [
"6.5",
"7.5"
],
"note": "shipped \u2014 Commons/Messages/Admin + AdminOperationsActor + DriverHostActor handlers"
},
{
"id": "8.2",
"subject": "DriverOperator authorization policy + docs/Security.md update",
"status": "completed",
"blockedBy": [
"6.5"
],
"note": "shipped \u2014 DriverOperator policy gates the panel buttons"
},
{
"id": "8.3",
"subject": "Wire Reconnect/Restart buttons into DriverStatusPanel",
"status": "completed",
"blockedBy": [
"8.1",
"8.2"
],
"note": "shipped \u2014 DriverOperator-gated Reconnect/Restart in DriverStatusPanel.razor"
},
{
"id": "9.1",
"subject": "DriverTagPicker.razor modal shell",
"status": "completed",
"blockedBy": [
"5.1"
],
"note": "shipped \u2014 Phase 9 typed address pickers for all 9 drivers (reconcile)"
},
{
"id": "9.2",
"subject": "Modbus address picker body + unit test",
"status": "completed",
"blockedBy": [
"9.1"
],
"note": "shipped"
},
{
"id": "9.3",
"subject": "AbCip address picker body + unit test",
"status": "completed",
"blockedBy": [
"9.1"
],
"note": "shipped"
},
{
"id": "9.4",
"subject": "AbLegacy address picker body + unit test",
"status": "completed",
"blockedBy": [
"9.1"
],
"note": "shipped"
},
{
"id": "9.5",
"subject": "S7 address picker body + unit test",
"status": "completed",
"blockedBy": [
"9.1"
],
"note": "shipped"
},
{
"id": "9.6",
"subject": "TwinCat address picker body + unit test",
"status": "completed",
"blockedBy": [
"9.1"
],
"note": "shipped"
},
{
"id": "9.7",
"subject": "FOCAS address picker body + unit test",
"status": "completed",
"blockedBy": [
"9.1"
],
"note": "shipped"
},
{
"id": "9.8",
"subject": "OpcUaClient picker body (free-text NodeId)",
"status": "completed",
"blockedBy": [
"9.1"
],
"note": "shipped"
},
{
"id": "9.9",
"subject": "Galaxy picker body (free-text tag_name.AttributeName)",
"status": "completed",
"blockedBy": [
"9.1"
],
"note": "shipped"
},
{
"id": "9.10",
"subject": "Historian.Wonderware picker body + unit test",
"status": "completed",
"blockedBy": [
"9.1"
],
"note": "shipped"
},
{
"id": "10.1",
"subject": "DriverTestConnectE2eTests (Modbus/AbCip/S7 vs Docker sims)",
"status": "completed",
"blockedBy": [
"8.3",
"9.10"
],
"commit": "494da22c",
"note": "shipped (skip-gated on sim reachability)"
},
{
"id": "10.2",
"subject": "DriverReconnectE2eTests",
"status": "completed",
"blockedBy": [
"8.3",
"9.10"
],
"commit": "494da22c",
"note": "shipped; deferred deeper deployed-driver Healthy->Reconnecting->Healthy transition CLOSED 2026-06-18 (docs/plans/2026-06-18-driver-pages-reconnect-e2e.md)"
},
{
"id": "10.3",
"subject": "DriverStatusHubE2eTests",
"status": "completed",
"blockedBy": [
"8.3",
"9.10"
],
"commit": "494da22c",
"note": "shipped (mock IHubContext + manual bridge spawn)"
},
{
"id": "10.4",
"subject": "Manual smoke checklist (documented)",
"status": "obsolete",
"blockedBy": [
"10.1",
"10.2",
"10.3"
],
"note": "Closed 2026-08-10 as obsolete (was: design \u00a78.3 checklist authored; automated coverage substantially supersedes it (new deployed-driver reconnect E2E + green driver E2E suite 2026-06-18). Full browser smoke recording NOT executed (deferred \u2014 login-disabled docker-dev rig drive).) \u2014 the routed /clusters/{id}/drivers flow this checklist smokes (DriverTypePicker, DriverEditRouter, the 8 *DriverPage shells) was retired in v3 Batch 2; no driver pages exist under Components/Pages/. The form bodies live on inside the /raw modals, which were live-gated repeatedly across the 2026-07/08 issue rounds (see deferment.md \u00a79)."
}
],
"lastUpdated": "2026-06-18"
}
}
@@ -1,26 +1,46 @@
{
"planPath": "docs/plans/2026-05-29-adminui-followups.md",
"branch": "feat/adminui-followups",
"lastUpdated": "2026-08-07",
"closureNote": "CLOSED 2026-08-07 by the overlap audit deferment.md §6.2 asked for before executing. This file recorded 19 pending / 0 completed and was called 'the largest unexecuted plan in the tree' and 'the only substantial engineering left' — that was bookkeeping drift, not backlog. Every task is shipped, superseded by v3, or moot; per-task evidence is in each closureNote below. The plan's own premise died with v3: WS1 rests on 'driver-embedded tag/device lists in DriverConfig JSON are the runtime source of truth', but v3 authors tags as Tag rows in /raw and the deploy artifact populates the driver's RawTags (ModbusDriverFactoryExtensions.cs:98-102), so hand-editing a list on the driver form no longer decides what the driver polls. The capability WS1 wanted was delivered in the v3 shape instead: per-driver typed tag editors via TagConfigEditorMap (11 editors, dispatched from RawTagModal.razor:79 + RawManualTagEntryModal.razor:92) and per-driver device forms via DeviceFormMap (9 forms, dispatched from DeviceModal.razor:45), both guarded by DriverFormMapParityTests + DriverDispatchMapParityTests. Verified reachable, not merely present — a supersession claim naming an unreachable replacement would repeat the dead-surface class this repo has hit repeatedly.",
"tasks": [
{"id": 11, "plan": 1, "subject": "Task 1: Generic CollectionEditor<TRow> component", "status": "pending"},
{"id": 12, "plan": 2, "subject": "Task 2: Modbus tag editor (proof) + tests", "status": "pending", "blockedBy": [11]},
{"id": 13, "plan": 3, "subject": "Task 3: AbCip device+tag editors + tests", "status": "pending", "blockedBy": [11]},
{"id": 14, "plan": 4, "subject": "Task 4: AbLegacy device+tag editors + tests", "status": "pending", "blockedBy": [11]},
{"id": 15, "plan": 5, "subject": "Task 5: TwinCAT device+tag editors + tests", "status": "pending", "blockedBy": [11]},
{"id": 16, "plan": 6, "subject": "Task 6: FOCAS device+tag editors + tests", "status": "pending", "blockedBy": [11]},
{"id": 17, "plan": 7, "subject": "Task 7: S7 tag editor + tests", "status": "pending", "blockedBy": [11]},
{"id": 18, "plan": 8, "subject": "Task 8: OpcUaClient endpoint-URL editor + tests", "status": "pending", "blockedBy": [11]},
{"id": 19, "plan": 9, "subject": "Task 9: ResilienceFormModel + tests", "status": "pending"},
{"id": 20, "plan": 10, "subject": "Task 10: Typed resilience form in DriverResilienceSection", "status": "pending", "blockedBy": [19]},
{"id": 21, "plan": 11, "subject": "Task 11: RoleMapper.Merge overload + tests", "status": "pending"},
{"id": 22, "plan": 12, "subject": "Task 12: Register ILdapGroupRoleMappingService in DI", "status": "pending"},
{"id": 23, "plan": 13, "subject": "Task 13: Wire DB merge into AuthEndpoints.LoginAsync", "status": "pending", "blockedBy": [21, 22]},
{"id": 24, "plan": 14, "subject": "Task 14: Add FleetAdmin authorization policy", "status": "pending"},
{"id": 25, "plan": 15, "subject": "Task 15: RoleGrants.razor global CRUD (FleetAdmin-gated)", "status": "pending", "blockedBy": [22, 24]},
{"id": 26, "plan": 16, "subject": "Task 16: LdapGroupRoleMapping service tests (global CRUD)", "status": "pending"},
{"id": 27, "plan": 17, "subject": "Task 17: Delete stale source comments", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 20, 25]},
{"id": 28, "plan": 18, "subject": "Task 18: Strip now-true rendered notes", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 25]},
{"id": 29, "plan": 19, "subject": "Task 19: Full verification (build + test + /run)", "status": "pending", "blockedBy": [20, 23, 26, 27, 28]}
],
"lastUpdated": "2026-05-29"
{"id": 11, "plan": 1, "subject": "Task 1: Generic CollectionEditor<TRow> component", "status": "completed",
"closureNote": "SHIPPED — Components/Shared/Drivers/CollectionEditor.razor exists with the designed shape (working-copy modal, commit-on-save, NewRow factory, Validate delegate)."},
{"id": 12, "plan": 2, "subject": "Task 2: Modbus tag editor (proof) + tests", "status": "completed",
"closureNote": "SUPERSEDED — delivered as Uns/TagEditors/ModbusTagConfigEditor.razor via TagConfigEditorMap, not as a CollectionEditor over an embedded JSON list. The embedded list it would have edited is no longer the runtime source of truth."},
{"id": 13, "plan": 3, "subject": "Task 3: AbCip device+tag editors + tests", "status": "completed",
"closureNote": "SUPERSEDED — DeviceForms/AbCipDeviceForm.razor + Uns/TagEditors/AbCipTagConfigEditor.razor."},
{"id": 14, "plan": 4, "subject": "Task 4: AbLegacy device+tag editors + tests", "status": "completed",
"closureNote": "SUPERSEDED — DeviceForms/AbLegacyDeviceForm.razor + Uns/TagEditors/AbLegacyTagConfigEditor.razor."},
{"id": 15, "plan": 5, "subject": "Task 5: TwinCAT device+tag editors + tests", "status": "completed",
"closureNote": "SUPERSEDED — DeviceForms/TwinCATDeviceForm.razor + Uns/TagEditors/TwinCATTagConfigEditor.razor."},
{"id": 16, "plan": 6, "subject": "Task 6: FOCAS device+tag editors + tests", "status": "completed",
"closureNote": "SUPERSEDED — DeviceForms/FocasDeviceForm.razor + Uns/TagEditors/FocasTagConfigEditor.razor."},
{"id": 17, "plan": 7, "subject": "Task 7: S7 tag editor + tests", "status": "completed",
"closureNote": "SUPERSEDED — Uns/TagEditors/S7TagConfigEditor.razor (plus DeviceForms/S7DeviceForm.razor, which this task did not ask for)."},
{"id": 18, "plan": 8, "subject": "Task 8: OpcUaClient endpoint-URL editor + tests", "status": "completed",
"closureNote": "SHIPPED — and it is the one task delivered in the plan's own shape: Forms/OpcUaClientDriverForm.razor is the sole production consumer of CollectionEditor. Endpoints genuinely still live in the driver config, which is why this one survived the v3 change and Tasks 2-7 did not."},
{"id": 19, "plan": 9, "subject": "Task 9: ResilienceFormModel + tests", "status": "completed",
"closureNote": "SHIPPED — Components/Shared/Drivers/ResilienceFormModel.cs + tests/Server/...AdminUI.Tests/ResilienceFormModelTests.cs."},
{"id": 20, "plan": 10, "subject": "Task 10: Typed resilience form in DriverResilienceSection", "status": "completed",
"closureNote": "SHIPPED — DriverResilienceSection.razor binds ResilienceFormModel and keeps the raw-JSON escape hatch at :67. NARROWER than designed, deliberately: two of the designed fields no longer exist. RecycleIntervalSeconds was deleted with the scheduled-recycle machinery (Gitea #522; see the tombstone comments at DriverResilienceOptions.cs:22 and ResilienceFormModel.cs:26) and the bulkhead knobs were deleted end-to-end by archreview R2-02 because Build never added a concurrency limiter. Authoring either today would re-create an inert knob."},
{"id": 21, "plan": 11, "subject": "Task 11: RoleMapper.Merge overload + tests", "status": "completed",
"closureNote": "SHIPPED — RoleMapper.Merge(baseline, dbRows) consumed by OtOpcUaGroupRoleMapper.MapAsync; covered by OtOpcUaGroupRoleMapperTests + CanonicalAdminRolesTests."},
{"id": 22, "plan": 12, "subject": "Task 12: Register ILdapGroupRoleMappingService in DI", "status": "completed",
"closureNote": "SHIPPED — Configuration/ServiceCollectionExtensions.cs:34 (EF-backed, Scoped). A driver-only node additionally binds NullLdapGroupRoleMappingService (Host/Program.cs:351) because mesh Phase 4 left it with no ConfigDb."},
{"id": 23, "plan": 13, "subject": "Task 13: Wire DB merge into AuthEndpoints.LoginAsync", "status": "completed",
"closureNote": "SHIPPED — AuthEndpoints.cs:100 calls roleMapper.MapAsync behind the shared IGroupRoleMapper<string> seam, which applies the appsettings GroupToRole baseline and merges system-wide DB grants."},
{"id": 24, "plan": 14, "subject": "Task 14: Add FleetAdmin authorization policy", "status": "completed",
"closureNote": "SHIPPED — AdminUiPolicies.FleetAdmin registered at Security/ServiceCollectionExtensions.cs:160 (RequireRole Administrator; the policy NAME was kept stable across the AdminRole rename)."},
{"id": 25, "plan": 15, "subject": "Task 15: RoleGrants.razor global CRUD (FleetAdmin-gated)", "status": "completed",
"closureNote": "SHIPPED — RoleGrants.razor:2 carries [Authorize(Policy = AdminUiPolicies.FleetAdmin)]; Add (:132 CreateAsync, IsSystemWide=true/ClusterId=null as designed) + Delete (:148) + the read-only appsettings fallback panel. No in-place Update, which is correct rather than missing: a row is only (LdapGroup, Role), so editing either field IS a different grant — delete-then-add is the whole of 'update' here."},
{"id": 26, "plan": 16, "subject": "Task 16: LdapGroupRoleMapping service tests (global CRUD)", "status": "completed",
"closureNote": "SHIPPED — tests/Core/...Configuration.Tests/LdapGroupRoleMappingServiceTests.cs."},
{"id": 27, "plan": 17, "subject": "Task 17: Delete stale source comments", "status": "completed",
"closureNote": "MOOT — the comments went with their files. FleetStatusHub.cs was deleted with the whole zero-client fleet-status channel (Gitea #542); DriverEditRouter.razor, DriverTypePicker.razor and DriverEdit.razor were deleted by v3 Batch 2, which retired the routed /clusters/{id}/drivers flow. Greps for the named comments (TODO(3.3/3.4), 'legacy DriverEdit', 'until the bridge lands') return nothing outside bin/obj."},
{"id": 28, "plan": 18, "subject": "Task 18: Strip now-true rendered notes", "status": "completed",
"closureNote": "MOOT — same cause: the driver pages that rendered 'list-editor coming in a follow-up phase' and 'typed-form-ifying Polly is a follow-up' no longer exist. Neither string survives anywhere in src/."},
{"id": 29, "plan": 19, "subject": "Task 19: Full verification (build + test + /run)", "status": "completed",
"closureNote": "DONE 2026-08-07 as part of the same session's live-gate run: solution build 0 errors, and the AdminUI surfaces this plan targets were driven on the docker-dev rig alongside the four live gates. See deferment.md §9."}
]
}
@@ -1,10 +1,36 @@
{
"planPath": "docs/plans/2026-06-14-driver-reconfigure-while-faulted-plan.md",
"tasks": [
{"id": 0, "subject": "Task 0: Feature branch (already created)", "status": "completed"},
{"id": 1, "subject": "Task 1: Adopt corrected config mid-(re)connect (DriverInstanceActor)", "status": "completed", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: Docs + bookkeeping", "status": "completed", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Live /run verification — DEFERRED (skipped)", "status": "pending", "blockedBy": [1]}
{
"id": 0,
"subject": "Task 0: Feature branch (already created)",
"status": "completed"
},
{
"id": 1,
"subject": "Task 1: Adopt corrected config mid-(re)connect (DriverInstanceActor)",
"status": "completed",
"blockedBy": [
0
]
},
{
"id": 2,
"subject": "Task 2: Docs + bookkeeping",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 3,
"subject": "Task 3: Live /run verification",
"status": "completed",
"blockedBy": [
1
],
"closureNote": "PASSED 2026-08-07 on docker-dev. DRV-ce2fb5217f5d faulted on a bad device config (:5999, \"initialize failed: Connection refused\"); a corrected config (:5020) was deployed while it was faulted and the driver adopted it and connected (10:22:13) with central-1 startedAt byte-identical before and after and RestartCount=0 \u2014 no node restart. The \"DEFERRED (skipped)\" block no longer held: docker-dev runs the AdminUI with DisableLogin, so /run needs no operator sign-in. Evidence: docs/plans/2026-08-07-adminui-followups-and-live-gates.md \u00a75."
}
],
"lastUpdated": "2026-06-14"
}
"lastUpdated": "2026-08-07"
}
@@ -12,17 +12,167 @@
"reconsiderGates": "T7 BUILT+SHIPPED (FB-7, master 30c9821f). T8 NOT built (awaiting explicit go-ahead). T9/T10 DONE (FB-9/FB-10, live-verified). T11 PARTIAL: Phase B native-alarm pipeline PROVEN live 2026-06-19 (Galaxy! ConditionId-prefix finding); Phase C HistoryRead + H6 ack->AVEVA + AbLegacy/FOCAS/TwinCAT probes remain AVEVA/hardware/no-local-target deferred."
},
"tasks": [
{"id": 1, "ref": "A1", "subject": "OpcUaClient history session-capture-before-gate race fix", "classification": "standard", "status": "completed", "commits": ["eb328e57","eede0c92"], "parallelizableWith": [2,3,4,5,6], "group": "A-actionable"},
{"id": 2, "ref": "A2", "subject": "Client.CLI enable/disable command (H4 client path)", "classification": "standard", "status": "completed", "commits": ["88d5ba45","f81fa768"], "parallelizableWith": [1,3,4,5,6], "group": "A-actionable"},
{"id": 3, "ref": "A3", "subject": "Cert-audit minor review nits (unreachable fallthrough comment + single PkiStoreRoot read)", "classification": "trivial", "status": "completed", "commits": ["6223bc97"], "parallelizableWith": [1,2,4,5,6], "group": "A-actionable"},
{"id": 4, "ref": "B2", "subject": "AdminUI: preserve edited alarm fields on Galaxy address re-pick", "classification": "small", "status": "completed", "commits": ["2dd723e1","e047af05"], "liveVerify": "substantial (picker+gateway-browse+config-commit confirmed; final pick-and-assert blocked by object-browser href quirk; preservation unit-tested)", "parallelizableWith": [1,2,3,5,6], "group": "B-deferred"},
{"id": 5, "ref": "B3", "subject": "AdminUI: refresh script dropdown label after inline create", "classification": "small", "status": "completed", "commits": ["da57c307","d8254630"], "liveVerify": "PASS (inline New script -> dropdown label = new script)", "parallelizableWith": [1,2,3,4,6], "group": "B-deferred"},
{"id": 6, "ref": "B1", "subject": "Write-outcome residuals: Bad-quality blip + AuditWriteUpdateEvent + sync fail-fast", "classification": "standard", "status": "completed", "commits": ["36eb14e8","85afb678"], "note": "production already shipped bb59fd4e 2026-06-15; delivered missing behavioural test coverage + private->internal seam", "parallelizableWith": [1,2,3,4,5], "group": "B-deferred"},
{"id": 7, "ref": "B4", "subject": "F10b surgical DataType/IsArray in-place writes", "classification": "standard", "status": "pending", "gate": "RECONSIDER — previously decided against (dirty); do NOT build without explicit go-ahead", "group": "B-reconsider"},
{"id": 8, "ref": "B5", "subject": "Alarm-severity SetSeverity surgical update", "classification": "small", "status": "pending", "gate": "RECONSIDER — operationally invisible (alarm engine overwrites); do NOT build without explicit go-ahead", "group": "B-reconsider"},
{"id": 9, "ref": "C1", "subject": "Modbus-Int64 full live authoring on docker-dev", "classification": "verify", "status": "pending", "note": "operator/rig verify-only; seed a Modbus driver", "group": "C-verify"},
{"id": 10, "ref": "C2", "subject": "S7 + AbCip Test-Connect probe happy-path live-verify", "classification": "verify", "status": "pending", "note": "needs lmxopcua-fix fixtures from the Windows VM", "group": "C-verify"},
{"id": 11, "ref": "C3", "subject": "Device-gated proofs (H6 ack→AVEVA, Phase C T7, Phase B T9, AbLegacy/TwinCAT/FOCAS probe)", "classification": "blocked", "status": "partial", "note": "Live-assessed 2026-06-19 (gateway 10.100.0.48:5120 reachable from this Mac, so the 'all blocked' label was partly stale). OUTCOMES: (Phase B T9) DONE — native-alarm PIPELINE proven end-to-end live on docker-dev: authored a Galaxy alarm tag (FullName=Galaxy!TestArea.TestMachine_001.TestAlarm001, alarm{alarmType:OffNormalAlarm,severity:750}) -> Galaxy driver opened the native-alarm subscription (previously-gated path) -> gateway transition materialised Part 9 condition GalaxyNativeAlarm (ns=2;s=EQ-55297329838d/GalaxyNativeAlarm) carrying the EXACT gateway raise time 2026-05-24T16:04:10.856Z, surfaced via ConditionRefresh. KEY FINDING: ConditionId match REQUIRES the 'Galaxy!' source prefix on the authored FullName (without it every transition is silently dropped). Severity 700 = MapSeverity(750) (MediumHigh bucket), not a bug. Net state Inactive/Unacked because the gateway replays weeks-old historical raises -> a fresh Active-state + live /alerts row needs an alarm triggered on the AVEVA side. (Phase C T7) DEFERRED config+data-gated — confirmed the historized Galaxy node materialises + server HistoryRead path returns GoodNoData/0-values with ServerHistorian.Enabled=false (correct NullHistorianDataSource degrade); live data needs enabling ServerHistorian + Wonderware sidecar on 10.100.0.48 + actual stored history (synthetic ref has none). (H6 ack->AVEVA) DEFERRED — ack SEND reachable but Galaxy AckMsg is write-only/fire-and-forget; confirming it LANDED needs AVEVA-side observation on 10.100.0.48. (AbLegacy/FOCAS/TwinCAT probe) DEFERRED — no local sims running + no live-target probe harness; TwinCAT(ADS) genuinely has no rig target. Rig restored (alarm tag removed, Galaxy back to value-only).", "group": "C-blocked"}
{
"id": 1,
"ref": "A1",
"subject": "OpcUaClient history session-capture-before-gate race fix",
"classification": "standard",
"status": "completed",
"commits": [
"eb328e57",
"eede0c92"
],
"parallelizableWith": [
2,
3,
4,
5,
6
],
"group": "A-actionable"
},
{
"id": 2,
"ref": "A2",
"subject": "Client.CLI enable/disable command (H4 client path)",
"classification": "standard",
"status": "completed",
"commits": [
"88d5ba45",
"f81fa768"
],
"parallelizableWith": [
1,
3,
4,
5,
6
],
"group": "A-actionable"
},
{
"id": 3,
"ref": "A3",
"subject": "Cert-audit minor review nits (unreachable fallthrough comment + single PkiStoreRoot read)",
"classification": "trivial",
"status": "completed",
"commits": [
"6223bc97"
],
"parallelizableWith": [
1,
2,
4,
5,
6
],
"group": "A-actionable"
},
{
"id": 4,
"ref": "B2",
"subject": "AdminUI: preserve edited alarm fields on Galaxy address re-pick",
"classification": "small",
"status": "completed",
"commits": [
"2dd723e1",
"e047af05"
],
"liveVerify": "substantial (picker+gateway-browse+config-commit confirmed; final pick-and-assert blocked by object-browser href quirk; preservation unit-tested)",
"parallelizableWith": [
1,
2,
3,
5,
6
],
"group": "B-deferred"
},
{
"id": 5,
"ref": "B3",
"subject": "AdminUI: refresh script dropdown label after inline create",
"classification": "small",
"status": "completed",
"commits": [
"da57c307",
"d8254630"
],
"liveVerify": "PASS (inline New script -> dropdown label = new script)",
"parallelizableWith": [
1,
2,
3,
4,
6
],
"group": "B-deferred"
},
{
"id": 6,
"ref": "B1",
"subject": "Write-outcome residuals: Bad-quality blip + AuditWriteUpdateEvent + sync fail-fast",
"classification": "standard",
"status": "completed",
"commits": [
"36eb14e8",
"85afb678"
],
"note": "production already shipped bb59fd4e 2026-06-15; delivered missing behavioural test coverage + private->internal seam",
"parallelizableWith": [
1,
2,
3,
4,
5
],
"group": "B-deferred"
},
{
"id": 7,
"ref": "B4",
"subject": "F10b surgical DataType/IsArray in-place writes",
"classification": "standard",
"status": "pending",
"gate": "RECONSIDER \u2014 previously decided against (dirty); do NOT build without explicit go-ahead",
"group": "B-reconsider"
},
{
"id": 8,
"ref": "B5",
"subject": "Alarm-severity SetSeverity surgical update",
"classification": "small",
"status": "pending",
"gate": "RECONSIDER \u2014 operationally invisible (alarm engine overwrites); do NOT build without explicit go-ahead",
"group": "B-reconsider"
},
{
"id": 9,
"ref": "C1",
"subject": "Modbus-Int64 full live authoring on docker-dev",
"classification": "verify",
"status": "completed",
"note": "VERIFIED PASS 2026-08-10 on docker-dev (rig rebuilt to master 481f4343, image parity asserted across all 6 nodes). Authored Int64 raw tag TASK9-INT64 (RawPath pymodbus/plc/Task9Int64, TagConfig region=HoldingRegisters address=200 dataType=Int64 BigEndian) via ConfigDb + headless deploy API; deployment b4d7c8be sealed (Status=2). Node advertises DataType i=8 (Int64), ValueRank -1, Good, on BOTH :4840 and :4841; value 347340207162261544 is the exact BigEndian decode of HR200-203=(1234,20,30,40). Value-change propagation proven by driving HR203 via FC06 (observed via subscribe on both centrals; registers restored after). Fixture caveat: the standard profile has NO naturally-churning 4-register span (HR100 is isolated; spans over it return ILLEGAL DATA ADDRESS), so churn was externally driven. No product gap. Tag row left in place, deployed and serving, consistent with prior gate rows.",
"group": "C-verify"
},
{
"id": 10,
"ref": "C2",
"subject": "S7 + AbCip Test-Connect probe happy-path live-verify",
"classification": "verify",
"status": "completed",
"note": "VERIFIED PASS 2026-08-10: DriverProbeHandshakeE2eTests 12/12 passed, 0 skipped (S7_Green_AgainstSim, AbCip_Green_AgainstSim with 'CIP session OK' proving a real authored-tag read, AbCip_WithNoAuthoredTags_ReportsTheControllerReachable, plus Modbus/OpcUaClient/local-rig/Galaxy legs \u2014 mxaccessgw on 10.100.0.48 happened to be reachable). Correction to the task's premise: skip-gating is pure TCP reachability (DockerFixtureAvailability.IsReachable), NOT env vars, and the checked-in defaults already point at 10.100.0.35 \u2014 runnable from macOS directly; the 'needs Windows-VM fixtures' note was stale. S7 fixture started then removed (net zero); AbCip was already up (5 days) and left as found.",
"group": "C-verify"
},
{
"id": 11,
"ref": "C3",
"subject": "Device-gated proofs (H6 ack\u2192AVEVA, Phase C T7, Phase B T9, AbLegacy/TwinCAT/FOCAS probe)",
"classification": "blocked",
"status": "partial",
"note": "Live-assessed 2026-06-19 (gateway 10.100.0.48:5120 reachable from this Mac, so the 'all blocked' label was partly stale). OUTCOMES: (Phase B T9) DONE \u2014 native-alarm PIPELINE proven end-to-end live on docker-dev: authored a Galaxy alarm tag (FullName=Galaxy!TestArea.TestMachine_001.TestAlarm001, alarm{alarmType:OffNormalAlarm,severity:750}) -> Galaxy driver opened the native-alarm subscription (previously-gated path) -> gateway transition materialised Part 9 condition GalaxyNativeAlarm (ns=2;s=EQ-55297329838d/GalaxyNativeAlarm) carrying the EXACT gateway raise time 2026-05-24T16:04:10.856Z, surfaced via ConditionRefresh. KEY FINDING: ConditionId match REQUIRES the 'Galaxy!' source prefix on the authored FullName (without it every transition is silently dropped). Severity 700 = MapSeverity(750) (MediumHigh bucket), not a bug. Net state Inactive/Unacked because the gateway replays weeks-old historical raises -> a fresh Active-state + live /alerts row needs an alarm triggered on the AVEVA side. (Phase C T7) DEFERRED config+data-gated \u2014 confirmed the historized Galaxy node materialises + server HistoryRead path returns GoodNoData/0-values with ServerHistorian.Enabled=false (correct NullHistorianDataSource degrade); live data needs enabling ServerHistorian + Wonderware sidecar on 10.100.0.48 + actual stored history (synthetic ref has none). (H6 ack->AVEVA) DEFERRED \u2014 ack SEND reachable but Galaxy AckMsg is write-only/fire-and-forget; confirming it LANDED needs AVEVA-side observation on 10.100.0.48. (AbLegacy/FOCAS/TwinCAT probe) DEFERRED \u2014 no local sims running + no live-target probe harness; TwinCAT(ADS) genuinely has no rig target. Rig restored (alarm tag removed, Galaxy back to value-only).",
"group": "C-blocked"
}
],
"lastUpdated": "2026-06-19"
}
}
@@ -1,23 +1,152 @@
{
"planPath": "docs/plans/2026-06-26-otopcua-fixedtree-followups.md",
"tasks": [
{"id": 1, "subject": "Task 1: Injectable discovery timeout (A)", "status": "completed", "nativeId": 38, "commits": ["c2c368dc"]},
{"id": 2, "subject": "Task 2: RediscoverPolicy enum + driver overrides (B1)", "status": "completed", "nativeId": 39, "commits": ["a378b572", "efbdaf85"]},
{"id": 3, "subject": "Task 3: DriverInstanceActor honors policy (B2)", "status": "completed", "blockedBy": [1, 2], "nativeId": 40, "commits": ["ce34816a", "a1a655e6"]},
{"id": 4, "subject": "Task 4: TriggerRediscovery message + handler (C1)", "status": "completed", "blockedBy": [3], "nativeId": 41, "commits": ["f7358bf4", "e7d5ebe9"]},
{"id": 5, "subject": "Task 5: EquipmentNode DriverInstanceId/DeviceId/DeviceHost (E projection)", "status": "completed", "nativeId": 42, "commits": ["cb7ce7f1", "915492a7"]},
{"id": 6, "subject": "Task 6: DriverHostActor cache-as-dict + driver-level resolution (E1)", "status": "completed", "blockedBy": [5], "nativeId": 43, "commits": ["adcd7b57"]},
{"id": 7, "subject": "Task 7: Re-trigger discovery on rebind drop (C2)", "status": "completed", "blockedBy": [4, 6], "nativeId": 44, "commits": ["53367148", "cde16063"]},
{"id": 8, "subject": "Task 8: Single SetDesiredSubscriptions per redeploy (D)", "status": "completed", "blockedBy": [7], "nativeId": 45, "commits": ["05c82079", "51721df5"]},
{"id": 9, "subject": "Task 9: Multi-device-per-driver partition (E2)", "status": "completed", "blockedBy": [5, 8], "nativeId": 46, "commits": ["50f08635", "0074f37a"]},
{"id": 10, "subject": "Task 10: Docs — follow-up notes + statuses", "status": "completed", "blockedBy": [9], "nativeId": 47},
{"id": 11, "subject": "Task 11: Build + offline suite + regression gate", "status": "pending", "blockedBy": [2, 4, 9, 10], "nativeId": 48}
{
"id": 1,
"subject": "Task 1: Injectable discovery timeout (A)",
"status": "completed",
"nativeId": 38,
"commits": [
"c2c368dc"
]
},
{
"id": 2,
"subject": "Task 2: RediscoverPolicy enum + driver overrides (B1)",
"status": "completed",
"nativeId": 39,
"commits": [
"a378b572",
"efbdaf85"
]
},
{
"id": 3,
"subject": "Task 3: DriverInstanceActor honors policy (B2)",
"status": "completed",
"blockedBy": [
1,
2
],
"nativeId": 40,
"commits": [
"ce34816a",
"a1a655e6"
]
},
{
"id": 4,
"subject": "Task 4: TriggerRediscovery message + handler (C1)",
"status": "completed",
"blockedBy": [
3
],
"nativeId": 41,
"commits": [
"f7358bf4",
"e7d5ebe9"
]
},
{
"id": 5,
"subject": "Task 5: EquipmentNode DriverInstanceId/DeviceId/DeviceHost (E projection)",
"status": "completed",
"nativeId": 42,
"commits": [
"cb7ce7f1",
"915492a7"
]
},
{
"id": 6,
"subject": "Task 6: DriverHostActor cache-as-dict + driver-level resolution (E1)",
"status": "completed",
"blockedBy": [
5
],
"nativeId": 43,
"commits": [
"adcd7b57"
]
},
{
"id": 7,
"subject": "Task 7: Re-trigger discovery on rebind drop (C2)",
"status": "completed",
"blockedBy": [
4,
6
],
"nativeId": 44,
"commits": [
"53367148",
"cde16063"
]
},
{
"id": 8,
"subject": "Task 8: Single SetDesiredSubscriptions per redeploy (D)",
"status": "completed",
"blockedBy": [
7
],
"nativeId": 45,
"commits": [
"05c82079",
"51721df5"
]
},
{
"id": 9,
"subject": "Task 9: Multi-device-per-driver partition (E2)",
"status": "completed",
"blockedBy": [
5,
8
],
"nativeId": 46,
"commits": [
"50f08635",
"0074f37a"
]
},
{
"id": 10,
"subject": "Task 10: Docs \u2014 follow-up notes + statuses",
"status": "completed",
"blockedBy": [
9
],
"nativeId": 47
},
{
"id": 11,
"subject": "Task 11: Build + offline suite + regression gate",
"status": "obsolete",
"blockedBy": [
2,
4,
9,
10
],
"nativeId": 48,
"note": "Closed 2026-08-10 as obsolete: the FixedTree-under-Equipment injection feature this gate verified was deleted wholesale when #507 closed as superseded (DiscoveredNodesReady, DiscoveredNodeMapper, HandleDiscoveredNodes, the connect-time discovery loop, and the 18 DiscoveryInjectionDormantV3 tests are all gone in v3). A verification gate for deleted code verifies nothing. ITagDiscovery itself lives on via the /raw browse picker (DiscoveryDriverBrowser), which has its own coverage."
}
],
"nativeTaskIds": {
"1": 38, "2": 39, "3": 40, "4": 41, "5": 42, "6": 43,
"7": 44, "8": 45, "9": 46, "10": 47, "11": 48
"1": 38,
"2": 39,
"3": 40,
"4": 41,
"5": 42,
"6": 43,
"7": 44,
"8": 45,
"9": 46,
"10": 47,
"11": 48
},
"lastUpdated": "2026-06-26T00:00:00Z",
"status": "code+docs complete; final build/suite gate pending",
"branch": "feat/focas-fixedtree-equipment-injection"
}
}
@@ -114,6 +114,21 @@ through a spec review + code review. Things the reviews changed or decided:
(out of this branch's scope). Recorded in `deferment.md` §0.1. Lower stakes there — the browse
modal resolves the device it was opened for and shows its own error banner — but the shape is
identical.
- **Ported 2026-08-09.** The `_openGeneration` counter now guards **every** post-await write in
`ResetAndResolveAsync`: the merged-config write, the group-context write
(`_groupPrefix`/`_effectiveDeviceId` — a second await the siblings do not have), the `catch`'s
error banner, and the `finally`'s `_resolving` + `StateHasChanged`. The `finally` is the one
departure from the sibling shape and it is load-bearing: this modal resolves inside a
`try`/`finally`, so a stale `return` still runs the tail and would drop the **live** open's
spinner mid-resolve. Pinned by four facts in `RawBrowseModalStaleOpenTests`
(`A_stale_open_never_feeds_the_browser_another_devices_config`,
`A_stale_group_resolve_never_retargets_the_displayed_open`,
`A_stale_open_completing_late_never_clears_the_live_opens_loading_state`,
`A_stale_resolve_failure_never_paints_its_error_over_the_live_open`), each observed red before
the guard and each re-falsified afterwards by reverting **only its own** guard. Driving this
modal as a class needs one thing the siblings did not: it calls `StateHasChanged` on the open
path, which throws without a render handle, so the test attaches an inert `Renderer` subclass
(file-local `#pragma warning disable BL0006`).
- **Component-class tests must not set `[Parameter]` properties directly**: analyzer BL0005 is an
error under `TreatWarningsAsErrors`. The two test files legitimately solve it two ways (file-local
pragma vs a reflection `SetParameters` helper); the reflection helper is the preferred convention
@@ -0,0 +1,281 @@
# 2026-08-07 — adminui-followups closure + the four runnable live gates
**Scope.** The two items `deferment.md` §0.1 named as the remaining untracked engineering:
the unexecuted `2026-05-29-adminui-followups` plan, and the live gates that need only the
docker-dev rig (no hardware, no licence). There are exactly **four** such gate tasks:
archreview **R2-03 T11** and **T12**, **R2-10 T10**, and
`2026-06-14-driver-reconfigure-while-faulted` **Task 3**. The other open gates (Galaxy
`ScanStateProbeParityTests`, the v2 GA exit criteria) need a licence or external tooling and were
not attempted.
**Tree:** `master` @ `21009874`. **Rig:** docker-dev, image rebuilt for this run.
---
## 0. Rig provenance (done before anything was gated)
The rig was running an image built **2026-08-06 17:55**, three hours *older* than the last code
commit (`8b10d327`, 2026-08-06 21:09). Gating on it would have been meaningless. Rebuilt, both
centrals `--force-recreate`, then:
- **Image parity asserted per container** (`:9200` round-robins central-1/central-2, so a single
check proves nothing): both on `sha256:49d52d72…`.
- **Positive check that the code under test is in the artifact** — not merely that the image is
new: `RecordBreakerClose` + `IsBreakerOpen` present in the shipped `Core.dll`,
`DriverResilienceStatusPublisherService` in `OtOpcUa.Host.dll`, `EvaluationResult` +
`ClearCompiledScripts` in `Runtime.dll`; assemblies stamped 2026-08-07 09:29 UTC.
---
## 1. adminui-followups — CLOSED by audit, no code written
`deferment.md` called this "the largest unexecuted plan in the tree" and "the only substantial
engineering left" (19 pending / 0 completed). The plan's own first step is an overlap audit, and the
audit says **every task is already shipped, superseded by v3, or moot.** Per-task evidence is in
`docs/plans/2026-05-29-adminui-followups.md.tasks.json`; the summary:
| Group | Verdict |
|---|---|
| WS1 Tasks 1, 8 (`CollectionEditor<TRow>`, OpcUaClient endpoints) | **Shipped in the plan's own shape** — `CollectionEditor.razor`, consumed by `OpcUaClientDriverForm.razor` |
| WS1 Tasks 2–7 (per-driver tag/device editors ×6) | **Superseded by v3** — `TagConfigEditorMap` (11 editors) + `DeviceFormMap` (9 device forms) |
| WS2 Tasks 9, 10 (resilience typed form) | **Shipped**, deliberately narrower — see below |
| WS3 Tasks 11–16 (LDAP→role map) | **Shipped** end to end, incl. the login merge at `AuthEndpoints.cs:100` |
| WS4 Tasks 17, 18 (comment cleanup) | **Moot** — the files were deleted by v3 Batch 2 and #542 |
Two things worth carrying forward:
- **The plan's premise died with v3.** WS1 rests on "driver-embedded tag/device lists in
`DriverConfig` JSON are the runtime source of truth". In v3 tags are `Tag` rows authored in `/raw`
and the deploy artifact populates the driver's `RawTags`
(`ModbusDriverFactoryExtensions.cs:98-102`), so editing a list on a driver form no longer decides
what the driver polls. The capability was delivered, in a different shape.
- **WS2 shipped narrower than designed, correctly.** Two designed fields no longer exist:
`RecycleIntervalSeconds` went with the scheduled-recycle machinery (#522) and the bulkhead knobs
were deleted end-to-end by archreview R2-02 because `Build` never added a concurrency limiter.
Authoring either today would re-create an inert knob.
The supersession claims were checked for **reachability**, not just existence —
`TagConfigEditorMap.Resolve` is dispatched from `RawTagModal.razor:79` and
`RawManualTagEntryModal.razor:92`, `DeviceFormMap.Resolve` from `DeviceModal.razor:45`, both guarded
by `DriverFormMapParityTests` + `DriverDispatchMapParityTests`. A "superseded by X" whose X is itself
dead surface is the exact failure this repo has hit repeatedly.
---
## 2. R2-03 T11 — full-suite sweep ✅ PASSED
| Leg | Result |
|---|---|
| Solution build | **0 errors** |
| `Runtime.Tests` (whole project) | **555/555**, 3 skipped |
| `RoslynVirtualTagEvaluatorTests` | **20/20** |
The 3 skips are the deliberate `EquipmentDeviceBindingRetired` tombstones, matching the count
`deferment.md` §4.4 predicts. (The plan names the suite `RoslynVirtualTagEvaluatorTests`; it lives in
`Host.IntegrationTests`, not a project a solution-wide `--filter` reaches by default.)
---
## 3. R2-03 T12 — VT fail→Bad→recover ✅ PASSED, with one finding
Both blockers recorded against the previous attempt are **gone**: the Modbus fixture at
`10.100.0.35:5020` is now reachable from inside the rig (so driver data flows and a VT can be staged
Good), which is what made this gate runnable at all.
**Method.** The first two attempts produced a Bad result whose value was empty, but each followed a
deploy — and a deploy creates a fresh VT child with no last value, which is *correct by design* (the
inputs-ready gate that suppresses a cold-start flash, `VirtualTagActor.cs:246`). To isolate the
variable the trip was made **data-driven and deployment-free**: the script fails on a raw tag's
value, and that value was flipped by writing Modbus holding register 205 directly on the fixture, so
the VT child survives across the transition.
```
[09:51:57] GateVt = 42 (0x00000000) Good
[09:52:51] GateVt = (0x80020000) Bad_InternalError <- reg205 := 1, no deployment
[09:53:26] GateVt = 42 (0x00000000) Good <- reg205 := 0, no deployment
```
- ✅ **Degradation** — script failure degrades the node to `0x80020000 BadInternalError` instead of
freezing on the last Good. Server-side cause confirmed in the log:
`VirtualTag VT-gatevt: script execution threw / System.InvalidOperationException`.
- ✅ **Recovery** — returns to Good with the same value, exercising the dedup bypass.
- ✅ **P7 no-recompile spot-check** — two byte-identical redeploys (`revisionHash` identical across
both, `94f68785…`) caused **no disturbance at all**: `Ever went BAD: 0`, VT held Good=42.
Production has no compile log or metric, so the observable is the *consequence* of not
recompiling; the gate itself is pinned by `VirtualTagHostActorTests` (exactly two clears across
first-generation / identical-redeploy / edited-expression).
### ⚠️ Finding — "carrying the last-known value" never reaches the client
`VirtualTagActor`'s xmldoc (`:22`) and the R2-03 row in `archreview/plans/STATUS.md` both state the
Bad publish carries the last-known value. **A client never sees it.** Two independent channels agree:
- subscription: `GateVt = (0x80020000)` — value empty;
- raw OPC UA **Read** service (which returns a `DataValue` rather than throwing):
`StatusCode 0x80020000, Value <NULL>`.
The VT logic is **not** at fault. `OnEvaluationFailed` sends `_hasLastValue ? _lastValue : null`, and
a new regression test — `VirtualTagActorTests.The_Bad_result_carries_the_last_known_value` — proves
the actor emits `Bad` carrying `42`. The value is lost below the actor, in the OPC UA layer
(`OtOpcUaNodeManager.WriteValue` assigns `variable.Value` unconditionally, so the loss is at
Read/Publish encoding, where a Bad `StatusCode` conventionally carries no value).
**Why no test caught it:** every existing failure assertion in `VirtualTagActorTests` checks
`.StatusCode` alone (`:87, :101, :123, :142, :180`) — the "carrying the last-known value" half was
asserted **nowhere**. That test is now added.
Not filed as a defect because the client-visible behaviour may be the correct OPC UA reading; what is
certainly wrong is the **documentation**, which promises an operator something no client can observe.
### Confirmed still true: the Client.CLI `read` limitation
`read` surfaces a Bad status as a `ServiceResultException` from `FetchValueAsync` rather than
printing it — exactly as the previous pass recorded. `ReadCommand.cs:52` does print `Status:`, but
the SDK throws before reaching it. **Use `subscribe`** (prints value *and* status, tracks
`everBad`), or a raw Read.
---
## 4. R2-10 T10 — breaker-OPEN ❌ NOT ACHIEVABLE AS SPECIFIED
The gate asks to point a driver at a dead endpoint and watch the breaker open. **It cannot open that
way, for a structural reason** — not because of the rig. Three attempts, each failing at a different
point, together characterise it:
| Attempt | What happened |
|---|---|
| Spare Modbus driver → blackhole (`sql:1433`, accepts TCP, never answers Modbus) | Driver had **no pollable tags** (its only tag skipped: "tag config did not map to a definition") — no capability calls at all |
| `MAIN-modbus` (13 live tags) → same blackhole | Driver **connected**, reads failed (`MainPressure` → `0x80050000 BadTimeout`, row showed DEGRADED / "Modbus socket closed mid-response") — yet tracker stayed `ConsecutiveFailures = 0`, panel showed **"All targets healthy."** |
| `line3-sql` → unusable connection string (Sql `ReadAsync` *does* propagate) | Fails in **`InitializeAsync`** (liveness check) before ever reaching a wrapped Read |
**Root cause.** The breaker only sees failures that propagate as exceptions out of a
`CapabilityInvoker`-wrapped call. On the dead-endpoint path nothing does:
1. **`IDriver.InitializeAsync` is not wrapped.** `DriverInstanceActor.cs:707` calls it directly; the
invoker wraps only Read / Write / Subscribe / Discover / AlarmAcknowledge (`:774, :827, :870,
:899, :1081`). A driver that cannot connect therefore contributes **zero** breaker input — which
directly refutes the gate's premise.
2. **Modbus swallows per-tag failures, in both directions.** `ReadAsync` converts them to
`Degraded` health (`:365-375, :838-855`) and `WriteAsync` converts them to a `WriteResult` status
code — neither throws. So even the fallback trigger the plan suggests ("a rapid idempotent
write-to-dead-endpoint") cannot work for Modbus.
The breaker itself is configured to open on **2 failures in a 30 s window** at ratio 1.0
(`DriverResiliencePipelineBuilder.cs:168-175`), so the previously-recorded explanation — that
R2-09's ConnectionBackoff throttles retries below the threshold — is not the whole story and is
arguably not the operative one: the failures never enter the pipeline at all.
**Operator consequence worth recording:** for the scenario operators care about most — a driver that
cannot reach its device — the resilience panel is **permanently quiet**. It showed *"All targets
healthy."* for a driver that was DEGRADED, timing out every read against a blackhole.
**What WAS verified live:** the panel renders on `/hosts` (rehomed there by #546 — the plan's "on the
driver page" step is stale, v3 Batch 2 retired those pages), the expanded row shows the RESILIENCE
block, and `DriverResilienceStatusPublisherService` is publishing real tracker state every 5 s
(`BreakerOpen`, `ConsecutiveFailures`, `CurrentInFlight`, `LastBreakerOpenUtc` all observed on the
wire). The reader/store/panel chain is live; only the breaker-open **state** remains unforced.
> Note: `DriverResilienceStatusChanged` going to `$DeadLetterWithNoSubscribers` on the node is
> **expected**, not a defect — the rig runs Phase-5 telemetry in `Grpc` mode, where the node always
> double-publishes and central consumes via the gRPC dialer, leaving the DPS copy unsubscribed.
**To actually close T10** someone must either wrap the connect path, stop drivers swallowing
capability failures, or pick a driver whose *wrapped* call fails after a successful init (Sql's
`ReadAsync` propagates — it needs a backend that dies *after* initialization, e.g. `docker pause` on
a dedicated fixture). Left OPEN with this root cause recorded.
---
## 5. driver-reconfigure-while-faulted Task 3 ✅ PASSED
The task was deferred in 2026-06 with "skip the live test part"; its block ("user-driven sign-in")
no longer holds because docker-dev runs the AdminUI with `DisableLogin`.
```
before central-1 startedAt=2026-08-07T09:50:29.939876456Z restarts=0
10:16:18 DriverInstance DRV-ce2fb5217f5d: initialize failed: Connection refused <- bad config (:5999)
10:22:13 DriverInstance DRV-ce2fb5217f5d: connected <- corrected config (:5020)
10:22:13 DriverInstance DRV-ce2fb5217f5d: subscribed to 1 refs
after central-1 startedAt=2026-08-07T09:50:29.939876456Z restarts=0
```
The driver **adopted the corrected config and connected while faulted**, and `startedAt` is
byte-identical before and after with `restarts=0` — **no node restart**, which is the whole point
(pre-fix it would retry the stale config forever).
---
## 6. Rig state
Restored to as-found: Modbus device back on `10.100.0.35:5020`, `line3-sql` back on `DevSql`,
all `ResilienceConfig` overrides cleared, the `SC-gatevt` script restored to its original body.
Post-restore verification: `MainPressure = 7 (Good)`, `GateVt = 7 (Good)` — the VT is back on its
original one-line body, so it mirrors `MainPressure` again rather than the `×6` used during the gate.
One anomaly recorded rather than explained: both centrals were **recreated** at 09:49:40/09:49:50
(new `Created`, `RestartCount=0`) by something this session did not issue. It postdates the
observations above and the old container's logs went with it, so it is noted, not attributed.
---
## 7. Addendum 2026-08-08 — R2-10 T10 ✅ PASSED after fixing the structural gap
§4 concluded the gate was not achievable as specified because the connect path never entered the
resilience pipeline. The user chose to fix that ("do option 1"), and the gate then passed against
the fixed build. Branch `feat/wrap-driver-init-in-resilience`.
### The fix
- **`DriverCapability.Initialize`** — `IDriver.InitializeAsync` routes through the capability
invoker in `DriverInstanceActor.InitializeAsync` (both the initial connect and the
`Reconnecting` retry path; the best-effort teardown `ShutdownAsync` stays deliberately
unwrapped). Keyed on the driver-instance id, like a bulk subscribe.
- **No pipeline retry for Initialize** — the actor's `RetryConnect` timer is the retry loop.
Enforced at the parser funnel (`retryCount` override forced to 0, same shape as the
write-shaped rule) and in every tier default.
- **Breaker threshold 2, not the Read-sized 5** — the breaker samples 30 s and the reconnect
timer fires every 10 s, so at most three init attempts fit one window; threshold 5 could never
open. Pinned by `Initialize_BreakerThreshold_IsReachableAtTheReconnectCadence` +
`Initialize_BreakerOpens_AtTierDefaults_AndFastFailsTheNextAttempt`.
- **Companion fix — respawn resets tracker snapshots.** `DriverCapabilityInvokerFactory.Create`
now calls `DriverResilienceStatusTracker.Invalidate(driverInstanceId)` alongside the pipeline
invalidation. Without it, a deploy during an outage discards the only breaker whose `OnClosed`
could clear the OPEN flag, stranding a false "Breaker OPEN" chip forever. Unreachable before
this change made breaker-open reachable; found by asking what clears the flag, not by a test.
- Wiring pinned by `Initialize_routes_through_the_capability_invoker` +
`An_invoker_fast_fail_on_init_keeps_the_actor_retrying_until_recovery` (falsification-controlled:
both go red with the actor wrap stashed; `Create_resets_the_instances_tracker_state_with_its_pipelines`
goes red with the factory call stashed).
### The gate run (docker-dev, image `df04c76ce06b`, symbol-verified on both centrals)
The staged failure was free: `rtu-gate` (`DRV-3616122eb53d`) was already authored against
`10.100.0.35:5021`, where nothing listens — connection refused on every init.
1. **Breaker OPEN, both replicas** — within ~20 s of boot, both centrals logged
`circuit-breaker OPENED: instance=DRV-3616122eb53d … capability=Initialize … Connection refused`,
re-opening each half-open probe cycle. Bonus coverage: `DRV-opcua1` (invalid config → init
throws) also tripped — a permanently-misconfigured driver was equally invisible before.
2. **Panel chip** — `/hosts` → rtu-gate → Controls showed the red **"BREAKER OPEN · OPENED 19S
AGO"** chip in the RESILIENCE section, row FAULTED with "Connection refused".
3. **Control** — healthy `MAIN-modbus` recorded zero breaker events throughout.
4. **Recovery clear** — a socat forwarder (`5021 → 5020` on the docker host) brought the endpoint
alive; both centrals logged `circuit-breaker closed (recovered)` + `reconnected` within 1 s of
each other, and the panel dropped to "All targets healthy."
5. **Teardown** — socat removed; 5021 dead again (as-found). rtu-gate returns to a truthfully-OPEN
breaker chip, which is the feature working.
### Two nuances against the original T10 wording
- **"Both-replica chips"** resolves as both nodes' *trackers* tripping (proven in both logs)
rendered through the single **active** AdminUI. Traefik's `/health/active` check deliberately
routes the UI to the active admin node only — the standby returns 503
(`Standby: active node for role 'admin' is …`), so there is never a second replica serving
chips. Discovered while trying to force the sticky-cookie LB onto central-1.
- **The staleness-dim leg was not exercised.** Both pair nodes publish under the same
`(instance, host)` key, so staleness requires *every* publisher for a key to stop — deliberate
node-kill churn this gate did not do. The dim is a pure render predicate (`IsStale` → opacity).
Also corrected while here: §4's "ConnectionBackoff throttles retries" explanation (the previously
recorded root cause) is confirmed wrong — the breaker config opens on 2 failures in 30 s; the
operative cause was always that no failure reached the pipeline at all.
@@ -39,4 +39,15 @@ public enum DriverCapability
/// <summary><see cref="IHistoryProvider"/> reads (Raw/Processed/AtTime/Events). Retries by default.</summary>
HistoryRead,
/// <summary>
/// <c>IDriver.InitializeAsync</c> — connection establishment (R2-10 T10 closure: without this,
/// a driver that cannot connect contributes zero breaker input and the resilience panel stays
/// quiet for the failure operators most care about). Does NOT retry — the
/// <c>DriverInstanceActor</c> reconnect timer is the retry loop, and pipeline-level retries
/// would stack inside each timer tick. The tier-default breaker threshold is deliberately low
/// (2): at the 10 s reconnect cadence at most three attempts fit the breaker's 30 s sampling
/// window, so a Read-sized threshold of 5 could never open on connect failures.
/// </summary>
Initialize,
}
@@ -48,7 +48,39 @@ public static class TagConfigJson
value = default;
return JsonEnumRead.Absent;
}
if (Enum.TryParse(e.GetString(), ignoreCase: true, out value))
return ClassifyEnumText(e.GetString(), out value);
}
/// <summary>
/// The text-level heart of <see cref="TryReadEnum{TEnum}"/>, and with it the single authority on
/// which enum spellings a TagConfig may carry: classifies an already-extracted enum STRING.
/// <para>
/// It is public because the authoring side reads the same blob through a different door — the
/// AdminUI's typed tag-config editors hold a mutable <c>JsonObject</c> key bag, not a
/// <see cref="JsonElement"/> — and an authoring gate that RESTATED the accept/reject boundary
/// would drift from the runtime the moment either side moved, in the one direction that hurts:
/// a value the modal accepts and the driver then rejects (Gitea #549). Both doors reach this
/// method.
/// </para>
/// <para>
/// <see langword="null"/> is <see cref="JsonEnumRead.Absent"/>, matching the JSON reader's
/// treatment of an absent or present-but-non-string property — so a caller that extracts "the
/// string value, or null" gets the runtime's leniency for free.
/// </para>
/// </summary>
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
/// <param name="text">The authored value's text, or <see langword="null"/> when there is none.</param>
/// <param name="value">The parsed enum on <see cref="JsonEnumRead.Valid"/>; otherwise <c>default</c>.</param>
/// <returns>The read outcome.</returns>
public static JsonEnumRead ClassifyEnumText<TEnum>(string? text, out TEnum value)
where TEnum : struct, Enum
{
if (text is null)
{
value = default;
return JsonEnumRead.Absent;
}
if (Enum.TryParse(text, ignoreCase: true, out value))
return JsonEnumRead.Valid;
value = default;
return JsonEnumRead.Invalid;
@@ -120,10 +152,26 @@ public static class TagConfigJson
/// <returns>The warning text, or <see langword="null"/>.</returns>
public static string? DescribeInvalidEnum<TEnum>(JsonElement o, string name)
where TEnum : struct, Enum
=> TryReadEnum<TEnum>(o, name, out _) != JsonEnumRead.Invalid
? null
: DescribeInvalidEnumText<TEnum>(name, o.TryGetProperty(name, out var e) ? e.GetString() : null);
/// <summary>
/// The text-level counterpart of <see cref="DescribeInvalidEnum{TEnum}(JsonElement, string)"/>,
/// over the same <see cref="ClassifyEnumText{TEnum}"/> verdict: a human-readable warning for a
/// present-but-invalid enum value, or <see langword="null"/> when it is absent
/// (<paramref name="text"/> <see langword="null"/>) or valid. The authoring gate uses this so its
/// wording, and not only its verdict, comes from the same place as the deploy gate's.
/// </summary>
/// <typeparam name="TEnum">The enum type expected.</typeparam>
/// <param name="name">The property name to describe.</param>
/// <param name="text">The authored value's text, or <see langword="null"/> when there is none.</param>
/// <returns>The warning text, or <see langword="null"/>.</returns>
public static string? DescribeInvalidEnumText<TEnum>(string name, string? text)
where TEnum : struct, Enum
{
if (TryReadEnum<TEnum>(o, name, out _) != JsonEnumRead.Invalid) return null;
var raw = o.TryGetProperty(name, out var e) ? e.GetString() : null;
if (ClassifyEnumText<TEnum>(text, out _) != JsonEnumRead.Invalid) return null;
var valid = string.Join(", ", Enum.GetNames<TEnum>());
return $"value '{raw}' for '{name}' is not a valid {typeof(TEnum).Name}; valid: {valid}";
return $"value '{text}' for '{name}' is not a valid {typeof(TEnum).Name}; valid: {valid}";
}
}
@@ -77,6 +77,10 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
// Cleanup (no longer the correctness mechanism): drop this instance's cached pipelines across all
// generations so lingering old-generation entries don't accumulate. No-op on first spawn.
_builder.Invalidate(driverInstanceId);
// The tracker's snapshots describe the breakers inside the pipelines just discarded, so they go
// too — a retained "Breaker OPEN" from a pre-respawn outage could never self-correct (only the
// discarded pipeline's OnClosed stamps the close) and would show a false OPEN chip forever.
_statusTracker.Invalidate(driverInstanceId);
return new CapabilityInvoker(
_builder,
@@ -82,6 +82,11 @@ public sealed record DriverResilienceOptions
[DriverCapability.AlarmSubscribe] = new(TimeoutSeconds: 5, RetryCount: 3, BreakerFailureThreshold: 5),
[DriverCapability.AlarmAcknowledge] = new(TimeoutSeconds: 5, RetryCount: 0, BreakerFailureThreshold: 5),
[DriverCapability.HistoryRead] = new(TimeoutSeconds: 30, RetryCount: 2, BreakerFailureThreshold: 5),
// Initialize: no retry (the DriverInstanceActor reconnect timer is the retry loop) and a
// threshold of 2, NOT the Read-sized 5 — the breaker samples over 30 s and the reconnect
// timer fires every 10 s, so at most three init attempts fit one window; a threshold of 5
// could never open on connect failures (R2-10 T10).
[DriverCapability.Initialize] = new(TimeoutSeconds: 5, RetryCount: 0, BreakerFailureThreshold: 2),
},
DriverTier.B => new Dictionary<DriverCapability, CapabilityPolicy>
{
@@ -93,6 +98,8 @@ public sealed record DriverResilienceOptions
[DriverCapability.AlarmSubscribe] = new(TimeoutSeconds: 8, RetryCount: 3, BreakerFailureThreshold: 5),
[DriverCapability.AlarmAcknowledge] = new(TimeoutSeconds: 8, RetryCount: 0, BreakerFailureThreshold: 5),
[DriverCapability.HistoryRead] = new(TimeoutSeconds: 60, RetryCount: 2, BreakerFailureThreshold: 5),
// See the Tier A Initialize note: threshold 2 is cadence-reachability, not a tuning choice.
[DriverCapability.Initialize] = new(TimeoutSeconds: 8, RetryCount: 0, BreakerFailureThreshold: 2),
},
DriverTier.C => new Dictionary<DriverCapability, CapabilityPolicy>
{
@@ -104,6 +111,8 @@ public sealed record DriverResilienceOptions
[DriverCapability.AlarmSubscribe] = new(TimeoutSeconds: 15, RetryCount: 1, BreakerFailureThreshold: 0),
[DriverCapability.AlarmAcknowledge] = new(TimeoutSeconds: 15, RetryCount: 0, BreakerFailureThreshold: 0),
[DriverCapability.HistoryRead] = new(TimeoutSeconds: 120, RetryCount: 1, BreakerFailureThreshold: 0),
// Tier C stays breaker-less like every other Tier C capability (process-level supervision).
[DriverCapability.Initialize] = new(TimeoutSeconds: 15, RetryCount: 0, BreakerFailureThreshold: 0),
},
_ => throw new ArgumentOutOfRangeException(nameof(tier), tier, $"No default policy table defined for tier {tier}."),
};
@@ -158,6 +158,17 @@ public static class DriverResilienceOptionsParser
retry = 0;
}
// Initialize's retry loop is the DriverInstanceActor reconnect timer, not the pipeline —
// pipeline retries would stack inside each timer tick (and, worse, stretch a single init
// attempt past the timer interval so attempts overlap). Same funnel-enforced invariant
// shape as the write-shaped rule above, different rationale.
if (capability == DriverCapability.Initialize && retry > 0)
{
AppendDiagnostic(ref diagnostic,
$"{capability} never retries (the reconnect timer is the retry loop); retryCount override ignored.");
retry = 0;
}
var breaker = merged.BreakerFailureThreshold;
if (breaker < ResiliencePolicyRanges.MinBreakerFailureThreshold)
{
@@ -83,6 +83,27 @@ public sealed class DriverResilienceStatusTracker
(_, existing) => existing with { LastBreakerClosedUtc = utcNow, LastSampledUtc = utcNow });
}
/// <summary>
/// Drop every snapshot for one driver instance. Called by
/// <c>DriverCapabilityInvokerFactory.Create</c> alongside
/// <c>DriverResiliencePipelineBuilder.Invalidate</c>: the pipelines carry the actual breaker
/// state, so once they are discarded on a respawn, a retained snapshot describes a breaker
/// that no longer exists — and a stale "Breaker OPEN" could never self-correct, because only
/// the discarded pipeline's <c>OnClosed</c> hook stamps the close.
/// </summary>
/// <param name="driverInstanceId">The driver instance whose snapshots should be dropped.</param>
/// <returns>The number of snapshots removed.</returns>
public int Invalidate(string driverInstanceId)
{
var removed = 0;
foreach (var key in _status.Keys)
{
if (key.DriverInstanceId == driverInstanceId && _status.TryRemove(key, out _))
removed++;
}
return removed;
}
/// <summary>Record a process recycle event (Tier C only).</summary>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="hostName">The host name.</param>
@@ -189,16 +189,40 @@ public static class AbCipTagDefinitionFactory
}
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// The RawPath handed to <see cref="FromTagConfig"/> during inspection — the mapper only copies it
/// onto <see cref="AbCipTagDefinition.Name"/> and never validates it.
/// </summary>
private const string InspectionRawPath = "(inspect)";
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns for a blob <see cref="FromTagConfig"/> would
/// <b>reject</b> — a structurally unparseable or non-object document, an absent / non-string /
/// blank <c>tagPath</c>, a present-but-invalid <c>dataType</c> (the runtime enum read is STRICT
/// since R2-11 Phase C, so a typo rejects the tag), or a malformed declared UDT member (see
/// <see cref="DescribeInvalidMember(JsonElement)"/>). Such a tag is skipped by the driver and reads
/// <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts the blob, when it is blank, or
/// when it is not an equipment-tag object. Never throws.
/// </summary>
/// <remarks>
/// <b>The verdict comes from <see cref="FromTagConfig"/>, not from a mirror</b> (Gitea #551) — see
/// <c>ModbusTagDefinitionFactory.Inspect</c>'s remarks for the full argument.
/// </remarks>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
// drawing nothing.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') return warnings;
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
@@ -208,16 +232,129 @@ public static class AbCipTagDefinitionFactory
warnings.Add("AbCip TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<AbCipDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
DescribeRejection(root, warnings);
}
catch (JsonException)
{
warnings.Add("AbCip TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
if (warnings.Count == 0)
{
warnings.Add(
"AbCip TagConfig was rejected by the driver's tag reader — the driver will skip this tag "
+ "and it will read BadNodeIdUnknown.");
}
return warnings;
}
/// <summary>
/// Explains why <see cref="FromTagConfig"/> refused an object-rooted blob, walking its checks in the
/// same order. Runs only after the mapper has already refused, so it can never invent strictness.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="warnings">The list to append warnings to.</param>
private static void DescribeRejection(JsonElement root, List<string> warnings)
{
if (!root.TryGetProperty("tagPath", out var tagPathEl) || tagPathEl.ValueKind != JsonValueKind.String)
{
warnings.Add(
"AbCip TagConfig has no string 'tagPath' — AbCip is a symbolic driver and addresses by "
+ "Logix tag path, so the driver will skip this tag and it will read BadNodeIdUnknown.");
}
else if (string.IsNullOrWhiteSpace(tagPathEl.GetString()))
{
warnings.Add("AbCip TagConfig has a blank 'tagPath' — the tag will not resolve (BadNodeIdUnknown).");
}
if (TagConfigJson.DescribeInvalidEnum<AbCipDataType>(root, "dataType") is { } w) warnings.Add(w);
if (DescribeInvalidMember(root) is { } memberWarning) warnings.Add(memberWarning);
}
/// <summary>
/// <b>The single authority on the declared-UDT-member rules</b>, consulted by <see cref="Inspect"/>
/// and by the AdminUI's <c>AbCipTagConfigModel.Validate</c> alike. Describes the FIRST malformed
/// entry of the optional <c>members</c> array — a non-object entry, one with no usable <c>name</c>,
/// or one whose <c>dataType</c> is a present-but-invalid <see cref="AbCipDataType"/> — or
/// <see langword="null"/> when every member reads.
/// <para>
/// The member rules are as strict as the top-level ones (<see cref="FromTagConfig"/> refuses the
/// WHOLE tag for one bad member), but no authoring surface checked them: the typed editor emits
/// only <c>tagPath</c>/<c>dataType</c>/<c>writable</c> and <c>members</c> rides the model's
/// preserved key bag untouched, so a hand-authored / CSV / API blob with a typo'd member
/// deployed clean and died at the driver — Gitea #551 item 3.
/// </para>
/// <para>
/// A <c>members</c> value that is absent or not a JSON array draws nothing: the mapper reads it
/// only when it is an array, and an array of zero usable members simply means "no member
/// fan-out". Saying otherwise here would make the gate stricter than the reader.
/// </para>
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <returns>The warning text, or <see langword="null"/> when the members read.</returns>
public static string? DescribeInvalidMember(JsonElement root)
{
if (!root.TryGetProperty("members", out var membersEl) || membersEl.ValueKind != JsonValueKind.Array)
return null;
var index = 0;
foreach (var member in membersEl.EnumerateArray())
{
var label = $"members[{index}]";
index++;
if (member.ValueKind != JsonValueKind.Object)
{
return $"AbCip {label} is not a JSON object — a declared UDT member needs a 'name' and may "
+ "carry a 'dataType'; the WHOLE tag is rejected (BadNodeIdUnknown).";
}
if (!member.TryGetProperty("name", out var nameEl)
|| nameEl.ValueKind != JsonValueKind.String
|| string.IsNullOrWhiteSpace(nameEl.GetString()))
{
return $"AbCip {label} has no usable 'name' — a declared UDT member must name itself; the "
+ "WHOLE tag is rejected (BadNodeIdUnknown).";
}
if (TagConfigJson.DescribeInvalidEnum<AbCipDataType>(member, "dataType") is { } dataTypeWarning)
{
return $"AbCip {label}: {dataTypeWarning} — the WHOLE tag is rejected (BadNodeIdUnknown).";
}
}
return null;
}
/// <summary>
/// String-input overload of <see cref="DescribeInvalidMember(JsonElement)"/>, for the AdminUI
/// authoring model — which holds a mutable <c>JsonObject</c> key bag rather than a
/// <see cref="JsonElement"/> and so reaches the same rule through a different door. Never throws: a
/// blank / unparseable / non-object blob draws nothing here, because those shapes are the OTHER
/// gates' business, not the member rule's.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <returns>The warning text, or <see langword="null"/> when the members read.</returns>
public static string? DescribeInvalidMember(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
try
{
using var doc = JsonDocument.Parse(tagConfig);
return doc.RootElement.ValueKind == JsonValueKind.Object
? DescribeInvalidMember(doc.RootElement)
: null;
}
catch (JsonException)
{
return null;
}
}
private static bool ReadBool(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.True;
@@ -119,16 +119,46 @@ public static class AbLegacyTagDefinitionFactory
}
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// The RawPath handed to <see cref="FromTagConfig"/> during inspection — the mapper only copies it
/// onto <see cref="AbLegacyTagDefinition.Name"/> and never validates it.
/// </summary>
private const string InspectionRawPath = "(inspect)";
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns for a blob <see cref="FromTagConfig"/> would
/// <b>reject</b> — a structurally unparseable or non-object document, an absent / non-string /
/// blank <c>address</c>, or a present-but-invalid <c>dataType</c> (the runtime enum read is STRICT
/// since R2-11 Phase C, so a typo rejects the tag). Such a tag is skipped by the driver and reads
/// <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts the blob, when it is blank, or
/// when it is not an equipment-tag object. Never throws.
/// </summary>
/// <remarks>
/// <b>The verdict comes from <see cref="FromTagConfig"/>, not from a mirror</b> (Gitea #551) — see
/// <c>ModbusTagDefinitionFactory.Inspect</c>'s remarks for the full argument.
/// <para>
/// An <c>arrayLength</c> above <see cref="AbLegacyArray.MaxElements"/> is deliberately NOT
/// warned about: the mapper CLAMPS it rather than rejecting, so the driver serves the tag. The
/// AdminUI model refuses it at authoring because the un-clamped length also sets the OPC UA
/// <c>ArrayDimensions</c>; a warning here could be promoted to a deploy ERROR and would then
/// block a tag the runtime happily reads.
/// </para>
/// </remarks>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
// drawing nothing.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') return warnings;
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
@@ -138,16 +168,47 @@ public static class AbLegacyTagDefinitionFactory
warnings.Add("AbLegacy TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
DescribeRejection(root, warnings);
}
catch (JsonException)
{
warnings.Add("AbLegacy TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
if (warnings.Count == 0)
{
warnings.Add(
"AbLegacy TagConfig was rejected by the driver's tag reader — the driver will skip this tag "
+ "and it will read BadNodeIdUnknown.");
}
return warnings;
}
/// <summary>
/// Explains why <see cref="FromTagConfig"/> refused an object-rooted blob, walking its checks in the
/// same order. Runs only after the mapper has already refused, so it can never invent strictness.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="warnings">The list to append warnings to.</param>
private static void DescribeRejection(JsonElement root, List<string> warnings)
{
if (!root.TryGetProperty("address", out var addr) || addr.ValueKind != JsonValueKind.String)
{
warnings.Add(
"AbLegacy TagConfig has no string 'address' (e.g. N7:0, B3:1/2, F8:5) — the driver will "
+ "skip this tag and it will read BadNodeIdUnknown.");
}
else if (string.IsNullOrWhiteSpace(addr.GetString()))
{
warnings.Add("AbLegacy TagConfig has a blank 'address' — the tag will not resolve (BadNodeIdUnknown).");
}
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(root, "dataType") is { } w) warnings.Add(w);
}
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
&& e.TryGetInt32(out var v) ? v : 0;
@@ -0,0 +1,161 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// The four fields a calc tag's <c>TagConfig</c> blob carries, as
/// <see cref="CalculationTagDefinitionFactory.TryReadTagConfig"/> read them. Deliberately not the
/// driver's <c>CalculationTagDefinition</c>: that record additionally carries the tag's RawPath identity
/// and the dependency refs extracted from the script source, neither of which is in the blob.
/// </summary>
/// <param name="ScriptId">Logical FK to the shared <c>Script</c> entity that computes the tag; never blank.</param>
/// <param name="ScriptSource">The resolved C# script source; empty when the blob carried none.</param>
/// <param name="ChangeTriggered">Re-evaluate whenever a declared dependency changes (default <see langword="true"/>).</param>
/// <param name="TimerIntervalMs">Periodic re-evaluation cadence in ms, or <see langword="null"/> for change-trigger only.</param>
public readonly record struct CalculationTagConfigFields(
string ScriptId,
string ScriptSource,
bool ChangeTriggered,
int? TimerIntervalMs);
/// <summary>
/// The authored-raw-tag reader for the <c>Calculation</c> pseudo-driver: one authority, consumed by both
/// the runtime (<c>CalculationTagDefinition.TryFromTagConfig</c>, which adds the RawPath identity and the
/// script's dependency refs on top) and the deploy-time TagConfig gate (<see cref="Inspect"/>,
/// deferment-register G-7).
/// <para>
/// <b>The JSON reading moved here from the driver; the definition-building did not.</b> Building a
/// <c>CalculationTagDefinition</c> needs <c>EquipmentScriptPaths.ExtractDependencyRefs</c>, which
/// lives in Commons and is genuinely runtime infrastructure — dragging it into a Contracts leaf to
/// co-locate the whole thing would make every consumer of this gate carry the scripting stack. The
/// split is at the blob boundary: everything the <i>blob</i> says is read here, once.
/// </para>
/// <para>
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryReadTagConfig"/>.</b> It reports
/// only two things: a blob the read REJECTS (which the driver skips as "not a calc tag"), and fields
/// that are present but in a shape the lenient read silently discards — <c>changeTriggered</c> that
/// is not a JSON boolean (read as <see langword="true"/>) and <c>timerIntervalMs</c> that is not a
/// positive JSON integer (read as no timer). Both are the sanctioned "present-but-invalid, silently
/// defaulted" warning class.
/// </para>
/// <para>
/// <b>What it deliberately does NOT warn about:</b> a tag with no trigger at all
/// (<c>changeTriggered:false</c> and no timer). The AdminUI's <c>CalculationTagConfigModel.Validate</c>
/// rejects that shape and the runtime accepts it, and this gate follows the <i>runtime</i> — it may
/// never call a blob the driver serves invalid, because the deploy gate can be configured to turn its
/// warnings into blocking errors. The same reasoning covers a sub-50 ms timer interval, which the
/// editor refuses and the driver honours.
/// </para>
/// </summary>
public static class CalculationTagDefinitionFactory
{
/// <summary>
/// Reads a calc tag's <c>TagConfig</c> blob
/// (<c>{ "scriptId", "scriptSource", "changeTriggered", "timerIntervalMs" }</c>). Returns
/// <see langword="false"/> — never throws — only when the blob is unreadable, is not a JSON object,
/// or names no <c>scriptId</c>: a tag with no identifiable script cannot be a calc tag. A present
/// <c>scriptId</c> with an empty/absent <c>scriptSource</c> still reads (the driver logs it and the
/// tag never computes).
/// </summary>
/// <param name="tagConfig">The calc tag's schemaless <c>TagConfig</c> JSON.</param>
/// <param name="fields">The read fields when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the blob names a script.</returns>
public static bool TryReadTagConfig(string tagConfig, out CalculationTagConfigFields fields)
{
fields = default;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
var scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String
? sidEl.GetString() : null;
if (string.IsNullOrWhiteSpace(scriptId)) return false;
var scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
? srcEl.GetString() ?? string.Empty : string.Empty;
// changeTriggered defaults to true (mini-design §2) — only an explicit false disables it.
var changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl)
&& ctEl.ValueKind == JsonValueKind.False);
var timerMs = root.TryGetProperty("timerIntervalMs", out var tEl)
&& tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0
? ms : (int?)null;
fields = new CalculationTagConfigFields(scriptId!, scriptSource, changeTriggered, timerMs);
return true;
}
catch (JsonException)
{
return false;
}
}
/// <summary>
/// Deploy-time inspection of a calc tag's <c>TagConfig</c> blob. Warns for a blob
/// <see cref="TryReadTagConfig"/> rejects (unparseable JSON, a non-object root, or no usable
/// <c>scriptId</c>), and for a present-but-discarded <c>changeTriggered</c> / <c>timerIntervalMs</c>
/// — see the class remarks for what is deliberately NOT warned about. Empty when the blob is clean,
/// blank, or not a JSON object at all. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
return warnings;
JsonDocument doc;
try
{
doc = JsonDocument.Parse(reference);
}
catch (JsonException)
{
warnings.Add("Calculation TagConfig is not valid JSON — the driver will skip this tag and it will never compute.");
return warnings;
}
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("Calculation TagConfig root is not a JSON object — the driver will skip this tag and it will never compute.");
return warnings;
}
if (!TryReadTagConfig(reference, out _))
{
warnings.Add(
"Calculation TagConfig has no usable 'scriptId' (a non-blank string) — the driver does not "
+ "treat this as a calc tag, skips it, and it never computes.");
}
// Present-but-discarded fields. The read is lenient here by design, so these say what was
// IGNORED — they never claim the tag is invalid.
if (root.TryGetProperty("changeTriggered", out var ct)
&& ct.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
warnings.Add(
$"value '{ct.GetRawText()}' for 'changeTriggered' is not a JSON boolean; anything but an "
+ "explicit false is read as TRUE, so this tag is change-triggered whatever the value looks like.");
}
if (root.TryGetProperty("timerIntervalMs", out var timer)
&& !(timer.ValueKind == JsonValueKind.Number && timer.TryGetInt32(out var ms) && ms > 0))
{
warnings.Add(
$"value '{timer.GetRawText()}' for 'timerIntervalMs' is not a positive whole number of "
+ "milliseconds; it is discarded and the tag gets NO periodic re-evaluation.");
}
}
return warnings;
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Calculation</RootNamespace>
</PropertyGroup>
<!-- The calc-tag TagConfig reader, shared by the driver (which turns it into a
CalculationTagDefinition) and the deploy-time TagConfig gate (G-7), mirroring
Driver.MTConnect.Contracts / Driver.Modbus.Contracts.
Deliberately reference-free, unlike its siblings: a calc tag's blob is
{ scriptId, scriptSource, changeTriggered, timerIntervalMs } — strings, a bool and an int, with
no DriverDataType or other Core.Abstractions type in it. Carrying the leaf reference anyway
would be dead weight that reads as a dependency. Add it the moment a field needs it. -->
</Project>
@@ -1,4 +1,3 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
@@ -40,45 +39,20 @@ public sealed record CalculationTagDefinition(
public static bool TryFromTagConfig(string tagConfig, string rawPath, out CalculationTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
string? scriptId;
string scriptSource;
bool changeTriggered;
int? timerMs;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
// The blob read itself lives in Driver.Calculation.Contracts so the deploy-time TagConfig gate
// (EquipmentTagConfigInspector) and this mapping cannot disagree about what a calc tag's blob says
// (G-7). Everything below is what the BLOB does not carry: the RawPath identity and the dependency
// refs extracted from the script source.
if (!CalculationTagDefinitionFactory.TryReadTagConfig(tagConfig, out var fields)) return false;
scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String
? sidEl.GetString() : null;
if (string.IsNullOrWhiteSpace(scriptId)) return false;
scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
? srcEl.GetString() ?? string.Empty : string.Empty;
// changeTriggered defaults to true (mini-design §2) — only an explicit false disables it.
changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl)
&& ctEl.ValueKind == JsonValueKind.False);
timerMs = root.TryGetProperty("timerIntervalMs", out var tEl)
&& tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0
? ms : null;
}
catch (JsonException)
{
return false;
}
var deps = EquipmentScriptPaths.ExtractDependencyRefs(scriptSource);
var deps = EquipmentScriptPaths.ExtractDependencyRefs(fields.ScriptSource);
def = new CalculationTagDefinition(
RawPath: rawPath,
ScriptId: scriptId!,
ScriptSource: scriptSource,
ChangeTriggered: changeTriggered,
TimerInterval: timerMs is { } m ? TimeSpan.FromMilliseconds(m) : null,
ScriptId: fields.ScriptId,
ScriptSource: fields.ScriptSource,
ChangeTriggered: fields.ChangeTriggered,
TimerInterval: fields.TimerIntervalMs is { } m ? TimeSpan.FromMilliseconds(m) : null,
DependencyRefs: deps);
return true;
}
@@ -19,6 +19,9 @@
for CompilationErrorException). Core.Scripting.Abstractions owns VirtualTagContext +
PassthroughScript; Commons owns VirtualTagEvalResult + IScriptCacheOwner; Core.Abstractions
owns DataValueSnapshot. -->
<!-- The calc tag's TagConfig reader (CalculationTagDefinitionFactory), shared with the deploy-time
TagConfig gate so the two cannot drift (G-7). -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
@@ -95,50 +95,159 @@ public static class FocasTagDefinitionFactory
}
/// <summary>
/// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid <c>dataType</c>
/// (silently defaulted by the lenient runtime), on an authored <c>writable:true</c> (the FOCAS wire
/// is read-only — <c>WriteAsync</c> always returns <c>BadNotWritable</c>), and on a structurally
/// unparseable TagConfig. Empty when clean or not an equipment-tag object. Never throws.
/// The RawPath handed to <see cref="FromTagConfig"/> during inspection — the mapper only copies it
/// onto <see cref="FocasTagDefinition.Name"/> and never validates it.
/// </summary>
private const string InspectionRawPath = "(inspect)";
/// <summary>
/// Deploy-time inspection (05/CONV-2, 05/UNDER-1). Two kinds of warning, and the distinction is
/// load-bearing:
/// <list type="bullet">
/// <item>
/// <b>Rejections</b> — a blob <see cref="FromTagConfig"/> would refuse: an unparseable or
/// non-object document, an absent / non-string / blank <c>address</c>, or a
/// present-but-invalid <c>dataType</c> (the runtime enum read is STRICT since R2-11 Phase C,
/// so a typo rejects the tag). The driver skips such a tag and it reads
/// <c>BadNodeIdUnknown</c> forever. These are diagnosed only when the mapper has actually
/// refused, so this gate can never be stricter than the runtime (Gitea #551).
/// </item>
/// <item>
/// <b>Advisories</b> — an <em>accepted</em> blob that still will not work: an unparseable
/// FOCAS address (#540; the mapper only requires a non-blank string, the driver's own init
/// then drops it) and an authored <c>writable:true</c> (the FOCAS wire is read-only, so
/// <c>WriteAsync</c> always returns <c>BadNotWritable</c>). These run whether or not the
/// blob parses, which is why FOCAS does not simply return empty on accept.
/// </item>
/// </list>
/// Empty for a clean, accepted blob, for a blank one, or for one that is not an equipment-tag
/// object. Never throws.
/// <para>
/// The distinction is <b>structural</b>, not a matter of wording (Gitea #556 item 2): the two
/// classes accumulate in separate lists, and the catch-all backstop — the only thing that
/// reports a refusal neither <see cref="DescribeRejection"/> branch can name — fires on "no
/// REJECTION was produced", never on "no warning at all". Gated on the total, an advisory
/// (which fires on an accepted blob too) would silently swallow it.
/// </para>
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 2: the two classes live in SEPARATE lists, and the backstop below gates on the
// REJECTION list alone. Sharing one list meant an advisory (which fires on an accepted blob too)
// could satisfy a `warnings.Count == 0` guard and silently swallow the catch-all rejection message
// — the only thing that reports a refusal neither DescribeRejection branch can name. The split is
// structural: nothing string-matches a message to work out which class it is.
var rejections = new List<string>();
var advisories = new List<string>();
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
// drawing nothing.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
return rejections;
// The accept gate: only a blob the driver REFUSES gets a rejection diagnosis. The advisories below
// apply either way — see the summary. The RAW reference is handed to the mapper: the driver reads
// the stored blob, not a trimmed copy of it.
var accepted = FromTagConfig(reference, InspectionRawPath, out _);
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("FOCAS TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
rejections.Add("FOCAS TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return rejections;
}
var w = TagConfigJson.DescribeInvalidEnum<FocasDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
// Gitea #540: an address the driver cannot parse is skipped at init and reads
// BadNodeIdUnknown forever, while driver health stays Healthy with zero errors. The
// AdminUI modal rejects it outright; this covers the paths that never touch the modal
// (CSV import, the API) by at least surfacing it on the deployment.
if (root.TryGetProperty("address", out var addr)
&& addr.ValueKind == JsonValueKind.String
&& addr.GetString() is { } address
&& !string.IsNullOrWhiteSpace(address)
&& FocasAddress.TryParse(address) is null)
{
warnings.Add(
$"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
"PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " +
"this tag at startup and it will read BadNodeIdUnknown.");
}
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
warnings.Add("FOCAS wire is read-only — an authored 'writable:true' still fails at the wire with BadNotWritable.");
if (!accepted) DescribeRejection(root, rejections);
DescribeAdvisories(root, advisories);
}
catch (JsonException)
{
warnings.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
rejections.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return rejections;
}
return warnings;
// Backstop: the mapper refused for a reason no branch above names. Gated on the REJECTION count,
// never on the total — an advisory says nothing about why the tag was refused.
if (!accepted && rejections.Count == 0)
{
rejections.Add(
"FOCAS TagConfig was rejected by the driver's tag reader — the driver will skip this tag "
+ "and it will read BadNodeIdUnknown.");
}
return Combine(rejections, advisories);
}
/// <summary>
/// Flattens the two warning classes into the single list <see cref="Inspect"/> returns, rejections
/// first — the order the pre-split implementation produced, so no caller sees a reordering.
/// </summary>
/// <param name="rejections">The rejection-class warnings.</param>
/// <param name="advisories">The advisory-class warnings.</param>
/// <returns>The combined list.</returns>
private static List<string> Combine(List<string> rejections, List<string> advisories)
{
if (advisories.Count == 0) return rejections;
rejections.AddRange(advisories);
return rejections;
}
/// <summary>
/// Explains why <see cref="FromTagConfig"/> refused an object-rooted blob, walking its checks in the
/// same order. Runs only after the mapper has already refused, so it can never invent strictness.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="rejections">The REJECTION-class list to append to (never the advisory list — see
/// <see cref="Inspect"/>'s backstop).</param>
private static void DescribeRejection(JsonElement root, List<string> rejections)
{
if (!root.TryGetProperty("address", out var addr) || addr.ValueKind != JsonValueKind.String)
{
rejections.Add(
"FOCAS TagConfig has no string 'address' — the driver will skip this tag and it will read "
+ "BadNodeIdUnknown.");
}
else if (string.IsNullOrWhiteSpace(addr.GetString()))
{
rejections.Add("FOCAS TagConfig has a blank 'address' — the tag will not resolve (BadNodeIdUnknown).");
}
if (TagConfigJson.DescribeInvalidEnum<FocasDataType>(root, "dataType") is { } w) rejections.Add(w);
}
/// <summary>
/// Warnings about a blob the mapper ACCEPTS but the driver still cannot serve as authored — see the
/// "advisories" bullet on <see cref="Inspect"/>.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="advisories">The ADVISORY-class list to append to. These fire whether or not the mapper
/// accepted, so they must never reach the rejection list — see <see cref="Inspect"/>'s backstop.</param>
private static void DescribeAdvisories(JsonElement root, List<string> advisories)
{
// Gitea #540: an address the driver cannot parse is skipped at init and reads BadNodeIdUnknown
// forever, while driver health stays Healthy with zero errors. The AdminUI modal rejects it
// outright; this covers the paths that never touch the modal (CSV import, the API).
if (root.TryGetProperty("address", out var addr)
&& addr.ValueKind == JsonValueKind.String
&& addr.GetString() is { } address
&& !string.IsNullOrWhiteSpace(address)
&& FocasAddress.TryParse(address) is null)
{
advisories.Add(
$"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
"PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " +
"this tag at startup and it will read BadNodeIdUnknown.");
}
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
advisories.Add("FOCAS wire is read-only — an authored 'writable:true' still fails at the wire with BadNotWritable.");
}
}
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
@@ -47,9 +48,15 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
}
/// <inheritdoc />
// Hardcoded literal: this project references Driver.Galaxy.Contracts, not Driver.Galaxy,
// so GalaxyDriverFactoryExtensions.DriverTypeName isn't available here.
public string DriverType => "GalaxyMxGateway";
/// <remarks>
/// Sourced from the shared <see cref="DriverTypeNames"/> set, reached through
/// <c>Commons</c>. This project references Driver.Galaxy.Contracts, not Driver.Galaxy, so
/// <c>GalaxyDriverFactoryExtensions.DriverTypeName</c> is not available here — but that was never a
/// reason to hand-write the string, which is what this line used to do (Gitea #558):
/// <see cref="DriverTypeNames.Galaxy"/> holds the same value and is the constant
/// <c>DriverTypeNamesGuardTests</c> already pins to the factory's registration.
/// </remarks>
public string DriverType => DriverTypeNames.Galaxy;
// Deserializes a GalaxyDriverOptions blob, opens a transient GalaxyRepositoryClient
// against the configured gateway endpoint, and returns a browse session over it. The
@@ -378,11 +378,13 @@ public sealed class GalaxyDriver
_subscriber = new TracedGalaxySubscriber(
new GatewayGalaxySubscriber(_ownedMxSession, _logger), _options.MxAccess.ClientName);
_dataWriter = new TracedGalaxyDataWriter(
// Let the writer borrow live MXAccess item handles the subscription registry already
// holds, so the first write to an already-subscribed tag skips a redundant AddItem.
// The writer always AddItems its own handle — a subscription's handle is plain-advised,
// and MXAccess both refuses a no-login write through it (SECURITY_ERROR 1008) and refuses
// upgrading its advise mode (E_INVALIDARG), so the former borrow-from-registry
// optimization could never commit a write. The advise limit is per handle, not per
// attribute: the writer's own supervisory-advised handle coexists with the subscription's.
new GatewayGalaxyDataWriter(
_ownedMxSession, _options.MxAccess.WriteUserId, _logger,
_subscriptions.TryResolveItemHandle),
_ownedMxSession, _options.MxAccess.WriteUserId, _logger),
_options.MxAccess.ClientName);
_supervisor = new ReconnectSupervisor(
@@ -18,19 +18,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <remarks>
/// Item handle cache survives across writes — repeated writes to the same tag avoid
/// re-AddItem. Per-tag failures are isolated: one bad write doesn't fail the batch.
/// When a <c>subscribedHandleSource</c> delegate is supplied, the first write to an
/// already-subscribed tag borrows the live handle from the subscription registry and
/// skips the AddItem round-trip entirely. Borrowed handles are intentionally NOT stored
/// in <c>_itemHandles</c> — the subscription registry owns their lifecycle (including
/// reconnect rebind), so each write re-borrows the fresh handle and no stale-cache
/// window is introduced.
/// The writer always owns its handles: it never borrows a subscription's live handle,
/// because a subscription handle is plain-advised and MXAccess enforces one advise per
/// handle — a no-login write through a plain-advised handle is refused (SECURITY_ERROR
/// detail 1008) and re-advising it supervisory is rejected (E_INVALIDARG), both
/// live-captured on the correlated write replies (06/S-1). The advise limit is per
/// handle, not per attribute, so the writer's own supervisory-advised handle coexists
/// with the subscription's plain-advised one on the same attribute.
/// </remarks>
public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
{
private readonly GalaxyMxSession _session;
private readonly int _writeUserId;
private readonly ILogger _logger;
private readonly Func<string, int?>? _subscribedHandleSource;
private readonly ConcurrentDictionary<string, int> _itemHandles =
new(StringComparer.OrdinalIgnoreCase);
// Item handles we've already AdviseSupervisory'd this session — supervisory advise is
@@ -41,7 +41,9 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
// Write failure-visibility meters (archreview 06/S-1). Reuse the pump's meter name so the single host
// listener catches both. advise_failed = writes short-circuited to Bad because AdviseSupervisory failed
// (the value could not have committed); unconfirmed = writes reported Good off an EMPTY gateway statuses
// array (command accepted, COM commit unconfirmed pending the gateway WriteComplete correlation follow-up).
// array. A correlated gateway (mxaccessgw ≥ 53f69cd rolls the worker's OnWriteComplete into every unary
// write reply's statuses[0]) keeps this counter FLAT — an increment means the gateway is pre-correlation
// or the completion wait timed out (WriteCompletionWaitMilliseconds), so the commit is unconfirmed.
private static readonly Meter WriterMeter = new(EventPump.MeterName);
private static readonly Counter<long> WriteAdviseFailed =
WriterMeter.CreateCounter<long>("galaxy.writes.advise_failed", unit: "{write}",
@@ -54,21 +56,11 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
/// <param name="session">The MXAccess gateway session.</param>
/// <param name="writeUserId">The user ID for write operations.</param>
/// <param name="logger">Optional logger for tracing.</param>
/// <param name="subscribedHandleSource">
/// Optional delegate that resolves a live MXAccess item handle from the subscription
/// registry for a given tag full reference. When the delegate returns a positive handle
/// the writer borrows it and skips the AddItem gateway round-trip. A return value of
/// <c>null</c>, 0, or negative is treated as "not available" and the writer falls back
/// to its own AddItem. Borrowed handles are NOT cached in this writer — the subscription
/// registry owns their lifecycle.
/// </param>
public GatewayGalaxyDataWriter(GalaxyMxSession session, int writeUserId, ILogger? logger = null,
Func<string, int?>? subscribedHandleSource = null)
public GatewayGalaxyDataWriter(GalaxyMxSession session, int writeUserId, ILogger? logger = null)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_writeUserId = writeUserId;
_logger = logger ?? NullLogger.Instance;
_subscribedHandleSource = subscribedHandleSource;
}
/// <inheritdoc />
@@ -94,7 +86,7 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
internal int CachedSupervisedHandleCount => _supervisedHandles.Count;
/// <summary>Count of real gateway <c>AddItem</c> round-trips this writer has issued. Stays zero
/// when every handle was served from cache or borrowed from the subscription registry. Test seam.</summary>
/// when every handle was served from the cache. Test seam.</summary>
internal int AddItemCallCount => Volatile.Read(ref _addItemCallCount);
/// <summary>
@@ -111,23 +103,6 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
if (supervised) _supervisedHandles.TryAdd(itemHandle, 0);
}
/// <summary>
/// Resolve an item handle WITHOUT touching the gateway: a handle this writer already AddItem'd
/// (<c>_itemHandles</c>), else a live subscription handle borrowed via
/// <c>_subscribedHandleSource</c>. Returns null when neither is available (the caller must
/// AddItem). A BORROWED handle is intentionally NOT stored in <c>_itemHandles</c> — the
/// subscription registry owns the borrowed handle's lifecycle (including reconnect rebind),
/// so the next write re-borrows the fresh handle and no stale-cache window is introduced.
/// </summary>
/// <param name="fullRef">The dotted tag full reference.</param>
/// <returns>A usable item handle, or null when an AddItem is required.</returns>
internal int? TryResolveCachedOrBorrowed(string fullRef)
{
if (_itemHandles.TryGetValue(fullRef, out var existing)) return existing;
if (_subscribedHandleSource?.Invoke(fullRef) is int borrowed && borrowed > 0) return borrowed;
return null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
@@ -190,13 +165,12 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
{
// A raw Write runs with NO user login (WriteUserId is typically 0), so MXAccess
// only COMMITS the value when the item is advised in SUPERVISORY mode. Without it
// the gateway's Write call doesn't throw (reply looks OK) but the value never
// reaches the galaxy — a knowingly-lost write. FAIL-CLOSED (archreview 06/S-1): when
// the write is refused by the automation object (SECURITY_ERROR detail 1008 on the
// correlated reply — live-captured). FAIL-CLOSED (archreview 06/S-1): when
// AdviseSupervisory fails we do NOT issue the raw Write; we return Bad so the
// #5 write-outcome self-correction reverts the phantom-Good node instead of leaving it.
// The real fix is a gateway-side WriteComplete correlation (mxaccessgw backlog) so the
// unary reply's Statuses carry the actual COM commit outcome; until then advise-status +
// the galaxy.writes.* meters are the honest signal.
// A correlated gateway (mxaccessgw ≥ 53f69cd) would also surface the refusal in the
// reply's statuses[0], but the short-circuit avoids a write we know cannot commit.
if (!await EnsureSupervisoryAdvisedAsync(ops, itemHandle, ct).ConfigureAwait(false))
{
WriteAdviseFailed.Add(1);
@@ -229,7 +203,7 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
private async Task<int> EnsureItemHandleAsync(
IMxWriteOps ops, string fullRef, CancellationToken ct)
{
if (TryResolveCachedOrBorrowed(fullRef) is int resolved) return resolved;
if (_itemHandles.TryGetValue(fullRef, out var existing)) return existing;
var handle = await ops.AddItemAsync(fullRef, ct).ConfigureAwait(false);
Interlocked.Increment(ref _addItemCallCount);
_itemHandles[fullRef] = handle;
@@ -323,12 +297,13 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
/// <summary>
/// Translate a gateway <see cref="MxCommandReply"/> into an OPC UA
/// <see cref="WriteResult"/>. Honours the protocol-level Status field first
/// (transport / dispatch failures), then the first MXAccess status row.
/// <para>An EMPTY statuses array is treated as <c>Good</c> but is PROVISIONAL: the reply proves
/// worker-side command acceptance, not the COM-side commit (the gateway does not yet correlate a
/// WriteComplete row onto the unary reply — mxaccessgw backlog). Each such reply increments
/// <c>galaxy.writes.unconfirmed</c> so the unconfirmed-write rate is operator-visible today
/// (archreview 06/S-1).</para>
/// (transport / dispatch failures), then the first MXAccess status row — which, against a
/// correlated gateway (mxaccessgw ≥ 53f69cd), is the worker's <c>OnWriteComplete</c> outcome:
/// the actual COM-side commit verdict, including refusals (archreview 06/S-1, closed).
/// <para>An EMPTY statuses array is treated as <c>Good</c> but is PROVISIONAL: it means the
/// gateway's bounded completion wait timed out (or the gateway pre-dates the correlation), so
/// the reply proves worker-side command acceptance only. Each such reply increments
/// <c>galaxy.writes.unconfirmed</c> so the unconfirmed-write rate stays operator-visible.</para>
/// </summary>
private WriteResult TranslateReply(MxCommandReply reply, string fullRef)
{
@@ -350,8 +325,8 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
return new WriteResult(StatusCodeMap.FromMxStatus(status, _logger));
}
// Empty statuses ⇒ provisional Good. Meter it so the unconfirmed-write rate is visible until the
// gateway-side WriteComplete correlation lands (archreview 06/S-1 cross-repo follow-up).
// Empty statuses ⇒ the gateway's completion wait timed out (or the gateway pre-dates the
// 53f69cd correlation) ⇒ provisional Good. Meter it so the unconfirmed-write rate is visible.
WriteUnconfirmed.Add(1);
return new WriteResult(StatusCodeMap.Good);
}
@@ -52,6 +52,11 @@ internal static class StatusCodeMap
public const uint BadOutOfService = 0x808D0000u;
public const uint BadWaitingForInitialData = 0x80320000u;
public const uint BadInternalError = 0x80020000u;
public const uint BadUserAccessDenied = 0x801F0000u;
public const uint BadOutOfRange = 0x803C0000u;
public const uint BadTypeMismatch = 0x80740000u;
public const uint BadNotReadable = 0x803A0000u;
public const uint BadNotWritable = 0x803B0000u;
/// <summary>
/// Fills a still-pending read when the caller's token fires before the gateway answers. Named here
@@ -111,10 +116,21 @@ internal static class StatusCodeMap
if (status is null) return Good;
if (status.IsSuccess()) return Good;
// Detail field carries the substatus when the worker translated MX-style codes;
// when zero, infer from detected_by.
var detail = (byte)(status.Detail & 0xFF);
if (detail != 0) return FromQualityByte(detail, logger);
// A detail in [1, 255] is an OPC DA quality substatus (read/data-change rows). Anything
// larger is an MXAccess MxStatus detail code — the write-completion rows the gateway
// correlates onto write replies carry these (e.g. 1007 operational, 1008 security) — and
// truncating one into the quality table maps a REFUSED WRITE to Good whenever the low byte
// lands in the Good band (1007 → 239 ≥ 192). Route those on the status category instead.
var detail = status.Detail;
if (detail is > 0 and <= 0xFF)
{
var mapped = FromQualityByte((byte)detail, logger);
// Invariant: a row that is not IsSuccess() must never surface as Good — a
// contradictory Good-band substatus on a failure row falls back to the category.
if ((mapped >> 30) != 0u) return mapped;
return FromCategory(status, logger);
}
if (detail > 0xFF) return FromCategory(status, logger);
// detected_by != Mxaccess (raw_detected_by != the MXAccess source enum) implies
// the failure happened pre-call (gateway, worker, transport) — surface as a
@@ -124,6 +140,48 @@ internal static class StatusCodeMap
return Bad;
}
/// <summary>
/// Map a failure row whose detail field is an MxStatus detail code rather than an OPC DA
/// quality byte (or contradicts the failure). Per-code first — the <c>MxStatusDetail</c>
/// vocabulary is published in mxaccessgw's gateway.md ("MxStatus Detail Vocabulary",
/// <c>75c71ad</c>, lifted from <c>Interop.aaMxDataConsumer.MxStatusDetail</c>) — then the
/// category bucket. Codes ≤ 0xFF (e.g. 1 = MX_E_RequestTimedOut) never reach here: they are
/// value-ambiguous with the quality-byte space and take the quality path, which still lands
/// them in the Bad family.
/// </summary>
private static uint FromCategory(MxStatusProxy status, ILogger? logger)
{
switch (status.Detail)
{
case 1004: return BadOutOfRange; // MX_E_DataOutOfRange
case 1005: return BadTypeMismatch; // MX_E_IncorrectDataType
case 1006: return BadNotReadable; // MX_E_NotReadable
case 1007: return BadNotWritable; // MX_E_NotWriteable (live-captured: read-only attr)
case 1008: // MX_E_WriteAccessDenied (live-captured: unadvised no-login write)
case 1012: // MX_E_SecuredWrite (attribute requires a secured write)
case 1013: // MX_E_VerifiedWrite (attribute requires a verified write)
case 1016: // MX_E_UserNotHavingAccessRights
case 1017: return BadUserAccessDenied; // MX_E_VerifierNotHavingVerifyRights
case 1010: // MX_E_ObjectInitializing (transient)
case 1011: return BadOutOfService; // MX_E_EngineInitializing (transient)
}
// Everything else — including 8000 MX_E_AutomationObjectSpecificError, deliberately, since
// the object supplies its own semantics — falls back to the category bucket.
var code = status.Category switch
{
MxStatusCategory.SecurityError => BadUserAccessDenied,
MxStatusCategory.CommunicationError => BadCommunicationError,
MxStatusCategory.ConfigurationError => BadConfigurationError,
_ => Bad,
};
logger?.LogWarning(
"MxStatus failure row category {Category} detail {Detail} mapped to 0x{Code:X8} at category " +
"level. Field capture welcome — extend StatusCodeMap.FromCategory with the detail code.",
status.Category, status.Detail, code);
return code;
}
/// <summary>
/// Convert an OPC UA status-code uint back to the OPC DA quality category
/// byte — Good=192, Uncertain=64, Bad=0 — by extracting the top-two bits of the
@@ -20,12 +20,6 @@ internal sealed class SubscriptionRegistry
{
private readonly ConcurrentDictionary<long, SubscriptionEntry> _bySubscriptionId = new();
private readonly ConcurrentDictionary<int, ImmutableHashSet<long>> _subscribersByItemHandle = new();
// Forward index for the Galaxy writer: fullRef (case-insensitive) → live item handle.
// Maintained in lock-step with _subscribersByItemHandle; entries are cleaned up when
// the last subscriber for a handle is removed, and TryResolveItemHandle guards against
// stale entries by cross-checking _subscribersByItemHandle at read time.
private readonly ConcurrentDictionary<string, int> _itemHandleByFullRef =
new(StringComparer.OrdinalIgnoreCase);
private long _nextSubscriptionId;
/// <summary>Gets the number of tracked subscriptions.</summary>
@@ -55,7 +49,6 @@ internal sealed class SubscriptionRegistry
binding.ItemHandle,
_ => [subscriptionId],
(_, set) => set.Add(subscriptionId));
_itemHandleByFullRef[binding.FullReference] = binding.ItemHandle;
}
}
@@ -77,10 +70,6 @@ internal sealed class SubscriptionRegistry
if (remaining.IsEmpty)
{
_subscribersByItemHandle.TryRemove(binding.ItemHandle, out _);
// Clean up the forward index only when this handle is still the current
// mapping — a concurrent re-add for the same ref must not be clobbered.
if (_itemHandleByFullRef.TryGetValue(binding.FullReference, out var fwd) && fwd == binding.ItemHandle)
_itemHandleByFullRef.TryRemove(binding.FullReference, out _);
}
else _subscribersByItemHandle[binding.ItemHandle] = remaining;
}
@@ -110,36 +99,6 @@ internal sealed class SubscriptionRegistry
return result;
}
/// <summary>
/// Resolve the live MXAccess item handle a current subscription holds for <paramref name="fullRef"/>,
/// or null when no live subscription covers it. The Galaxy writer borrows this handle to skip a
/// redundant AddItem round-trip on the first write to an already-subscribed tag.
/// </summary>
/// <remarks>
/// The <c>_itemHandleByFullRef</c> forward map is only a hint — resolution is authoritative:
/// it confirms a <em>live</em> subscription actually binds this <paramref name="fullRef"/> to
/// the candidate handle (via the per-entry reverse index), so a stale forward entry can never
/// hand the writer a dead handle, nor a handle that now belongs to a different tag. This keeps
/// a tag resolvable while ANY subscription still binds it (the legitimate
/// multiple-driver-subscriptions-per-tag case) yet returns null the moment the last one drops.
/// </remarks>
/// <param name="fullRef">The dotted tag full reference (e.g. <c>TestMachine_002.TestFloat</c>).</param>
/// <returns>The live item handle, or null when none is currently subscribed.</returns>
public int? TryResolveItemHandle(string fullRef)
{
if (fullRef is null) return null;
if (!_itemHandleByFullRef.TryGetValue(fullRef, out var handle)) return null;
if (!_subscribersByItemHandle.TryGetValue(handle, out var subs)) return null; // dead handle
// Confirm a live subscription genuinely binds fullRef -> handle (not a stale forward entry
// pointing at a handle the gateway has since associated with another tag).
foreach (var subId in subs)
if (_bySubscriptionId.TryGetValue(subId, out var entry)
&& entry.FullRefByItemHandle.TryGetValue(handle, out var liveRef)
&& string.Equals(liveRef, fullRef, StringComparison.OrdinalIgnoreCase))
return handle;
return null;
}
/// <summary>Snapshot every active binding for diagnostic output.</summary>
/// <returns>A flattened list of every tag binding across all tracked subscriptions.</returns>
@@ -177,10 +136,6 @@ internal sealed class SubscriptionRegistry
if (remaining.IsEmpty)
{
_subscribersByItemHandle.TryRemove(binding.ItemHandle, out _);
// Clean up the forward index only when this handle is still the current
// mapping — the add loop below will overwrite it with the fresh handle.
if (_itemHandleByFullRef.TryGetValue(binding.FullReference, out var fwd) && fwd == binding.ItemHandle)
_itemHandleByFullRef.TryRemove(binding.FullReference, out _);
}
else _subscribersByItemHandle[binding.ItemHandle] = remaining;
}
@@ -193,7 +148,6 @@ internal sealed class SubscriptionRegistry
binding.ItemHandle,
_ => [subscriptionId],
(_, set) => set.Add(subscriptionId));
_itemHandleByFullRef[binding.FullReference] = binding.ItemHandle;
}
}
@@ -0,0 +1,263 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The authored-raw-tag reader for the MTConnect driver: one authority, consumed by both the runtime
/// (<c>MTConnectDriver</c>'s <c>RawPath → definition</c> table build) and the deploy-time TagConfig gate
/// (<see cref="Inspect"/>, deferment-register G-7).
/// <para>
/// <b>This parse used to be private to <c>MTConnectDriver</c>.</b> It moved here — unchanged —
/// rather than being mirrored, because a mirror of an accept/reject boundary drifts the moment
/// either side is touched, and the drift is invisible: the gate would go on describing a shape the
/// driver no longer reads. Nothing about the reading changed in the move; the driver now calls
/// <see cref="TryFromTagConfig"/> where it called its own copy.
/// </para>
/// <para>
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryFromTagConfig"/></b> — it asks the
/// parse for the verdict and only explains a rejection, so a blob the driver serves can never draw
/// a warning calling it invalid.
/// </para>
/// </summary>
public static class MTConnectTagDefinitionFactory
{
/// <summary>
/// Reader options, mirroring <c>MTConnectDriver</c>'s config reader (from which this moved). Note
/// there is deliberately <b>no</b> <c>JsonStringEnumConverter</c>: the type-carrying field stays a
/// <c>string?</c> and is parsed by name below, which is the project-wide guard against the
/// numerically-serialized-enum trap.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>
/// Reads one authored raw tag's <c>TagConfig</c> blob: the DataItem id it binds, and the definition
/// its blob declares (<see langword="null"/> when the blob declares no type and the caller must fall
/// back to the Agent's <c>/probe</c> declaration). Never throws.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="dataItemId">The Agent DataItem id the tag binds, when this returns <see langword="true"/>.</param>
/// <param name="authored">The definition the blob declared, or <see langword="null"/> when it declared no type.</param>
/// <returns>
/// <see langword="false"/> when the blob is unreadable, names no DataItem id, or carries a
/// <c>driverDataType</c>/<c>dataType</c> that is not a <see cref="DriverDataType"/> — a
/// present-but-invalid type rejects the tag rather than silently defaulting, which would publish the
/// Agent's text as a Good value of the wrong type (the sibling drivers' strict-enum posture).
/// </returns>
public static bool TryFromTagConfig(string? tagConfig, out string dataItemId, out MTConnectTagDefinition? authored)
{
dataItemId = string.Empty;
authored = null;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
MTConnectRawTagConfigDto? blob;
try
{
blob = JsonSerializer.Deserialize<MTConnectRawTagConfigDto>(tagConfig, JsonOptions);
}
catch (JsonException)
{
return false;
}
if (blob is null) return false;
var id = FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address);
if (id is null) return false;
dataItemId = id;
// "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed
// MTConnectTagConfigModel writes) — see the DTO remarks. THIS PRECEDENCE IS MIRRORED by that
// model's own load (Gitea #550): a non-blank driverDataType out-ranks dataType there too, so the
// editor displays the type the driver will actually read rather than a key it is overriding.
var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType);
if (authoredType is null) return true;
if (!TryParseDataType(authoredType, out var parsed)) return false;
authored = new MTConnectTagDefinition(
dataItemId,
parsed,
blob.IsArray ?? false,
blob.ArrayDim ?? 0,
blob.MtCategory,
blob.MtType,
blob.MtSubType,
blob.Units);
return true;
}
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob: warns for a blob
/// <see cref="TryFromTagConfig"/> would reject — unparseable JSON, no DataItem id under any of the
/// three accepted spellings, or a present-but-invalid coercion type (including the digits-only
/// numerically-serialized-enum shape). Empty when the parse accepts the blob, when it is blank, or
/// when it is not a JSON object at all. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
return warnings;
// The accept gate: whatever the driver reads draws nothing. See the class remarks.
if (TryFromTagConfig(reference, out _, out _)) return warnings;
MTConnectRawTagConfigDto? blob;
try
{
blob = JsonSerializer.Deserialize<MTConnectRawTagConfigDto>(reference, JsonOptions);
}
catch (JsonException)
{
warnings.Add("MTConnect TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
if (blob is null)
{
warnings.Add("MTConnect TagConfig is a JSON null — the tag binds no DataItem and will be skipped.");
return warnings;
}
if (FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address) is null)
{
warnings.Add(
"MTConnect TagConfig names no DataItem id (expected 'fullName', 'dataItemId' or 'address') — "
+ "the driver will skip this tag and it will read BadNodeIdUnknown.");
}
var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType);
if (authoredType is not null && !TryParseDataType(authoredType, out _))
{
var field = string.IsNullOrWhiteSpace(blob.DriverDataType) ? "dataType" : "driverDataType";
warnings.Add(
$"value '{authoredType}' for '{field}' is not a valid DriverDataType name; valid: "
+ string.Join(", ", Enum.GetNames<DriverDataType>()));
}
if (warnings.Count == 0)
{
warnings.Add(
"MTConnect TagConfig was rejected by the driver's tag reader — the driver will skip this tag "
+ "and it will read BadNodeIdUnknown.");
}
return warnings;
}
/// <summary>
/// <b>The single authority on which coercion-type spellings an MTConnect tag may carry</b>, consulted
/// by the runtime + deploy gate (through <see cref="TryFromTagConfig"/> / <see cref="Inspect"/>) and
/// by the AdminUI's authoring model (<c>MTConnectTagConfigModel.Validate</c>) alike.
/// <para>
/// It is public for exactly that second caller. The authoring model used to read the type with a
/// lenient enum parse that fell back to <c>String</c> on ANY unrecognised value, so a misspelled
/// name ("Strng") from a hand-authored blob, a CSV import or the API was accepted at authoring —
/// and silently rewritten — while this parse rejected it and the tag died at the driver
/// (Gitea #549). A second copy of the rule in the AdminUI would have reopened that gap the
/// moment either side moved, so there is one rule and both sides ask it.
/// </para>
/// <para>
/// A blank/absent value is ACCEPTED: it declares no override, and <see cref="TryFromTagConfig"/>
/// then falls back to the Agent's <c>/probe</c> declaration rather than rejecting the tag.
/// </para>
/// </summary>
/// <param name="raw">The authored type text, or <see langword="null"/>/blank when none was authored.</param>
/// <returns><see langword="true"/> when the driver would accept this authored type.</returns>
public static bool IsAcceptedDataTypeName(string? raw)
=> string.IsNullOrWhiteSpace(raw) || TryParseDataType(raw.Trim(), out _);
/// <summary>
/// Parses an authored coercion type by NAME. A digits-only value is rejected outright, BEFORE the
/// parse: <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> accepts <c>"8"</c> as readily
/// as <c>"Float64"</c> and returns the member with that ordinal, so a numerically-serialized enum
/// (the repo-wide authoring trap) would otherwise coerce every value of that tag to a type nobody
/// chose, under Good quality.
/// </summary>
/// <param name="raw">The authored type text.</param>
/// <param name="parsed">The parsed type when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="raw"/> is a defined member NAME.</returns>
private static bool TryParseDataType(string raw, out DriverDataType parsed)
{
parsed = default;
if (raw.All(char.IsAsciiDigit)) return false;
return Enum.TryParse(raw, ignoreCase: true, out parsed) && Enum.IsDefined(parsed);
}
/// <summary>The first of <paramref name="candidates"/> that carries text, trimmed; else null.</summary>
/// <param name="candidates">The candidate values, in precedence order.</param>
/// <returns>The first non-blank candidate, trimmed, or null.</returns>
private static string? FirstNonBlank(params string?[] candidates)
{
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim();
}
return null;
}
/// <summary>
/// A raw tag's <c>TagConfig</c> blob: the driver-specific address of one authored raw tag.
/// Every field is optional — the blob is operator-authorable and reaches the driver through
/// several authoring surfaces that populate different subsets of it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Three accepted spellings of the DataItem id</b>, tried in order:
/// <c>fullName</c> (the driver's own tag shape, and what the typed editor writes),
/// <c>dataItemId</c> (the protocol's own word for it, and the spelling an operator
/// hand-authoring the blob reaches for first), and <c>address</c> (what
/// <c>RawBrowseCommitMapper</c> writes for a driver with no typed editor of its own — the
/// shape every browse-committed MTConnect tag currently arrives in). Accepting all three
/// costs two dictionary probes; rejecting two of them would make a tag committed from the
/// address picker silently unreadable.
/// </para>
/// <para>
/// <b>Two accepted spellings of the coercion type</b>, likewise: <c>dataType</c> — what
/// the AdminUI's <c>MTConnectTagConfigModel</c> writes, and therefore what every tag
/// authored through the typed editor carries — and <c>driverDataType</c>, matching the
/// driver's own <c>tags[]</c> field name. Reading only one of them would make the editor
/// and the driver disagree about the type of every tag.
/// </para>
/// <para>
/// <b>The alias out-ranks the canonical key, and the editor mirrors that</b> (Gitea #550).
/// <c>MTConnectTagConfigModel</c> used to read <c>dataType</c> alone, so a blob carrying a
/// valid <c>driverDataType</c> was displayed and saved under a type the driver then
/// overrode — the editor showed a lie — while an INVALID one sailed through authoring and
/// was rejected here. It now reads both spellings in this precedence and normalises onto
/// <c>dataType</c> on save, dropping the alias, so a tag edited through the editor keeps
/// the type this parse read for it.
/// </para>
/// <para>
/// Kept separate from the driver's strict <c>tags[]</c> DTO rather than reusing it: there a
/// missing <c>fullName</c> is a config error worth failing the deployment over. A raw tag's
/// blob is authored elsewhere and its failure mode is per-tag, not per-driver.
/// </para>
/// </remarks>
private sealed class MTConnectRawTagConfigDto
{
public string? FullName { get; init; }
public string? DataItemId { get; init; }
public string? Address { get; init; }
public string? DriverDataType { get; init; }
public string? DataType { get; init; }
public bool? IsArray { get; init; }
public int? ArrayDim { get; init; }
public string? MtCategory { get; init; }
public string? MtType { get; init; }
public string? MtSubType { get; init; }
public string? Units { get; init; }
}
}
@@ -3250,7 +3250,7 @@ public sealed class MTConnectDriver
{
if (entry is null || string.IsNullOrWhiteSpace(entry.RawPath)) continue;
if (!TryReadRawTag(entry, out var dataItemId, out var authored))
if (!MTConnectTagDefinitionFactory.TryFromTagConfig(entry.TagConfig, out var dataItemId, out var authored))
{
_skippedTags.Skip(
entry.RawPath,
@@ -3392,71 +3392,6 @@ public sealed class MTConnectDriver
return map.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>
/// Reads one authored raw tag: the DataItem id it binds, and the definition its blob declares
/// (null when the blob declares no type and the caller must fall back).
/// </summary>
/// <returns>
/// <see langword="false"/> when the blob is unreadable, names no DataItem id, or carries a
/// <c>driverDataType</c> that is not a <see cref="DriverDataType"/> — a present-but-invalid
/// type rejects the tag rather than silently defaulting, which would publish the Agent's text
/// as a Good value of the wrong type (the sibling drivers' strict-enum posture).
/// </returns>
private static bool TryReadRawTag(
RawTagEntry entry, out string dataItemId, out MTConnectTagDefinition? authored)
{
dataItemId = string.Empty;
authored = null;
if (string.IsNullOrWhiteSpace(entry.TagConfig)) return false;
MTConnectRawTagConfigDto? blob;
try
{
blob = JsonSerializer.Deserialize<MTConnectRawTagConfigDto>(entry.TagConfig, JsonOptions);
}
catch (JsonException)
{
return false;
}
if (blob is null) return false;
var id = FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address);
if (id is null) return false;
dataItemId = id;
// "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed
// MTConnectTagConfigModel writes) — see the DTO remarks.
var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType);
if (authoredType is null) return true;
// A digits-only type is rejected outright, BEFORE the parse: Enum.TryParse accepts "8" as
// readily as "Float64" and returns the member with that ordinal, so a numerically-serialized
// enum (the repo-wide authoring trap) would otherwise coerce every value of that tag to a
// type nobody chose, under Good quality. The AdminUI's MTConnectTagConfigModel.Validate
// refuses the same shape; this is the runtime half of that guard.
if (authoredType.All(char.IsAsciiDigit)
|| !Enum.TryParse<DriverDataType>(authoredType, ignoreCase: true, out var parsed)
|| !Enum.IsDefined(parsed))
{
return false;
}
authored = new MTConnectTagDefinition(
dataItemId,
parsed,
blob.IsArray ?? false,
blob.ArrayDim ?? 0,
blob.MtCategory,
blob.MtType,
blob.MtSubType,
blob.Units);
return true;
}
/// <summary>
/// The definition for a raw tag whose blob declared no type: the Agent's own <c>/probe</c>
/// declaration when the model is in hand, else <see cref="DriverDataType.String"/> — the one
@@ -3505,17 +3440,6 @@ public sealed class MTConnectDriver
return map.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>The first of <paramref name="candidates"/> that carries text, trimmed; else null.</summary>
private static string? FirstNonBlank(params string?[] candidates)
{
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim();
}
return null;
}
/// <summary>
/// Parses an enum authored as a <b>name</b>. Config surfaces serialize enums as names
/// project-wide; a numerically-serialized enum is the known trap that faults a driver at
@@ -3735,51 +3659,6 @@ public sealed class MTConnectDriver
public MTConnectWriteDto? Write { get; init; }
}
/// <summary>
/// A raw tag's <c>TagConfig</c> blob: the driver-specific address of one authored raw tag.
/// Every field is optional — the blob is operator-authorable and reaches the driver through
/// several authoring surfaces that populate different subsets of it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Three accepted spellings of the DataItem id</b>, tried in order:
/// <c>fullName</c> (the driver's own tag shape, and what the typed editor writes),
/// <c>dataItemId</c> (the protocol's own word for it, and the spelling an operator
/// hand-authoring the blob reaches for first), and <c>address</c> (what
/// <c>RawBrowseCommitMapper</c> writes for a driver with no typed editor of its own — the
/// shape every browse-committed MTConnect tag currently arrives in). Accepting all three
/// costs two dictionary probes; rejecting two of them would make a tag committed from the
/// address picker silently unreadable.
/// </para>
/// <para>
/// <b>Two accepted spellings of the coercion type</b>, likewise: <c>dataType</c> — what
/// the AdminUI's <c>MTConnectTagConfigModel</c> writes, and therefore what every tag
/// authored through the typed editor carries — and <c>driverDataType</c>, matching the
/// driver's own <c>tags[]</c> field name. Reading only one of them would make the editor
/// and the driver disagree about the type of every tag.
/// </para>
/// <para>
/// Kept separate from <see cref="MTConnectTagDto"/> rather than reusing it: that DTO is
/// the strict <c>tags[]</c> surface, where a missing <c>fullName</c> is a config error
/// worth failing the deployment over. A raw tag's blob is authored elsewhere and its
/// failure mode is per-tag, not per-driver.
/// </para>
/// </remarks>
private sealed class MTConnectRawTagConfigDto
{
public string? FullName { get; init; }
public string? DataItemId { get; init; }
public string? Address { get; init; }
public string? DriverDataType { get; init; }
public string? DataType { get; init; }
public bool? IsArray { get; init; }
public int? ArrayDim { get; init; }
public string? MtCategory { get; init; }
public string? MtType { get; init; }
public string? MtSubType { get; init; }
public string? Units { get; init; }
}
/// <summary>One authored tag. <c>DriverDataType</c> stays a string — see <see cref="ParseEnum{T}"/>.</summary>
private sealed class MTConnectTagDto
{
@@ -130,18 +130,47 @@ public static class ModbusTagDefinitionFactory
}
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob (05/CONV-2). Returns
/// human-readable warnings for present-but-invalid enum values (<c>region</c> / <c>dataType</c> /
/// <c>byteOrder</c> — which the lenient runtime silently defaults) and for a structurally
/// unparseable TagConfig (which the runtime turns into a silent <c>BadNodeIdUnknown</c>). Empty when
/// the blob is clean or is not an equipment-tag TagConfig object. Never throws.
/// The RawPath handed to <see cref="FromTagConfig"/> during inspection. The mapper only copies it
/// onto <see cref="ModbusTagDefinition.Name"/> and never validates it, so inspection is free to pass
/// a placeholder — the deploy gate is inspecting a blob, not a placed tag.
/// </summary>
private const string InspectionRawPath = "(inspect)";
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob (05/CONV-2). Returns
/// human-readable warnings for a blob <see cref="FromTagConfig"/> would <b>reject</b> — a
/// structurally unparseable or non-object document, an absent <c>region</c>, an absent or
/// out-of-range <c>address</c>, a present-but-invalid <c>region</c> / <c>dataType</c> /
/// <c>byteOrder</c> / <c>stringByteOrder</c> (each rejects the tag: the runtime enum reads are
/// STRICT since R2-11 Phase C), or a type-dependent guard (<c>String</c> with no
/// <c>stringLength</c>, <c>BitInRegister</c> with a <c>bitIndex</c> outside 0–15). Such a tag is
/// skipped by the driver and reads <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts
/// the blob, when it is blank, or when it is not an equipment-tag TagConfig object. Never throws.
/// </summary>
/// <remarks>
/// <b>The verdict comes from <see cref="FromTagConfig"/>, not from a mirror</b> (Gitea #551). The
/// rule this gate must never break is being stricter than the runtime — a blob the driver serves
/// must not draw a warning calling it invalid — and running the mapper first makes that structural
/// rather than a promise. It also closes the opposite gap: the previous hand-list of enum checks
/// was silent for every rejection cause that was not on it (an absent <c>region</c>, a bad
/// <c>address</c>, <c>stringByteOrder</c>), so a tag the driver refuses deployed clean.
/// </remarks>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
// drawing nothing, which is the pre-existing convention shared by all eleven inspectors.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') return warnings;
// The accept gate: whatever the driver reads draws nothing. See the remarks. The RAW reference is
// handed to the mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
@@ -151,23 +180,90 @@ public static class ModbusTagDefinitionFactory
warnings.Add("Modbus TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
foreach (var w in new[]
{
TagConfigJson.DescribeInvalidEnum<ModbusRegion>(root, "region"),
TagConfigJson.DescribeInvalidEnum<ModbusDataType>(root, "dataType"),
TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(root, "byteOrder"),
})
{
if (w is not null) warnings.Add(w);
}
DescribeRejection(root, warnings);
}
catch (JsonException)
{
warnings.Add("Modbus TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
// Backstop: the mapper refused for a reason nothing above names (it also swallows FormatException
// and InvalidOperationException). Saying so beats reporting a clean blob the driver will skip.
if (warnings.Count == 0)
{
warnings.Add(
"Modbus TagConfig was rejected by the driver's tag reader — the driver will skip this tag "
+ "and it will read BadNodeIdUnknown.");
}
return warnings;
}
/// <summary>
/// Explains why <see cref="FromTagConfig"/> refused an object-rooted blob, walking its checks in the
/// same order. Every branch mirrors a <c>return false</c> there — and it runs only after the mapper
/// has already refused, so it can never invent strictness of its own.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="warnings">The list to append warnings to.</param>
private static void DescribeRejection(JsonElement root, List<string> warnings)
{
if (!root.TryGetProperty("region", out _))
{
warnings.Add(
"Modbus TagConfig has no 'region' — every Modbus tag must name one (valid: "
+ string.Join(", ", Enum.GetNames<ModbusRegion>())
+ "); the driver will skip this tag and it will read BadNodeIdUnknown.");
}
if (!root.TryGetProperty("address", out var addr))
{
warnings.Add("Modbus TagConfig has no 'address' — the tag will not resolve (BadNodeIdUnknown).");
}
else if (addr.ValueKind != JsonValueKind.Number
|| !addr.TryGetInt32(out var address)
|| address < 0 || address > ushort.MaxValue)
{
warnings.Add(
$"value '{addr.GetRawText()}' for 'address' is not a Modbus register address (an integer "
+ $"0–{ushort.MaxValue}); the tag will not resolve (BadNodeIdUnknown).");
}
// Strict enums, in the order FromTagConfig reads them. stringByteOrder is read as strictly as its
// three siblings, and was the one the previous hand-list forgot (Gitea #551).
foreach (var w in new[]
{
TagConfigJson.DescribeInvalidEnum<ModbusRegion>(root, "region"),
TagConfigJson.DescribeInvalidEnum<ModbusDataType>(root, "dataType"),
TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(root, "byteOrder"),
TagConfigJson.DescribeInvalidEnum<ModbusStringByteOrder>(root, "stringByteOrder"),
})
{
if (w is not null) warnings.Add(w);
}
// Type-dependent guards. The byte/ushort NARROWING is part of the rule, not an accident of the
// mapper: it reads `(byte)bitIndex` / `(ushort)stringLength`, so 256 is bit 0 and is ACCEPTED.
// Describing the un-narrowed value here would call a blob invalid that the driver serves.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusDataType.Int16, out var dataType)) return;
if (dataType == ModbusDataType.String && (ushort)ReadInt(root, "stringLength") < 1)
{
warnings.Add(
"Modbus String tag has no 'stringLength' of at least 1 — a zero-length string reads zero "
+ "registers, which is an illegal Modbus request, so the tag is rejected (BadNodeIdUnknown).");
}
if (dataType == ModbusDataType.BitInRegister && (byte)ReadInt(root, "bitIndex") > 15)
{
warnings.Add(
"Modbus BitInRegister tag has a 'bitIndex' outside 0–15 — the bit mask would overflow a "
+ "16-bit register, so the tag is rejected (BadNodeIdUnknown).");
}
}
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
&& e.TryGetInt32(out var v) ? v : 0;
@@ -49,7 +49,10 @@ public static class MqttTagDefinitionFactory
/// <summary>
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3),
/// and framing is decided solely by <c>JsonDocument.Parse</c> plus the
/// root-<see cref="JsonValueKind.Object"/> check — so leading whitespace is skipped exactly as the JSON
/// spec says, and a padded blob reaches the same verdict its unpadded form does (Gitea #559).
/// Enum fields (<c>payloadFormat</c> / <c>dataType</c>) and <c>qos</c> are read STRICTLY — a
/// present-but-invalid (typo'd) value rejects the whole tag (returns <see langword="false"/> ⇒ the
/// driver surfaces <c>BadNodeIdUnknown</c>) rather than silently defaulting to a wrong-typed Good or
@@ -60,6 +63,14 @@ public static class MqttTagDefinitionFactory
/// for it. Rejecting it at runtime would turn an authoring mistake into a silent
/// <c>BadNodeIdUnknown</c> with no stated cause.
/// </para>
/// <para>
/// ⚠️ <b><c>payloadFormat</c> is the one key whose verdict differs between the two readers</b>
/// (Gitea #557 item 2). It is read STRICTLY here, so a typo takes the tag dark in Plain mode;
/// <see cref="FromSparkplugTagConfig"/> never reads it at all, so the same blob is served
/// happily in Sparkplug mode. That asymmetry is intentional — see the contract note there —
/// and <see cref="Inspect"/> reports it as an ADVISORY because no tag-level gate can see which
/// mode the tag's driver runs.
/// </para>
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
@@ -68,7 +79,14 @@ public static class MqttTagDefinitionFactory
public static bool FromTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false;
// Gitea #559: the blank guard only — NOT a `tagConfig[0] != '{'` framing check. That check ran on
// the UNTRIMMED string, so a blob carrying leading whitespace (which a CSV or API authoring path can
// introduce invisibly, and which no author ever means as semantic) was refused here while the six
// sibling factories' mappers handed the identical blob to JsonDocument.Parse, which skips leading
// whitespace per the JSON spec. JsonDocument.Parse + the ValueKind check below are the single framing
// authority now, exactly as they are for every other driver: an array/number/string root fails the
// ValueKind check, and anything unparseable is caught as a JsonException.
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
@@ -121,7 +139,9 @@ public static class MqttTagDefinitionFactory
/// <c>(groupId, edgeNodeId, deviceId?, metricName)</c> tuple; <c>deviceId</c> is optional (absent
/// means the metric is published by the edge node itself), and there is <b>no</b> <c>topic</c> —
/// a Sparkplug driver subscribes one group-wide filter and routes by the decoded topic + birth
/// certificate, never by a per-tag topic. Never throws.
/// certificate, never by a per-tag topic. Never throws. Framing is
/// <c>JsonDocument.Parse</c> plus the root-object check, so leading
/// whitespace does not change the verdict (Gitea #559) — see <see cref="FromTagConfig"/>.
/// </summary>
/// <remarks>
/// <para>
@@ -139,6 +159,26 @@ public static class MqttTagDefinitionFactory
/// validated — a blob retyped in the editor keeps them, and rejecting the tag for a stale
/// leftover key would take a correctly-authored Sparkplug tag dark with no stated cause.
/// </para>
/// <para>
/// <b>The <c>payloadFormat</c> contract (Gitea #557 item 2), decided and pinned.</b> An invalid
/// <c>payloadFormat</c> is ACCEPTED here and REFUSED by <see cref="FromTagConfig"/>, and the
/// union verdict <see cref="Inspect"/> and the AdminUI's <c>MqttTagConfigModel</c> both use
/// therefore admits it. That is the intended contract, on three pieces of evidence: this reader
/// hard-codes <see cref="MqttPayloadFormat.Json"/> and never consults the key; the Sparkplug
/// ingest path (<c>SparkplugIngestor</c> → this method) never reads
/// <see cref="MqttTagDefinition.PayloadFormat"/> — only <c>MqttSubscriptionManager</c>, the
/// PLAIN path, decodes by it; and <c>MqttTagConfigModel.ToJson</c> deliberately omits the key
/// entirely in Sparkplug mode (Gitea #513). So in Sparkplug mode the tag genuinely works, and a
/// gate that rejected it would be stricter than the runtime — the one thing these gates must
/// never be.
/// </para>
/// <para>
/// ⚠️ The cost is that the served behaviour depends on the DRIVER's <see cref="MqttMode"/>,
/// which no tag row records. <see cref="Inspect"/> closes that with an ADVISORY naming the key
/// and the mode dependency, so the operator is told without the gate over-claiming. The
/// authoring gate keeps the same verdict (accept) by construction — it consults the identical
/// union — and its own Plain-mode half already refuses the value outright.
/// </para>
/// </remarks>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
@@ -147,7 +187,10 @@ public static class MqttTagDefinitionFactory
public static bool FromSparkplugTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false;
// Gitea #559: the blank guard only — see the identical note on FromTagConfig. JsonDocument.Parse +
// the ValueKind check are the single framing authority; a leading-whitespace blob reaches the same
// verdict its unpadded form does, which is what every sibling factory has always done.
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
@@ -205,50 +248,180 @@ public static class MqttTagDefinitionFactory
}
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob. Returns human-readable
/// warnings for a structurally unparseable blob (which the runtime turns into a silent
/// <c>BadNodeIdUnknown</c>), for present-but-invalid <c>payloadFormat</c> / <c>dataType</c> /
/// <c>qos</c> values, and for a <b>wildcard</b> tag topic (a tag bound to <c>+</c> / <c>#</c>
/// would receive values from many topics — ambiguous, and almost never what the operator meant).
/// Empty when the blob is clean or is not an equipment-tag TagConfig object. Never throws.
/// The RawPath handed to the mappers during inspection — they only copy it onto
/// <see cref="MqttTagDefinition.Name"/> and never validate it.
/// </summary>
private const string InspectionRawPath = "(inspect)";
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob. Two kinds of warning:
/// <list type="bullet">
/// <item>
/// <b>Rejections</b> — a blob NEITHER runtime mapper would read: an unparseable or
/// non-object document, present-but-invalid <c>payloadFormat</c> / <c>dataType</c> /
/// <c>qos</c> values (all read strictly), or an address that names neither a Plain
/// <c>topic</c> nor a complete Sparkplug <c>groupId</c>/<c>edgeNodeId</c>/<c>metricName</c>
/// tuple. Such a tag is skipped by the driver and reads <c>BadNodeIdUnknown</c> forever.
/// </item>
/// <item>
/// <b>Advisories</b> — a <b>wildcard</b> tag topic (<c>+</c> / <c>#</c>), which the mapper
/// deliberately ACCEPTS (ambiguous, not unparseable) so this runs either way; and a
/// present-but-invalid <c>payloadFormat</c> on an ACCEPTED blob, which can only mean the
/// Sparkplug reader took it and the Plain reader would not (Gitea #557 item 2).
/// </item>
/// </list>
/// Empty for a clean, readable blob, for a blank one, or for one that is not an equipment-tag
/// TagConfig object. Never throws.
/// <para>
/// The two classes are kept in <b>separate lists</b> (Gitea #556 item 2), and the catch-all
/// backstop fires on "no REJECTION was produced", never on "no warning at all" — otherwise an
/// advisory would silently swallow the only message that reports a refusal nothing can name.
/// ⚠️ Since Gitea #559 removed the mappers' own framing gate, that shape has <b>no</b> known
/// vehicle: the leading-whitespace blob that used to reach it is now accepted or refused on its
/// content alone, and every remaining <c>return false</c> in either mapper has a matching
/// <see cref="DescribeRejection"/> branch. The backstop stays as defence-in-depth for the next
/// refusal cause someone adds without one — the same posture as the FOCAS inspector's, and pinned
/// the same way (a count relation, not a constructed vehicle).
/// </para>
/// </summary>
/// <remarks>
/// <b>The accept gate is the UNION of the two mappers</b> (Gitea #551), because <c>Inspect</c> is
/// handed a blob with no mode: the driver's <see cref="MqttMode"/> decides which of
/// <see cref="FromTagConfig"/> / <see cref="FromSparkplugTagConfig"/> reads it, and nothing about a
/// tag row says which. Gating on the Plain mapper alone would warn that every correctly-authored
/// Sparkplug tag has no topic — a gate stricter than the runtime, the one thing it must never be.
/// </remarks>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 2: the two classes live in SEPARATE lists, and the backstop below gates on the
// REJECTION list alone. Sharing one list meant an advisory (which fires on an accepted blob too)
// could satisfy a `warnings.Count == 0` guard and silently swallow the catch-all rejection message
// — the only thing that reports a refusal neither DescribeRejection branch can name. That shape has
// no vehicle today (see the summary), so this is the structural guarantee, not a live fix.
var rejections = new List<string>();
var advisories = new List<string>();
// The framing pre-filter is purely an is-this-even-a-TagConfig filter, exactly as it is for the six
// sibling factories: a reference that is not object-framed is a bare name or some other artefact,
// not a TagConfig this inspector has anything to say about, so it draws nothing. It TRIMS FIRST
// (Gitea #556 item 1) and the mappers no longer carry a framing gate of their own at all (Gitea
// #559 — JsonDocument.Parse skips leading whitespace, so " {…}" is served), which means this filter
// and the mappers agree on padded blobs instead of the filter being the laxer of the two.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
return rejections;
// The RAW reference is handed to the mappers — the driver reads the stored blob, not a trimmed
// copy of it, so the verdict here is the verdict the driver will reach.
var accepted = FromTagConfig(reference, InspectionRawPath, out _)
|| FromSparkplugTagConfig(reference, InspectionRawPath, out _);
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("Mqtt TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
foreach (var w in new[]
{
TagConfigJson.DescribeInvalidEnum<MqttPayloadFormat>(root, "payloadFormat"),
TagConfigJson.DescribeInvalidEnum<DriverDataType>(root, "dataType"),
DescribeInvalidQos(root),
})
{
if (w is not null) warnings.Add(w);
}
var topic = ReadString(root, "topic");
if (topic.IndexOfAny(TopicWildcards) >= 0)
{
warnings.Add(
$"value '{topic}' for 'topic' contains an MQTT wildcard (+ or #); a tag's topic must be " +
"concrete, or the tag will be fed by every matching topic.");
rejections.Add("Mqtt TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return rejections;
}
if (!accepted) DescribeRejection(root, rejections);
DescribeAdvisories(root, accepted, advisories);
}
catch (JsonException)
{
warnings.Add("Mqtt TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
rejections.Add("Mqtt TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return rejections;
}
// Backstop: neither mapper read the blob and no branch above names why. Gated on the REJECTION
// count, never on the total — an advisory says nothing about why the tag was refused.
if (!accepted && rejections.Count == 0)
{
rejections.Add(
"Mqtt TagConfig was rejected by the driver's tag reader — the driver will skip this tag "
+ "and it will read BadNodeIdUnknown.");
}
if (advisories.Count == 0) return rejections;
rejections.AddRange(advisories); // rejections first — the order the pre-split shape produced
return rejections;
}
/// <summary>
/// Warnings about a blob at least one mapper reads (or would read but for another fault) that the
/// driver still cannot serve as authored — see the "advisories" bullet on <see cref="Inspect"/>.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="accepted">Whether the union accept gate read the blob.</param>
/// <param name="advisories">The ADVISORY-class list to append to. These must never reach the rejection
/// list — see <see cref="Inspect"/>'s backstop.</param>
private static void DescribeAdvisories(JsonElement root, bool accepted, List<string> advisories)
{
// The mappers accept a wildcard topic on purpose (ambiguous, not unparseable), so this runs
// whether or not the blob was accepted.
var topic = ReadString(root, MqttTagConfigKeys.Topic);
if (topic.IndexOfAny(TopicWildcards) >= 0)
{
advisories.Add(
$"value '{topic}' for 'topic' contains an MQTT wildcard (+ or #); a tag's topic must be " +
"concrete, or the tag will be fed by every matching topic.");
}
// Gitea #557 item 2 — the ONE key whose verdict depends on the DRIVER'S mode, reported as an
// advisory because no tag-level gate can see that mode. A present-but-invalid payloadFormat is
// fatal to the Plain parse and invisible to the Sparkplug one, so a union accept here can only
// have come from Sparkplug. Promoting it to a rejection would be stricter than the runtime for
// every Sparkplug deployment (the tag IS served); staying silent would hide that the same row
// goes dark the moment its driver is Plain. See the contract note on FromSparkplugTagConfig.
if (accepted && TagConfigJson.DescribeInvalidEnum<MqttPayloadFormat>(root, "payloadFormat") is { } pf)
{
advisories.Add(
pf + " — this tag is accepted only because the Sparkplug B reader ignores 'payloadFormat' "
+ "entirely. If this device's driver runs Plain mode the tag is REJECTED and reads "
+ "BadNodeIdUnknown; either fix the value or remove the key.");
}
}
/// <summary>
/// Explains why BOTH mappers refused an object-rooted blob. The strict-field checks are shared by
/// the two shapes; the address check names what each shape needed. Runs only after both mappers have
/// already refused, so it can never invent strictness.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="rejections">The REJECTION-class list to append to (never the advisory list — see
/// <see cref="Inspect"/>'s backstop).</param>
private static void DescribeRejection(JsonElement root, List<string> rejections)
{
foreach (var w in new[]
{
// payloadFormat appears in BOTH classes, and which one it lands in is decided by the accept
// gate, not by the key: here it is a genuine rejection cause (both mappers refused, so the
// driver serves the tag in neither mode), while on an ACCEPTED blob the same invalid value is
// the mode-dependent advisory in DescribeAdvisories (Gitea #557 item 2).
TagConfigJson.DescribeInvalidEnum<MqttPayloadFormat>(root, "payloadFormat"),
TagConfigJson.DescribeInvalidEnum<DriverDataType>(root, "dataType"),
DescribeInvalidQos(root),
})
{
if (w is not null) rejections.Add(w);
}
// Address: Plain needs a topic, Sparkplug needs the whole binding tuple. Neither is satisfied, or
// one of the strict fields above already refused both — only say it when the address is the cause.
var hasTopic = !string.IsNullOrWhiteSpace(ReadString(root, MqttTagConfigKeys.Topic));
var hasSparkplugTuple = !string.IsNullOrWhiteSpace(ReadString(root, MqttTagConfigKeys.GroupId))
&& !string.IsNullOrWhiteSpace(ReadString(root, MqttTagConfigKeys.EdgeNodeId))
&& !string.IsNullOrWhiteSpace(ReadString(root, MqttTagConfigKeys.MetricName));
if (!hasTopic && !hasSparkplugTuple)
{
rejections.Add(
"Mqtt TagConfig names no address — a Plain-mode tag needs a 'topic', a Sparkplug B tag "
+ "needs 'groupId' + 'edgeNodeId' + 'metricName'. The driver will skip this tag and it "
+ "will read BadNodeIdUnknown.");
}
return warnings;
}
/// <summary>
@@ -6,6 +6,7 @@ using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
@@ -41,7 +42,13 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
}
/// <inheritdoc />
public string DriverType => "OpcUaClient";
/// <remarks>
/// Sourced from the shared <see cref="DriverTypeNames"/> set rather than the hand-written literal
/// this line used to carry (Gitea #558). The value is unchanged; the constant is what
/// <c>DriverTypeNamesGuardTests</c> pins to the factory's own registration, so a rename on either
/// side can no longer leave this browser indexed under a key nothing dispatches to.
/// </remarks>
public string DriverType => DriverTypeNames.OpcUaClient;
/// <inheritdoc />
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
@@ -8,6 +8,10 @@
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
<!-- NamespaceMap. Deliberately the .Session leaf and NOT the runtime .Driver project: the AdminUI
references THIS project, so a .Driver reference would pull the driver runtime into the
AdminUI's deploy graph (the Gitea #512 shape). See the .Session csproj block comment. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
@@ -31,7 +31,8 @@ public sealed record OpcUaClientDriverOptions
/// with the cluster's raw OPC UA Client tags. Each <see cref="RawTagEntry"/> carries the tag's
/// <b>RawPath</b> identity plus a driver <c>TagConfig</c> blob whose <c>nodeId</c> key holds the
/// upstream OPC UA node id string the driver dials. The driver builds a <c>RawPath → nodeId</c>
/// resolution table (via <see cref="NamespaceMap"/>) from this list at connect time; a
/// resolution table (via <c>NamespaceMap</c>, in the sibling <c>.Session</c> project) from this
/// list at connect time; a
/// read/write/subscribe/history reference is always a RawPath resolved through that table.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
@@ -0,0 +1,110 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// The authored-raw-tag reader for the OPC UA Client (gateway) driver: one authority, consumed by both
/// the runtime (<c>NamespaceMap</c>'s <c>RawPath → upstream node id</c> table build) and the
/// deploy-time TagConfig gate (<see cref="Inspect"/>, deferment-register G-7).
/// <para>
/// <b>This read used to be private to <c>NamespaceMap</c>.</b> It moved here — unchanged —
/// rather than being mirrored: a mirror of an accept/reject boundary drifts the moment either side
/// is touched, and the drift is silent, with the gate describing a shape the driver no longer
/// reads. <c>NamespaceMap</c> now calls <see cref="TryReadNodeId"/>.
/// </para>
/// <para>
/// <b><c>NamespaceMap</c> is named by text, not <c>cref</c>, on purpose.</b> It lives in the
/// sibling <c>Driver.OpcUaClient.Session</c> project, which references THIS one — the dependency
/// runs that way round so this assembly stays dependency-light for ControlPlane, which
/// project-references it for the G-7 gate. A <c>cref</c> here would need the reverse reference.
/// </para>
/// <para>
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryReadNodeId"/></b> — it asks the
/// read for the verdict and only explains a rejection, so a blob the driver resolves can never draw
/// a warning calling it invalid. Nothing else in the blob is checked: the upstream node id is an
/// opaque reference string that the SDK re-binds against the live session, so whether it names a
/// node that exists is knowable only at connect time, not at deploy time.
/// </para>
/// </summary>
public static class OpcUaClientTagDefinitionFactory
{
/// <summary>
/// Read the upstream node id string from an authored <c>TagConfig</c> blob's <c>nodeId</c> key
/// (camelCase, per the AdminUI <c>OpcUaClientTagConfigModel</c>; a legacy PascalCase <c>NodeId</c>
/// is also accepted). Never throws — a malformed blob yields <see langword="false"/>.
/// <para>
/// ⚠️ <b>The two spellings are PRESENCE-shadows, not first-non-blank.</b> The <c>||</c> below
/// short-circuits on the mere presence of <c>nodeId</c> — whatever it holds — and the string /
/// non-blank check then runs ONCE, on that one element. So the legacy <c>NodeId</c> is consulted
/// only when <c>nodeId</c> is <i>wholly absent</i>: a blank or non-string <c>nodeId</c> beside a
/// valid legacy <c>NodeId</c> is a rejected blob, not a fallback. The AdminUI model mirrors this
/// walk exactly (Gitea #557 item 1) rather than the looser first-non-blank shape MTConnect's
/// reader uses, which would let authoring accept a blob this reader refuses.
/// </para>
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="nodeId">The upstream node id string when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the blob carries a usable upstream node reference.</returns>
public static bool TryReadNodeId(string tagConfig, out string nodeId)
{
nodeId = string.Empty;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
if ((root.TryGetProperty("nodeId", out var n) || root.TryGetProperty("NodeId", out n))
&& n.ValueKind == JsonValueKind.String)
{
var s = n.GetString();
if (!string.IsNullOrWhiteSpace(s)) { nodeId = s; return true; }
}
}
catch (JsonException) { /* malformed blob — treat as unresolvable */ }
return false;
}
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob: warns for a blob
/// <see cref="TryReadNodeId"/> would reject — unparseable JSON, a non-object root, or no usable
/// <c>nodeId</c> (absent, non-string, or blank). Such a tag is dropped from the driver's resolution
/// table and reads <c>BadNodeIdInvalid</c> forever, which before Gitea #541 was the quietest skip
/// of the twelve drivers — not even a log line. Empty when the blob resolves, is blank, or is not a
/// JSON object at all. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
return warnings;
// The accept gate: whatever the driver resolves draws nothing. See the class remarks.
if (TryReadNodeId(reference, out _)) return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
{
warnings.Add(
"OpcUaClient TagConfig root is not a JSON object — the tag binds no upstream node and "
+ "will read BadNodeIdInvalid.");
return warnings;
}
}
catch (JsonException)
{
warnings.Add("OpcUaClient TagConfig is not valid JSON — the tag will not resolve (BadNodeIdInvalid).");
return warnings;
}
warnings.Add(
"OpcUaClient TagConfig has no usable 'nodeId' (a non-blank string holding the upstream "
+ "nsu=…/ns=N;… reference) — the driver drops this tag from its resolution table and it reads "
+ "BadNodeIdInvalid.");
return warnings;
}
}
@@ -6,11 +6,12 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
</ItemGroup>
<ItemGroup>
<!-- v3: NamespaceMap resolves a RawPath (RawTagEntry identity) to the upstream node id.
RawTagEntry lives in the Core.Abstractions leaf beside EquipmentTagRefResolver. -->
<!-- DEPENDENCY-LIGHT ON PURPOSE: Core.Abstractions and nothing else, the same shape as
Calculation.Contracts. ControlPlane project-references this for the G-7 deploy-time
TagConfig gate (OpcUaClientTagDefinitionFactory.Inspect), so anything added here lands in
every host that loads the control plane. The OPC UA client SDK used to be here, for
NamespaceMap; NamespaceMap now lives in the sibling .Session leaf. Pinned by
ContractsIsDependencyLightTests (Driver.OpcUaClient.Tests). -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -1,4 +1,3 @@
using System.Text.Json;
using Opc.Ua;
using Opc.Ua.Client;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -53,6 +52,17 @@ public readonly record struct RawTagBinding(string NodeId, bool WriteIdempotent)
/// driver then re-binds against the live session via the static
/// <see cref="TryResolve(ISession?, string, out NodeId)"/>.
/// </para>
/// <para>
/// <b>Why this type sits in <c>.Session</c> and not <c>.Contracts</c>.</b> Its whole public
/// surface is OPC UA SDK-typed (<see cref="ISession"/>, <see cref="NamespaceTable"/>,
/// <see cref="NodeId"/>), so while it lived in <c>.Contracts</c> that project had to carry
/// <c>OPCFoundation.NetStandard.Opc.Ua.Client</c> — and once ControlPlane project-referenced
/// <c>.Contracts</c> for the G-7 deploy-time TagConfig gate, the whole OPC UA client stack
/// came with it into every host that loads the control plane. The blob read this class needs
/// stayed behind in <c>.Contracts</c> as
/// <c>OpcUaClientTagDefinitionFactory.TryReadNodeId</c> (the ONE authority the deploy gate and
/// this table share), which is why the reference runs <c>.Session → .Contracts</c>.
/// </para>
/// </remarks>
public sealed class NamespaceMap
{
@@ -119,37 +129,14 @@ public sealed class NamespaceMap
foreach (var entry in rawTags)
{
if (string.IsNullOrWhiteSpace(entry.RawPath)) continue;
if (!TryReadNodeId(entry.TagConfig, out var nodeId)) continue;
// The blob read lives on OpcUaClientTagDefinitionFactory so the deploy-time TagConfig gate and
// this table build cannot disagree about what a usable nodeId is (G-7).
if (!OpcUaClientTagDefinitionFactory.TryReadNodeId(entry.TagConfig, out var nodeId)) continue;
table[entry.RawPath] = new RawTagBinding(nodeId, entry.WriteIdempotent);
}
return table;
}
/// <summary>
/// Read the upstream node id string from an authored <c>TagConfig</c> blob's <c>nodeId</c> key
/// (camelCase, per the AdminUI <c>OpcUaClientTagConfigModel</c>; a legacy PascalCase <c>NodeId</c>
/// is also accepted). Never throws — a malformed blob yields <see langword="false"/>.
/// </summary>
private static bool TryReadNodeId(string tagConfig, out string nodeId)
{
nodeId = string.Empty;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
if ((root.TryGetProperty("nodeId", out var n) || root.TryGetProperty("NodeId", out n))
&& n.ValueKind == JsonValueKind.String)
{
var s = n.GetString();
if (!string.IsNullOrWhiteSpace(s)) { nodeId = s; return true; }
}
}
catch (JsonException) { /* malformed blob — treat as unresolvable */ }
return false;
}
/// <summary>Number of namespaces captured. Index 0 is always the OPC UA core namespace.</summary>
public int Count => _uris.Length;
@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Namespace stays ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient (no .Session suffix) so moving
NamespaceMap here changed no consumer's `using`. -->
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session</AssemblyName>
</PropertyGroup>
<!--
The live-OPC-UA-session layer of the OpcUaClient driver: everything the driver AND the AdminUI's
browser both need that is typed in ISession / NodeId / NamespaceTable. Today that is exactly
NamespaceMap.
WHY IT IS NOT IN .Contracts. It used to be, and .Contracts therefore carried
OPCFoundation.NetStandard.Opc.Ua.Client. When G-7 wired OpcUaClientTagDefinitionFactory.Inspect into
the deploy-time TagConfig gate, ControlPlane gained a ProjectReference to .Contracts — and with it
the whole OPC UA client stack, transitively, into every host that loads the control plane. A
Contracts project is meant to be the dependency-light parse/shape layer (see Calculation.Contracts:
Core.Abstractions and nothing else), so the SDK-typed half moved out rather than the gate doing
without.
WHY NOT JUST PUT IT IN THE .Driver PROJECT (the Mqtt.Browser-style deliberate exception). The
AdminUI project-references .Browser and NOT the runtime .Driver. Having .Browser reference .Driver
to reach NamespaceMap would pull the whole driver runtime — plus Core, Opc.Ua.Configuration and
Secrets.Abstractions — into the AdminUI's build/deploy graph. That is precisely the violation Gitea
#512 spent two extractions removing from Driver.Mqtt.Browser; re-introducing the same shape here to
save one csproj would be trading a measured regression for a little less churn.
KEEP THIS LEAF THIN. Anything addable here that is NOT session-typed belongs in .Contracts, or the
dependency-light property this split exists to buy erodes one file at a time. The pin is
ContractsIsDependencyLightTests in Driver.OpcUaClient.Tests.
-->
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
</ItemGroup>
<ItemGroup>
<!-- RawTagEntry (the v3 authored-tag identity NamespaceMap indexes) lives in the Core.Abstractions
leaf; OpcUaClientTagDefinitionFactory is the ONE authority for reading a TagConfig blob's
nodeId, shared with the deploy gate — NamespaceMap calls it rather than re-parsing. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
</ItemGroup>
</Project>
@@ -14,6 +14,9 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
<!-- NamespaceMap (ISession-typed) — shared with .Browser, which is why it is its own leaf rather
than living here. See the block comment in the .Session csproj. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
@@ -111,16 +111,67 @@ public static class S7TagDefinitionFactory
}
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig (a silent
/// <c>BadNodeIdUnknown</c> at runtime). Empty when clean or not an equipment-tag object. Never throws.
/// The <c>TagConfig</c> key carrying the S7 data type. Shared with the AdminUI authoring model so
/// "does the blob declare a type?" is asked of one spelling, not two (Gitea #551).
/// </summary>
public const string DataTypeKey = "dataType";
/// <summary>
/// The RawPath handed to <see cref="FromTagConfig"/> during inspection — the mapper only copies it
/// onto <see cref="S7TagDefinition.Name"/> and never validates it.
/// </summary>
private const string InspectionRawPath = "(inspect)";
/// <summary>
/// <b>The single authority on whether an S7 blob DECLARES a data type</b>, consulted by
/// <see cref="Inspect"/> and by the AdminUI's <c>S7TagConfigModel.Validate</c> alike.
/// <para>
/// S7 is the one driver in the fleet whose reader <b>requires the key to be present</b>
/// (<see cref="FromTagConfig"/> refuses a blob without it, where every sibling falls back to a
/// default), so a hand-authored / CSV / API blob that omits it used to pass both gates and die
/// at the driver with <c>BadNodeIdUnknown</c> — Gitea #551 item 2.
/// </para>
/// <para>
/// Presence is judged on the KEY, not on the value: a non-string token
/// (<c>"dataType": 8</c>) satisfies the presence check and is then treated as absent by
/// <see cref="TagConfigJson.TryReadEnumStrict{TEnum}"/>, so the blob is ACCEPTED as
/// <see cref="S7DataType.Int16"/>. Judging the value here would make this gate stricter than
/// the reader.
/// </para>
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <returns><see langword="true"/> when the blob carries a <c>dataType</c> key of any kind.</returns>
public static bool DeclaresDataType(JsonElement root) => root.TryGetProperty(DataTypeKey, out _);
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns for a blob <see cref="FromTagConfig"/> would
/// <b>reject</b> — a structurally unparseable or non-object document, an absent / non-string /
/// blank <c>address</c>, an <b>absent</b> <c>dataType</c> (see <see cref="DeclaresDataType"/>), a
/// present-but-invalid <c>dataType</c> (the runtime enum read is STRICT since R2-11 Phase C, so a
/// typo rejects the tag), or an out-of-range <c>stringLength</c> on a <c>String</c> tag. Such a tag
/// is skipped by the driver and reads <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts
/// the blob, when it is blank, or when it is not an equipment-tag object. Never throws.
/// </summary>
/// <remarks>
/// <b>The verdict comes from <see cref="FromTagConfig"/>, not from a mirror</b> (Gitea #551) — see
/// <c>ModbusTagDefinitionFactory.Inspect</c>'s remarks for the full argument.
/// </remarks>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
// drawing nothing.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') return warnings;
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
@@ -130,16 +181,67 @@ public static class S7TagDefinitionFactory
warnings.Add("S7 TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<S7DataType>(root, "dataType");
if (w is not null) warnings.Add(w);
DescribeRejection(root, warnings);
}
catch (JsonException)
{
warnings.Add("S7 TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
if (warnings.Count == 0)
{
warnings.Add(
"S7 TagConfig was rejected by the driver's tag reader — the driver will skip this tag and "
+ "it will read BadNodeIdUnknown.");
}
return warnings;
}
/// <summary>
/// Explains why <see cref="FromTagConfig"/> refused an object-rooted blob, walking its checks in the
/// same order. Runs only after the mapper has already refused, so it can never invent strictness.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="warnings">The list to append warnings to.</param>
private static void DescribeRejection(JsonElement root, List<string> warnings)
{
if (!root.TryGetProperty("address", out var addrEl) || addrEl.ValueKind != JsonValueKind.String)
{
warnings.Add(
"S7 TagConfig has no string 'address' (e.g. DB1.DBW0, M0.0, I0.0, QD4) — the driver will "
+ "skip this tag and it will read BadNodeIdUnknown.");
}
else if (string.IsNullOrWhiteSpace(addrEl.GetString()))
{
warnings.Add("S7 TagConfig has a blank 'address' — the tag will not resolve (BadNodeIdUnknown).");
}
if (!DeclaresDataType(root))
{
warnings.Add(
"S7 TagConfig has no 'dataType' — the S7 reader REQUIRES the key to be present (unlike its "
+ "sibling drivers, which default it), so the driver will skip this tag and it will read "
+ "BadNodeIdUnknown. Valid: " + string.Join(", ", Enum.GetNames<S7DataType>()) + ".");
}
if (TagConfigJson.DescribeInvalidEnum<S7DataType>(root, DataTypeKey) is { } w) warnings.Add(w);
if (TagConfigJson.TryReadEnumStrict(root, DataTypeKey, S7DataType.Int16, out var dataType)
&& dataType == S7DataType.String)
{
var stringLength = ReadInt(root, "stringLength");
if (stringLength < 0 || stringLength > MaxStringLength)
{
warnings.Add(
$"S7 String tag has a 'stringLength' of {stringLength} — an S7 string is 0–"
+ $"{MaxStringLength} characters, so the tag is rejected (BadNodeIdUnknown).");
}
}
}
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
&& e.TryGetInt32(out var v) ? v : 0;
@@ -1,6 +1,8 @@
using System.Data;
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
@@ -149,12 +151,34 @@ internal sealed class SqlBrowseSession : IBrowseSession
var columns = await ReadColumnsAsync(
reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);
// Computed from the columns ALREADY READ for this call — the catalog is not queried a second time.
var timestampColumn = PreferredTimestampColumn(columns);
// Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
// catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
// that legitimately carries both "Value" and "value".
foreach (var column in columns)
{
if (!string.Equals(column.Name, reference.Column, StringComparison.Ordinal)) continue;
// A column NAME alone cannot address a Sql tag — the table has to travel with it, or the
// browse-commit has nothing to build a SqlTagConfigModel from and falls through to the
// generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The
// schema is carried separately so the commit mapper can qualify the table itself rather
// than parsing a joined string back apart.
var addressFields = new Dictionary<string, string>(StringComparer.Ordinal)
{
[SqlBrowseAddressFields.Schema] = reference.Schema,
[SqlBrowseAddressFields.Table] = reference.Table!,
[SqlBrowseAddressFields.ColumnName] = column.Name,
};
// The third part of a wide-row binding: WHICH row. Stated only when the catalog actually names
// a date/time column; the key is absent otherwise, never blank, so the mapper can tell "this
// table cannot be bound by browse" from "an older session said nothing".
if (timestampColumn is not null)
addressFields[SqlBrowseAddressFields.TopByTimestamp] = timestampColumn;
return
[
new AttributeInfo(
@@ -163,17 +187,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
IsArray: false,
SecurityClass: ReadOnlySecurityClass,
IsAlarm: false,
// A column NAME alone cannot address a Sql tag — the table has to travel with it, or the
// browse-commit has nothing to build a SqlTagConfigModel from and falls through to the
// generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The
// schema is carried separately so the commit mapper can qualify the table itself rather
// than parsing a joined string back apart.
AddressFields: new Dictionary<string, string>(StringComparer.Ordinal)
{
["schema"] = reference.Schema,
["table"] = reference.Table!,
["columnName"] = column.Name,
}),
AddressFields: addressFields),
];
}
@@ -245,6 +259,45 @@ internal sealed class SqlBrowseSession : IBrowseSession
return nodes;
}
/// <summary>
/// Picks the table's preferred <c>topByTimestamp</c> column — the one a browse-committed wide-row tag
/// orders by to read the newest row (Gitea #552) — or <see langword="null"/> when the table carries no
/// date/time column at all.
/// </summary>
/// <param name="columns">The table's columns, in the catalog order the dialect's column SQL returns.</param>
/// <returns>The chosen column name, or <see langword="null"/> when there is no candidate.</returns>
/// <remarks>
/// <para><b>Candidacy is decided by the column's declared TYPE, never by its name.</b> A name test
/// (<c>*_ts</c>, <c>*time*</c>, …) is a guess that reads as knowledge: it is locale-dependent, it
/// silently misses <c>Recorded</c>, and — the part that actually breaks data — it would happily order
/// a wide-row read by a <em>string</em> column called <c>timestamp</c>, binding the tag to whatever
/// row sorts last lexically. So the only input is
/// <see cref="ISqlDialect.MapColumnType"/> reporting <see cref="DriverDataType.DateTime"/>, which is
/// the same catalog metadata the browse tree already renders per column, on every dialect.</para>
/// <para><b>Tie-break: FIRST in catalog order.</b> Every dialect's <see cref="ISqlDialect.ListColumnsSql"/>
/// orders by ordinal position (<c>ORDINAL_POSITION</c> / <c>column_id</c>), so "first" means the
/// earliest-declared date/time column and is stable across calls, connections and servers — an
/// unordered "any candidate" would let two operators browsing the same table commit two differently
/// bound tags. On the usual multi-candidate shape (a <c>CreatedUtc</c>/<c>ModifiedUtc</c> pair) it
/// picks the earlier-declared one, which is a preference and not a fact about the data.</para>
/// <para><b>It is a visible, editable DEFAULT, not a hidden binding.</b> The commit writes it into the
/// tag's <c>rowSelector.topByTimestamp</c>, where the typed Sql editor shows it and the operator can
/// change it — to the other timestamp, or to a <c>whereColumn</c>/<c>whereValue</c> pair entirely.
/// Nothing about the choice is recoverable only from the browse session.</para>
/// <para>The browsed column itself is deliberately NOT excluded: picking a table's timestamp column as
/// a tag reads the newest row's timestamp, which is a legitimate (and self-consistent) tag.</para>
/// </remarks>
private string? PreferredTimestampColumn(IReadOnlyList<(string Name, string DataType)> columns)
{
foreach (var column in columns)
{
if (string.IsNullOrWhiteSpace(column.Name)) continue;
if (_dialect.MapColumnType(column.DataType) == DriverDataType.DateTime) return column.Name;
}
return null;
}
/// <summary>
/// Reads one table's columns. A table that no longer exists (or never did) yields an empty list rather
/// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// The keys <c>SqlBrowseSession</c> states in a browsed column's <c>AttributeInfo.AddressFields</c>, and
/// that <c>RawBrowseCommitMapper</c> reads back when it builds the committed tag's <c>TagConfig</c>.
/// <para><b>Why a shared constant rather than a literal at each end.</b> A Sql tag's binding is a tuple
/// (table + column + which row), so — exactly as for MQTT's Sparkplug tuple and
/// <c>MqttTagConfigKeys</c> — the producing session states the parts and the mapper must never
/// re-infer them. Two independently spelled string literals agree by coincidence, and a disagreement is
/// silent: the mapper simply sees no field, and emits a config missing whatever the session was trying
/// to say. That is precisely how Gitea #552 shipped — a wide-row blob with no <c>rowSelector</c>, which
/// both <see cref="SqlEquipmentTagParser"/> and the authoring gate refuse.</para>
/// <para>These are the <em>browse channel's</em> names, not the tag blob's schema. Three of them happen
/// to match a <c>TagConfig</c> key because the mapper writes them straight through;
/// <see cref="TopByTimestamp"/> matches the key <em>nested inside</em> <c>rowSelector</c> — deliberately
/// not spelled <c>timestampColumn</c>, which is the blob's unrelated top-level source-timestamp
/// field.</para>
/// </summary>
public static class SqlBrowseAddressFields
{
/// <summary>The catalog schema owning the browsed column's table (e.g. <c>dbo</c>, <c>sales</c>).</summary>
public const string Schema = "schema";
/// <summary>The unqualified table (or view) name the browsed column belongs to.</summary>
public const string Table = "table";
/// <summary>The browsed column's own name — the wide-row tag's value column.</summary>
public const string ColumnName = "columnName";
/// <summary>
/// The table's preferred date/time-typed column, when it has one: the commit writes it as the
/// wide-row <c>rowSelector.topByTimestamp</c>, i.e. "read the newest row". Absent when the table
/// carries no date/time column at all, which is not a shape a browse can bind — see
/// <c>RawBrowseCommitMapper.DescribeUncommittableLeaf</c>.
/// </summary>
public const string TopByTimestamp = "topByTimestamp";
}
@@ -0,0 +1,183 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// Deploy-time inspection of a Sql raw tag's <c>TagConfig</c> blob (deferment-register G-7). The Sql
/// driver already owns its runtime parse in this assembly
/// (<see cref="SqlEquipmentTagParser.TryParse"/>), so this type adds <b>only</b> the operator-facing
/// half: it never re-implements the accept/reject decision, it asks the parser and then explains a
/// rejection.
/// <para>
/// <b>Why the verdict comes from the parser and not from a mirror.</b> The one rule this gate must
/// never break is being stricter than the runtime: a blob the driver happily serves must not draw a
/// deploy warning calling it invalid. Running <see cref="SqlEquipmentTagParser.TryParse"/> first and
/// returning empty on success makes that structural rather than a promise — a warning is reachable
/// only for a blob the driver would have refused (and then skipped silently, reading
/// <c>BadNodeIdUnknown</c> forever).
/// </para>
/// <para>
/// The class is named <c>SqlTagDefinitionFactory</c> to match the six sibling
/// <c>*TagDefinitionFactory.Inspect</c> entry points the deploy gate discovers by reflection
/// (<c>EquipmentTagConfigInspectorCoverageTests</c>); the mapping half of a "factory" already lives
/// on <see cref="SqlEquipmentTagParser"/> and is deliberately not duplicated here.
/// </para>
/// </summary>
public static class SqlTagDefinitionFactory
{
/// <summary>
/// The RawPath handed to the parser during inspection. The parser only copies it onto
/// <see cref="SqlTagDefinition.Name"/> and never validates it, so inspection is free to pass a
/// placeholder — the deploy gate is inspecting a blob, not a placed tag.
/// </summary>
private const string InspectionRawPath = "(inspect)";
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob. Returns human-readable warnings
/// for a blob <see cref="SqlEquipmentTagParser.TryParse"/> would reject — an unparseable or
/// non-object document, a blob the driver does not recognise as a Sql one, a present-but-invalid
/// <c>model</c> / <c>type</c> enum, the P3-deferred <c>Query</c> model, a missing <c>table</c>, or a
/// missing model-required field. Empty when the parser accepts the blob, when the blob is blank, or
/// when it is not a JSON object at all (the sibling factories' "not an equipment-tag TagConfig"
/// convention). Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
return warnings;
// The accept gate: whatever the driver serves draws nothing. See the class remarks.
if (SqlEquipmentTagParser.TryParse(reference, InspectionRawPath, out _)) return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("Sql TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
DescribeRejection(root, warnings);
}
catch (JsonException)
{
warnings.Add("Sql TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
// Backstop: the parser refused for a reason nothing above names (it also swallows FormatException).
// Saying so is worth more than reporting a clean blob the driver will silently skip.
if (warnings.Count == 0)
{
warnings.Add(
"Sql TagConfig was rejected by the driver's tag parser — the driver will skip this tag and it "
+ "will read BadNodeIdUnknown.");
}
return warnings;
}
/// <summary>
/// Explains why <see cref="SqlEquipmentTagParser.TryParse"/> refused an object-rooted blob, walking
/// its checks in the same order. Every branch here mirrors a <c>return false</c> in the parser — this
/// runs only after the parser has already refused, so it can never invent strictness of its own.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="warnings">The list to append warnings to.</param>
private static void DescribeRejection(JsonElement root, List<string> warnings)
{
// Recognition (parser: a "driver":"Sql" marker or a "model" discriminator).
var hasModel = root.TryGetProperty("model", out _);
var driver = ReadString(root, "driver");
if (!hasModel && !string.Equals(driver, "Sql", StringComparison.OrdinalIgnoreCase))
{
warnings.Add(
"Sql TagConfig carries neither a 'model' field nor 'driver':'Sql', so the driver does not "
+ "recognise it as a Sql tag and will skip it (BadNodeIdUnknown).");
return;
}
// Strict enums: a typo'd value rejects the whole tag rather than silently defaulting.
var modelWarning = TagConfigJson.DescribeInvalidEnum<SqlTagModel>(root, "model");
if (modelWarning is not null) warnings.Add(modelWarning);
var typeWarning = TagConfigJson.DescribeInvalidEnum<DriverDataType>(root, "type");
if (typeWarning is not null) warnings.Add(typeWarning);
if (modelWarning is not null) return; // the model is unknown; the per-model checks cannot run
var model = TagConfigJson.ReadEnumOrDefault(root, "model", SqlTagModel.KeyValue);
if (model == SqlTagModel.Query)
{
warnings.Add(
"Sql tag model 'Query' is deferred (design §5.4 / P3) and is not served — the driver will "
+ "skip this tag.");
return;
}
if (string.IsNullOrWhiteSpace(ReadString(root, "table")))
warnings.Add("Sql TagConfig has no 'table' — every Sql tag model requires one.");
switch (model)
{
case SqlTagModel.KeyValue:
if (string.IsNullOrWhiteSpace(ReadString(root, "keyColumn")))
warnings.Add("Sql KeyValue tag has no 'keyColumn'.");
if (string.IsNullOrWhiteSpace(ReadString(root, "valueColumn")))
warnings.Add("Sql KeyValue tag has no 'valueColumn'.");
if (ReadString(root, "keyValue") is null)
warnings.Add("Sql KeyValue tag has no 'keyValue' (an empty string is allowed; an absent key is not).");
break;
case SqlTagModel.WideRow:
if (string.IsNullOrWhiteSpace(ReadString(root, "columnName")))
warnings.Add("Sql WideRow tag has no 'columnName'.");
if (!HasRowSelector(root))
{
warnings.Add(
"Sql WideRow tag has no usable 'rowSelector' — it needs a 'whereColumn'/'whereValue' "
+ "pair or a 'topByTimestamp' column, or the driver cannot tell which row to read.");
}
break;
default:
break;
}
}
/// <summary>
/// Whether a WideRow blob names a row the driver can select, mirroring the parser's
/// <c>hasWherePair || topByTimestamp</c> test (a where-pair needs BOTH keys, though
/// <c>whereValue</c> may legitimately be an empty string).
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <returns><see langword="true"/> when the blob names a selectable row.</returns>
private static bool HasRowSelector(JsonElement root)
{
if (!root.TryGetProperty("rowSelector", out var sel) || sel.ValueKind != JsonValueKind.Object)
return false;
var hasWherePair = !string.IsNullOrWhiteSpace(ReadString(sel, "whereColumn"))
&& HasScalar(sel, "whereValue");
return hasWherePair || !string.IsNullOrWhiteSpace(ReadString(sel, "topByTimestamp"));
}
/// <summary>Reads a string-valued property; null when absent or not a JSON string (as the parser does).</summary>
/// <param name="o">The object to read from.</param>
/// <param name="name">The property name.</param>
/// <returns>The string value, or null.</returns>
private static string? ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
/// <summary>Whether a property is present as a scalar the parser's <c>ReadScalar</c> would capture.</summary>
/// <param name="o">The object to read from.</param>
/// <param name="name">The property name.</param>
/// <returns><see langword="true"/> when the property is a string, number or boolean.</returns>
private static bool HasScalar(JsonElement o, string name)
=> o.TryGetProperty(name, out var e)
&& e.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False;
}
@@ -109,16 +109,40 @@ public static class TwinCATTagDefinitionFactory
}
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// The RawPath handed to <see cref="FromTagConfig"/> during inspection — the mapper only copies it
/// onto <see cref="TwinCATTagDefinition.Name"/> and never validates it.
/// </summary>
private const string InspectionRawPath = "(inspect)";
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns for a blob <see cref="FromTagConfig"/> would
/// <b>reject</b> — a structurally unparseable or non-object document, an absent / non-string /
/// blank <c>symbolPath</c>, a present-but-invalid <c>dataType</c> (the runtime enum read is STRICT
/// since R2-11 Phase C, so a typo rejects the tag), or the unsupported
/// <see cref="TwinCATDataType.Structure"/> type. Such a tag is skipped by the driver and reads
/// <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts the blob, when it is blank, or
/// when it is not an equipment-tag object. Never throws.
/// </summary>
/// <remarks>
/// <b>The verdict comes from <see cref="FromTagConfig"/>, not from a mirror</b> (Gitea #551) — see
/// <c>ModbusTagDefinitionFactory.Inspect</c>'s remarks for the full argument.
/// </remarks>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
// drawing nothing.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') return warnings;
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
@@ -128,16 +152,56 @@ public static class TwinCATTagDefinitionFactory
warnings.Add("TwinCAT TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<TwinCATDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
DescribeRejection(root, warnings);
}
catch (JsonException)
{
warnings.Add("TwinCAT TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
if (warnings.Count == 0)
{
warnings.Add(
"TwinCAT TagConfig was rejected by the driver's tag reader — the driver will skip this tag "
+ "and it will read BadNodeIdUnknown.");
}
return warnings;
}
/// <summary>
/// Explains why <see cref="FromTagConfig"/> refused an object-rooted blob, walking its checks in the
/// same order. Runs only after the mapper has already refused, so it can never invent strictness.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="warnings">The list to append warnings to.</param>
private static void DescribeRejection(JsonElement root, List<string> warnings)
{
if (!root.TryGetProperty("symbolPath", out var symbol) || symbol.ValueKind != JsonValueKind.String)
{
warnings.Add(
"TwinCAT TagConfig has no string 'symbolPath' (e.g. MAIN.bStart) — the driver will skip "
+ "this tag and it will read BadNodeIdUnknown.");
}
else if (string.IsNullOrWhiteSpace(symbol.GetString()))
{
warnings.Add("TwinCAT TagConfig has a blank 'symbolPath' — the tag will not resolve (BadNodeIdUnknown).");
}
if (TagConfigJson.DescribeInvalidEnum<TwinCATDataType>(root, "dataType") is { } w) warnings.Add(w);
if (TagConfigJson.TryReadEnumStrict(root, "dataType", TwinCATDataType.DInt, out var dataType)
&& dataType == TwinCATDataType.Structure)
{
warnings.Add(
"TwinCAT 'dataType' is Structure — the driver does not support UDT / FB-instance tags and "
+ "rejects them (BadNodeIdUnknown). Declare the atomic-typed members instead, or use "
+ "EnableControllerBrowse to discover them individually.");
}
}
/// <summary>
/// Reads the optional 1-D array length: <c>arrayLength</c> (a positive int32; values above
/// <see cref="int.MaxValue"/> are treated as absent/scalar) honoured ONLY when <c>isArray</c>
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
public sealed class ResilienceFormModel
{
public static readonly string[] Capabilities =
["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead"];
["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead", "Initialize"];
// RecycleIntervalSeconds is GONE (Gitea #522) — the scheduled-recycle machinery it authored was
// deleted, so the field configured nothing. It is deliberately NOT explicitly stripped from stored
@@ -279,6 +279,15 @@
[Parameter] public EventCallback OnSaved { get; set; }
private bool _open; // guards re-seeding on unrelated re-renders
/// <summary>
/// Identifies WHICH open a post-await write belongs to. <see cref="_open"/> is a bool and cannot
/// tell two opens apart, and Blazor does not serialize <c>OnParametersSetAsync</c> against its own
/// outstanding task — so a close-reopen onto another device overlaps the first open's resolve, and
/// last-completion-wins would point "Connect &amp; browse" at a controller nobody is looking at.
/// </summary>
private int _openGeneration;
private bool _resolving;
private bool _canBrowse;
private string? _disabledReason;
@@ -348,6 +357,8 @@
private async Task ResetAndResolveAsync()
{
var generation = ++_openGeneration;
_token = Guid.Empty;
_opening = false;
_openError = null;
@@ -374,6 +385,10 @@
// Merged Driver+Device config — endpoint lives in DeviceConfig in v3, so the browser must dial
// the merged blob (same merge the driver-probe/test-connect path uses).
var merged = await Svc.LoadMergedProbeConfigAsync(DeviceId);
// A superseded open must never install its device under the displayed modal — see
// _openGeneration. Every post-await write below is behind this check.
if (IsStale(generation)) { return; }
if (merged is { } m)
{
_driverType = m.DriverType;
@@ -384,6 +399,8 @@
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
if (IsStale(generation)) { return; } // same race, second await: this one retargets the commit
if (ctx is { } c)
{
_groupPrefix = c.GroupPath;
@@ -401,16 +418,37 @@
}
catch (Exception ex)
{
_canBrowse = false;
_disabledReason = $"Could not load device config: {ex.Message}";
// A superseded open that FAILS is the same defect wearing the catch block: its diagnostic
// names a device the operator is no longer looking at, and it would disable browsing for the
// one they are.
if (!IsStale(generation))
{
_canBrowse = false;
_disabledReason = $"Could not load device config: {ex.Message}";
}
}
finally
{
_resolving = false;
StateHasChanged();
// The tail state belongs to an open too: `return`ing out of the try above still runs this, so
// an unguarded finally would drop the LIVE open's spinner mid-resolve and render the
// half-reset panel underneath it.
if (!IsStale(generation))
{
_resolving = false;
StateHasChanged();
}
}
}
/// <summary>
/// Whether the open that captured <paramref name="generation"/> has been superseded — by a later
/// open, or by the modal being closed outright (a close raises no new generation, so
/// <see cref="Visible"/> is the half that catches it).
/// </summary>
/// <param name="generation">The <see cref="_openGeneration"/> value captured when the open started.</param>
/// <returns><see langword="true"/> when no state may be written for that open.</returns>
private bool IsStale(int generation) => generation != _openGeneration || !Visible;
private async Task OpenBrowseAsync()
{
_opening = true;
@@ -267,9 +267,16 @@ public static class RawBrowseCommitMapper
return BuildSqlTagConfig(address, addressFields);
// Unknown/flat-address driver (e.g. Modbus is not browsable): record the reference under a generic
// "address" key so nothing is lost. Every BROWSABLE driver is handled above — guarded by
// RawBrowseCommitMapperParityTests, which enumerates DriverTypeNames.All rather than trusting
// this comment.
// "address" key so nothing is lost. RawBrowseCommitMapperParityTests enumerates DriverTypeNames.All
// and holds this line to two claims rather than to the assertion the comment used to make:
// - a driver type with a bespoke IDriverBrowser registered by AddAdminUI() — read back from the
// real registration, not a list — must NOT reach here; and
// - any type that does reach here must be named in that test's GenericKeyExemptions with a reason,
// and every name on that list must still reach here (Gitea #558) — an exemption for a driver
// that has since gained a branch above is a stale claim, and a silently inert one.
// The universal DiscoveryDriverBrowser's arm is not derivable from the AdminUI assembly (it needs
// the runtime drivers + the Host's IDriverFactory), so it is covered by the exemption list rather
// than mechanically — see the test's remarks for the residual trust that leaves (Gitea #555).
return WriteSingleKey("address", address);
}
@@ -341,18 +348,43 @@ public static class RawBrowseCommitMapper
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <returns>The operator-facing reason, or <c>null</c> when the leaf is committable.</returns>
/// <remarks>
/// Only MQTT can fail this today, and only in one shape: a leaf whose attribute lookup returned
/// nothing, so no address was stated. Committing it anyway is the defect this whole seam exists
/// to close — a Sparkplug tuple cannot be reconstructed from the node id, so the row would deploy
/// clean and then report <c>BadNodeIdUnknown</c> forever with nothing to point at. Every
/// single-reference driver is unaffected: its address IS the node id.
/// <para>
/// Exactly two drivers can fail this, and both for the same underlying reason: their binding is a
/// TUPLE, so a leaf can be selectable in the tree and still not carry enough to bind. Every
/// single-reference driver is unaffected — its address IS the node id.
/// </para>
/// <para>
/// <b>MQTT</b>: a leaf whose attribute lookup returned nothing, so no address was stated. Committing
/// it anyway is the defect this whole seam exists to close — a Sparkplug tuple cannot be
/// reconstructed from the node id, so the row would deploy clean and then report
/// <c>BadNodeIdUnknown</c> forever with nothing to point at.
/// </para>
/// <para>
/// <b>Sql</b> (Gitea #552): a column of a table carrying no date/time column. A wide-row tag must say
/// which ROW it reads, and with no timestamp to order by the only remaining selector is a
/// <c>whereColumn</c>/<c>whereValue</c> pair — whose value is <em>data</em>, not schema, and therefore
/// is not something any browse can know. Refusing here rather than letting the import's own gate
/// refuse the blob is what makes the message name the table and the fix; the gate would only report
/// that a row selector is missing, on every selected column at once.
/// </para>
/// </remarks>
public static string? DescribeUncommittableLeaf(
string driverType,
string browseName,
IReadOnlyDictionary<string, string>? addressFields)
{
if (!Is(driverType, DriverTypeNames.Mqtt)) return null;
if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields);
if (Is(driverType, DriverTypeNames.Sql)) return DescribeUncommittableSqlLeaf(browseName, addressFields);
return null;
}
/// <summary>Describes an MQTT leaf that states no address at all, or <c>null</c> when it states one.</summary>
/// <param name="browseName">The leaf's browse name, for the message.</param>
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <returns>The operator-facing reason, or <c>null</c>.</returns>
private static string? DescribeUncommittableMqttLeaf(
string browseName, IReadOnlyDictionary<string, string>? addressFields)
{
if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is not null) return null;
if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null;
@@ -361,6 +393,31 @@ public static class RawBrowseCommitMapper
+ "the tag manually.";
}
/// <summary>
/// Describes a Sql column leaf whose wide-row binding cannot be completed from the catalog, or
/// <c>null</c> when it can (Gitea #552).
/// </summary>
/// <param name="browseName">The leaf's browse name, for the message.</param>
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <returns>The operator-facing reason, or <c>null</c>.</returns>
private static string? DescribeUncommittableSqlLeaf(
string browseName, IReadOnlyDictionary<string, string>? addressFields)
{
if (TryGetField(addressFields, SqlBrowseAddressFields.Table) is not { } table)
{
return $"'{browseName}' could not be committed: the browse session reported no Sql table for it "
+ "(its attribute lookup returned nothing). Re-open the browser and re-select it, or author "
+ "the tag manually.";
}
if (TryGetField(addressFields, SqlBrowseAddressFields.TopByTimestamp) is not null) return null;
return $"'{browseName}' could not be committed: '{table}' has no date/time column, so which ROW the "
+ "tag reads cannot be derived from the schema. Author it in the tag editor with a "
+ "whereColumn/whereValue row selector — the value it matches on is data, not schema, so the "
+ "browser cannot supply it.";
}
/// <summary>
/// Reads the browse leaf's driver-native data type out of the stated address fields, falling
/// back to <paramref name="fallback"/> — the driver model's own default — when the browser
@@ -438,17 +495,49 @@ public static class RawBrowseCommitMapper
/// <para>Falls back to the generic key when the fields are absent — an older browser build, or a
/// hand-made selection — rather than emitting a half-built Sql blob.</para>
/// </summary>
/// <param name="address">The leaf's browse node id — the column-name fallback only.</param>
/// <param name="addressFields">The structured address the browse session stated.</param>
/// <returns>The serialised <c>TagConfig</c> JSON.</returns>
/// <remarks>
/// <para>
/// <b>A wide-row tag needs three things, not two: the table, the column, and WHICH ROW</b>
/// (Gitea #552). This branch used to emit only the first two, and a selector-less wide-row blob is
/// refused by <c>SqlEquipmentTagParser</c> and by <c>SqlTagConfigModel.Validate</c> alike — so every
/// browse-committed Sql tag was unreadable, first silently (<c>BadNodeIdUnknown</c> forever) and then,
/// once #551 put the gate on the import, loudly at commit.
/// </para>
/// <para>
/// <b>The row selector is READ from the session's stated fields, never inferred here</b> — same rule
/// as MQTT's Sparkplug tuple. The session holds the table's column metadata and names a date/time
/// column by TYPE (see <c>SqlBrowseSession.PreferredTimestampColumn</c>); this mapper has only a
/// column name and a table name, from which any selector it invented would be a guess about data.
/// </para>
/// <para>
/// <b>When no candidate was stated the blob stays selector-less on purpose.</b> The alternatives are
/// worse: a fabricated selector binds the tag to a row nobody chose, and dropping to the generic
/// <c>{"address": …}</c> key throws away the table the session did know. Kept in the wide-row shape,
/// the blob opens in the typed editor with everything but the selector filled in — and the commit
/// itself does not reach here, because <see cref="DescribeUncommittableLeaf"/> refuses that leaf in
/// words first.
/// </para>
/// <para>
/// The blob's top-level <c>timestampColumn</c> (the SOURCE-timestamp column, which makes the driver
/// stamp values with the row's time instead of the poll time) is deliberately left unset: ordering by
/// a column is not a claim that it timestamps the value, and turning it on silently would change every
/// browse-committed tag's published SourceTimestamp.
/// </para>
/// </remarks>
private static string BuildSqlTagConfig(string address, IReadOnlyDictionary<string, string>? addressFields)
{
if (addressFields is null
|| !addressFields.TryGetValue("table", out var table)
|| !addressFields.TryGetValue(SqlBrowseAddressFields.Table, out var table)
|| string.IsNullOrWhiteSpace(table))
{
return WriteSingleKey("address", address);
}
addressFields.TryGetValue("schema", out var schema);
addressFields.TryGetValue("columnName", out var columnName);
addressFields.TryGetValue(SqlBrowseAddressFields.Schema, out var schema);
addressFields.TryGetValue(SqlBrowseAddressFields.ColumnName, out var columnName);
// Qualify the table with its schema when the schema is not the default, so a tag authored against
// "sales.Readings" cannot silently resolve to "dbo.Readings".
@@ -461,6 +550,13 @@ public static class RawBrowseCommitMapper
Model = SqlTagModel.WideRow,
Table = qualified,
ColumnName = string.IsNullOrWhiteSpace(columnName) ? address : columnName,
// Null when the session stated none: SqlRowSelectorModel.ToJsonObject then returns null and the
// "rowSelector" key is omitted entirely, rather than written as an empty or blank-valued object.
RowSelector = new SqlRowSelectorModel
{
TopByTimestamp = TryGetField(addressFields, SqlBrowseAddressFields.TopByTimestamp),
},
}.ToJson();
}
@@ -19,6 +19,23 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// columns. A typed column therefore <b>wins</b> over a conflicting key carried in the <c>TagConfigJson</c>
/// fallback; the review model flags when that override happened so the operator sees it.
/// </para>
/// <para>
/// The assembled blob is then put to the driver's own <see cref="TagConfigValidator"/> (Gitea #551): the
/// typed columns were always strictly parsed, but the free-form <c>TagConfigJson</c> column reached the
/// database unread, so a CSV could author a tag the driver rejects — one that deploys clean and reads
/// <c>BadNodeIdUnknown</c> forever. A failing row is an <b>error</b>, blocking the all-or-nothing commit;
/// it is never silently skipped. This check is for the operator's benefit (it names the row before
/// commit); the guarantee lives in <see cref="IRawTreeService.ImportTagsAsync"/>, which re-runs it.
/// </para>
/// <para>
/// For that gate to mean anything the merge must hand it the row <b>as written</b>, which it did not
/// (Gitea #553): the model-backed maps round-tripped the free-form cell through a driver model's lenient
/// <c>FromJson</c>/<c>ToJson</c>, so a typo'd value under a key the model owns was rewritten to that model's
/// default before any gate ran — the row passed, and the tag the operator actually described was gone.
/// <see cref="CsvDriverTagMap.ApplyTypedColumns"/> now preserves the cell's own tokens for every key no
/// non-blank typed column overrode, and clears a column's out-ranking aliases when one does. The verdict on
/// those tokens is still the gate's alone — nothing about which values are legal is restated here.
/// </para>
/// </summary>
public static class RawTagCsvMapper
{
@@ -84,7 +101,7 @@ public static class RawTagCsvMapper
continue;
}
reviewed.Add(BuildRow(r, headers, headerIndex, cells, map, groupPathPrefix));
reviewed.Add(BuildRow(r, headers, headerIndex, cells, driverType, map, groupPathPrefix));
}
FlagDuplicates(reviewed);
@@ -96,6 +113,7 @@ public static class RawTagCsvMapper
string[] headers,
IReadOnlyDictionary<string, int> headerIndex,
string[] cells,
string driverType,
CsvDriverTagMap? map,
string? groupPathPrefix)
{
@@ -153,7 +171,13 @@ public static class RawTagCsvMapper
if (row.TryGetValue(col.Header, out var cv) && !string.IsNullOrWhiteSpace(cv))
{
typedUsed.Add(col.Header);
if (baseKeys.Contains(col.JsonKey))
// Every spelling the column speaks for, not just the one it writes: an MTConnect cell
// carrying "driverDataType" is overridden by the MtDataType column exactly as a
// "dataType" cell is, and the operator must see that in the grid (Gitea #553). This
// report is also what makes accepting the override — rather than erroring on the
// contradiction — an open decision instead of a silent one.
if (col.OwnedJsonKeys.Any(baseKeys.Contains))
{
overrodeFallback.Add(col.Header);
}
@@ -193,6 +217,19 @@ public static class RawTagCsvMapper
// Alarm flags — an alarm is authored iff Alarm.AlarmType is present.
json = ApplyAlarm(json, Cell("Alarm.AlarmType"), Cell("Alarm.Severity"), Cell("Alarm.HistorizeToAveva"));
// The driver-typed config gate, on the FINAL assembled blob (Gitea #551 item 1). Until this,
// the typed columns above were strictly parsed but the free-form TagConfigJson column was not
// checked at all, so a CSV could import a blob the driver rejects — a tag that deploys clean
// and then reads BadNodeIdUnknown forever. Surfacing it here, on the staged review row, is what
// lets the operator SEE which row is wrong before committing; IRawTreeService.ImportTagsAsync
// re-checks it as the actual guarantee (a form/grid cannot cover the API or a future caller).
// A rejected row is an ERROR, never a skip: the commit is all-or-nothing, and "skipped" would
// claim the tag is already present.
if (TagConfigValidator.Validate(driverType, json) is { } configError)
{
return Invalid(rowNumber, cells, $"TagConfig: {configError}");
}
var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json));
return new RawCsvReviewRow
@@ -1,5 +1,6 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
@@ -834,6 +835,9 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
t => t.DeviceId == deviceId && t.TagGroupId == tagGroupId && t.Name == input.Name, ct))
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here.");
if (await ValidateTagConfigAsync(db, deviceId, tagConfig, ct) is { } configError)
return new UnsMutationResult(false, configError);
var id = NewId("TAG");
db.Tags.Add(new Tag
{
@@ -872,6 +876,9 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
&& t.Name == input.Name && t.TagId != tagId, ct))
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here.");
if (await ValidateTagConfigAsync(db, entity.DeviceId, tagConfig, ct) is { } configError)
return new UnsMutationResult(false, configError);
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
entity.Name = input.Name;
entity.DataType = input.DataType;
@@ -902,6 +909,10 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct))
return new RawTagImportResult(0, new[] { $"Device '{deviceId}' not found." });
// The device's driver type, resolved ONCE: an import is per-device, and a device hangs off exactly
// one driver, so every row in the batch is judged by the same driver's rules (Gitea #551 item 1).
var driverType = await ResolveDriverTypeAsync(db, deviceId, ct);
// Existing groups on the device: a (parent-group-id | null, name) → group-id lookup we extend with
// pending (auto-created) groups so a repeated path within the batch resolves to one group.
var groupLookup = (await db.TagGroups.AsNoTracking()
@@ -951,6 +962,15 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
continue;
}
// The driver-typed config gate. A rejected row is an ERROR — the import is all-or-nothing, so
// the batch inserts nothing and the row is NAMED; it is never skipped ("skipped" would claim
// the tag is already present). See ValidateTagConfigAsync for why this lives in the service.
if (TagConfigValidator.Validate(driverType, tagConfig) is { } configError)
{
errors.Add($"{rowLabel}: TagConfig: {configError}");
continue;
}
if (!takenNames.Add((groupId, row.Tag.Name)))
{
errors.Add($"{rowLabel}: a tag named '{row.Tag.Name}' already exists at that path.");
@@ -1196,6 +1216,55 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
}
/// <summary>
/// The driver-type string of the driver a device hangs off, or <see langword="null"/> when the
/// device (or its driver) is gone. Joined in exactly as <c>LoadDeviceForEditAsync</c> does — a
/// device carries no driver type of its own.
/// </summary>
/// <param name="db">The open context.</param>
/// <param name="deviceId">The device whose driver type to resolve.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The canonical driver-type string, or null.</returns>
private static Task<string?> ResolveDriverTypeAsync(
OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct)
=> db.Devices.AsNoTracking()
.Where(dev => dev.DeviceId == deviceId)
.Join(db.DriverInstances.AsNoTracking(),
dev => dev.DriverInstanceId,
drv => drv.DriverInstanceId,
(dev, drv) => drv.DriverType)
.FirstOrDefaultAsync(ct);
/// <summary>
/// The driver-typed <c>TagConfig</c> gate, applied on the SERVER for every tag-writing path —
/// create, update and bulk import alike (Gitea #551 item 1).
/// <para>
/// <b>It lives here rather than in the modals because a form cannot be the guarantee.</b> The
/// tag modal and the manual-entry grid already run <see cref="TagConfigValidator"/> for fast
/// feedback, but the CSV import did not, and the API and any future caller reach none of them —
/// so a blob the driver rejects could be persisted, deploy clean, and read
/// <c>BadNodeIdUnknown</c> forever. This repo also has no bUnit, so a rule only a page enforces
/// is a rule no test can reach.
/// </para>
/// <para>
/// The rule itself is not stated here: <see cref="TagConfigValidator"/> dispatches to the
/// driver's typed model, which in turn asks the driver's own reader. An unmapped driver type
/// (Galaxy) validates to null, exactly as it does in the modals.
/// </para>
/// </summary>
/// <param name="db">The open context.</param>
/// <param name="deviceId">The device the tag belongs to (supplies the driver type).</param>
/// <param name="tagConfig">The normalised TagConfig JSON about to be persisted.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>An operator-facing error, or <see langword="null"/> when the config is acceptable.</returns>
private static async Task<string?> ValidateTagConfigAsync(
OtOpcUaConfigDbContext db, string deviceId, string tagConfig, CancellationToken ct)
{
var driverType = await ResolveDriverTypeAsync(db, deviceId, ct);
var error = TagConfigValidator.Validate(driverType, tagConfig);
return error is null ? null : $"TagConfig: {error}";
}
/// <summary>Ordinal (case-sensitive) equality for a nullable-parent + name tag-group key.</summary>
private sealed class GroupKeyComparer : IEqualityComparer<(string? Parent, string Name)>
{
@@ -22,6 +22,10 @@ public sealed class AbCipTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The TagConfig JSON string to parse, or <c>null</c>/empty for a new tag.</param>
@@ -35,6 +39,7 @@ public sealed class AbCipTagConfigModel
TagPath = TagConfigJson.GetString(o, "tagPath") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", AbCipDataType.DInt),
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_source = json ?? "",
_bag = o,
};
}
@@ -48,11 +53,46 @@ public sealed class AbCipTagConfigModel
TagConfigJson.Set(_bag, "tagPath", TagPath.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <remarks>
/// The <c>dataType</c> check reads the ORIGINAL key bag rather than the leniently-defaulted
/// <see cref="DataType"/>, because <c>AbCipTagDefinitionFactory</c> reads it with
/// <c>TryReadEnumStrict</c> — a present-but-invalid string rejects the whole tag at the driver
/// (<c>BadNodeIdUnknown</c>). Without this the blob would be accepted here, silently rewritten to
/// <c>DInt</c> on save, and — on the CSV-import and API paths, which save it verbatim — deployed
/// into a tag the driver skips (Gitea #549).
/// <para>
/// <b>Declared UDT members are checked too (Gitea #551), and the rule is not restated here.</b>
/// The member rules are as strict as the top-level ones — one member with no <c>name</c> or a
/// typo'd <c>dataType</c> rejects the WHOLE tag — but nothing on the authoring side saw them:
/// this editor emits only <c>tagPath</c>/<c>dataType</c>/<c>writable</c>, so <c>members</c>
/// rides <see cref="_bag"/> untouched from whatever hand-authored / CSV / API blob produced it.
/// The verdict AND the wording come from
/// <see cref="AbCipTagDefinitionFactory.DescribeInvalidMember(string)"/>, the same authority the
/// driver's reader and the deploy gate consult, so this gate cannot drift from the runtime in
/// either direction.
/// </para>
/// </remarks>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(TagPath) ? "Tag path is required." : null;
{
if (TagConfigJson.DescribeInvalidEnum<AbCipDataType>(_bag, "dataType") is { } dtError) { return dtError; }
if (string.IsNullOrWhiteSpace(TagPath)) { return "Tag path is required."; }
if (AbCipTagDefinitionFactory.DescribeInvalidMember(TagConfigJson.Serialize(_bag)) is { } memberError)
{
return memberError;
}
// Gitea #554 backstop. The three checks above already mirror the whole of the reader's rejection
// surface, so this is expected to be silent for AbCip — it is here so a future reader rule cannot
// become a gap without anyone noticing, which is the failure #549/#551 kept repeating.
return TagConfigParseGate.Describe(
_source,
j => AbCipTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
AbCipTagDefinitionFactory.Inspect,
"AbCip");
}
}
@@ -22,6 +22,10 @@ public sealed class AbLegacyTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The TagConfig JSON string to parse, or <c>null</c>/empty for a new tag.</param>
@@ -35,6 +39,7 @@ public sealed class AbLegacyTagConfigModel
Address = TagConfigJson.GetString(o, "address") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", AbLegacyDataType.Int),
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_source = json ?? "",
_bag = o,
};
}
@@ -48,25 +53,44 @@ public sealed class AbLegacyTagConfigModel
TagConfigJson.Set(_bag, "address", Address.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
{
// Strict enum, read from the ORIGINAL key bag: AbLegacyTagDefinitionFactory reads "dataType" with
// TryReadEnumStrict, so a present-but-invalid string rejects the whole tag at the driver
// (BadNodeIdUnknown) while the lenient load above would default it to Int. Accepting it here would
// let the CSV-import and API paths — which save the blob verbatim — deploy an unreadable tag
// (Gitea #549).
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(_bag, "dataType") is { } dtError)
return dtError;
if (string.IsNullOrWhiteSpace(Address))
return "Address is required.";
// Array-length cap: PCCC data-file elements are limited to AbLegacyArray.MaxElements (256).
// The driver-agnostic materialiser uses the raw arrayLength to set OPC UA ArrayDimensions, but the
// driver clamps to 256 at runtime — so an authored length > 256 produces a Part 6 §5.3.2 mismatch.
// Reject it here at author-time so the error surfaces in the TagModal before the config is saved.
// DELIBERATELY STRICTER THAN THE RUNTIME, and re-confirmed as such by the Gitea #554 survey — do not
// "align" it. PCCC data-file elements are limited to AbLegacyArray.MaxElements (256). The reader
// CLAMPS an authored length above that (Math.Min) and serves the tag, but the driver-agnostic
// materialiser sets OPC UA ArrayDimensions from the RAW authored length, so the served array is
// narrower than the node advertises — a Part 6 §5.3.2 mismatch, and the authored number is silently
// not honoured. Refusing at author-time, where the operator can still fix the number, is the honest
// treatment; the deploy gate deliberately stays silent about it (see AbLegacyTagDefinitionFactory
// .Inspect) because a warning there can be promoted to a blocking error against a tag the runtime
// does serve.
var isArray = TagConfigJson.GetBool(_bag, "isArray");
var arrayLength = TagConfigJson.GetInt(_bag, "arrayLength");
if (isArray && arrayLength > AbLegacyArray.MaxElements)
return $"AbLegacy array length cannot exceed {AbLegacyArray.MaxElements} (PCCC data-file element limit).";
return null;
// Gitea #554 backstop: the checks above mirror the reader's whole rejection surface today, so this is
// expected to be silent for AbLegacy. It is here so a future reader rule cannot become a gap.
return TagConfigParseGate.Describe(
_source,
j => AbLegacyTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
AbLegacyTagDefinitionFactory.Inspect,
"AbLegacy");
}
}
@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -21,6 +22,10 @@ public sealed class CalculationTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
/// original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
@@ -38,6 +43,7 @@ public sealed class CalculationTagConfigModel
ScriptId = TagConfigJson.GetString(o, "scriptId") ?? "",
ChangeTriggered = TagConfigJson.GetBool(o, "changeTriggered", true),
TimerIntervalMs = timer,
_source = json ?? "",
_bag = o,
};
}
@@ -51,11 +57,19 @@ public sealed class CalculationTagConfigModel
TagConfigJson.Set(_bag, "scriptId", string.IsNullOrWhiteSpace(ScriptId) ? null : ScriptId);
TagConfigJson.Set(_bag, "changeTriggered", ChangeTriggered);
TagConfigJson.Set(_bag, "timerIntervalMs", TimerIntervalMs);
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validates the calc binding: a script is required, at least one trigger (change or timer)
/// must be set, and a set timer must be ≥ 50 ms.</summary>
/// <remarks>
/// The trigger and 50 ms rules are <b>DELIBERATELY STRICTER THAN THE RUNTIME</b>, which serves a
/// trigger-less tag (it simply never computes) and honours a 1 ms cadence. The Gitea #554 survey
/// re-confirmed both and left them alone — they are documented on
/// <c>CalculationTagDefinitionFactory</c> as the reason its deploy gate must NOT restate them, since a
/// deploy warning can be promoted to a blocking error against a config the driver serves. The parse
/// consult below is additive and cannot weaken them.
/// </remarks>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
{
@@ -71,6 +85,15 @@ public sealed class CalculationTagConfigModel
{
return "Timer interval must be at least 50 ms.";
}
return null;
// Gitea #554 backstop — expected silent for Calculation, whose reader refuses only a blob with no
// usable 'scriptId'. ⚠️ It gates on the PARSE VERDICT: this driver's Inspect emits advisories for
// blobs the reader ACCEPTS (a non-boolean 'changeTriggered', a discarded 'timerIntervalMs'), so a
// non-empty-inspection gate would refuse configs the runtime serves.
return TagConfigParseGate.Describe(
_source,
j => CalculationTagDefinitionFactory.TryReadTagConfig(j, out _),
CalculationTagDefinitionFactory.Inspect,
"Calculation");
}
}
@@ -19,9 +19,35 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// a header directly onto a JSON key.
/// </summary>
/// <param name="Header">The CSV column header (PascalCase, e.g. <c>ModbusDataType</c>).</param>
/// <param name="JsonKey">The root TagConfig JSON key this column reads/writes (camelCase, e.g. <c>dataType</c>).</param>
/// <param name="JsonKey">The root TagConfig JSON key this column <b>writes</b> (camelCase, e.g. <c>dataType</c>).</param>
/// <param name="ModelProperty">The backing model property name, or <c>null</c> for a model-less map.</param>
public sealed record CsvTypedColumn(string Header, string JsonKey, string? ModelProperty);
/// <param name="ReadKeys">
/// The full ordered set of root JSON keys the <b>runtime</b> reads for this column's value, most-preferred
/// first, or <c>null</c> when the column owns only <paramref name="JsonKey"/>. Supplied for the drivers
/// whose blob accepts more than one spelling of a field (MTConnect's <c>driverDataType</c>/<c>dataType</c>
/// and <c>fullName</c>/<c>dataItemId</c>/<c>address</c>) — see <see cref="OwnedJsonKeys"/> for why the CSV
/// path has to know the whole set and not just the one it writes.
/// </param>
public sealed record CsvTypedColumn(
string Header,
string JsonKey,
string? ModelProperty,
IReadOnlyList<string>? ReadKeys = null)
{
/// <summary>
/// Every root JSON key this column speaks for, in the <b>runtime's</b> read precedence — i.e.
/// <see cref="ReadKeys"/> when supplied, else just <see cref="JsonKey"/>.
/// <para>
/// A column that writes <c>dataType</c> while the driver prefers a <c>driverDataType</c> alias does
/// not actually control the tag's type: the alias, carried through in the free-form
/// <c>TagConfigJson</c> cell, out-ranks it at the driver, so an operator's edit of the typed column
/// is accepted and then silently ignored (Gitea #553). Knowing the whole set is what lets the
/// export residual drop the alias, the export cell show the <i>effective</i> value, and a firing
/// column clear the alias so it genuinely wins.
/// </para>
/// </summary>
public IReadOnlyList<string> OwnedJsonKeys { get; } = ReadKeys is { Count: > 0 } ? ReadKeys : [JsonKey];
}
/// <summary>
/// Per-driver mapping between a CSV row's <b>typed</b> columns and a tag's driver-specific
@@ -46,8 +72,50 @@ public abstract class CsvDriverTagMap
/// <summary>The typed column headers, in order.</summary>
public IReadOnlyList<string> Headers => Columns.Select(c => c.Header).ToArray();
/// <summary>The root TagConfig JSON keys this map owns (used to compute the export residual).</summary>
public IReadOnlyList<string> JsonKeys => Columns.Select(c => c.JsonKey).ToArray();
/// <summary>The root TagConfig JSON keys this map owns (used to compute the export residual) — every
/// <see cref="CsvTypedColumn.OwnedJsonKeys"/> entry, aliases included, so the residual can never carry a
/// spelling that out-ranks the typed column beside it (Gitea #553).</summary>
public IReadOnlyList<string> JsonKeys => Columns.SelectMany(c => c.OwnedJsonKeys).Distinct(StringComparer.Ordinal).ToArray();
/// <summary>
/// Applies one typed column's already-parsed effect to <paramref name="target"/>: removes every
/// <b>other</b> spelling the runtime would read for that column, so the value the column supplied is
/// the one the driver actually sees. Callers write the column's own <see cref="CsvTypedColumn.JsonKey"/>
/// themselves (model-backed maps do it through the model). Gitea #553.
/// </summary>
/// <param name="target">The merged TagConfig object being assembled.</param>
/// <param name="column">The typed column that supplied a value on this row.</param>
protected static void ClearOutrankingAliases(JsonObject target, CsvTypedColumn column)
{
foreach (var key in column.OwnedJsonKeys)
{
if (!string.Equals(key, column.JsonKey, StringComparison.Ordinal))
{
target.Remove(key);
}
}
}
/// <summary>
/// Reads one typed column's <b>effective</b> cell value out of a stored TagConfig for export: the
/// first non-blank string under <see cref="CsvTypedColumn.OwnedJsonKeys"/>, in the runtime's own
/// precedence. Exporting the canonical key alone would print the spelling the driver is <i>ignoring</i>
/// and leave the winning alias in the residual, which is how the re-import trap in Gitea #553 got set.
/// </summary>
/// <param name="source">The stored TagConfig object.</param>
/// <param name="column">The typed column to project.</param>
/// <returns>The effective raw node, or <c>null</c> when no spelling carries a value.</returns>
protected static JsonNode? ReadEffective(JsonObject source, CsvTypedColumn column)
{
foreach (var key in column.OwnedJsonKeys)
{
if (!source.TryGetPropertyValue(key, out var node) || node is null) { continue; }
if (node is JsonValue sv && sv.TryGetValue<string>(out var s) && string.IsNullOrWhiteSpace(s)) { continue; }
return node;
}
return null;
}
/// <summary>
/// Folds the typed columns present (non-blank) in <paramref name="row"/> onto
@@ -122,18 +190,58 @@ public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
public override IReadOnlyList<CsvTypedColumn> Columns { get; }
/// <inheritdoc/>
/// <remarks>
/// <b>The base cell's own tokens are restored verbatim over the model's serialisation (Gitea #553).</b>
/// The model round-trip is what merges the typed columns and stamps a default for a key the row
/// declares nowhere — but <c>FromJson</c> is deliberately lenient (it has to LOAD a poisoned blob so an
/// operator can fix it), so a <c>"dataType":"Intt16"</c> the row carried in its free-form
/// <c>TagConfigJson</c> cell came back out of <c>ToJson</c> as the model's <i>default</i> — the
/// operator's typo replaced by a valid-looking value before the import gate, or any later gate, could
/// ever see it. Only keys the model does not own survived to be judged.
/// <para>
/// So every key the base cell actually carried is written back afterwards, unless a non-blank
/// typed column overrode it. Nothing here decides whether a token is <i>good</i>: that stays with
/// <see cref="TagConfigValidator"/> on the assembled blob (and with
/// <c>IRawTreeService.ImportTagsAsync</c> behind it), which asks each driver model's own
/// <c>Validate()</c> — one authority. This method's only job is to stop destroying the evidence
/// before that authority is consulted.
/// </para>
/// <para>
/// A key a non-blank column DID override is <b>not</b> restored, and its out-ranking aliases are
/// cleared: the typed column winning over a conflicting cell key is this map's documented contract,
/// the review grid reports each such override by name, and refusing the row instead would leave an
/// operator no way to repair a poisoned cell through the very columns that exist for it.
/// </para>
/// </remarks>
public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary<string, string> row)
{
var baseObj = TagConfigJson.ParseOrNew(baseJson);
var model = _fromJson(baseJson);
var overridden = new HashSet<string>(StringComparer.Ordinal);
var fired = new List<CsvTypedColumn>();
foreach (var col in _columns)
{
if (row.TryGetValue(col.Meta.Header, out var raw) && !string.IsNullOrWhiteSpace(raw))
{
col.Set(model, raw.Trim());
fired.Add(col.Meta);
foreach (var key in col.Meta.OwnedJsonKeys) { overridden.Add(key); }
}
}
return _toJson(model);
var merged = TagConfigJson.ParseOrNew(_toJson(model));
foreach (var (key, node) in baseObj)
{
if (!overridden.Contains(key))
{
merged[key] = node?.DeepClone();
}
}
foreach (var col in fired) { ClearOutrankingAliases(merged, col); }
return TagConfigJson.Serialize(merged);
}
/// <inheritdoc/>
@@ -214,6 +322,10 @@ public sealed class RawKeyCsvDriverTagMap : CsvDriverTagMap
CsvRawKeyKind.Int => JsonValue.Create(CsvValues.ParseInt(col.Meta.Header, trimmed)),
_ => JsonValue.Create(trimmed),
};
// Without this the cell's alias survives beside the key just written and OUT-RANKS it at the
// driver, so the operator's column edit is accepted and then ignored (Gitea #553).
ClearOutrankingAliases(o, col.Meta);
}
}
@@ -227,7 +339,7 @@ public sealed class RawKeyCsvDriverTagMap : CsvDriverTagMap
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var col in _columns)
{
if (o.TryGetPropertyValue(col.Meta.JsonKey, out var node) && node is JsonValue v)
if (ReadEffective(o, col.Meta) is JsonValue v)
{
result[col.Meta.Header] = col.Kind switch
{
@@ -480,10 +592,18 @@ public static class CsvColumnMap
new CsvRawKeyColumn(new CsvTypedColumn("MqttDeviceId", "deviceId", null), CsvRawKeyKind.String),
}),
// MTConnect is the one driver whose blob accepts several spellings per field, so its two identity
// columns declare the runtime's whole read precedence rather than just the key they write —
// mirroring MTConnectTagDefinitionFactory.TryFromTagConfig's FirstNonBlank(...) pairs, and the
// MTConnectTagConfigModel load that already mirrors them for the editor (Gitea #550/#553).
new RawKeyCsvDriverTagMap(DriverTypeNames.MTConnect, new[]
{
new CsvRawKeyColumn(new CsvTypedColumn("DataItemId", "fullName", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtDataType", "dataType", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(
new CsvTypedColumn("DataItemId", "fullName", null, ["fullName", "dataItemId", "address"]),
CsvRawKeyKind.String),
new CsvRawKeyColumn(
new CsvTypedColumn("MtDataType", "dataType", null, ["driverDataType", "dataType"]),
CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtCategory", "mtCategory", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtType", "mtType", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtSubType", "mtSubType", null), CsvRawKeyKind.String),
@@ -18,6 +18,10 @@ public sealed class FocasTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or null/empty for a fresh model.</param>
@@ -30,6 +34,7 @@ public sealed class FocasTagConfigModel
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
Address = TagConfigJson.GetString(o, "address") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", FocasDataType.Int32),
_source = json ?? "",
_bag = o,
};
}
@@ -42,7 +47,7 @@ public sealed class FocasTagConfigModel
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
TagConfigJson.Set(_bag, "address", Address.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
@@ -59,8 +64,30 @@ public sealed class FocasTagConfigModel
/// </remarks>
public string? Validate()
{
// Strict enum, read from the ORIGINAL key bag: FocasTagDefinitionFactory reads "dataType" with
// TryReadEnumStrict, so a present-but-invalid string rejects the whole tag at the driver
// (BadNodeIdUnknown) while the lenient load above defaults it to Int32. Accepting it here would
// let the CSV-import and API paths — which save the blob verbatim — deploy an unreadable tag,
// the same shape as the #540 address gap this method already closes (Gitea #549).
if (TagConfigJson.DescribeInvalidEnum<FocasDataType>(_bag, "dataType") is { } dtError) return dtError;
if (string.IsNullOrWhiteSpace(Address)) return "Address is required.";
return FocasAddress.TryParse(Address) is null ? DescribeUnparseableAddress(Address) : null;
// DELIBERATELY STRICTER THAN THE RUNTIME (Gitea #540), and the #554 survey re-confirmed it: the
// reader ACCEPTS an unparseable address — FocasTagDefinitionFactory.Inspect only advises about it —
// and the driver then skips the tag at startup. That is the defect #540 closed, so this check stays
// ahead of the parse consult below and is never replaced by it.
if (FocasAddress.TryParse(Address) is null) return DescribeUnparseableAddress(Address);
// Gitea #554 backstop. ⚠️ It gates on the PARSE VERDICT, never on Inspect being non-empty: FOCAS's
// inspector emits advisories for blobs the reader ACCEPTS (an unparseable address, an authored
// 'writable':true on a read-only wire), and promoting those to authoring errors would make the modal
// stricter than the runtime and leave rows nobody can save.
return TagConfigParseGate.Describe(
_source,
j => FocasTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
FocasTagDefinitionFactory.Inspect,
"FOCAS");
}
/// <summary>
@@ -1,5 +1,6 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -34,7 +35,9 @@ public sealed class MTConnectTagConfigModel
/// <summary>Logical data type override for the DataItem's value. Defaults to <see cref="DriverDataType.String"/>
/// — MTConnect is weakly typed on the wire, so an unset override should never coerce to a numeric type
/// that can fail to parse (mirrors <c>MTConnectDataTypeInference</c>'s own String-leaning default).</summary>
/// that can fail to parse (mirrors <c>MTConnectDataTypeInference</c>'s own String-leaning default).
/// Loaded from <c>driverDataType</c> or <c>dataType</c> in the driver's own precedence (see
/// <see cref="DataTypeKeys"/>); always saved as <c>dataType</c>, with the alias dropped.</summary>
public DriverDataType DataType { get; set; } = DriverDataType.String;
/// <summary>The DataItem's probe-sourced <c>category</c> (<c>SAMPLE</c>/<c>EVENT</c>/<c>CONDITION</c>). Read-only display metadata.</summary>
@@ -57,14 +60,29 @@ public sealed class MTConnectTagConfigModel
/// consumed by the driver or the runtime.</summary>
public string? MtComponent { get; set; }
// The as-loaded raw "dataType" JSON value, kept only to detect the ParseEnum numeric-text trap in
// Validate() below — Enum.TryParse (which TagConfigJson.GetEnum uses) silently accepts a quoted
// number ("8" -> Float64) exactly as readily as a name, so a config that was ever hand-authored or
// migrated with a numeric dataType would otherwise load into a *valid-looking* enum value with no
// signal that it isn't a name. ToJson() always re-writes DataType as a name, so this can only ever
// be non-null on the FIRST load of an already-poisoned config, never after a save through this model.
// The as-loaded raw coercion-type JSON token — read under the DRIVER's precedence (see DataTypeKeys)
// — kept so Validate() below can put it to the DRIVER's own accept/reject rule
// (MTConnectTagDefinitionFactory.IsAcceptedDataTypeName) rather than to the lenient load. A plain
// Enum.TryParse falls back to String on ANY unrecognised value, so without this a misspelled name
// ("Strng") or a quoted number ("8" -> Float64, the ParseEnum numeric-text trap) loads as a
// *valid-looking* enum value with no signal that it is not what the operator wrote — while the strict
// runtime rejects the tag outright (Gitea #549). Captured as the raw TOKEN, not via GetString: the
// runtime deserialises both spellings into `string?` DTO fields, so a NON-string token
// (`"dataType": 8`, `"driverDataType": 8`) throws JsonException there and rejects the tag too, where
// GetString would report it as simply absent. ToJson() always re-writes dataType as a name and drops
// the alias, so this can only ever be poisoned on the FIRST load of a blob authored elsewhere
// (hand-edited, CSV import, API), never after a save through this model.
private string? _rawDataType;
// Which of DataTypeKeys supplied _rawDataType, so a rejection names the spelling the operator actually
// wrote. Naming "dataType" for a blob that carries "driverDataType" sends them to a key that is not
// there — the runtime's own Inspect() names the offending field for the same reason.
private string _rawDataTypeKey = CanonicalDataTypeKey;
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate). Same capture-the-source pattern as _rawDataType.
private string? _source;
private JsonObject _bag = new();
/// <summary>
@@ -79,6 +97,34 @@ public sealed class MTConnectTagConfigModel
/// </summary>
private static readonly string[] IdKeys = ["fullName", "dataItemId", "address"];
/// <summary>
/// The accepted spellings of the coercion type, <b>in the runtime's precedence order</b>, mirroring
/// <c>MTConnectTagDefinitionFactory.TryFromTagConfig</c>'s
/// <c>FirstNonBlank(blob.DriverDataType, blob.DataType)</c>: <c>driverDataType</c> (the driver's own
/// <c>tags[]</c> field name, and the spelling a hand-authored or driver-shaped blob carries) wins
/// over <c>dataType</c> (this model's canonical key).
/// <para>
/// Reading only <c>dataType</c> was Gitea <b>#550</b>, and it broke in both directions at once: a
/// blob carrying <c>"driverDataType": "Strng"</c> sailed through authoring while the runtime
/// rejected the tag (the residual slice of #549), and — worse — a VALID <c>driverDataType</c>
/// silently overrode whatever type this editor displayed and saved, so the editor showed a lie.
/// </para>
/// <para>
/// <see cref="ToJson"/> therefore normalises onto <c>dataType</c> and DROPS the alias, exactly as
/// it does for <see cref="IdKeys"/>. Both halves are load-bearing together: the precedence read
/// alone would leave the alias in the bag to out-rank the key the editor writes on the next load,
/// and the drop alone would retype every existing <c>driverDataType</c> tag to whatever the
/// lenient <c>dataType</c> read defaulted to on its next save.
/// </para>
/// </summary>
private static readonly string[] DataTypeKeys = ["driverDataType", CanonicalDataTypeKey];
/// <summary>The spelling of the coercion type this model writes — the LAST of <see cref="DataTypeKeys"/>,
/// i.e. the one the driver reads only when no alias out-ranks it. <see cref="ToJson"/> drops every other
/// entry of that array rather than naming the alias a second time, so adding a third spelling to the
/// read cannot leave the normalisation behind.</summary>
private const string CanonicalDataTypeKey = "dataType";
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
@@ -86,17 +132,20 @@ public sealed class MTConnectTagConfigModel
public static MTConnectTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
var (rawDataTypeKey, rawDataType) = ReadRawDataType(o);
return new MTConnectTagConfigModel
{
FullName = ReadId(o),
DataType = TagConfigJson.GetEnum(o, "dataType", DriverDataType.String),
DataType = ParseDataType(rawDataType),
MtCategory = TagConfigJson.GetString(o, "mtCategory"),
MtType = TagConfigJson.GetString(o, "mtType"),
MtSubType = TagConfigJson.GetString(o, "mtSubType"),
Units = TagConfigJson.GetString(o, "units"),
MtDevice = TagConfigJson.GetString(o, "mtDevice"),
MtComponent = TagConfigJson.GetString(o, "mtComponent"),
_rawDataType = TagConfigJson.GetString(o, "dataType"),
_rawDataType = rawDataType,
_rawDataTypeKey = rawDataTypeKey,
_source = json ?? "",
_bag = o,
};
}
@@ -109,21 +158,38 @@ public sealed class MTConnectTagConfigModel
public string ToJson()
{
// Normalise onto the canonical key: a blob loaded via an alias must not keep the alias, or the
// two spellings drift on the next edit and the driver silently reads the stale one.
// two spellings drift on the next edit and the driver silently reads the stale one. This holds
// for the coercion type (driverDataType, Gitea #550) exactly as it does for the id spellings —
// and because driverDataType OUT-RANKS dataType at the driver, leaving it behind would mean the
// type the operator just picked never reached the runtime at all.
TagConfigJson.Set(_bag, "dataItemId", null);
TagConfigJson.Set(_bag, "address", null);
TagConfigJson.Set(_bag, "fullName", FullName.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
foreach (var alias in DataTypeKeys)
{
if (alias != CanonicalDataTypeKey) { TagConfigJson.Set(_bag, alias, null); }
}
TagConfigJson.Set(_bag, CanonicalDataTypeKey, DataType);
TagConfigJson.Set(_bag, "mtCategory", Blank(MtCategory));
TagConfigJson.Set(_bag, "mtType", Blank(MtType));
TagConfigJson.Set(_bag, "mtSubType", Blank(MtSubType));
TagConfigJson.Set(_bag, "units", Blank(Units));
TagConfigJson.Set(_bag, "mtDevice", Blank(MtDevice));
TagConfigJson.Set(_bag, "mtComponent", Blank(MtComponent));
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <remarks>
/// The <c>dataType</c> rule is not stated here — it is asked of
/// <see cref="MTConnectTagDefinitionFactory.IsAcceptedDataTypeName"/>, the same predicate the driver
/// and the deploy gate use. One authority, so this gate can never end up weaker than the runtime
/// again (Gitea #549). Note that the previous, digits-only check is NOT retained as a tailored
/// special case: the runtime draws no distinction between a bare number and a misspelled name —
/// both fail that one predicate and both draw the same <c>Inspect</c> warning — so a second message
/// here would be a second statement of a rule this model does not own.
/// </remarks>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
{
@@ -132,16 +198,58 @@ public sealed class MTConnectTagConfigModel
return "A DataItem id (fullName) is required.";
}
// See the _rawDataType remarks: a quoted number parses as a "valid" enum today but is never
// something this editor itself would have written, so surface it instead of silently accepting it.
if (_rawDataType is { Length: > 0 } raw && raw.All(char.IsAsciiDigit))
// See the _rawDataType remarks: the value the blob CARRIES is judged, not the leniently-defaulted
// DataType above, because the CSV-import and API paths save the blob verbatim.
if (!MTConnectTagDefinitionFactory.IsAcceptedDataTypeName(_rawDataType))
{
return $"dataType \"{raw}\" is a numeric value, not a named type — re-select a data type.";
return $"{_rawDataTypeKey} \"{_rawDataType}\" is not a valid DriverDataType name — re-select a "
+ $"data type. Valid: {string.Join(", ", Enum.GetNames<DriverDataType>())}.";
}
return null;
// Gitea #554 backstop — expected silent for MTConnect, whose reader refuses only an unparseable
// document, a JSON null, an id under none of the three spellings, and an unreadable coercion type,
// all covered above. It is here so a future reader rule cannot become a gap.
return TagConfigParseGate.Describe(
_source,
j => MTConnectTagDefinitionFactory.TryFromTagConfig(j, out _, out _),
MTConnectTagDefinitionFactory.Inspect,
"MTConnect");
}
// The as-loaded coercion-type TOKEN as text (see the _rawDataType and DataTypeKeys remarks), read in
// the RUNTIME's precedence so this model judges — and displays — the token the driver would actually
// read. Per key, mirroring FirstNonBlank(blob.DriverDataType, blob.DataType) over a DTO whose two
// fields are `string?`:
// · absent / JSON null → the field deserialises to null, which FirstNonBlank skips: try the next.
// · blank or whitespace → likewise blank to FirstNonBlank: try the next. A present-but-blank
// driverDataType must NOT shadow a populated dataType.
// · any non-string token → STOP. The runtime deserialise throws JsonException on it and rejects
// the whole tag, so it can never fall through to the other spelling;
// yield its JSON text so Validate() refuses exactly what the driver does.
// · non-blank string → STOP, this is the effective token.
// Nothing matched ⇒ null, the one shape the runtime treats as "no type declared" (it then falls back
// to the Agent's /probe declaration rather than rejecting the tag).
private static (string Key, string? Value) ReadRawDataType(JsonObject o)
{
foreach (var key in DataTypeKeys)
{
if (!o.TryGetPropertyValue(key, out var node) || node is null) { continue; }
if (node is not JsonValue v || !v.TryGetValue<string>(out var s)) { return (key, node.ToJsonString()); }
if (string.IsNullOrWhiteSpace(s)) { continue; }
return (key, s);
}
return (CanonicalDataTypeKey, null);
}
// The lenient load of the effective token (see ReadRawDataType) for DISPLAY: an unrecognised value
// falls back to String so a poisoned blob still opens and can be fixed. Validate() — not this — is
// what refuses it, against the driver's own predicate.
private static DriverDataType ParseDataType(string? raw)
=> raw is not null && Enum.TryParse<DriverDataType>(raw.Trim(), ignoreCase: true, out var v)
? v
: DriverDataType.String;
// Normalises a blank/whitespace-only string to null so TagConfigJson.Set omits the key rather than
// persisting an empty string.
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
@@ -32,6 +32,11 @@ public sealed class ModbusTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when it was built by object initialiser rather than
// loaded. See TagConfigParseGate's remarks: the lenient load above cannot be the input to the parse
// consult, because it is the leniency ("address":"notanumber" ⇒ 0) that hides the defect.
private string? _source;
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
@@ -41,6 +46,7 @@ public sealed class ModbusTagConfigModel
var o = TagConfigJson.ParseOrNew(json);
return new ModbusTagConfigModel
{
_source = json ?? "",
Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters),
Address = TagConfigJson.GetInt(o, "address"),
DataType = TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
@@ -68,6 +74,42 @@ public sealed class ModbusTagConfigModel
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <remarks>
/// All FOUR enums are read from the ORIGINAL key bag, not from the leniently-defaulted typed
/// fields above: <c>ModbusTagDefinitionFactory</c> reads each with <c>TryReadEnumStrict</c>, so a
/// present-but-invalid string rejects the whole tag at the driver (<c>BadNodeIdUnknown</c>) where
/// the load silently substitutes <c>HoldingRegisters</c>/<c>Int16</c>/<c>BigEndian</c>/
/// <c>HighByteFirst</c>. Accepting one here would let the CSV-import and API paths — which save the
/// blob verbatim — deploy a tag the driver skips (Gitea #549). Also note a <b>wrong-but-valid</b>
/// region is not this gate's business: only a spelling the runtime cannot read is rejected.
/// <para>
/// <c>stringByteOrder</c> is the fourth (Gitea #551): the runtime has always read it as
/// strictly as its three siblings, but this model — and the deploy gate's <c>Inspect</c> —
/// checked only the other three, so a typo'd <c>stringByteOrder</c> passed BOTH gates and died
/// at the driver. It is not in <see cref="ToJson"/>'s exposed-field list either, so it can only
/// ever reach the bag from a hand-authored blob, a CSV import or the API — exactly the paths
/// this gate exists for.
/// </para>
/// <para>
/// <b>Everything the mapper refuses for a NON-enum reason is covered by the parse consult</b>
/// (Gitea #554), not by rules restated here: an absent <c>region</c>, an absent or non-numeric or
/// out-of-0–65535 <c>address</c>, a <c>String</c> tag with no <c>stringLength</c>, a
/// <c>BitInRegister</c> tag whose <c>bitIndex</c> narrows above 15. Modbus was the one editor with
/// no required field at all, so <c>{}</c> — what the modal persists for a tag whose config was
/// never touched — validated clean and then read <c>BadNodeIdUnknown</c> forever. The enum checks
/// stay ahead of the consult because they name the offending spelling, which the mapper's boolean
/// verdict cannot.
/// </para>
/// </remarks>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate() => null;
public string? Validate()
=> TagConfigJson.DescribeInvalidEnum<ModbusRegion>(_bag, "region")
?? TagConfigJson.DescribeInvalidEnum<ModbusDataType>(_bag, "dataType")
?? TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(_bag, "byteOrder")
?? TagConfigJson.DescribeInvalidEnum<ModbusStringByteOrder>(_bag, "stringByteOrder")
?? TagConfigParseGate.Describe(
_source,
j => ModbusTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
ModbusTagDefinitionFactory.Inspect,
"Modbus");
}
@@ -154,6 +154,10 @@ public sealed class MqttTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <summary>
/// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
/// original key (so fields this editor doesn't expose — history intent, array/alarm objects, and
@@ -193,6 +197,7 @@ public sealed class MqttTagConfigModel
// Same "dataType" key as Plain's DataType above, read as an optional override — see the
// MetricDataType doc comment for why there are two typed fields over one JSON key.
MetricDataType = GetEnumNullable<DriverDataType>(o, DataTypeKey),
_source = json ?? "",
_bag = o,
};
}
@@ -245,7 +250,7 @@ public sealed class MqttTagConfigModel
TagConfigJson.Set(_bag, EdgeNodeIdKey, string.IsNullOrWhiteSpace(EdgeNodeId) ? null : EdgeNodeId.Trim());
TagConfigJson.Set(_bag, DeviceIdKey, string.IsNullOrWhiteSpace(DeviceId) ? null : DeviceId.Trim());
TagConfigJson.Set(_bag, MetricNameKey, string.IsNullOrWhiteSpace(MetricName) ? null : MetricName.Trim());
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>
@@ -296,8 +301,27 @@ public sealed class MqttTagConfigModel
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
{
if (Mode == MqttMode.SparkplugB) { return ValidateSparkplug(); }
var error = Mode == MqttMode.SparkplugB ? ValidateSparkplug() : ValidatePlain();
if (error is not null) { return error; }
// Gitea #554 backstop, gating on the PARSE VERDICT (never on Inspect being non-empty — the MQTT
// inspector advises about a wildcard topic on blobs the reader ACCEPTS). The verdict is the UNION of
// the two readers, matching MqttTagDefinitionFactory.Inspect's own accept gate: a blob is served if
// EITHER shape reads it, and this model's Mode is only an inference from the descriptor keys.
// Expected to be near-silent — one shape it does newly catch is a `"qos": null`, which the reader
// refuses (Null is not a Number) while the model's own QoS check treats as absent.
return TagConfigParseGate.Describe(
_source,
j => MqttTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _)
|| MqttTagDefinitionFactory.FromSparkplugTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
MqttTagDefinitionFactory.Inspect,
"Mqtt");
}
/// <summary>Plain-mode half of <see cref="Validate"/> — see its remarks for the full rationale.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
private string? ValidatePlain()
{
if (DescribeInvalidEnum<MqttPayloadFormat>(_bag, PayloadFormatKey) is { } pfError) { return pfError; }
if (DescribeInvalidEnum<DriverDataType>(_bag, DataTypeKey) is { } dtError) { return dtError; }
if (DescribeInvalidQos(_bag) is { } qosError) { return qosError; }
@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -9,21 +10,58 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <remarks>
/// Under the v3 identity contract, <c>TagConfig</c> no longer carries identity — the upstream
/// node reference is an ordinary driver-specific address field. The key is the camelCase
/// <c>nodeId</c> (replacing the legacy PascalCase <c>FullName</c> identity convention). The
/// <c>nodeId</c> (replacing the legacy PascalCase <c>FullName</c> identity convention); a legacy
/// PascalCase <c>NodeId</c> is still READ, at the driver reader's own precedence, and normalised away
/// on save — see <see cref="NodeIdKeys"/>. The
/// driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
/// <c>historianTagname</c>) are NOT modelled here — they are owned by the TagModal-merge seam
/// (<see cref="TagHistorizeConfig"/>) and survive a load→save of this model as preserved unknown keys.
/// </remarks>
public sealed class OpcUaClientTagConfigModel
{
/// <summary>The camelCase TagConfig JSON key holding the upstream node reference.</summary>
/// <summary>The camelCase TagConfig JSON key holding the upstream node reference — the spelling this
/// model writes, and the LAST of <see cref="NodeIdKeys"/>, i.e. the one the reader consults only when
/// no earlier spelling is present.</summary>
internal const string NodeIdKey = "nodeId";
/// <summary>Upstream OPC UA node reference the tag binds to (the driver-side full reference). Required.</summary>
/// <summary>
/// The accepted spellings of the upstream node reference, <b>in the reader's precedence order</b>,
/// mirroring <see cref="OpcUaClientTagDefinitionFactory.TryReadNodeId"/>'s
/// <c>TryGetProperty("nodeId", …) || TryGetProperty("NodeId", …)</c>: the v3 camelCase
/// <c>nodeId</c> first, then the legacy PascalCase <c>NodeId</c> a pre-v3 blob carries.
/// <para>
/// ⚠️ <b>This precedence is PRESENCE-shadows, not first-non-blank</b> — see
/// <see cref="ReadNodeId"/>. The reader's <c>||</c> short-circuits on the mere PRESENCE of
/// <c>nodeId</c> and only then asks whether the value is a non-blank string, so a present-but-blank
/// or present-but-non-string <c>nodeId</c> beside a perfectly good legacy <c>NodeId</c> is a
/// REJECTED blob, not a fallback. Copying <c>MTConnectTagConfigModel</c>'s <c>FirstNonBlank</c>
/// walk here would make this editor accept a blob the driver skips — the absolute direction
/// Gitea #554 forbids.
/// </para>
/// <para>
/// Reading only <c>nodeId</c> was Gitea <b>#557</b> item 1: a legacy blob was SERVED by the driver
/// yet opened with an empty reference field and could not be saved untouched, so the editor was
/// stricter than the reader — authoring blocking config the runtime honours.
/// <see cref="ToJson"/> therefore normalises onto <see cref="NodeIdKey"/> and DROPS the legacy
/// spelling, exactly as <c>MTConnectTagConfigModel</c> does for its own aliases. Both halves are
/// load-bearing together: the precedence read alone would leave the legacy key in the bag to be
/// re-read on the next load, and the drop alone would blank every legacy tag's reference on its
/// next save.
/// </para>
/// </summary>
private static readonly string[] NodeIdKeys = [NodeIdKey, "NodeId"];
/// <summary>Upstream OPC UA node reference the tag binds to (the driver-side full reference). Required.
/// Loaded from <c>nodeId</c>, or from the legacy PascalCase <c>NodeId</c> when <c>nodeId</c> is wholly
/// absent (see <see cref="NodeIdKeys"/>); always saved as <c>nodeId</c>, with the legacy key dropped.</summary>
public string NodeId { get; set; } = "";
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The tag's TagConfig JSON (null/blank/malformed ⇒ defaults).</param>
@@ -33,24 +71,73 @@ public sealed class OpcUaClientTagConfigModel
var o = TagConfigJson.ParseOrNew(json);
return new OpcUaClientTagConfigModel
{
NodeId = TagConfigJson.GetString(o, NodeIdKey) ?? "",
NodeId = ReadNodeId(o),
_source = json ?? "",
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
/// <c>nodeId</c> is written camelCase (the v3 address key); any history keys merged by the TagModal
/// (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as preserved unknown
/// keys.</summary>
/// <c>nodeId</c> is written camelCase (the v3 address key) and every legacy spelling of it is DROPPED
/// (see <see cref="NodeIdKeys"/>); any history keys merged by the TagModal (<c>isHistorized</c> /
/// <c>historianTagname</c>) are carried through untouched as preserved unknown keys — they are
/// disjoint from the address key, so the normalisation below cannot disturb that merge.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
// Normalise onto the canonical key: a blob loaded via the legacy spelling must not keep it, or the
// two spellings drift on the next edit and a later reader change could pick the stale one. Driven
// off NodeIdKeys rather than naming "NodeId" a second time, so a spelling added to the READ can
// never be left behind by the DROP.
foreach (var legacy in NodeIdKeys)
{
if (legacy != NodeIdKey) { TagConfigJson.Set(_bag, legacy, null); }
}
TagConfigJson.Set(_bag, NodeIdKey, NodeId.Trim());
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <remarks>
/// The Gitea #554 parse consult is a backstop here, and the required-reference check above is now
/// exactly as precedence-aware as the reader (Gitea #557 item 1): <see cref="ReadNodeId"/> yields
/// the very token <see cref="OpcUaClientTagDefinitionFactory.TryReadNodeId"/> would consult — blank
/// for every shape it refuses, non-blank for every shape it serves — so the fast path agrees with
/// the parse verdict on the whole rejection surface rather than under-reading one spelling of it.
/// Neither half is redundant: the fast path is the only one that fires for a model built by object
/// initialiser (no source to parse), and the parse consult is what stops a future reader rule from
/// silently becoming an authoring gap.
/// </remarks>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(NodeId) ? "An upstream node reference (nodeId) is required." : null;
=> string.IsNullOrWhiteSpace(NodeId)
? "An upstream node reference (nodeId) is required."
: TagConfigParseGate.Describe(
_source,
j => OpcUaClientTagDefinitionFactory.TryReadNodeId(j, out _),
OpcUaClientTagDefinitionFactory.Inspect,
"OpcUaClient");
// The as-loaded node reference, read in the READER's precedence (see NodeIdKeys). Per spelling,
// mirroring `TryGetProperty("nodeId", out n) || TryGetProperty("NodeId", out n)` followed by ONE
// `n.ValueKind == String && !IsNullOrWhiteSpace(s)` check:
// · wholly absent → the || moves on: try the next spelling.
// · present, any JSON null
// or non-string token → STOP. The || has already short-circuited on this spelling, and the single
// kind check then refuses the whole blob — it can NEVER fall through to the
// legacy key. Yield "" so Validate() refuses exactly what the driver does.
// · present string → STOP, this is the effective token, blank or not. A blank one likewise
// shadows the legacy key and is a refusal, not a fallback.
// Nothing present ⇒ "", the no-reference-authored shape the reader also refuses.
private static string ReadNodeId(JsonObject o)
{
foreach (var key in NodeIdKeys)
{
if (!o.TryGetPropertyValue(key, out var node)) { continue; }
return node is JsonValue v && v.TryGetValue<string>(out var s) ? s : "";
}
return "";
}
}
@@ -22,6 +22,23 @@ public sealed class S7TagConfigModel
private JsonObject _bag = new();
// Whether the blob this model was LOADED FROM was missing the "dataType" key entirely — captured
// because the lenient load above cannot tell "absent" from "present": both land on Int16. S7 is the one
// driver whose reader REQUIRES the key (S7TagDefinitionFactory.DeclaresDataType), so a blob without one
// is rejected outright and reads BadNodeIdUnknown; without this capture that blob validated clean
// (Gitea #551). Same capture-the-as-loaded-shape pattern as MTConnectTagConfigModel's _rawDataType.
//
// It records a property of the SOURCE, so the default (false) is the honest answer for a model built by
// object initialiser rather than loaded — RawBrowseCommitMapper does exactly that, and ToJson() then
// writes the key from the typed DataType field. ToJson() likewise clears it: a blob that has been
// re-serialised through this model always carries the key, so it can only ever be true on the FIRST
// load of a blob authored elsewhere (hand-edited, CSV import, API).
private bool _dataTypeMissingInSource;
// The blob this model was LOADED FROM, or null when built by object initialiser — the parse consult's
// input (Gitea #554). Same capture-the-source reasoning as _dataTypeMissingInSource above.
private string? _source;
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or null/empty for a fresh model.</param>
@@ -32,9 +49,11 @@ public sealed class S7TagConfigModel
return new S7TagConfigModel
{
Address = TagConfigJson.GetString(o, "address") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", S7DataType.Int16),
DataType = TagConfigJson.GetEnum(o, S7TagDefinitionFactory.DataTypeKey, S7DataType.Int16),
StringLength = TagConfigJson.GetInt(o, "stringLength"),
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_dataTypeMissingInSource = !o.ContainsKey(S7TagDefinitionFactory.DataTypeKey),
_source = json ?? "",
_bag = o,
};
}
@@ -45,14 +64,61 @@ public sealed class S7TagConfigModel
public string ToJson()
{
TagConfigJson.Set(_bag, "address", Address.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, S7TagDefinitionFactory.DataTypeKey, DataType);
_dataTypeMissingInSource = false;
TagConfigJson.Set(_bag, "stringLength", StringLength);
TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
/// <remarks>
/// The <c>dataType</c> check reads the ORIGINAL key bag rather than the leniently-defaulted
/// <see cref="DataType"/>: <c>S7TagDefinitionFactory</c> reads it with <c>TryReadEnumStrict</c>,
/// so a present-but-invalid string rejects the whole tag at the driver (<c>BadNodeIdUnknown</c>)
/// where the load silently substitutes <c>Int16</c>. Accepting it here would let the CSV-import
/// and API paths — which save the blob verbatim — deploy an unreadable tag (Gitea #549).
/// <para>
/// <b>An ABSENT <c>dataType</c> is rejected too (Gitea #551), and S7 is the only driver where
/// that is right.</b> <c>S7TagDefinitionFactory.FromTagConfig</c> refuses a blob that does not
/// carry the key at all — unique in the fleet, where every sibling falls back to a default — so
/// such a tag deployed clean through both gates and then read <c>BadNodeIdUnknown</c> forever.
/// Presence is judged on the key, not the value, matching
/// <see cref="S7TagDefinitionFactory.DeclaresDataType"/>: a non-string token is present-enough
/// for the reader, which then treats it as absent and accepts the tag as <c>Int16</c>.
/// </para>
/// <para>
/// <b>The one blob this can newly block in the modal is one that was already dead.</b> The
/// typed editor pushes <see cref="ToJson"/> — which always writes <c>dataType</c> — only when a
/// field CHANGES, so opening a legacy key-less blob and saving it untouched hands this method
/// the as-loaded string. That save is refused, and correctly: the tag it would persist is one
/// the driver already skips. Picking any data type in the editor writes the key and clears it.
/// </para>
/// </remarks>
public string? Validate()
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
{
if (TagConfigJson.DescribeInvalidEnum<S7DataType>(_bag, S7TagDefinitionFactory.DataTypeKey) is { } dtError)
{
return dtError;
}
if (string.IsNullOrWhiteSpace(Address)) { return "Address is required."; }
if (_dataTypeMissingInSource)
{
return "A data type is required — the S7 driver rejects a tag whose TagConfig carries no "
+ $"'{S7TagDefinitionFactory.DataTypeKey}' key (it would read BadNodeIdUnknown). Valid: "
+ string.Join(", ", Enum.GetNames<S7DataType>()) + ".";
}
// Gitea #554: the reader's remaining, non-enum refusal — a String tag whose 'stringLength' is
// negative or above 254 (S7 REJECTS it rather than clamping) — is not restated here. The verdict and
// the wording both come from the driver's own reader, via TagConfigParseGate.
return TagConfigParseGate.Describe(
_source,
j => S7TagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
S7TagDefinitionFactory.Inspect,
"S7");
}
}
@@ -50,6 +50,10 @@ public sealed class SqlTagConfigModel
private string? _invalidModelRaw;
private string? _invalidTypeRaw;
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
/// original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
@@ -74,6 +78,7 @@ public sealed class SqlTagConfigModel
RowSelector = SqlRowSelectorModel.FromJson(selectorObj),
Type = type,
_bag = o,
_source = json ?? "",
_invalidModelRaw = invalidModel,
_invalidTypeRaw = invalidType,
};
@@ -98,7 +103,7 @@ public sealed class SqlTagConfigModel
if (selector is null) { _bag.Remove("rowSelector"); }
else { _bag["rowSelector"] = selector; }
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validates the model against the exact accept/reject boundary of
@@ -121,7 +126,7 @@ public sealed class SqlTagConfigModel
if (string.IsNullOrWhiteSpace(KeyColumn)) { return "keyColumn is required for a KeyValue tag."; }
if (string.IsNullOrWhiteSpace(ValueColumn)) { return "valueColumn is required for a KeyValue tag."; }
if (KeyValue is null) { return "keyValue is required for a KeyValue tag."; }
return null;
break;
case SqlTagModel.WideRow:
if (string.IsNullOrWhiteSpace(ColumnName)) { return "columnName is required for a WideRow tag."; }
@@ -132,11 +137,21 @@ public sealed class SqlTagConfigModel
{
return "a WideRow tag needs a row selector: a whereColumn/whereValue pair, or topByTimestamp.";
}
return null;
break;
default:
return "model is not a supported Sql tag model.";
}
// Gitea #554: the parser's RECOGNITION check is the rule nothing above states, and it is the one Sql
// rejection an otherwise-complete blob can hit — TryParse refuses a config that carries neither a
// 'model' key nor "driver":"Sql", because it cannot tell a Sql tag from any other driver's blob. The
// editor always writes 'model' on save, so this only ever fires for a hand-authored / CSV / API blob.
return TagConfigParseGate.Describe(
_source,
j => SqlEquipmentTagParser.TryParse(j, TagConfigParseGate.AuthoringRawPath, out _),
SqlTagDefinitionFactory.Inspect,
"Sql");
}
// Reads an enum-valued string field the way the parser's TryReadEnumStrict does: absent OR present-but-
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using RuntimeTagConfigJson = ZB.MOM.WW.OtOpcUa.Core.Abstractions.TagConfigJson;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -65,6 +66,33 @@ public static class TagConfigJson
public static TEnum GetEnum<TEnum>(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum
=> GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Describes a present-but-invalid enum field the way the RUNTIME parser judges it, or
/// <c>null</c> when the field is absent, present-but-non-string, or valid.
/// <para>
/// <see cref="GetEnum{TEnum}"/> is deliberately lenient — it must be, because a model has to
/// LOAD a poisoned blob to let an operator fix it — but leniency at the save gate is the
/// Gitea #549 defect: every driver's runtime read is
/// <c>TagConfigJson.TryReadEnumStrict</c>, which REJECTS the whole tag on a present-but-invalid
/// string (R2-11 Phase C), so a typo the modal accepts and rewrites is a tag the driver would
/// have skipped with <c>BadNodeIdUnknown</c> on the CSV-import and API paths, which save the
/// blob verbatim. A model's <c>Validate()</c> therefore calls this on its ORIGINAL key bag.
/// </para>
/// <para>
/// The verdict and the wording both come from
/// <see cref="RuntimeTagConfigJson.DescribeInvalidEnumText{TEnum}"/> — the same authority the
/// runtime parsers and the deploy gate consult — rather than being restated here. Passing
/// <see cref="GetString"/>'s null-for-non-string result reproduces the runtime's
/// <c>Absent</c> treatment of a non-string token exactly.
/// </para>
/// </summary>
/// <typeparam name="TEnum">The enum type expected.</typeparam>
/// <param name="o">The model's preserved TagConfig key bag, as loaded.</param>
/// <param name="name">The property name to check.</param>
/// <returns>The error text, or <c>null</c> when the runtime would accept the field.</returns>
public static string? DescribeInvalidEnum<TEnum>(JsonObject o, string name) where TEnum : struct, Enum
=> RuntimeTagConfigJson.DescribeInvalidEnumText<TEnum>(name, GetString(o, name));
/// <summary>Sets a string/number/enum-name value (enums via ToString()). A null value REMOVES the key, so it is omitted from the serialised JSON.</summary>
/// <param name="o">The JSON object to mutate.</param>
/// <param name="name">The property name to set.</param>
@@ -0,0 +1,89 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// The non-enum half of the authoring gate (Gitea #554): a driver's <c>&lt;Driver&gt;TagConfigModel.Validate()</c>
/// asks that driver's own runtime parse whether it would read the blob, and refuses the save when it
/// would not. One authority for the VERDICT and for the WORDING — the same shape #551 gave the deploy
/// gate, pointed at the other end of the pipe.
/// <para>
/// <b>Why the models could not simply grow more rules.</b> Before this, <c>Validate()</c> judged the
/// enum keys (against the runtime's own <c>DescribeInvalidEnumText</c>, Gitea #549/#551) plus a few
/// hand-written required-field checks, and nothing else. Every remaining reason a driver's reader
/// refuses a tag — an absent <c>region</c>, an <c>"address":"notanumber"</c>, an S7
/// <c>stringLength</c> of 300, a TwinCAT <c>dataType:"Structure"</c> — met no gate until deploy, or
/// until the driver skipped the tag. Re-stating each of those in each model would have been N more
/// hand-mirrored rules to drift, which is exactly the defect #551 closed on the deploy side.
/// </para>
/// </summary>
/// <remarks>
/// <para>
/// ⚠️ <b>The gate is the PARSE verdict, never the inspector's output.</b> An <c>Inspect</c> is
/// allowed to say things about a blob its parse ACCEPTS — FOCAS warns that an unparseable address
/// will be skipped and that <c>writable:true</c> cannot write, MQTT warns about a wildcard topic,
/// Calculation warns about a discarded <c>timerIntervalMs</c>. Treating a non-empty inspection as an
/// authoring error would make the modal STRICTER than the runtime and leave rows nobody can save —
/// the #520/#535 trap. So <see cref="Describe"/> calls the inspector only once the parse has already
/// refused, purely to word the refusal.
/// </para>
/// <para>
/// ⚠️ <b>It judges the blob the model was LOADED FROM, not the model's current fields.</b> The
/// lenient load is what hides these defects in the first place — <c>"address":"notanumber"</c> loads
/// as <c>0</c> — so a check against the typed fields would accept exactly the blobs this exists to
/// refuse. It is the same capture-the-source reasoning as <c>S7TagConfigModel</c>'s
/// <c>_dataTypeMissingInSource</c>, and it is also the honest input: on every authoring path the
/// string handed to <c>TagConfigValidator</c> is the string about to be persisted.
/// </para>
/// <para>
/// A model built by object initialiser rather than loaded (browse-commit's mapper, tests) passes
/// <see langword="null"/> and is not parse-checked — it has no source, and its <c>ToJson()</c> output
/// is what gets persisted. That output is covered instead by the fleet acceptance-parity theory in
/// <c>TagConfigAuthoringDeployParityTests</c>, which drives every mapped driver's freshly-authored
/// blob through both gates.
/// </para>
/// </remarks>
internal static class TagConfigParseGate
{
/// <summary>
/// The RawPath handed to a driver's parse during authoring inspection. Every mapper only copies it
/// onto the produced definition's identity and never validates it, so a placeholder is correct — the
/// gate is judging a blob, not a placed tag (the same reasoning as each factory's own
/// <c>InspectionRawPath</c>).
/// </summary>
internal const string AuthoringRawPath = "(authoring)";
/// <summary>
/// Returns an operator-facing error when <paramref name="parseAccepts"/> refuses
/// <paramref name="source"/>, worded by <paramref name="inspect"/>; <see langword="null"/> when the
/// parse accepts it or when there is no source to judge.
/// </summary>
/// <param name="source">The blob the model was loaded from, or <see langword="null"/> when it was not
/// loaded from JSON at all (see the class remarks).</param>
/// <param name="parseAccepts">The driver's own runtime parse, wrapped to a verdict.</param>
/// <param name="inspect">The driver's <c>Inspect</c>, consulted ONLY after a refusal, for wording.</param>
/// <param name="driverLabel">The driver name used by the backstop message.</param>
/// <returns>The error message, or <see langword="null"/> when the config is acceptable.</returns>
internal static string? Describe(
string? source,
Func<string, bool> parseAccepts,
Func<string, IReadOnlyList<string>> inspect,
string driverLabel)
{
ArgumentNullException.ThrowIfNull(parseAccepts);
ArgumentNullException.ThrowIfNull(inspect);
if (source is null) { return null; }
// A blank config is persisted as "{}" by every authoring path (RawTagModal, the manual-entry grid,
// RawTreeService), so that is the blob whose verdict the operator actually needs.
var blob = string.IsNullOrWhiteSpace(source) ? "{}" : source;
if (parseAccepts(blob)) { return null; }
var warnings = inspect(blob);
return warnings.Count > 0
? string.Join(" ", warnings)
// Every inspector short-circuits on a blob that does not start with '{', and some cannot name
// every refusal their mapper makes. Saying so beats letting a tag the driver skips be saved.
: $"{driverLabel} TagConfig is rejected by the driver's tag reader — the driver would skip this "
+ "tag and it would read BadNodeIdUnknown.";
}
}
@@ -22,6 +22,10 @@ public sealed class TwinCATTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The TagConfig JSON string to parse, or null/empty for a fresh model.</param>
@@ -35,6 +39,7 @@ public sealed class TwinCATTagConfigModel
SymbolPath = TagConfigJson.GetString(o, "symbolPath") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", TwinCATDataType.DInt),
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_source = json ?? "",
_bag = o,
};
}
@@ -48,11 +53,33 @@ public sealed class TwinCATTagConfigModel
TagConfigJson.Set(_bag, "symbolPath", SymbolPath.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
/// <remarks>
/// The <c>dataType</c> check reads the ORIGINAL key bag rather than the leniently-defaulted
/// <see cref="DataType"/>: <c>TwinCATTagDefinitionFactory</c> reads it with
/// <c>TryReadEnumStrict</c>, so a present-but-invalid string rejects the whole tag at the driver
/// (<c>BadNodeIdUnknown</c>) where the load silently substitutes <c>DInt</c>. Accepting it here
/// would let the CSV-import and API paths — which save the blob verbatim — deploy an unreadable
/// tag (Gitea #549).
/// <para>
/// <b><c>dataType: "Structure"</c> is the reason this model needed more than an enum check</b>
/// (Gitea #554): it is a perfectly valid <see cref="TwinCATDataType"/> NAME, so every spelling
/// gate passes it, and the reader refuses it anyway — the driver does not serve UDT / FB-instance
/// tags. The rule is not restated here; the parse consult asks the reader and quotes its answer.
/// </para>
/// </remarks>
public string? Validate()
=> string.IsNullOrWhiteSpace(SymbolPath) ? "Symbol path is required." : null;
{
if (TagConfigJson.DescribeInvalidEnum<TwinCATDataType>(_bag, "dataType") is { } dtError) { return dtError; }
if (string.IsNullOrWhiteSpace(SymbolPath)) { return "Symbol path is required."; }
return TagConfigParseGate.Describe(
_source,
j => TwinCATTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
TwinCATTagDefinitionFactory.Inspect,
"TwinCAT");
}
}
@@ -26,6 +26,12 @@
<!-- #520: API-key minting + the reserved-principal rule live in Core.Authorization, because
both ends need them — the OPC UA server resolves principals, the AdminUI authors keys. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<!-- #549: the typed tag-config editors ask Core.Abstractions' TagConfigJson whether an authored
enum spelling is one the RUNTIME parsers accept, so authoring can never be weaker than the
driver. That type is load-bearing for the save gate — referenced directly rather than
consumed transitively through Core, so a future re-plumbing of Core cannot silently take it
away. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
</ItemGroup>
@@ -246,8 +246,11 @@ public sealed class AdminOperationsActor : ReceiveActor
}
// R2-11 (05/CONV-2): deploy-time TagConfig strictness inspection over equipment tags. The
// driver-typed EquipmentTagConfigInspector surfaces present-but-invalid enum values (which the
// lenient runtime silently defaults) + structurally-unparseable TagConfig. In Warn mode
// driver-typed EquipmentTagConfigInspector surfaces every blob the driver's own reader would
// REJECT — a present-but-invalid enum value, an absent or malformed address field, a
// structurally-unparseable TagConfig — each of which makes the driver skip the tag so it reads
// BadNodeIdUnknown forever. (Pre-R2-11 Phase C the runtime silently defaulted a typo'd enum;
// it has rejected the tag since, so these warnings describe rejections.) In Warn mode
// (default) these are non-blocking (logged + appended to the accepted result Message); in Error
// mode they fold into the reject list so a typo'd config cannot be re-deployed until fixed.
// Running servers are untouched either way — the gate only sees re-deploys.
@@ -1,10 +1,14 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
using ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
@@ -13,20 +17,35 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
/// Deploy-time TagConfig inspection dispatched by <c>DriverType</c> (R2-11, 05/CONV-2). Mirrors the
/// AdminUI <c>TagConfigValidator</c> shape: a case-insensitive map from the canonical driver-type string
/// to that driver's lightweight <c>Contracts</c> parser <c>Inspect()</c>, returning human-readable
/// warnings (present-but-invalid enum values that the lenient runtime silently defaults, plus
/// structurally-unparseable TagConfig). Never throws.
/// warnings. Never throws.
/// <para><b>The warnings describe REJECTIONS, not silent defaults.</b> Every driver's runtime read has
/// been strict since R2-11 Phase C (<c>TagConfigJson.TryReadEnumStrict</c>): a present-but-invalid enum
/// value — like an absent or malformed address field, or an unparseable document — makes the reader
/// refuse the whole tag, so the driver skips it and it reads <c>BadNodeIdUnknown</c> forever with the
/// deployment otherwise green. That is what these warnings are for. (Before Phase C the runtime did
/// silently substitute a default for a typo'd enum; the doc-comments here and on the per-driver
/// <c>Inspect</c> methods still said so until Gitea #551.) Because a warning can be PROMOTED to a
/// blocking error by <c>TagConfigValidationMode.Error</c>, each <c>Inspect</c> asks its driver's own
/// reader for the verdict and only explains a refusal — never restating the rule, and so never able to
/// be stricter than the runtime it reports on.</para>
/// <para><b>Coverage is every driver that ships an <c>Inspect</c>, and that invariant is guarded</b> by
/// <c>EquipmentTagConfigInspectorCoverageTests</c>, which reflects over the referenced
/// <c>*.Contracts</c> assemblies — so a driver that gains an inspector cannot be silently left out. This
/// is the same guard shape the driver-form dispatch maps use, and for the same reason: the previous
/// hand-maintained list had drifted (MQTT shipped an <c>Inspect</c> and was never added).</para>
/// <para><b>Residual gap, deliberately recorded rather than papered over.</b> Galaxy, OpcUaClient, Sql,
/// MTConnect and Calculation have <b>no</b> <c>Inspect</c> in a Contracts assembly, so this deploy-time
/// gate cannot check them — while the AdminUI's <c>TagConfigValidator</c> DOES cover all but Galaxy via
/// its <c>*TagConfigModel.Validate()</c> models, which live in the AdminUI project and cannot be
/// referenced from here. The asymmetry runs the wrong way: config authored through the modal is checked,
/// but config arriving by CSV import or API is not. Closing it means promoting those models (or an
/// equivalent parser) into each driver's Contracts assembly. Tracked as deferment-register G-7.</para>
/// <para><b>The G-7 gap is CLOSED (2026-08-09).</b> Sql, MTConnect, Calculation and OpcUaClient used to
/// have no <c>Inspect</c> in a Contracts assembly, so this gate was blind to them while the AdminUI's
/// <c>TagConfigValidator</c> checked them at authoring — the asymmetry ran the wrong way, since config
/// arriving by CSV import or API meets ONLY this gate. Each now ships a Contracts-side
/// <c>*TagDefinitionFactory.Inspect</c>, wired below. Three of the four wrap the driver's own runtime
/// parse rather than mirroring the AdminUI model, which is what keeps the gate from ever being stricter
/// than the runtime: <c>SqlTagDefinitionFactory</c> wraps <c>SqlEquipmentTagParser</c>, and the
/// MTConnect / OpcUaClient / Calculation blob readers were MOVED into their Contracts assemblies (the
/// drivers now call them) rather than copied.</para>
/// <para><b>Galaxy is the sole remaining unmapped type, deliberately.</b> Its tag reference is a dotted
/// <c>tag_name.AttributeName</c> authored through the Galaxy address picker, with no typed shape to
/// check — the AdminUI has no typed editor for it either (it uses the raw-JSON textarea), so this is
/// symmetric rather than a gap. It gains an entry here if it ever gains a Contracts-side parser.</para>
/// </summary>
public static class EquipmentTagConfigInspector
{
@@ -40,6 +59,11 @@ public static class EquipmentTagConfigInspector
[DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect,
[DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect,
[DriverTypeNames.Mqtt] = MqttTagDefinitionFactory.Inspect,
// G-7 (2026-08-09): the four the gate used to be blind to.
[DriverTypeNames.Sql] = SqlTagDefinitionFactory.Inspect,
[DriverTypeNames.MTConnect] = MTConnectTagDefinitionFactory.Inspect,
[DriverTypeNames.Calculation] = CalculationTagDefinitionFactory.Inspect,
[DriverTypeNames.OpcUaClient] = OpcUaClientTagDefinitionFactory.Inspect,
};
/// <summary>Inspects a tag's <c>TagConfig</c> for the given driver type. Returns the driver's warnings,
@@ -50,6 +50,16 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
<!-- G-7: the four drivers the deploy gate used to be blind to, while the AdminUI's
TagConfigValidator checked them. Adding the reference is what makes
EquipmentTagConfigInspectorCoverageTests demand a dispatch-map entry for them — the guard scans
the output directory, so an unreferenced Contracts assembly is invisible to it.
OpcUaClient.Contracts is dependency-light (Core.Abstractions only) — the OPC UA client SDK it
used to pull in transitively left with NamespaceMap, to Driver.OpcUaClient.Session. -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
</ItemGroup>
</Project>
@@ -687,6 +687,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
var log = _log;
var driverInstanceId = _driverInstanceId;
var driver = _driver;
var invoker = _invoker;
_ = Task.Run(async () =>
{
try
@@ -695,6 +696,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
try
{
// Deliberately UNWRAPPED: teardown is best-effort cleanup of an already-dead
// transport — a shutdown failure must not count toward the Initialize breaker.
await driver.ShutdownAsync(CancellationToken.None);
}
catch (Exception ex)
@@ -704,7 +707,20 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
"initialising anyway", driverInstanceId);
}
}
await driver.InitializeAsync(driverConfigJson, CancellationToken.None);
// R2-10 T10: the connect path was the ONE capability call the invoker never saw, so a
// driver that could not connect contributed zero breaker input and the resilience panel
// stayed quiet for a dead endpoint. Keyed on the driver-instance id (init spans the
// whole driver, like a bulk subscribe). The Initialize policy never retries — this
// actor's RetryConnect timer is the retry loop — and its pipeline timeout now bounds a
// hanging connect via the ct the call site must honour. An open breaker fast-fails the
// attempt with BrokenCircuitException, which lands in the catch below exactly like a
// real connect failure: InitializeFailed → keep retrying on the timer. The 15 s break
// window is shorter than two 10 s timer ticks, so recovery costs at most one extra tick.
await invoker.ExecuteAsync(
DriverCapability.Initialize,
driverInstanceId,
async ct => await driver.InitializeAsync(driverConfigJson, ct),
CancellationToken.None);
self.Tell(new InitializeSucceeded(generation));
}
catch (Exception ex)
@@ -22,6 +22,29 @@ public sealed class DriverCapabilityInvokerFactoryTests
ILogger? logger = null) =>
new(builder, new DriverResilienceStatusTracker(), _ => tier, logger);
/// <summary>
/// A respawn (every <c>Create</c>) resets the instance's tracker state alongside its pipeline
/// cache. The pipelines carry the actual breaker state and are discarded by the same call, so a
/// snapshot recorded against them — a "Breaker OPEN" from a pre-deploy outage — refers to a
/// breaker that no longer exists; keeping it would show a false OPEN chip forever, because only
/// the discarded pipeline's <c>OnClosed</c> could have cleared it.
/// </summary>
[Fact]
public void Create_resets_the_instances_tracker_state_with_its_pipelines()
{
var tracker = new DriverResilienceStatusTracker();
var factory = new DriverCapabilityInvokerFactory(
new DriverResiliencePipelineBuilder(), tracker, _ => DriverTier.A);
tracker.RecordBreakerOpen("d1", "d1", DateTime.UtcNow);
tracker.RecordBreakerOpen("d2", "d2", DateTime.UtcNow);
factory.Create("d1", "Modbus");
tracker.TryGet("d1", "d1").ShouldBeNull(
"the respawn discarded the pipeline whose breaker this snapshot described");
tracker.TryGet("d2", "d2")!.IsBreakerOpen.ShouldBeTrue("other instances must be untouched");
}
/// <summary>
/// End-to-end proof the per-instance override REACHES execution: a ResilienceConfig that sets
/// Read's retryCount to 0 must suppress the tier-A Read retry — a failing Read is attempted once,
@@ -331,6 +331,19 @@ public sealed class DriverResilienceOptionsParserTests
diag.ShouldContain("AlarmAcknowledge never retries");
}
/// <summary>An Initialize retryCount override is forced to 0 with a diagnostic — the
/// DriverInstanceActor reconnect timer is the retry loop, so pipeline retries would stack.</summary>
[Fact]
public void InitializeRetryOverride_IsForcedToZero_WithDiagnostic()
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(
DriverTier.A, "{\"capabilityPolicies\":{\"Initialize\":{\"retryCount\":3}}}", out var diag);
options.Resolve(DriverCapability.Initialize).RetryCount.ShouldBe(0);
diag.ShouldNotBeNull();
diag.ShouldContain("Initialize never retries");
}
/// <summary>A Read (idempotent) retryCount override is still honored — the invariant is write-shaped only.</summary>
[Fact]
public void ReadRetryOverride_StillHonored()
@@ -78,6 +78,39 @@ public sealed class DriverResilienceOptionsTests
defaults[DriverCapability.AlarmAcknowledge].RetryCount.ShouldBe(0);
}
/// <summary>
/// Initialize never retries by default — the <c>DriverInstanceActor</c> reconnect timer IS the
/// retry loop; pipeline retries would stack inside each timer tick.
/// </summary>
/// <param name="tier">The driver tier to test.</param>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
[InlineData(DriverTier.C)]
public void Initialize_NeverRetries_ByDefault(DriverTier tier)
{
var defaults = DriverResilienceOptions.GetTierDefaults(tier);
defaults[DriverCapability.Initialize].RetryCount.ShouldBe(0);
}
/// <summary>
/// The Initialize breaker threshold must be reachable at the reconnect cadence (R2-10 T10):
/// the breaker samples over 30 s and the reconnect timer fires every 10 s, so at most THREE
/// init attempts fit one sampling window. A threshold above 3 can never open on connect
/// failures — the exact silent-inertness this capability exists to fix — so the default is
/// pinned at 2 (Polly's minimum enabled throughput). Tier C stays breaker-less, consistent
/// with every other Tier C capability (the supervisor's process-level breaker owns it).
/// </summary>
[Theory]
[InlineData(DriverTier.A, 2)]
[InlineData(DriverTier.B, 2)]
[InlineData(DriverTier.C, 0)]
public void Initialize_BreakerThreshold_IsReachableAtTheReconnectCadence(DriverTier tier, int expectedThreshold)
{
var defaults = DriverResilienceOptions.GetTierDefaults(tier);
defaults[DriverCapability.Initialize].BreakerFailureThreshold.ShouldBe(expectedThreshold);
}
/// <summary>Verifies that idempotent capabilities retry by default.</summary>
/// <param name="tier">The driver tier to test.</param>
/// <param name="capability">The driver capability to test.</param>
@@ -315,6 +315,47 @@ public sealed class DriverResiliencePipelineBuilderTests
snap.IsBreakerOpen.ShouldBeFalse("the breaker must read closed after a successful recovery probe");
}
/// <summary>
/// R2-10 T10 closure: with the TIER DEFAULTS (no override), consecutive Initialize failures
/// open the breaker — and a subsequent attempt fast-fails WITHOUT reaching the call site.
/// The load-bearing half is the threshold: at the 10 s reconnect cadence at most three init
/// attempts fit the 30 s sampling window, so this test drives exactly the tier-default
/// threshold of failures and demands an open breaker — it goes red if anyone raises the
/// Initialize threshold back into never-opens territory (5), the silent inertness the
/// live gate proved.
/// </summary>
[Fact]
public async Task Initialize_BreakerOpens_AtTierDefaults_AndFastFailsTheNextAttempt()
{
var tracker = new DriverResilienceStatusTracker();
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
var pipeline = builder.GetOrCreate("drv-init", "drv-init", DriverCapability.Initialize, TierAOptions);
var threshold = TierAOptions.Resolve(DriverCapability.Initialize).BreakerFailureThreshold;
for (var i = 0; i < threshold; i++)
{
await Should.ThrowAsync<InvalidOperationException>(async () =>
await pipeline.ExecuteAsync(async _ =>
{
await Task.Yield();
throw new InvalidOperationException("connect refused");
}));
}
tracker.TryGet("drv-init", "drv-init")!.IsBreakerOpen.ShouldBeTrue(
"the tier-default threshold of consecutive connect failures must open the Initialize breaker");
// The open breaker fast-fails the next attempt without invoking the connect at all.
var reached = false;
await Should.ThrowAsync<BrokenCircuitException>(async () =>
await pipeline.ExecuteAsync(_ =>
{
reached = true;
return ValueTask.CompletedTask;
}));
reached.ShouldBeFalse("an open breaker must not let the connect attempt through");
}
/// <summary>Verifies that the tracker isolates counters per host.</summary>
[Fact]
public async Task Tracker_IsolatesCounters_PerHost()
@@ -90,6 +90,27 @@ public sealed class DriverResilienceStatusTrackerTests
tracker.TryGet("drv", "host")!.IsBreakerOpen.ShouldBeTrue();
}
/// <summary>
/// Invalidate drops every entry for one instance and leaves other instances alone. Without
/// this, a driver-child respawn (deploy) while a breaker is OPEN strands a permanently-stale
/// "Breaker OPEN" snapshot: the respawn discards the pipelines whose <c>OnClosed</c> is the
/// only thing that stamps the close, so nothing can ever clear the flag again.
/// </summary>
[Fact]
public void Invalidate_DropsTheInstance_LeavesOthers()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordBreakerOpen("drv", "host-a", Now);
tracker.RecordBreakerOpen("drv", "host-b", Now);
tracker.RecordBreakerOpen("other", "host-a", Now);
tracker.Invalidate("drv");
tracker.TryGet("drv", "host-a").ShouldBeNull();
tracker.TryGet("drv", "host-b").ShouldBeNull();
tracker.TryGet("other", "host-a")!.IsBreakerOpen.ShouldBeTrue();
}
/// <summary>Verifies that RecordRecycle populates the LastRecycleUtc timestamp.</summary>
[Fact]
public void RecordRecycle_Populates_LastRecycleUtc()
@@ -0,0 +1,161 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Gitea #551 item 3: <see cref="AbCipTagDefinitionFactory.Inspect"/> is <b>accept-gate-first</b> — it
/// asks <see cref="AbCipTagDefinitionFactory.FromTagConfig"/> for the verdict and only explains a
/// refusal. See <c>ModbusInspectAcceptGateTests</c>'s remarks for why both directions (never stricter,
/// never silent) are pinned.
/// <para>
/// AbCip's uncovered surface was the declared-UDT <c>members</c> array: the mapper rejects the WHOLE
/// tag for one member with no <c>name</c> or a typo'd <c>dataType</c>, but neither this gate nor the
/// AdminUI editor (which emits only <c>tagPath</c>/<c>dataType</c>/<c>writable</c> and carries
/// <c>members</c> through its preserved key bag) ever looked.
/// </para>
/// </summary>
public sealed class AbCipInspectAcceptGateTests
{
private const string RawPath = "cell/abcip/dev1/T1";
/// <summary>Blobs the mapper ACCEPTS — the gate must say nothing about any of them.</summary>
public static TheoryData<string> Accepted() =>
[
"""{"tagPath":"Program:Main.Speed","dataType":"DInt"}""",
"""{"tagPath":"Speed"}""",
"""{"tagPath":"Motor","dataType":"Structure","members":[{"name":"Run","dataType":"Bool"}]}""",
// members present but not an ARRAY is ignored by the mapper, so it must be ignored here too.
"""{"tagPath":"Speed","members":{"name":"Run"}}""",
"""{"tagPath":"Speed","members":[]}""",
// A member's writable / writeIdempotent / array keys are all optional.
"""{"tagPath":"Motor","members":[{"name":"Run"}]}""",
];
/// <summary>Blobs the mapper REJECTS — the gate must produce at least one warning for each.</summary>
public static TheoryData<string> Rejected() =>
[
"""{"dataType":"DInt"}""", // no tagPath
"""{"tagPath":"","dataType":"DInt"}""", // blank tagPath
"""{"tagPath":123}""", // tagPath not a string
"""{"tagPath":"Speed","dataType":"DIntt"}""", // typo'd top-level dataType
"""{"tagPath":"Motor","members":[{"dataType":"Bool"}]}""", // member with no name
"""{"tagPath":"Motor","members":[{"name":" ","dataType":"Bool"}]}""", // member with a blank name
"""{"tagPath":"Motor","members":[{"name":"Run","dataType":"Booll"}]}""", // typo'd member dataType
"""{"tagPath":"Motor","members":["Run"]}""", // member not an object
];
[Theory]
[MemberData(nameof(Accepted))]
public void A_config_the_mapper_accepts_draws_no_warning(string tagConfig)
{
AbCipTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeTrue("fixture must be accepted");
AbCipTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty();
}
[Theory]
[MemberData(nameof(Rejected))]
public void A_config_the_mapper_rejects_is_never_passed_over_in_silence(string tagConfig)
{
AbCipTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeFalse("fixture must be rejected");
AbCipTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty();
}
[Fact]
public void An_absent_tagPath_is_named()
=> AbCipTagDefinitionFactory.Inspect("""{"dataType":"DInt"}""")
.ShouldContain(w => w.Contains("'tagPath'"));
[Fact]
public void A_typo_in_a_member_dataType_is_named_with_its_index()
=> AbCipTagDefinitionFactory.Inspect(
"""{"tagPath":"Motor","members":[{"name":"Run","dataType":"Bool"},{"name":"Speed","dataType":"Reall"}]}""")
.ShouldContain(w => w.Contains("members[1]") && w.Contains("Reall"));
[Fact]
public void A_member_with_no_name_is_named_with_its_index()
=> AbCipTagDefinitionFactory.Inspect("""{"tagPath":"Motor","members":[{"dataType":"Bool"}]}""")
.ShouldContain(w => w.Contains("members[0]") && w.Contains("name"));
// ---- DescribeInvalidMember is the shared authority the AdminUI model asks (one rule, two doors) ----
[Theory]
[InlineData("""{"tagPath":"Motor","members":[{"name":"Run","dataType":"Bool"}]}""", false)]
[InlineData("""{"tagPath":"Motor"}""", false)]
[InlineData("""{"tagPath":"Motor","members":[]}""", false)]
[InlineData("""{"tagPath":"Motor","members":{"name":"Run"}}""", false)]
[InlineData("not json at all", false)]
[InlineData("""{"tagPath":"Motor","members":[{"name":"Run","dataType":"Booll"}]}""", true)]
[InlineData("""{"tagPath":"Motor","members":[{"dataType":"Bool"}]}""", true)]
public void DescribeInvalidMember_over_a_string_matches_the_mapper(string tagConfig, bool expectWarning)
=> (AbCipTagDefinitionFactory.DescribeInvalidMember(tagConfig) is not null).ShouldBe(expectWarning);
[Fact]
public void A_blank_reference_draws_nothing()
=> AbCipTagDefinitionFactory.Inspect(" ").ShouldBeEmpty();
[Fact]
public void Unparseable_json_names_the_json()
=> AbCipTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON"));
// ---- Gitea #556 item 1: leading whitespace no longer defeats the framing pre-filter ----
/// <summary>
/// Padding a blob with leading whitespace. A driver is handed whatever authoring persisted, and
/// <c>FromTagConfig</c> passes it straight to <c>JsonDocument.Parse</c>, which skips leading
/// whitespace — so the MAPPER's verdict is identical padded or not, and the gate's must be too.
/// </summary>
private const string Pad = " \t\r\n ";
/// <summary>
/// Never stricter, under padding: a padded blob the mapper still accepts draws nothing. (This
/// direction already held before #556 — the pre-filter returned empty for everything padded — and
/// is kept so a future "just trim everywhere" change cannot start warning about a served tag.)
/// </summary>
/// <param name="tagConfig">An accepted fixture, padded below.</param>
[Theory]
[MemberData(nameof(Accepted))]
public void Leading_whitespace_leaves_an_accepted_config_silent(string tagConfig)
{
var padded = Pad + tagConfig;
AbCipTagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
.ShouldBeTrue("the mapper's JsonDocument.Parse skips leading whitespace");
AbCipTagDefinitionFactory.Inspect(padded).ShouldBeEmpty();
}
/// <summary>
/// Never silent, under padding — the #556 item 1 defect itself. The pre-filter tested
/// <c>reference[0] != '{'</c>, so EVERY padded blob returned empty and a padded config the driver
/// refuses deployed clean. Asserting equality with the untrimmed inspection (rather than merely
/// "not empty") also pins that padding changes no message.
/// </summary>
/// <param name="tagConfig">A rejected fixture, padded below.</param>
[Theory]
[MemberData(nameof(Rejected))]
public void Leading_whitespace_draws_exactly_the_warnings_the_untrimmed_blob_draws(string tagConfig)
{
var padded = Pad + tagConfig;
AbCipTagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
.ShouldBeFalse("the mapper's verdict is unchanged by padding");
AbCipTagDefinitionFactory.Inspect(padded).ShouldBe(AbCipTagDefinitionFactory.Inspect(tagConfig));
}
/// <summary>
/// The other half of the same fix, and the one it would be easy to lose: trimming widens the gate
/// ONLY for JSON objects. A reference that is still not object-framed after the trim is not an
/// equipment-tag TagConfig at all — the pre-existing convention shared by all eleven inspectors —
/// so it keeps drawing nothing even though the mapper refuses it.
/// </summary>
/// <param name="reference">A padded, non-object reference.</param>
[Theory]
[InlineData(" just a string")]
[InlineData(" [1,2,3]")]
[InlineData(" \t\r\n 42")]
[InlineData(" \"a\"")]
[InlineData(" ")]
public void A_non_object_reference_still_draws_nothing_after_trimming(string reference)
{
AbCipTagDefinitionFactory.FromTagConfig(reference, RawPath, out _).ShouldBeFalse();
AbCipTagDefinitionFactory.Inspect(reference).ShouldBeEmpty();
}
}
@@ -0,0 +1,125 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Gitea #551: <see cref="AbLegacyTagDefinitionFactory.Inspect"/> is <b>accept-gate-first</b> — it asks
/// <see cref="AbLegacyTagDefinitionFactory.FromTagConfig"/> for the verdict and only explains a refusal.
/// See <c>ModbusInspectAcceptGateTests</c>'s remarks for why both directions (never stricter, never
/// silent) are pinned.
/// </summary>
public sealed class AbLegacyInspectAcceptGateTests
{
private const string RawPath = "cell/ablegacy/dev1/T1";
/// <summary>Blobs the mapper ACCEPTS — the gate must say nothing about any of them.</summary>
public static TheoryData<string> Accepted() =>
[
"""{"address":"N7:0","dataType":"Int"}""",
"""{"address":"B3:1/2"}""",
"""{"address":"F8:5","dataType":"Float","isArray":true,"arrayLength":10}""",
// An over-long array is CLAMPED by the mapper, not rejected — so the gate must stay silent, even
// though the AdminUI model refuses it (the un-clamped length also sets OPC UA ArrayDimensions).
"""{"address":"F8:5","dataType":"Float","isArray":true,"arrayLength":9999}""",
];
/// <summary>Blobs the mapper REJECTS — the gate must produce at least one warning for each.</summary>
public static TheoryData<string> Rejected() =>
[
"""{"dataType":"Int"}""", // no address
"""{"address":"","dataType":"Int"}""", // blank address
"""{"address":7,"dataType":"Int"}""", // address not a string
"""{"address":"N7:0","dataType":"Intt"}""", // typo'd dataType
];
[Theory]
[MemberData(nameof(Accepted))]
public void A_config_the_mapper_accepts_draws_no_warning(string tagConfig)
{
AbLegacyTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeTrue("fixture must be accepted");
AbLegacyTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty();
}
[Theory]
[MemberData(nameof(Rejected))]
public void A_config_the_mapper_rejects_is_never_passed_over_in_silence(string tagConfig)
{
AbLegacyTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeFalse("fixture must be rejected");
AbLegacyTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty();
}
[Fact]
public void An_absent_address_is_named()
=> AbLegacyTagDefinitionFactory.Inspect("""{"dataType":"Int"}""")
.ShouldContain(w => w.Contains("'address'"));
[Fact]
public void A_blank_reference_draws_nothing()
=> AbLegacyTagDefinitionFactory.Inspect(" ").ShouldBeEmpty();
[Fact]
public void Unparseable_json_names_the_json()
=> AbLegacyTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON"));
// ---- Gitea #556 item 1: leading whitespace no longer defeats the framing pre-filter ----
/// <summary>
/// Padding a blob with leading whitespace. A driver is handed whatever authoring persisted, and
/// <c>FromTagConfig</c> passes it straight to <c>JsonDocument.Parse</c>, which skips leading
/// whitespace — so the MAPPER's verdict is identical padded or not, and the gate's must be too.
/// </summary>
private const string Pad = " \t\r\n ";
/// <summary>
/// Never stricter, under padding: a padded blob the mapper still accepts draws nothing. (This
/// direction already held before #556 — the pre-filter returned empty for everything padded — and
/// is kept so a future "just trim everywhere" change cannot start warning about a served tag.)
/// </summary>
/// <param name="tagConfig">An accepted fixture, padded below.</param>
[Theory]
[MemberData(nameof(Accepted))]
public void Leading_whitespace_leaves_an_accepted_config_silent(string tagConfig)
{
var padded = Pad + tagConfig;
AbLegacyTagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
.ShouldBeTrue("the mapper's JsonDocument.Parse skips leading whitespace");
AbLegacyTagDefinitionFactory.Inspect(padded).ShouldBeEmpty();
}
/// <summary>
/// Never silent, under padding — the #556 item 1 defect itself. The pre-filter tested
/// <c>reference[0] != '{'</c>, so EVERY padded blob returned empty and a padded config the driver
/// refuses deployed clean. Asserting equality with the untrimmed inspection (rather than merely
/// "not empty") also pins that padding changes no message.
/// </summary>
/// <param name="tagConfig">A rejected fixture, padded below.</param>
[Theory]
[MemberData(nameof(Rejected))]
public void Leading_whitespace_draws_exactly_the_warnings_the_untrimmed_blob_draws(string tagConfig)
{
var padded = Pad + tagConfig;
AbLegacyTagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
.ShouldBeFalse("the mapper's verdict is unchanged by padding");
AbLegacyTagDefinitionFactory.Inspect(padded).ShouldBe(AbLegacyTagDefinitionFactory.Inspect(tagConfig));
}
/// <summary>
/// The other half of the same fix, and the one it would be easy to lose: trimming widens the gate
/// ONLY for JSON objects. A reference that is still not object-framed after the trim is not an
/// equipment-tag TagConfig at all — the pre-existing convention shared by all eleven inspectors —
/// so it keeps drawing nothing even though the mapper refuses it.
/// </summary>
/// <param name="reference">A padded, non-object reference.</param>
[Theory]
[InlineData(" just a string")]
[InlineData(" [1,2,3]")]
[InlineData(" \t\r\n 42")]
[InlineData(" \"a\"")]
[InlineData(" ")]
public void A_non_object_reference_still_draws_nothing_after_trimming(string reference)
{
AbLegacyTagDefinitionFactory.FromTagConfig(reference, RawPath, out _).ShouldBeFalse();
AbLegacyTagDefinitionFactory.Inspect(reference).ShouldBeEmpty();
}
}
@@ -0,0 +1,201 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Gitea #551: <see cref="FocasTagDefinitionFactory.Inspect"/> gained an accept gate for its
/// <b>rejection</b> diagnosis — see <c>ModbusInspectAcceptGateTests</c>'s remarks for why both
/// directions are pinned.
/// <para>
/// FOCAS is the one factory that deliberately does NOT return empty on accept: two of its warnings
/// are <b>advisories</b> about blobs the mapper ACCEPTS but the driver still cannot serve as
/// authored — an unparseable FOCAS address (#540, dropped at the driver's own init) and an authored
/// <c>writable:true</c> (the wire is read-only). Folding FOCAS into the plain "accept ⇒ empty"
/// shape would have silently deleted both, so they are pinned here alongside the new rejections.
/// </para>
/// </summary>
public sealed class FocasInspectAcceptGateTests
{
private const string RawPath = "cell/focas/dev1/T1";
/// <summary>Blobs the mapper ACCEPTS <b>and</b> that carry no advisory — the gate must be silent.</summary>
public static TheoryData<string> CleanAndAccepted() =>
[
"""{"address":"R100","dataType":"Int32"}""",
"""{"address":"X0.0","dataType":"Bit"}""",
"""{"address":"PARAM:1020"}""",
"""{"address":"FIXED:Identity/SeriesNumber","dataType":"String"}""",
];
/// <summary>Blobs the mapper REJECTS — the gate must produce at least one warning for each.</summary>
public static TheoryData<string> Rejected() =>
[
"""{"dataType":"Int32"}""", // no address
"""{"address":"","dataType":"Int32"}""", // blank address
"""{"address":100}""", // address not a string
"""{"address":"R100","dataType":"Intt32"}""", // typo'd dataType
];
[Theory]
[MemberData(nameof(CleanAndAccepted))]
public void A_clean_config_the_mapper_accepts_draws_no_warning(string tagConfig)
{
FocasTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeTrue("fixture must be accepted");
FocasTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty();
}
[Theory]
[MemberData(nameof(Rejected))]
public void A_config_the_mapper_rejects_is_never_passed_over_in_silence(string tagConfig)
{
FocasTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeFalse("fixture must be rejected");
FocasTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty();
}
[Fact]
public void An_absent_address_is_named()
=> FocasTagDefinitionFactory.Inspect("""{"dataType":"Int32"}""")
.ShouldContain(w => w.Contains("'address'"));
// ---- The two advisories the accept gate must NOT swallow ----
[Fact]
public void An_unparseable_address_still_warns_even_though_the_mapper_accepts_it()
{
const string Blob = """{"address":"not-an-address","dataType":"Int32"}""";
FocasTagDefinitionFactory.FromTagConfig(Blob, RawPath, out _).ShouldBeTrue("the mapper accepts any non-blank address");
FocasTagDefinitionFactory.Inspect(Blob).ShouldContain(w => w.Contains("not parseable"));
}
[Fact]
public void An_authored_writable_still_warns_even_though_the_mapper_accepts_it()
{
const string Blob = """{"address":"R100","dataType":"Int32","writable":true}""";
FocasTagDefinitionFactory.FromTagConfig(Blob, RawPath, out _).ShouldBeTrue("the mapper accepts writable:true");
FocasTagDefinitionFactory.Inspect(Blob).ShouldContain(w => w.Contains("read-only"));
}
/// <summary>A rejected blob still gets its advisories — the two diagnoses are independent.</summary>
[Fact]
public void A_rejected_config_reports_the_rejection_and_the_advisory_together()
{
var warnings = FocasTagDefinitionFactory.Inspect(
"""{"address":"not-an-address","dataType":"Intt32","writable":true}""");
warnings.ShouldContain(w => w.Contains("dataType"));
warnings.ShouldContain(w => w.Contains("not parseable"));
warnings.ShouldContain(w => w.Contains("read-only"));
}
[Fact]
public void A_blank_reference_draws_nothing()
=> FocasTagDefinitionFactory.Inspect(" ").ShouldBeEmpty();
[Fact]
public void Unparseable_json_names_the_json()
=> FocasTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON"));
// ---- Gitea #556 item 2: an advisory can no longer swallow the catch-all rejection ----
/// <summary>
/// The structural invariant the backstop exists to hold: a blob the mapper REFUSES always draws
/// strictly more than the advisories that same blob would draw if it were accepted. Before #556 the
/// backstop was gated on <c>warnings.Count == 0</c> — and an advisory fires on an ACCEPTED blob
/// too, so it says nothing about the refusal while still satisfying that guard. Any refusal no
/// <c>DescribeRejection</c> branch can name was therefore dropped whenever an advisory was present.
/// Comparing the two counts states the property without string-matching a message to guess its
/// class, which is the whole point of keeping the two classes in separate lists.
/// <para>
/// ⚠️ FOCAS's own backstop is UNREACHABLE today (audited): every <c>return false</c> in
/// <see cref="FocasTagDefinitionFactory.FromTagConfig"/> — absent / non-string / blank
/// <c>address</c>, invalid <c>dataType</c> — has a matching describe branch, and
/// <c>TryReadEnumStrict</c> and <c>DescribeInvalidEnum</c> share one classifier so they cannot
/// disagree. So the guard is protective here rather than a live fix. It is now protective
/// fleet-wide: MQTT's used to be live — a padded blob its own mappers refused and every branch
/// found well-formed — until Gitea #559 removed those mappers' framing gates, since the refusal
/// was itself the defect. <c>MqttInspectAcceptGateTests</c> pins the same count relation.
/// What this pins is the property that must survive the next rejection cause someone adds
/// without a describe branch.
/// </para>
/// </summary>
[Fact]
public void A_rejected_config_always_draws_more_than_its_advisories_alone()
{
// Identical but for the dataType typo, so both carry the SAME two advisories.
const string Accepted = """{"address":"not-an-address","dataType":"Int32","writable":true}""";
const string Rejected = """{"address":"not-an-address","dataType":"Intt32","writable":true}""";
FocasTagDefinitionFactory.FromTagConfig(Accepted, RawPath, out _).ShouldBeTrue("control must be accepted");
FocasTagDefinitionFactory.FromTagConfig(Rejected, RawPath, out _).ShouldBeFalse("subject must be rejected");
var advisoriesOnly = FocasTagDefinitionFactory.Inspect(Accepted);
var withRejection = FocasTagDefinitionFactory.Inspect(Rejected);
advisoriesOnly.Count.ShouldBe(2, "the control's warnings are purely advisory");
withRejection.Count.ShouldBeGreaterThan(
advisoriesOnly.Count,
"a refusal must add a rejection-class warning — the backstop supplies one when nothing names it");
}
// ---- Gitea #556 item 1: leading whitespace no longer defeats the framing pre-filter ----
/// <summary>
/// Padding a blob with leading whitespace. A driver is handed whatever authoring persisted, and
/// <c>FromTagConfig</c> passes it straight to <c>JsonDocument.Parse</c>, which skips leading
/// whitespace — so the MAPPER's verdict is identical padded or not, and the gate's must be too.
/// </summary>
private const string Pad = " \t\r\n ";
/// <summary>
/// Never stricter, under padding: a padded blob the mapper still accepts draws nothing. (This
/// direction already held before #556 — the pre-filter returned empty for everything padded — and
/// is kept so a future "just trim everywhere" change cannot start warning about a served tag.)
/// </summary>
/// <param name="tagConfig">An accepted fixture, padded below.</param>
[Theory]
[MemberData(nameof(CleanAndAccepted))]
public void Leading_whitespace_leaves_an_accepted_config_silent(string tagConfig)
{
var padded = Pad + tagConfig;
FocasTagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
.ShouldBeTrue("the mapper's JsonDocument.Parse skips leading whitespace");
FocasTagDefinitionFactory.Inspect(padded).ShouldBeEmpty();
}
/// <summary>
/// Never silent, under padding — the #556 item 1 defect itself. The pre-filter tested
/// <c>reference[0] != '{'</c>, so EVERY padded blob returned empty and a padded config the driver
/// refuses deployed clean. Asserting equality with the untrimmed inspection (rather than merely
/// "not empty") also pins that padding changes no message.
/// </summary>
/// <param name="tagConfig">A rejected fixture, padded below.</param>
[Theory]
[MemberData(nameof(Rejected))]
public void Leading_whitespace_draws_exactly_the_warnings_the_untrimmed_blob_draws(string tagConfig)
{
var padded = Pad + tagConfig;
FocasTagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
.ShouldBeFalse("the mapper's verdict is unchanged by padding");
FocasTagDefinitionFactory.Inspect(padded).ShouldBe(FocasTagDefinitionFactory.Inspect(tagConfig));
}
/// <summary>
/// The other half of the same fix, and the one it would be easy to lose: trimming widens the gate
/// ONLY for JSON objects. A reference that is still not object-framed after the trim is not an
/// equipment-tag TagConfig at all — the pre-existing convention shared by all eleven inspectors —
/// so it keeps drawing nothing even though the mapper refuses it.
/// </summary>
/// <param name="reference">A padded, non-object reference.</param>
[Theory]
[InlineData(" just a string")]
[InlineData(" [1,2,3]")]
[InlineData(" \t\r\n 42")]
[InlineData(" \"a\"")]
[InlineData(" ")]
public void A_non_object_reference_still_draws_nothing_after_trimming(string reference)
{
FocasTagDefinitionFactory.FromTagConfig(reference, RawPath, out _).ShouldBeFalse();
FocasTagDefinitionFactory.Inspect(reference).ShouldBeEmpty();
}
}
@@ -89,96 +89,10 @@ public sealed class GatewayGalaxyDataWriterTests
writer.CachedSupervisedHandleCount.ShouldBe(0);
}
// ===== subscribedHandleSource / TryResolveCachedOrBorrowed tests =====
/// <summary>
/// When a <c>subscribedHandleSource</c> delegate returns a positive handle for the
/// requested ref, <see cref="GatewayGalaxyDataWriter.TryResolveCachedOrBorrowed"/> must
/// return that handle. Critically, the borrowed handle must NOT be stored in the writer's
/// own item-handle cache (the subscription registry owns its lifecycle), and
/// <see cref="GatewayGalaxyDataWriter.AddItemCallCount"/> must remain zero (no real
/// gateway round-trip issued).
/// </summary>
[Fact]
public void TryResolveCachedOrBorrowed_returns_borrowed_handle_without_caching_it()
{
var writer = new GatewayGalaxyDataWriter(
MinimalSession(), writeUserId: 0, logger: null,
subscribedHandleSource: fr => fr == "Tag.X" ? 7 : (int?)null);
var result = writer.TryResolveCachedOrBorrowed("Tag.X");
result.ShouldBe(7);
writer.CachedItemHandleCount.ShouldBe(0); // borrow NOT stored in _itemHandles
writer.AddItemCallCount.ShouldBe(0); // no real AddItem issued
}
/// <summary>
/// When a handle is present in the writer's own <c>_itemHandles</c> cache (seeded via
/// <see cref="GatewayGalaxyDataWriter.SeedHandleCachesForTest"/>), that cached handle must
/// win over the <c>subscribedHandleSource</c> delegate — the writer's own earlier AddItem
/// takes priority.
/// </summary>
[Fact]
public void TryResolveCachedOrBorrowed_cached_handle_wins_over_source()
{
// Source would return 99 for "Tag.Y", but the writer already cached handle 42 for it.
var writer = new GatewayGalaxyDataWriter(
MinimalSession(), writeUserId: 0, logger: null,
subscribedHandleSource: fr => fr == "Tag.Y" ? 99 : (int?)null);
writer.SeedHandleCachesForTest("Tag.Y", itemHandle: 42, supervised: false);
var result = writer.TryResolveCachedOrBorrowed("Tag.Y");
result.ShouldBe(42); // cached wins, not 99 from the source
}
/// <summary>
/// When no <c>subscribedHandleSource</c> is provided (<c>null</c>), the method must
/// return <c>null</c> for a ref that has never been cached — preserving today's
/// AddItem-required behavior.
/// </summary>
[Fact]
public void TryResolveCachedOrBorrowed_null_source_returns_null_when_not_cached()
{
var writer = new GatewayGalaxyDataWriter(MinimalSession(), writeUserId: 0);
var result = writer.TryResolveCachedOrBorrowed("Tag.Z");
result.ShouldBeNull();
}
/// <summary>
/// A <c>subscribedHandleSource</c> that returns 0 (a sentinel meaning "subscribe
/// pending / failed") must be treated as no borrow — the method returns <c>null</c>
/// so the caller issues a fresh AddItem.
/// </summary>
[Fact]
public void TryResolveCachedOrBorrowed_source_returning_zero_treated_as_no_borrow()
{
var writer = new GatewayGalaxyDataWriter(
MinimalSession(), writeUserId: 0, logger: null,
subscribedHandleSource: _ => 0);
var result = writer.TryResolveCachedOrBorrowed("Tag.W");
result.ShouldBeNull();
}
/// <summary>
/// A <c>subscribedHandleSource</c> that returns a negative value (another failure
/// sentinel) must also be treated as no borrow — the method returns <c>null</c>.
/// </summary>
[Fact]
public void TryResolveCachedOrBorrowed_source_returning_negative_treated_as_no_borrow()
{
var writer = new GatewayGalaxyDataWriter(
MinimalSession(), writeUserId: 0, logger: null,
subscribedHandleSource: _ => -5);
var result = writer.TryResolveCachedOrBorrowed("Tag.V");
result.ShouldBeNull();
}
// The former subscribedHandleSource / TryResolveCachedOrBorrowed tests are gone with the
// borrow-for-write optimization itself: a subscription's handle is plain-advised, and MXAccess
// both refuses a no-login write through it (SECURITY_ERROR detail 1008) and refuses upgrading
// its advise mode (E_INVALIDARG) — live-captured on the correlated write replies (06/S-1). The
// writer now always AddItems its own supervisory-advised handle, which MXAccess accepts even
// while the same attribute is subscribed (the advise limit is per handle, not per attribute).
}
@@ -125,52 +125,43 @@ public sealed class GatewayGalaxyLiveReopenAndWriteTests
}
/// <summary>
/// <b>Borrow</b> — the writer reuses a live MXAccess item handle the subscription registry
/// already holds, so the first write to an already-subscribed tag skips a redundant AddItem.
/// Proves the load-bearing premise (a subscription handle is usable for a no-login supervisory
/// write that commits) AND the optimization (zero AddItem on the borrow path). Control: a writer
/// with no registry source on the same session AddItems exactly once.
/// <b>Write-while-subscribed</b> — the writer must commit a no-login supervisory write to a
/// tag that is ALREADY live-subscribed on the same session, via its own AddItem'd handle.
/// This replaces the retired borrow smoke: the borrow optimization's premise ("a subscription
/// handle is usable for a committing no-login write") was disproven by the correlated write
/// replies — a plain-advised subscription handle refuses the write (SECURITY_ERROR detail
/// 1008) and refuses an advise upgrade (E_INVALIDARG). What this pins instead is the fix's
/// premise: MXAccess's one-advise-per-handle limit is per HANDLE, not per attribute, so the
/// writer's own supervisory-advised handle commits while the subscription keeps its own.
/// </summary>
[Fact]
public async Task Live_writer_borrows_subscription_handle_and_skips_AddItem()
public async Task Live_write_to_subscribed_tag_commits_via_own_handle()
{
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
var ct = TestContext.Current.CancellationToken;
var clientOptions = BuildClientOptions(endpoint, apiKey);
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUaBorrowSmoke"));
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUaSubWriteSmoke"));
await session.ConnectAsync(clientOptions, ct);
// Subscribe the tag so the registry holds the gateway's REAL item handle for it.
// Live subscription on the tag — the state the borrow path used to piggyback on.
var subscriber = new GatewayGalaxySubscriber(session);
var subResults = await subscriber.SubscribeBulkAsync([WriteRef], bufferedUpdateIntervalMs: 0, ct);
var match = subResults.SingleOrDefault(r =>
string.Equals(r.TagAddress, WriteRef, StringComparison.OrdinalIgnoreCase) && r.WasSuccessful);
match.ShouldNotBeNull($"the gateway should accept a subscription on {WriteRef}");
match!.ItemHandle.ShouldBeGreaterThan(0, "a successful subscribe must return a positive item handle");
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding(WriteRef, match.ItemHandle)]);
// Borrow case: a writer wired to the registry must commit the write WITHOUT a fresh AddItem,
// and must NOT cache the borrowed handle (the registry owns its lifecycle).
var borrowingWriter = new GatewayGalaxyDataWriter(
session, writeUserId: 0, logger: null, subscribedHandleSource: registry.TryResolveItemHandle);
var borrowed = await borrowingWriter.WriteAsync(
// The production writer must AddItem its own handle and commit, despite the live subscription.
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
var result = await writer.WriteAsync(
[new WriteRequest(WriteRef, 2727.0f)], _ => SecurityClassification.FreeAccess, ct);
borrowed.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "the borrowed-handle write should commit Good (0)");
borrowingWriter.AddItemCallCount.ShouldBe(0, "the writer should reuse the subscription handle, not AddItem");
borrowingWriter.CachedItemHandleCount.ShouldBe(0, "a borrowed handle must not be cached in the writer");
// Control: a writer with NO source on the same session must AddItem exactly once for the same tag.
var plainWriter = new GatewayGalaxyDataWriter(session, writeUserId: 0);
var control = await plainWriter.WriteAsync(
[new WriteRequest(WriteRef, 3838.0f)], _ => SecurityClassification.FreeAccess, ct);
control.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "the control write should commit Good (0)");
plainWriter.AddItemCallCount.ShouldBe(1, "with no borrow source the writer must AddItem once");
result.ShouldHaveSingleItem().StatusCode.ShouldBe(0u,
"a write to an already-subscribed tag must commit Good via the writer's own handle");
writer.AddItemCallCount.ShouldBe(1, "the writer owns its handle — exactly one AddItem");
writer.CachedSupervisedHandleCount.ShouldBe(1, "the writer's own handle should be supervisory-advised");
TestContext.Current.SendDiagnosticMessage(
$"borrow smoke: subscribed {WriteRef} -> handle {match.ItemHandle}; borrowed write Good with AddItemCallCount=0; control AddItemCallCount=1");
$"write-while-subscribed smoke: subscribed {WriteRef} (handle {match!.ItemHandle}); own-handle write Good, AddItemCallCount=1");
}
/// <summary>
@@ -201,6 +192,56 @@ public sealed class GatewayGalaxyLiveReopenAndWriteTests
$"fail-closed guard smoke: normal advised write {WriteRef}=5151 returned Good; handle supervised");
}
/// <summary>
/// 06/S-1 confirmation smoke — the gateway (mxaccessgw ≥ <c>53f69cd</c>) correlates the worker's
/// <c>OnWriteComplete</c> onto the unary write reply as <c>statuses[0]</c>, so the production
/// writer's verdict is now the real COM commit outcome, not worker-side acceptance:
/// <list type="bullet">
/// <item><description>a committed write returns Good off a CONFIRMED status row
/// (<c>success:-1, category:OK</c>) — pre-correlation this was a provisional Good off an
/// empty statuses array;</description></item>
/// <item><description>a REFUSED write (read-only attribute) returns a Bad-family code off
/// the correlated failure row (live shape: <c>category:OPERATIONAL_ERROR, detail:1007</c>)
/// — the capability that never existed before: pre-correlation the refusal was invisible
/// and reported Good, so the #5 write-outcome self-correction never fired for Galaxy.
/// </description></item>
/// </list>
/// Requires both deployed gateway halves at the correlation build (server ≥ b948e69, worker ≥
/// 53f69cd); against an older gateway the refusal leg fails — that is the point of the gate.
/// </summary>
[Fact]
public async Task Live_correlated_write_statuses_confirm_commit_and_surface_refusal()
{
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
var ct = TestContext.Current.CancellationToken;
var clientOptions = BuildClientOptions(endpoint, apiKey);
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUa06S1Smoke"));
await session.ConnectAsync(clientOptions, ct);
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
// Happy path: the confirmed commit comes back Good (statuses[0] success row, not empty-array
// provisional). The commit itself is proven by Live_supervisory_write_commits_and_persists_to_galaxy.
var good = await writer.WriteAsync(
[new WriteRequest(WriteRef, 6262.0f)], _ => SecurityClassification.FreeAccess, ct);
good.ShouldHaveSingleItem().StatusCode.ShouldBe(0u,
"a committed write should return Good off the correlated status row");
// Refusal path: Tagname is read-only, so the automation object refuses the write and the
// correlated failure row must surface as a Bad-family code (top two bits 10b). Pre-correlation
// this returned Good. Assert the family, not the exact code — the detail→code mapping may
// gain per-code specificity (StatusCodeMap.FromCategory) without invalidating the gate.
var refused = await writer.WriteAsync(
[new WriteRequest("TestMachine_002.Tagname", "refused-write-probe")],
_ => SecurityClassification.FreeAccess, ct);
var refusedCode = refused.ShouldHaveSingleItem().StatusCode;
((refusedCode >> 30) & 0x3u).ShouldBe(2u,
$"a refused write must surface a Bad-family code off the correlated failure row (got 0x{refusedCode:X8})");
TestContext.Current.SendDiagnosticMessage(
$"06/S-1 smoke: {WriteRef}=6262 -> Good confirmed; Tagname write refused with 0x{refusedCode:X8}");
}
private static (string Endpoint, string ApiKey) RequireLiveGatewayOrSkip()
{
var endpoint = Environment.GetEnvironmentVariable("MXGW_ENDPOINT");
@@ -0,0 +1,133 @@
using ZB.MOM.WW.MxGateway.Client;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Live meter legs for the 06/S-1 write-confirmation gate — the observation that closed the
/// archreview finding: against a correlated gateway (mxaccessgw server ≥ <c>b948e69</c>, worker ≥
/// <c>53f69cd</c>) every unary write reply carries the worker's <c>OnWriteComplete</c> outcome as
/// <c>statuses[0]</c>, so <c>galaxy.writes.unconfirmed</c> — which used to increment on EVERY
/// write — stays flat, and only a completion-wait timeout (or a pre-correlation gateway) meters.
/// <para>
/// ⚠️ Run these FILTERED (one test class), never as part of a full-assembly run with the env vars
/// set: the offline <see cref="GatewayGalaxyDataWriterTests"/> exercise the unconfirmed path
/// against fakes, and xunit runs test classes in parallel, so their increments would pollute the
/// process-wide counters these tests listen to.
/// </para>
/// </summary>
[Trait("Category", "Integration")]
public sealed class GatewayGalaxyWriteMeterLiveTests
{
private const string WriteRef = "TestMachine_002.TestFloat";
/// <summary>
/// Confirmed writes keep both write meters flat: five committed writes and one refusal, all
/// carrying a correlated status row — zero <c>unconfirmed</c> (the counter is presence-based,
/// so the refusal must not increment it either) and zero <c>advise_failed</c>.
/// </summary>
[Fact]
public async Task Live_confirmed_writes_keep_unconfirmed_meter_flat()
{
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
long unconfirmed = 0;
long adviseFailed = 0;
using var listener = new System.Diagnostics.Metrics.MeterListener();
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Name is "galaxy.writes.unconfirmed" or "galaxy.writes.advise_failed")
l.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, _, _) =>
{
if (instrument.Name == "galaxy.writes.unconfirmed") Interlocked.Add(ref unconfirmed, value);
else Interlocked.Add(ref adviseFailed, value);
});
listener.Start();
var ct = TestContext.Current.CancellationToken;
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUa06S1Meter"));
await session.ConnectAsync(BuildClientOptions(endpoint, apiKey), ct);
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
for (var i = 0; i < 5; i++)
{
var r = await writer.WriteAsync(
[new WriteRequest(WriteRef, 7000.0f + i)], _ => SecurityClassification.FreeAccess, ct);
Assert.Equal(0u, r[0].StatusCode);
}
var refused = await writer.WriteAsync(
[new WriteRequest("TestMachine_002.Tagname", "meter-leg-refusal")],
_ => SecurityClassification.FreeAccess, ct);
Assert.Equal(2u, (refused[0].StatusCode >> 30) & 0x3u);
listener.Dispose();
Assert.Equal(0, Interlocked.Read(ref unconfirmed));
Assert.Equal(0, Interlocked.Read(ref adviseFailed));
}
/// <summary>
/// Timeout-control leg — run ONLY while the target gateway has the completion wait disabled
/// (<c>MxGateway__Worker__WriteCompletionWaitMilliseconds=0</c>, an NSSM env-var flip +
/// service restart on a deployed instance): every reply is then empty-statuses, so the writer
/// reports provisional Good and meters each write unconfirmed. Gated on
/// <c>MXGW_WRITE_WAIT_DISABLED=1</c> so it can never run (and fail) against a
/// normally-configured gateway. Remove the override (same nssm command with <c>-</c>) and
/// restart afterwards; <see cref="Live_confirmed_writes_keep_unconfirmed_meter_flat"/>
/// passing again proves the restoration.
/// </summary>
[Fact]
public async Task Live_wait_disabled_gateway_yields_provisional_good_and_metered_unconfirmed()
{
if (Environment.GetEnvironmentVariable("MXGW_WRITE_WAIT_DISABLED") != "1")
Assert.Skip("Set MXGW_WRITE_WAIT_DISABLED=1 (with the gateway's completion wait set to 0) to run the timeout leg.");
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
long unconfirmed = 0;
using var listener = new System.Diagnostics.Metrics.MeterListener();
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Name == "galaxy.writes.unconfirmed") l.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref unconfirmed, value));
listener.Start();
var ct = TestContext.Current.CancellationToken;
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUa06S1Leg3"));
await session.ConnectAsync(BuildClientOptions(endpoint, apiKey), ct);
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
for (var i = 0; i < 3; i++)
{
var r = await writer.WriteAsync(
[new WriteRequest(WriteRef, 7100.0f + i)], _ => SecurityClassification.FreeAccess, ct);
Assert.Equal(0u, r[0].StatusCode); // provisional Good off the empty-statuses reply
}
listener.Dispose();
Assert.Equal(3, Interlocked.Read(ref unconfirmed));
}
private static (string Endpoint, string ApiKey) RequireLiveGatewayOrSkip()
{
var endpoint = Environment.GetEnvironmentVariable("MXGW_ENDPOINT");
var apiKey = Environment.GetEnvironmentVariable("GALAXY_MXGW_API_KEY");
if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey))
Assert.Skip("Set MXGW_ENDPOINT + GALAXY_MXGW_API_KEY to run the live write-meter legs.");
return (endpoint!, apiKey!);
}
private static MxGatewayClientOptions BuildClientOptions(string endpoint, string apiKey) => new()
{
Endpoint = new Uri(endpoint, UriKind.Absolute),
ApiKey = apiKey,
UseTls = false,
ConnectTimeout = TimeSpan.FromSeconds(10),
DefaultCallTimeout = TimeSpan.FromSeconds(30),
StreamTimeout = TimeSpan.FromSeconds(30),
};
}
@@ -108,6 +108,72 @@ public sealed class StatusCodeMapTests
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad);
}
// ===== 06/S-1 write-completion rows: detail is an MxStatus detail code, NOT a quality byte =====
// The gateway (≥ 53f69cd) correlates the worker's OnWriteComplete onto the unary write reply as
// statuses[0]. Those rows carry MxStatus detail codes (live-captured on windev: 1007 operational
// refusal on a read-only attribute, 1008 security refusal on a plain-advised handle). The old
// truncation `(byte)(Detail & 0xFF)` pushed 1007 → 239 and 1008 → 240 into the OPC DA Good band,
// mapping a REFUSED write to Good — no node revert, silently-lost write reported committed.
/// <summary>
/// Per-code mapping from the MxStatusDetail vocabulary (mxaccessgw gateway.md @ 75c71ad).
/// The 1007/1008 rows are the exact live-captured shapes from the 06/S-1 gate.
/// </summary>
[Theory]
[InlineData(1004, MxStatusCategory.OperationalError, 0x803C0000u)] // MX_E_DataOutOfRange → BadOutOfRange
[InlineData(1005, MxStatusCategory.OperationalError, 0x80740000u)] // MX_E_IncorrectDataType → BadTypeMismatch
[InlineData(1006, MxStatusCategory.OperationalError, 0x803A0000u)] // MX_E_NotReadable → BadNotReadable
[InlineData(1007, MxStatusCategory.OperationalError, 0x803B0000u)] // MX_E_NotWriteable → BadNotWritable (live: read-only Tagname)
[InlineData(1008, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_WriteAccessDenied → BadUserAccessDenied (live: plain-advised write)
[InlineData(1012, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_SecuredWrite → BadUserAccessDenied
[InlineData(1013, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_VerifiedWrite → BadUserAccessDenied
[InlineData(1016, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_UserNotHavingAccessRights → BadUserAccessDenied
[InlineData(1017, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_VerifierNotHavingVerifyRights → BadUserAccessDenied
[InlineData(1010, MxStatusCategory.OperationalError, 0x808D0000u)] // MX_E_ObjectInitializing → BadOutOfService (transient)
[InlineData(1011, MxStatusCategory.OperationalError, 0x808D0000u)] // MX_E_EngineInitializing → BadOutOfService (transient)
public void FromMxStatus_KnownDetailCodes_MapPerCode(int detail, MxStatusCategory category, uint expected)
{
var s = new MxStatusProxy { Success = 0, Category = category, Detail = detail, RawDetectedBy = 5 };
StatusCodeMap.FromMxStatus(s).ShouldBe(expected);
}
/// <summary>
/// Unrecognized large details route by category — including 8000
/// MX_E_AutomationObjectSpecificError, which deliberately stays category-level (the object
/// supplies its own semantics).
/// </summary>
[Theory]
[InlineData(2001, MxStatusCategory.CommunicationError, 0x80050000u)] // BadCommunicationError
[InlineData(2001, MxStatusCategory.ConfigurationError, 0x80890000u)] // BadConfigurationError
[InlineData(2001, MxStatusCategory.SoftwareError, 0x80000000u)] // unmapped category → generic Bad
[InlineData(8000, MxStatusCategory.OperationalError, 0x80000000u)] // MX_E_AutomationObjectSpecificError → category-level
[InlineData(8000, MxStatusCategory.SecurityError, 0x801F0000u)] // ... which for a security row is BadUserAccessDenied
public void FromMxStatus_UnmappedLargeDetail_RoutesByCategory(int detail, MxStatusCategory category, uint expected)
{
var s = new MxStatusProxy { Success = 0, Category = category, Detail = detail };
StatusCodeMap.FromMxStatus(s).ShouldBe(expected);
}
/// <summary>
/// Invariant: a row that is not IsSuccess() must NEVER map to Good — a Good-band quality
/// byte (e.g. 216 = GoodLocalOverride) on a failure row is contradictory and falls back to
/// the category route. Uncertain-band substatuses remain allowed (uncertain is not Good).
/// </summary>
[Fact]
public void FromMxStatus_FailureRow_GoodBandQualityByte_NeverMapsGood()
{
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SecurityError, Detail = 216 };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied);
}
/// <summary>An Uncertain-band quality substatus on a failure row still maps to its specific Uncertain code.</summary>
[Fact]
public void FromMxStatus_FailureRow_UncertainBandQualityByte_KeepsUncertainCode()
{
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 68 };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.UncertainLastUsableValue);
}
/// <summary>Verifies that top two bits of status codes follow OPC UA convention.</summary>
[Fact]
public void TopByteCategoryBits_StayWithinOpcUaConvention()
@@ -1,156 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Tests for <see cref="SubscriptionRegistry.TryResolveItemHandle"/> — the forward
/// fullRef → live item-handle lookup the Galaxy writer uses to skip a redundant
/// AddItem round-trip when an already-subscribed tag is written.
/// </summary>
public sealed class SubscriptionRegistryHandleResolveTests
{
/// <summary>
/// Verifies that after registering a binding, TryResolveItemHandle returns the correct handle.
/// </summary>
[Fact]
public void Register_ThenResolve_ReturnsHandle()
{
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.A", 5)]);
registry.TryResolveItemHandle("Tag.A").ShouldBe(5);
}
/// <summary>
/// Verifies that TryResolveItemHandle is case-insensitive on the full reference.
/// </summary>
[Fact]
public void Register_ThenResolve_IsCaseInsensitive()
{
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.A", 5)]);
registry.TryResolveItemHandle("tag.a").ShouldBe(5);
}
/// <summary>
/// Verifies that a full reference that was never registered resolves to null.
/// </summary>
[Fact]
public void NeverRegistered_ReturnsNull()
{
var registry = new SubscriptionRegistry();
registry.TryResolveItemHandle("Tag.NotHere").ShouldBeNull();
}
/// <summary>
/// Verifies that after Remove(), the forward lookup returns null.
/// </summary>
[Fact]
public void Remove_ThenResolve_ReturnsNull()
{
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.A", 5)]);
registry.Remove(1);
registry.TryResolveItemHandle("Tag.A").ShouldBeNull();
}
/// <summary>
/// Verifies that after Rebind() the forward lookup returns the new handle, not the old one.
/// </summary>
[Fact]
public void Rebind_ThenResolve_ReturnsNewHandle()
{
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.A", 5)]);
registry.Rebind(1, [new TagBinding("Tag.A", 99)]);
registry.TryResolveItemHandle("Tag.A").ShouldBe(99);
}
/// <summary>
/// Verifies that a binding with ItemHandle <= 0 (gateway-rejected) is not resolvable.
/// </summary>
[Fact]
public void FailedBinding_ZeroHandle_IsNotResolvable()
{
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.Failed", 0)]);
registry.TryResolveItemHandle("Tag.Failed").ShouldBeNull();
}
/// <summary>
/// Verifies that a binding with a negative ItemHandle is not resolvable.
/// </summary>
[Fact]
public void FailedBinding_NegativeHandle_IsNotResolvable()
{
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.Failed", -1)]);
registry.TryResolveItemHandle("Tag.Failed").ShouldBeNull();
}
/// <summary>
/// Verifies the liveness guard: after the only subscriber of a handle is removed,
/// the forward lookup returns null even if a stale forward-map entry lingers.
/// </summary>
[Fact]
public void Remove_OnlySubscriber_LivenessGuard_ReturnsNull()
{
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.A", 5)]);
registry.Remove(1);
// After removal the subscriber set for handle 5 is gone, so TryResolveItemHandle
// must return null regardless of whether the forward entry was cleaned up.
registry.TryResolveItemHandle("Tag.A").ShouldBeNull();
}
/// <summary>
/// A tag may legitimately appear in multiple driver subscriptions (separate OPC UA monitored
/// items on the same Galaxy attribute) — they share one gw item handle. Removing ONE of them
/// must keep the tag resolvable while another subscription still binds it, so the writer keeps
/// borrowing instead of falling back to AddItem.
/// </summary>
[Fact]
public void Remove_OneOfTwoSubscribersForSameRef_StillResolves()
{
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.A", 5)]);
registry.Register(2, [new TagBinding("Tag.A", 5)]);
registry.Remove(1);
// sub2 still binds Tag.A -> 5, so the borrow must still be offered.
registry.TryResolveItemHandle("Tag.A").ShouldBe(5);
}
/// <summary>
/// Authoritative resolution: if two DIFFERENT refs ever map to the same numeric handle and the
/// subscription for one is removed, that ref must NOT resolve to the handle that now belongs to
/// the other ref — a wrong-tag write would be the worst outcome. Resolution confirms a live
/// subscription genuinely binds fullRef -> handle, not just that the handle is alive.
/// </summary>
[Fact]
public void Remove_CrossRefSameHandle_DoesNotResolveToTheOtherRefsHandle()
{
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.A", 5)]);
registry.Register(2, [new TagBinding("Tag.B", 5)]);
registry.Remove(1);
// Tag.A's subscription is gone; handle 5 is still alive but now only Tag.B binds it.
registry.TryResolveItemHandle("Tag.A").ShouldBeNull();
registry.TryResolveItemHandle("Tag.B").ShouldBe(5);
}
}
@@ -0,0 +1,189 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Gitea #551 items 2/3: <see cref="ModbusTagDefinitionFactory.Inspect"/> is <b>accept-gate-first</b> —
/// it asks <see cref="ModbusTagDefinitionFactory.FromTagConfig"/> for the verdict and only explains a
/// refusal.
/// <para>
/// Two properties, and they pull in opposite directions, which is why both are pinned here. The
/// gate must never be <b>stricter</b> than the mapper (a warning can be promoted to a blocking
/// deploy error by <c>TagConfigValidationMode.Error</c>, so a warning on a blob the driver serves
/// would refuse a working config); and it must never be <b>silent</b> on a blob the mapper refuses
/// (that tag deploys green and then reads <c>BadNodeIdUnknown</c> forever). The previous hand-list
/// of enum checks satisfied the first and failed the second for every cause not on the list —
/// an absent <c>region</c>, a bad <c>address</c>, <c>stringByteOrder</c>, and the two
/// type-dependent guards.
/// </para>
/// </summary>
public sealed class ModbusInspectAcceptGateTests
{
private const string RawPath = "cell/modbus/dev1/T1";
private const string Clean = """{"region":"HoldingRegisters","address":1,"dataType":"Int16"}""";
/// <summary>Every blob below is one the mapper ACCEPTS, so the gate must say nothing about it.</summary>
public static TheoryData<string> Accepted() =>
[
Clean,
"""{"region":"Coils","address":0,"dataType":"Bool"}""",
"""{"region":"HoldingRegisters","address":65535,"dataType":"String","stringLength":10,"stringByteOrder":"LowByteFirst"}""",
"""{"region":"HoldingRegisters","address":1,"dataType":"BitInRegister","bitIndex":15}""",
// The byte/ushort NARROWING is part of the mapper's rule: bitIndex 256 truncates to bit 0 and is
// ACCEPTED. A gate that described the un-narrowed value would refuse a tag the driver serves.
"""{"region":"HoldingRegisters","address":1,"dataType":"BitInRegister","bitIndex":256}""",
// A non-string enum token reads as ABSENT, not invalid — the mapper accepts it as the default.
"""{"region":"HoldingRegisters","address":1,"dataType":3}""",
];
/// <summary>Every blob below is one the mapper REJECTS, so the gate must produce at least one warning.</summary>
public static TheoryData<string> Rejected() =>
[
"""{"address":1,"dataType":"Int16"}""", // no region
"""{"region":"HoldingRegisters","dataType":"Int16"}""", // no address
"""{"region":"HoldingRegisters","address":-1,"dataType":"Int16"}""", // address < 0
"""{"region":"HoldingRegisters","address":65536,"dataType":"Int16"}""", // address > ushort
"""{"region":"HoldingRegisters","address":"1","dataType":"Int16"}""", // address not a number
"""{"region":"HoldingRegisterz","address":1,"dataType":"Int16"}""", // typo'd region
"""{"region":"HoldingRegisters","address":1,"dataType":"Intt16"}""", // typo'd dataType
"""{"region":"HoldingRegisters","address":1,"dataType":"Int16","byteOrder":"Bigg"}""", // typo'd byteOrder
"""{"region":"HoldingRegisters","address":1,"dataType":"Int16","stringByteOrder":"Nope"}""", // typo'd stringByteOrder
"""{"region":"HoldingRegisters","address":1,"dataType":"String"}""", // String with no length
"""{"region":"HoldingRegisters","address":1,"dataType":"BitInRegister","bitIndex":16}""", // bit out of range
];
[Theory]
[MemberData(nameof(Accepted))]
public void A_config_the_mapper_accepts_draws_no_warning(string tagConfig)
{
ModbusTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeTrue("fixture must be accepted");
ModbusTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty();
}
[Theory]
[MemberData(nameof(Rejected))]
public void A_config_the_mapper_rejects_is_never_passed_over_in_silence(string tagConfig)
{
ModbusTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeFalse("fixture must be rejected");
ModbusTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty();
}
// ---- The specific causes the issue named, each with a message that points at the right key ----
[Fact]
public void An_absent_region_is_named()
=> ModbusTagDefinitionFactory.Inspect("""{"address":1,"dataType":"Int16"}""")
.ShouldContain(w => w.Contains("'region'"));
[Fact]
public void An_out_of_range_address_is_named()
=> ModbusTagDefinitionFactory.Inspect("""{"region":"Coils","address":70000,"dataType":"Int16"}""")
.ShouldContain(w => w.Contains("'address'"));
/// <summary>Gitea #551 item 3: read strictly by the mapper since R2-11, checked by nothing until now.</summary>
[Fact]
public void A_typo_in_stringByteOrder_is_named()
=> ModbusTagDefinitionFactory.Inspect(
"""{"region":"HoldingRegisters","address":1,"dataType":"Int16","stringByteOrder":"HighByteFrist"}""")
.ShouldContain(w => w.Contains("stringByteOrder") && w.Contains("HighByteFrist"));
[Fact]
public void A_String_tag_with_no_stringLength_is_named()
=> ModbusTagDefinitionFactory.Inspect("""{"region":"HoldingRegisters","address":1,"dataType":"String"}""")
.ShouldContain(w => w.Contains("stringLength"));
[Fact]
public void A_BitInRegister_tag_with_a_bit_over_15_is_named()
=> ModbusTagDefinitionFactory.Inspect(
"""{"region":"HoldingRegisters","address":1,"dataType":"BitInRegister","bitIndex":16}""")
.ShouldContain(w => w.Contains("bitIndex"));
// ---- Framing rules preserved from the pre-#551 shape ----
[Fact]
public void A_blank_reference_draws_nothing()
=> ModbusTagDefinitionFactory.Inspect(" ").ShouldBeEmpty();
/// <summary>
/// The pre-existing framing convention, deliberately left alone by #551 and shared by all eleven
/// inspectors: a reference that does not START with <c>{</c> is not an equipment-tag TagConfig at
/// all, so it draws nothing — even though the mapper would refuse it. Widening this is a separate
/// decision about what a TagConfig IS, not about how a refusal is explained.
/// </summary>
/// <param name="reference">The non-object reference.</param>
[Theory]
[InlineData("just a string")]
[InlineData("[1,2,3]")]
[InlineData("42")]
public void A_non_object_reference_draws_nothing_because_it_is_not_a_TagConfig(string reference)
{
ModbusTagDefinitionFactory.FromTagConfig(reference, RawPath, out _).ShouldBeFalse();
ModbusTagDefinitionFactory.Inspect(reference).ShouldBeEmpty();
}
[Fact]
public void Unparseable_json_names_the_json()
=> ModbusTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON"));
// ---- Gitea #556 item 1: leading whitespace no longer defeats the framing pre-filter ----
/// <summary>
/// Padding a blob with leading whitespace. A driver is handed whatever authoring persisted, and
/// <c>FromTagConfig</c> passes it straight to <c>JsonDocument.Parse</c>, which skips leading
/// whitespace — so the MAPPER's verdict is identical padded or not, and the gate's must be too.
/// </summary>
private const string Pad = " \t\r\n ";
/// <summary>
/// Never stricter, under padding: a padded blob the mapper still accepts draws nothing. (This
/// direction already held before #556 — the pre-filter returned empty for everything padded — and
/// is kept so a future "just trim everywhere" change cannot start warning about a served tag.)
/// </summary>
/// <param name="tagConfig">An accepted fixture, padded below.</param>
[Theory]
[MemberData(nameof(Accepted))]
public void Leading_whitespace_leaves_an_accepted_config_silent(string tagConfig)
{
var padded = Pad + tagConfig;
ModbusTagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
.ShouldBeTrue("the mapper's JsonDocument.Parse skips leading whitespace");
ModbusTagDefinitionFactory.Inspect(padded).ShouldBeEmpty();
}
/// <summary>
/// Never silent, under padding — the #556 item 1 defect itself. The pre-filter tested
/// <c>reference[0] != '{'</c>, so EVERY padded blob returned empty and a padded config the driver
/// refuses deployed clean. Asserting equality with the untrimmed inspection (rather than merely
/// "not empty") also pins that padding changes no message.
/// </summary>
/// <param name="tagConfig">A rejected fixture, padded below.</param>
[Theory]
[MemberData(nameof(Rejected))]
public void Leading_whitespace_draws_exactly_the_warnings_the_untrimmed_blob_draws(string tagConfig)
{
var padded = Pad + tagConfig;
ModbusTagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
.ShouldBeFalse("the mapper's verdict is unchanged by padding");
ModbusTagDefinitionFactory.Inspect(padded).ShouldBe(ModbusTagDefinitionFactory.Inspect(tagConfig));
}
/// <summary>
/// The other half of the same fix, and the one it would be easy to lose: trimming widens the gate
/// ONLY for JSON objects. A reference that is still not object-framed after the trim is not an
/// equipment-tag TagConfig at all — the pre-existing convention shared by all eleven inspectors —
/// so it keeps drawing nothing even though the mapper refuses it.
/// </summary>
/// <param name="reference">A padded, non-object reference.</param>
[Theory]
[InlineData(" just a string")]
[InlineData(" [1,2,3]")]
[InlineData(" \t\r\n 42")]
[InlineData(" \"a\"")]
[InlineData(" ")]
public void A_non_object_reference_still_draws_nothing_after_trimming(string reference)
{
ModbusTagDefinitionFactory.FromTagConfig(reference, RawPath, out _).ShouldBeFalse();
ModbusTagDefinitionFactory.Inspect(reference).ShouldBeEmpty();
}
}
@@ -0,0 +1,271 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Gitea #551: <see cref="MqttTagDefinitionFactory.Inspect"/> gained an accept gate — see
/// <c>ModbusInspectAcceptGateTests</c>'s remarks for why both directions (never stricter, never silent)
/// are pinned.
/// <para>
/// MQTT's gate is the <b>UNION</b> of its two mappers, because <c>Inspect</c> is handed a blob with
/// no mode: the driver's <c>MqttMode</c> decides whether
/// <see cref="MqttTagDefinitionFactory.FromTagConfig"/> or
/// <see cref="MqttTagDefinitionFactory.FromSparkplugTagConfig"/> reads it, and nothing on a tag row
/// says which. Gating on the Plain mapper alone would warn that every correctly-authored Sparkplug
/// tag has no topic — the falsification control below is exactly that case.
/// </para>
/// </summary>
public sealed class MqttInspectAcceptGateTests
{
private const string RawPath = "cell/mqtt/dev1/T1";
private static bool EitherModeAccepts(string tagConfig)
=> MqttTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _)
|| MqttTagDefinitionFactory.FromSparkplugTagConfig(tagConfig, RawPath, out _);
/// <summary>Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent.</summary>
private static readonly string[] AcceptedBlobs =
[
"""{"topic":"plant/line1/temp"}""",
"""{"topic":"plant/line1/temp","payloadFormat":"Json","dataType":"Float64","qos":1}""",
// Sparkplug: no topic at all, and that is CORRECT — the control this whole union exists for.
"""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temp"}""",
"""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Node Control/Rebirth"}""",
];
/// <summary>Blobs NEITHER mapper accepts — the gate must produce at least one warning for each.</summary>
private static readonly string[] RejectedBlobs =
[
"{}", // no address of either shape
"""{"payloadFormat":"Json","dataType":"String"}""", // still no address
"""{"groupId":"G","edgeNodeId":"E"}""", // incomplete Sparkplug tuple
"""{"topic":"a/b","payloadFormat":"Jsonn"}""", // typo'd payloadFormat
"""{"topic":"a/b","dataType":"Flooat64"}""", // typo'd dataType
"""{"topic":"a/b","qos":5}""", // out-of-range qos
"""{"groupId":"G","edgeNodeId":"E","metricName":"M","qos":"high"}""", // non-numeric qos, both modes
];
/// <summary>Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent.</summary>
/// <returns>The accepted fixtures.</returns>
public static TheoryData<string> Accepted() => [.. AcceptedBlobs];
/// <summary>Blobs NEITHER mapper accepts — the gate must produce at least one warning for each.</summary>
/// <returns>The rejected fixtures.</returns>
public static TheoryData<string> Rejected() => [.. RejectedBlobs];
[Theory]
[MemberData(nameof(Accepted))]
public void A_config_a_mapper_accepts_draws_no_warning(string tagConfig)
{
EitherModeAccepts(tagConfig).ShouldBeTrue("fixture must be accepted by at least one mode");
MqttTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty();
}
[Theory]
[MemberData(nameof(Rejected))]
public void A_config_neither_mapper_accepts_is_never_passed_over_in_silence(string tagConfig)
{
EitherModeAccepts(tagConfig).ShouldBeFalse("fixture must be rejected by both modes");
MqttTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty();
}
[Fact]
public void A_blob_with_no_address_of_either_shape_names_both()
{
var warnings = MqttTagDefinitionFactory.Inspect("{}");
warnings.ShouldContain(w => w.Contains("topic") && w.Contains("metricName"));
}
/// <summary>The wildcard advisory is about an ACCEPTED blob, so the accept gate must not swallow it.</summary>
[Fact]
public void A_wildcard_topic_still_warns_even_though_the_mapper_accepts_it()
{
const string Blob = """{"topic":"plant/+/temp"}""";
MqttTagDefinitionFactory.FromTagConfig(Blob, RawPath, out _).ShouldBeTrue("a wildcard topic is accepted on purpose");
MqttTagDefinitionFactory.Inspect(Blob).ShouldContain(w => w.Contains("wildcard"));
}
[Fact]
public void A_blank_reference_draws_nothing()
=> MqttTagDefinitionFactory.Inspect(" ").ShouldBeEmpty();
[Fact]
public void Unparseable_json_names_the_json()
=> MqttTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON"));
// ---- Gitea #559: leading whitespace no longer changes ANY verdict (was #556 item 1) ----
/// <summary>Padding a blob with leading whitespace.</summary>
private const string Pad = " \t\r\n ";
/// <summary>Every fixture above — accepted and rejected alike — so the padding claim is checked on both.</summary>
/// <returns>The full fixture corpus.</returns>
public static TheoryData<string> AcceptedAndRejected() => [.. AcceptedBlobs, .. RejectedBlobs];
/// <summary>
/// Gitea #559. MQTT used to be the one factory of the seven whose RUNTIME mappers carried their own
/// <c>[0] != '{'</c> framing gate — run on the UNTRIMMED string — so a blob whose only sin was
/// leading whitespace was refused by this driver alone and read <c>BadNodeIdUnknown</c> forever,
/// while the identical blob was served by Modbus, S7, AbCip, AbLegacy, TwinCAT and FOCAS (whose
/// mappers hand it to a whitespace-tolerant <c>JsonDocument.Parse</c>). The mappers now do the same,
/// so the pad is inert everywhere: the verdict AND the inspection are whatever the unpadded blob's
/// content earns.
/// <para>
/// Asserting <b>parity with the unpadded fixture</b> rather than hardcoding "accepted" is the
/// stronger form — it holds for the rejected fixtures too, and it goes red if a future change
/// makes padding matter in either direction.
/// </para>
/// </summary>
/// <param name="tagConfig">A fixture, padded below.</param>
[Theory]
[MemberData(nameof(AcceptedAndRejected))]
public void Leading_whitespace_changes_neither_the_verdict_nor_the_inspection(string tagConfig)
{
var padded = Pad + tagConfig;
EitherModeAccepts(padded).ShouldBe(
EitherModeAccepts(tagConfig),
$"leading whitespace must not change the union verdict. Blob: {tagConfig}");
MqttTagDefinitionFactory.Inspect(padded).ShouldBe(
MqttTagDefinitionFactory.Inspect(tagConfig),
$"leading whitespace must not change the inspection. Blob: {tagConfig}");
}
/// <summary>
/// The anti-vacuity control for the theory above: the corpus it runs over must contain both verdicts,
/// or "padded == unpadded" could hold across a corpus that is uniformly refused (which is exactly what
/// the pre-#559 code did — every padded blob was refused, so a parity claim stated only over the
/// accepted fixtures would have been the thing that changed).
/// </summary>
[Fact]
public void The_padding_corpus_carries_both_verdicts()
{
string[] blobs = [.. AcceptedBlobs, .. RejectedBlobs];
blobs.ShouldContain(b => EitherModeAccepts(Pad + b), "no padded fixture is ACCEPTED");
blobs.ShouldContain(b => !EitherModeAccepts(Pad + b), "no padded fixture is REFUSED");
}
/// <summary>
/// Trimming widens the gate ONLY for JSON objects — a reference still not object-framed after the
/// trim is not an equipment-tag TagConfig at all and keeps drawing nothing.
/// </summary>
/// <param name="reference">A padded, non-object reference.</param>
[Theory]
[InlineData(" just a string")]
[InlineData(" [1,2,3]")]
[InlineData(" \t\r\n 42")]
[InlineData(" \"a\"")]
[InlineData(" ")]
public void A_non_object_reference_still_draws_nothing_after_trimming(string reference)
{
EitherModeAccepts(reference).ShouldBeFalse();
MqttTagDefinitionFactory.Inspect(reference).ShouldBeEmpty();
}
// ---- Gitea #556 item 2: an advisory can no longer swallow the catch-all rejection ----
/// <summary>
/// The structural invariant the backstop exists to hold: a blob the readers REFUSE always draws
/// strictly more than the advisories that same blob would draw if it were accepted. Before #556 the
/// backstop was gated on <c>warnings.Count == 0</c> — and an advisory fires on an ACCEPTED blob too,
/// so it says nothing about the refusal while still satisfying that guard. Any refusal no
/// <c>DescribeRejection</c> branch can name was therefore dropped whenever an advisory was present.
/// Comparing the two counts states the property without string-matching a message to guess its class,
/// which is the whole point of keeping the two classes in separate lists.
/// <para>
/// ⚠️ <b>Gitea #559 made MQTT's own backstop UNREACHABLE, so this replaced a constructed
/// vehicle.</b> That vehicle was a leading-whitespace blob: refused by both mappers' own framing
/// gates while every describe branch — which walks the PARSED document — found it perfectly
/// well-formed. #559 deleted those gates (the refusal was the defect), and no other vehicle
/// exists: a union refusal carrying a <c>topic</c> requires a strict-field failure, which is
/// named; without a topic and without the full Sparkplug tuple the no-address branch fires, which
/// is also named. So the guard is protective here rather than a live fix — exactly the posture
/// <c>FocasInspectAcceptGateTests</c> pins for FOCAS, and pinned the same way. What this holds is
/// the property that must survive the next rejection cause someone adds without a describe branch.
/// </para>
/// </summary>
[Fact]
public void A_rejected_config_always_draws_more_than_its_advisories_alone()
{
// Identical but for the out-of-range qos, so both carry the SAME wildcard advisory.
const string Accepted = """{"topic":"plant/+/temp","qos":2}""";
const string Rejected = """{"topic":"plant/+/temp","qos":5}""";
EitherModeAccepts(Accepted).ShouldBeTrue("control must be accepted");
EitherModeAccepts(Rejected).ShouldBeFalse("subject must be rejected");
var advisoriesOnly = MqttTagDefinitionFactory.Inspect(Accepted);
var withRejection = MqttTagDefinitionFactory.Inspect(Rejected);
advisoriesOnly.Count.ShouldBe(1, "the control's only warning is the wildcard advisory");
withRejection.Count.ShouldBeGreaterThan(
advisoriesOnly.Count,
"a refusal must add a rejection-class warning — the backstop supplies one when nothing names it");
}
// ---- Gitea #557 item 2: the payloadFormat mode-asymmetry contract ----
/// <summary>
/// The decided contract. An invalid <c>payloadFormat</c> is fatal to the Plain reader and invisible
/// to the Sparkplug one (which hard-codes <c>Json</c> and never consults the key), so the UNION
/// verdict both gates use ADMITS a Sparkplug-shaped blob carrying one. That is intended: in
/// Sparkplug mode the driver genuinely serves the tag, and a gate that rejected it would be
/// stricter than the runtime. The cost — the served behaviour depends on the driver's
/// <c>MqttMode</c>, which no tag row records — is paid by an ADVISORY, not by changing the verdict.
/// </summary>
[Fact]
public void An_invalid_payloadFormat_on_a_Sparkplug_blob_is_accepted_and_advised_not_rejected()
{
const string Blob =
"""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temp","payloadFormat":"Jsonn"}""";
MqttTagDefinitionFactory.FromSparkplugTagConfig(Blob, RawPath, out _)
.ShouldBeTrue("the Sparkplug reader never consults payloadFormat");
MqttTagDefinitionFactory.FromTagConfig(Blob, RawPath, out _)
.ShouldBeFalse("the Plain reader reads payloadFormat strictly (and this blob has no topic)");
var warnings = MqttTagDefinitionFactory.Inspect(Blob);
warnings.ShouldContain(w => w.Contains("payloadFormat") && w.Contains("Jsonn"));
warnings.ShouldContain(w => w.Contains("Plain mode"), "the advisory must name the mode dependency");
// The verdict itself is unchanged — this is an advisory, not a rejection. A rejection would name
// the tag as unresolvable, which is false for every Sparkplug deployment.
warnings.ShouldNotContain(w => w.Contains("rejected by the driver's tag reader"));
}
/// <summary>
/// The falsification control for the rule above: the SAME invalid <c>payloadFormat</c> on a blob
/// the union REFUSES is a rejection, not an advisory — so the advisory branch cannot be a blanket
/// "payloadFormat is always advisory", which would under-report a Plain tag that is dead in every
/// mode. The class a key lands in is decided by the accept gate, never by the key.
/// </summary>
[Fact]
public void The_same_invalid_payloadFormat_on_a_rejected_blob_is_a_rejection()
{
const string Blob = """{"topic":"plant/line1/temp","payloadFormat":"Jsonn"}""";
EitherModeAccepts(Blob).ShouldBeFalse("Plain refuses the enum, Sparkplug has no binding tuple");
var warnings = MqttTagDefinitionFactory.Inspect(Blob);
warnings.ShouldContain(w => w.Contains("payloadFormat") && w.Contains("Jsonn"));
warnings.ShouldNotContain(w => w.Contains("Plain mode"), "an unserved tag is not a mode-dependent case");
}
/// <summary>
/// Both gates must keep agreeing on the VERDICT (Gitea #557 item 2). The #554 authoring consult in
/// <c>MqttTagConfigModel</c> asks the identical union and only words a REFUSAL, so an advisory can
/// never leak into the modal as a blocking error. Re-stated here as the union itself, since the
/// AdminUI model is not referenced from this project.
/// </summary>
[Fact]
public void The_authoring_consults_union_is_the_same_union_the_deploy_gate_uses()
{
const string Blob =
"""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temp","payloadFormat":"Jsonn"}""";
// Both gates read this verdict; the deploy gate's non-empty inspection above does not change it.
EitherModeAccepts(Blob).ShouldBeTrue();
MqttTagDefinitionFactory.Inspect(Blob).ShouldNotBeEmpty("advisories are allowed on an accepted blob");
}
}
@@ -148,7 +148,10 @@ public sealed class MqttTagDefinitionFactoryTests
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("factory/oven/temp")] // a bare reference, not a TagConfig blob (no leading '{')
// Since Gitea #559 the mapper carries no framing gate of its own: these are refused by
// JsonDocument.Parse (a JsonException) and by the root-ValueKind check respectively, which is exactly
// how the six sibling factories have always refused them.
[InlineData("factory/oven/temp")] // a bare reference, not JSON at all ⇒ JsonException
[InlineData("[1,2,3]")] // valid JSON, wrong root kind
[InlineData("{ not json at all")] // unparseable — must not throw
[InlineData("""{"payloadFormat":"Raw"}""")] // no topic ⇒ nothing to subscribe to
@@ -218,7 +221,10 @@ public sealed class MqttTagDefinitionFactoryTests
[InlineData("")]
[InlineData(" ")]
[InlineData("factory/oven/temp")]
[InlineData("[1,2,3]")] // no leading '{' ⇒ not a TagConfig blob at all (mirrors Modbus)
// Inspect keeps a framing PRE-FILTER (trimmed since #556): a reference that is not object-framed is not
// an equipment-tag TagConfig at all, so it draws nothing rather than a rejection. The mappers no longer
// carry one (#559) — they reach the same verdict via Parse + ValueKind. Mirrors Modbus.
[InlineData("[1,2,3]")]
public void Inspect_NotATagConfigBlob_ReturnsEmpty(string? reference)
=> MqttTagDefinitionFactory.Inspect(reference!).ShouldBeEmpty();
@@ -259,4 +265,87 @@ public sealed class MqttTagDefinitionFactoryTests
.ShouldBeTrue();
plain.Topic.ShouldBe("a/b");
}
// ---- Gitea #559: the runtime mappers are whitespace-tolerant, like every sibling factory ----
/// <summary>
/// A pad containing every whitespace form JSON allows before a value — space, tab, CR, LF.
/// </summary>
private const string Pad = " \t\r\n ";
/// <summary>
/// Gitea #559. Both mappers used to carry <c>tagConfig[0] != '{'</c> — evaluated on the UNTRIMMED
/// string — so a TagConfig blob with leading whitespace was refused by MQTT alone and the tag read
/// <c>BadNodeIdUnknown</c> forever, while the same blob was served by the other six structural-parse
/// drivers (whose mappers hand it straight to <c>JsonDocument.Parse</c>, which skips leading
/// whitespace per the JSON spec). No author means leading whitespace as semantic, and a CSV or API
/// authoring path can introduce it invisibly.
/// <para>
/// ⚠️ It asserts the produced <b>definition</b>, not just the bool: a fix that accepted the padded
/// blob but mapped it differently (e.g. by trimming into the wrong field) would pass a
/// verdict-only test. <see cref="MqttTagDefinition"/> is a record, so this is value equality over
/// every field.
/// </para>
/// </summary>
/// <param name="blob">A valid Plain-shape blob, padded below.</param>
[Theory]
[InlineData("""{"topic":"factory/oven/temp"}""")]
[InlineData("""{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64","qos":1}""")]
[InlineData("""{"topic":"factory/oven/temp","writeTopic":"factory/oven/setpoint","retainSeed":false}""")]
public void FromTagConfig_LeadingWhitespace_MapsIdenticallyToTheUnpaddedBlob(string blob)
{
MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var unpadded)
.ShouldBeTrue("the unpadded control must be accepted");
MqttTagDefinitionFactory.FromTagConfig(Pad + blob, RawPath, out var padded)
.ShouldBeTrue("leading whitespace is not semantic — JsonDocument.Parse skips it");
padded.ShouldBe(unpadded);
}
/// <summary>The same claim for the Sparkplug reader — it carried the identical gate (Gitea #559).</summary>
/// <param name="blob">A valid Sparkplug-shape blob, padded below.</param>
[Theory]
[InlineData("""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temp"}""")]
[InlineData("""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Speed","dataType":"Float64","qos":1}""")]
public void FromSparkplugTagConfig_LeadingWhitespace_MapsIdenticallyToTheUnpaddedBlob(string blob)
{
MqttTagDefinitionFactory.FromSparkplugTagConfig(blob, RawPath, out var unpadded)
.ShouldBeTrue("the unpadded control must be accepted");
MqttTagDefinitionFactory.FromSparkplugTagConfig(Pad + blob, RawPath, out var padded)
.ShouldBeTrue("leading whitespace is not semantic — JsonDocument.Parse skips it");
padded.ShouldBe(unpadded);
}
/// <summary>
/// The falsification control: trimming widens the mappers ONLY for JSON objects. A padded blank, a
/// padded array and padded non-JSON are still refused by both readers — <c>JsonDocument.Parse</c>
/// plus the root-<c>ValueKind</c> check is the whole framing authority, and it is not laxer than the
/// first-byte check was for anything but whitespace.
/// </summary>
/// <param name="reference">A padded reference that is not a TagConfig object.</param>
[Theory]
[InlineData(" \t\r\n ")] // whitespace only
[InlineData(" \t\r\n [1,2,3]")] // valid JSON, wrong root kind
[InlineData(" \t\r\n garbage")] // not JSON at all
[InlineData(" \t\r\n 42")] // number root
[InlineData(" \t\r\n \"a\"")] // string root
[InlineData(" \t\r\n factory/oven/temp")] // a bare reference, padded
public void BothMappers_RefusePaddedNonTagConfigReferences(string reference)
{
MqttTagDefinitionFactory.FromTagConfig(reference, RawPath, out _).ShouldBeFalse();
MqttTagDefinitionFactory.FromSparkplugTagConfig(reference, RawPath, out _).ShouldBeFalse();
}
/// <summary>
/// A padded blob whose CONTENT is bad is still refused for that content — the pad neither rescues a
/// malformed blob nor supplies a second reason to refuse a good one.
/// </summary>
/// <param name="blob">A padded blob that is object-framed but unreadable.</param>
[Theory]
[InlineData("""{"topic":"a/b","payloadFormat":"Jsonn"}""")]
[InlineData("""{"topic":"a/b","qos":5}""")]
[InlineData("{}")]
public void FromTagConfig_LeadingWhitespace_DoesNotRescueAMalformedBlob(string blob)
=> MqttTagDefinitionFactory.FromTagConfig(Pad + blob, RawPath, out _).ShouldBeFalse();
}
@@ -22,7 +22,9 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
<!-- NamespaceMap.FromTable — it moved from .Contracts to the .Session leaf when .Contracts was
made dependency-light (ControlPlane references .Contracts for the G-7 deploy gate). -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
</ItemGroup>
@@ -0,0 +1,74 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Pins the dependency shape of <c>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts</c>: it must not
/// reference the OPC UA client SDK.
/// <para>
/// <b>Why this matters beyond tidiness.</b> Deferment-register G-7 wired
/// <see cref="OpcUaClientTagDefinitionFactory.Inspect"/> into the deploy-time TagConfig gate, which
/// made <c>ControlPlane</c> project-reference this Contracts assembly. ControlPlane is loaded by
/// every admin- and driver-role host, so whatever Contracts drags in, they all carry. Contracts
/// used to hold <c>NamespaceMap</c> — <c>ISession</c>/<c>NodeId</c>-typed throughout — and with it a
/// <c>OPCFoundation.NetStandard.Opc.Ua.Client</c> PackageReference; NamespaceMap moved to the
/// sibling <c>.Session</c> leaf so the parse-only half could stay light (the same shape as
/// <c>Calculation.Contracts</c>: Core.Abstractions and nothing else).
/// </para>
/// <para>
/// <b>Direction matters: this is an ABSENCE assertion, and that is what makes it sound here.</b>
/// The assembly manifest is an unreliable witness for PRESENCE — the C# compiler elides a reference
/// nothing in the IL actually uses, so "assembly X is referenced" can be false while the csproj says
/// otherwise (the trap <c>EquipmentTagConfigInspectorCoverageTests</c> documents, which is why that
/// one scans the output directory instead). Absence has no such hole in the direction that matters:
/// the moment someone adds Opc.Ua-typed code to Contracts, the compiler MUST emit the reference and
/// this test goes red. A csproj PackageReference that nothing consumes is elided and slips past —
/// accepted deliberately, because an unused package reference is not the failure being guarded.
/// </para>
/// <para>
/// Falsified 2026-08-09 by temporarily adding an <c>Opc.Ua.NodeId</c>-typed member to Contracts:
/// the assertion failed naming <c>Opc.Ua.Core</c>, and passed again once removed.
/// </para>
/// </summary>
public sealed class ContractsIsDependencyLightTests
{
/// <summary>The Contracts assembly's manifest must name no OPC UA SDK assembly.</summary>
[Fact]
public void The_Contracts_assembly_does_not_reference_the_OPC_UA_SDK()
{
var contracts = typeof(OpcUaClientTagDefinitionFactory).Assembly;
// Positive control: name the assembly under test, so a rename/retarget cannot silently point this
// at something else that trivially satisfies the assertion below.
contracts.GetName().Name.ShouldBe("ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts");
var opcUa = contracts.GetReferencedAssemblies()
.Select(a => a.Name ?? string.Empty)
.Where(n => n.StartsWith("Opc.Ua", StringComparison.OrdinalIgnoreCase))
.OrderBy(n => n, StringComparer.Ordinal)
.ToArray();
opcUa.ShouldBeEmpty(
"OpcUaClient.Contracts must stay free of the OPC UA client SDK — ControlPlane project-references "
+ "it for the G-7 deploy-time TagConfig gate, so every host that loads the control plane would "
+ "carry the stack. Put SDK-typed code in Driver.OpcUaClient.Session instead. Found: "
+ string.Join(", ", opcUa));
}
/// <summary>
/// The counterpart the assertion above is meaningful against: the SDK-typed half really did land
/// somewhere, and that somewhere does reference the SDK. Without this, "no Opc.Ua reference
/// anywhere" would read as a pass.
/// </summary>
[Fact]
public void The_Session_assembly_is_where_the_OPC_UA_SDK_reference_lives()
{
var session = typeof(NamespaceMap).Assembly;
session.GetName().Name.ShouldBe("ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session");
session.GetReferencedAssemblies()
.Select(a => a.Name ?? string.Empty)
.ShouldContain(n => n.StartsWith("Opc.Ua", StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,159 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Gitea #551 item 2: <see cref="S7TagDefinitionFactory.Inspect"/> is <b>accept-gate-first</b> — it asks
/// <see cref="S7TagDefinitionFactory.FromTagConfig"/> for the verdict and only explains a refusal. See
/// <c>ModbusInspectAcceptGateTests</c>'s remarks for why both directions (never stricter, never silent)
/// are pinned.
/// <para>
/// S7 carries the fleet's one unique rule: the reader REQUIRES the <c>dataType</c> key to be
/// present. A blob with only an <c>address</c> therefore deploys green and reads
/// <c>BadNodeIdUnknown</c> forever — and neither this gate nor the AdminUI model said a word about
/// it until #551.
/// </para>
/// </summary>
public sealed class S7InspectAcceptGateTests
{
private const string RawPath = "cell/s7/dev1/T1";
/// <summary>Blobs the mapper ACCEPTS — the gate must say nothing about any of them.</summary>
public static TheoryData<string> Accepted() =>
[
"""{"address":"DB1.DBW0","dataType":"Int16"}""",
"""{"address":"M0.0","dataType":"Bool"}""",
"""{"address":"DB1.DBB0","dataType":"String","stringLength":254}""",
// stringLength is irrelevant to a non-String tag, so an out-of-range remnant is ACCEPTED.
"""{"address":"DB1.DBW0","dataType":"Int16","stringLength":9999}""",
// A non-string dataType token satisfies the PRESENCE rule and then reads as absent ⇒ Int16.
"""{"address":"DB1.DBW0","dataType":7}""",
];
/// <summary>Blobs the mapper REJECTS — the gate must produce at least one warning for each.</summary>
public static TheoryData<string> Rejected() =>
[
"""{"dataType":"Int16"}""", // no address
"""{"address":"","dataType":"Int16"}""", // blank address
"""{"address":123,"dataType":"Int16"}""", // address not a string
"""{"address":"DB1.DBW0"}""", // no dataType key at all (#551)
"""{"address":"DB1.DBW0","dataType":"Intt16"}""", // typo'd dataType
"""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""", // over the 254 S7 limit
"""{"address":"DB1.DBB0","dataType":"String","stringLength":-1}""", // negative length
];
[Theory]
[MemberData(nameof(Accepted))]
public void A_config_the_mapper_accepts_draws_no_warning(string tagConfig)
{
S7TagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeTrue("fixture must be accepted");
S7TagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty();
}
[Theory]
[MemberData(nameof(Rejected))]
public void A_config_the_mapper_rejects_is_never_passed_over_in_silence(string tagConfig)
{
S7TagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeFalse("fixture must be rejected");
S7TagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty();
}
/// <summary>The headline case: present-and-typo'd was reported; ABSENT was not (Gitea #551 item 2).</summary>
[Fact]
public void An_absent_dataType_is_named()
=> S7TagDefinitionFactory.Inspect("""{"address":"DB1.DBW0"}""")
.ShouldContain(w => w.Contains("no 'dataType'"));
[Fact]
public void An_absent_address_is_named()
=> S7TagDefinitionFactory.Inspect("""{"dataType":"Int16"}""")
.ShouldContain(w => w.Contains("'address'"));
[Fact]
public void An_out_of_range_string_length_is_named()
=> S7TagDefinitionFactory.Inspect("""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""")
.ShouldContain(w => w.Contains("stringLength"));
// ---- DeclaresDataType is the shared authority the AdminUI model asks (one rule, two doors) ----
[Theory]
[InlineData("""{"dataType":"Int16"}""", true)]
[InlineData("""{"dataType":7}""", true)] // presence is judged on the KEY, not the value
[InlineData("""{"dataType":null}""", true)]
[InlineData("""{"address":"DB1.DBW0"}""", false)]
public void DeclaresDataType_judges_the_key_not_the_value(string tagConfig, bool expected)
{
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
S7TagDefinitionFactory.DeclaresDataType(doc.RootElement).ShouldBe(expected);
}
[Fact]
public void A_blank_reference_draws_nothing()
=> S7TagDefinitionFactory.Inspect(" ").ShouldBeEmpty();
[Fact]
public void Unparseable_json_names_the_json()
=> S7TagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON"));
// ---- Gitea #556 item 1: leading whitespace no longer defeats the framing pre-filter ----
/// <summary>
/// Padding a blob with leading whitespace. A driver is handed whatever authoring persisted, and
/// <c>FromTagConfig</c> passes it straight to <c>JsonDocument.Parse</c>, which skips leading
/// whitespace — so the MAPPER's verdict is identical padded or not, and the gate's must be too.
/// </summary>
private const string Pad = " \t\r\n ";
/// <summary>
/// Never stricter, under padding: a padded blob the mapper still accepts draws nothing. (This
/// direction already held before #556 — the pre-filter returned empty for everything padded — and
/// is kept so a future "just trim everywhere" change cannot start warning about a served tag.)
/// </summary>
/// <param name="tagConfig">An accepted fixture, padded below.</param>
[Theory]
[MemberData(nameof(Accepted))]
public void Leading_whitespace_leaves_an_accepted_config_silent(string tagConfig)
{
var padded = Pad + tagConfig;
S7TagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
.ShouldBeTrue("the mapper's JsonDocument.Parse skips leading whitespace");
S7TagDefinitionFactory.Inspect(padded).ShouldBeEmpty();
}
/// <summary>
/// Never silent, under padding — the #556 item 1 defect itself. The pre-filter tested
/// <c>reference[0] != '{'</c>, so EVERY padded blob returned empty and a padded config the driver
/// refuses deployed clean. Asserting equality with the untrimmed inspection (rather than merely
/// "not empty") also pins that padding changes no message.
/// </summary>
/// <param name="tagConfig">A rejected fixture, padded below.</param>
[Theory]
[MemberData(nameof(Rejected))]
public void Leading_whitespace_draws_exactly_the_warnings_the_untrimmed_blob_draws(string tagConfig)
{
var padded = Pad + tagConfig;
S7TagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
.ShouldBeFalse("the mapper's verdict is unchanged by padding");
S7TagDefinitionFactory.Inspect(padded).ShouldBe(S7TagDefinitionFactory.Inspect(tagConfig));
}
/// <summary>
/// The other half of the same fix, and the one it would be easy to lose: trimming widens the gate
/// ONLY for JSON objects. A reference that is still not object-framed after the trim is not an
/// equipment-tag TagConfig at all — the pre-existing convention shared by all eleven inspectors —
/// so it keeps drawing nothing even though the mapper refuses it.
/// </summary>
/// <param name="reference">A padded, non-object reference.</param>
[Theory]
[InlineData(" just a string")]
[InlineData(" [1,2,3]")]
[InlineData(" \t\r\n 42")]
[InlineData(" \"a\"")]
[InlineData(" ")]
public void A_non_object_reference_still_draws_nothing_after_trimming(string reference)
{
S7TagDefinitionFactory.FromTagConfig(reference, RawPath, out _).ShouldBeFalse();
S7TagDefinitionFactory.Inspect(reference).ShouldBeEmpty();
}
}
@@ -3,6 +3,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
@@ -398,4 +399,116 @@ public sealed class SqlBrowseSessionTests
// Every catalog command went through the provider hook (Oracle's BindByName lives there).
dialect.ConfiguredCommands.ShouldBe(2);
}
// ---- the wide-row ROW SELECTOR candidate (Gitea #552) -----------------------------------------
/// <summary>
/// The session states the three parts of a Sql binding it can know — the table, the column, and the
/// table's own date/time column, which the commit turns into <c>rowSelector.topByTimestamp</c>.
/// Without the third the committed blob is a wide-row tag that never says which ROW it reads, which
/// the driver's parser and the authoring gate both refuse.
/// </summary>
[Fact]
public async Task AttributesAsync_statesTheTablesSoleDateTimeColumn_asTheRowSelectorCandidate()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema,
SqliteBrowseFixture.TimestampedTable,
SqliteBrowseFixture.TimestampedTableValueColumn);
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
fields[SqlBrowseAddressFields.Schema].ShouldBe(SqliteDialect.MainSchema);
fields[SqlBrowseAddressFields.Table].ShouldBe(SqliteBrowseFixture.TimestampedTable);
fields[SqlBrowseAddressFields.ColumnName].ShouldBe(SqliteBrowseFixture.TimestampedTableValueColumn);
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.TimestampedTableTimeColumn);
}
/// <summary>
/// The tie-break is FIRST IN CATALOG ORDER (every dialect's column SQL orders by ordinal position),
/// so two operators browsing the same table commit the same binding.
/// <para>The fixture's two candidates are named so catalog order and alphabetical order DISAGREE —
/// <c>zulu_time</c> is declared first and sorts last. A tie-break that quietly became "any candidate"
/// or "sorted by name" fails here rather than passing by luck.</para>
/// </summary>
[Fact]
public async Task AttributesAsync_breaksAMultiCandidateTie_onCatalogOrderNotName()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema,
SqliteBrowseFixture.MultiTimeTable,
SqliteBrowseFixture.MultiTimeValueColumn);
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(
SqliteBrowseFixture.MultiTimeFirstColumn,
"the first date/time column in catalog order wins, not the first alphabetically");
// Guards the fixture itself: the assertion above is only a tie-break test while the two disagree.
string.CompareOrdinal(
SqliteBrowseFixture.MultiTimeFirstColumn, SqliteBrowseFixture.MultiTimeSecondColumn)
.ShouldBeGreaterThan(0, "the expected pick must NOT also be the alphabetically first one");
}
/// <summary>
/// A table with no date/time column states NO candidate — the key is absent, never blank — so the
/// commit can tell "this table cannot be bound by browse" from "the session said nothing".
/// <para>The control that makes this sharp: <c>TagValues</c> holds <c>sample_ts</c>, a column that
/// LOOKS like a timestamp and is declared <c>TEXT</c>. Candidacy is decided by the mapped type, so a
/// name heuristic — which would order the wide-row read by a string column, binding the tag to
/// whichever row sorts last lexically — fails this test.</para>
/// </summary>
[Fact]
public async Task AttributesAsync_statesNoCandidate_whenNoColumnIsDateTimeTyped()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn);
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
fields.ShouldContainKey(SqlBrowseAddressFields.Table);
fields.ShouldNotContainKey(SqlBrowseAddressFields.TopByTimestamp);
// The name-heuristic control, stated explicitly: the tempting column IS present and IS a string.
var stringTyped = await session.AttributesAsync(
SqlBrowseNodeId.ForColumn(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, "sample_ts"),
CancellationToken.None);
stringTyped.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String));
}
/// <summary>
/// The candidate costs no extra catalog round-trip: it is computed from the columns the attributes
/// call already read. Counting the dialect's configured commands is the cheapest honest proxy —
/// one <c>ListColumnsSql</c> execution, exactly as before #552.
/// </summary>
[Fact]
public async Task AttributesAsync_computesTheCandidate_withoutASecondCatalogQuery()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var dialect = new ColonMarkerDialect();
await using var session = new SqlBrowseSession(db.Connection, dialect);
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema,
SqliteBrowseFixture.TimestampedTable,
SqliteBrowseFixture.TimestampedTableValueColumn);
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.TimestampedTableTimeColumn);
dialect.ConfiguredCommands.ShouldBe(1);
}
}
@@ -62,6 +62,40 @@ public sealed class SqliteBrowseFixture : IAsyncDisposable
/// <summary>A table name that exists in no catalog — the "vanished table" / zero-column probe.</summary>
public const string NonExistentTable = "NoSuchTable";
/// <summary>
/// A wide-row table with exactly ONE date/time-typed column — the sole-candidate case for the
/// browse-commit row selector (Gitea #552).
/// </summary>
/// <remarks>
/// Note <see cref="KeyValueTable"/> is the no-candidate control and needs no table of its own: its
/// <c>sample_ts</c> column is declared <c>TEXT</c>, so it is a timestamp by NAME and a
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String"/> by type — exactly the
/// column a name heuristic would order a wide-row read by, and exactly the one the type test must
/// refuse.
/// </remarks>
public const string TimestampedTable = "Readings";
/// <summary>The one <c>DATETIME</c> column of <see cref="TimestampedTable"/>.</summary>
public const string TimestampedTableTimeColumn = "recorded";
/// <summary>A <see cref="TimestampedTable"/> value column — an ordinary wide-row signal.</summary>
public const string TimestampedTableValueColumn = "oven_temp";
/// <summary>
/// A table carrying TWO date/time columns, deliberately named so that catalog order and alphabetical
/// order disagree — <see cref="MultiTimeFirstColumn"/> is declared first but sorts last.
/// </summary>
public const string MultiTimeTable = "MultiStamped";
/// <summary>The FIRST-declared date/time column of <see cref="MultiTimeTable"/> — the expected pick.</summary>
public const string MultiTimeFirstColumn = "zulu_time";
/// <summary>The second-declared date/time column of <see cref="MultiTimeTable"/> — alphabetically first.</summary>
public const string MultiTimeSecondColumn = "alpha_time";
/// <summary>A <see cref="MultiTimeTable"/> value column, sitting BETWEEN the two timestamps.</summary>
public const string MultiTimeValueColumn = "level";
private readonly string _databasePath;
private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection)
@@ -165,6 +199,17 @@ public sealed class SqliteBrowseFixture : IAsyncDisposable
"{ExoticColumn}" GEOGRAPHY NULL,
blob_col BLOB NULL
);
CREATE TABLE "{TimestampedTable}" (
"{TimestampedTableValueColumn}" REAL NULL,
"{TimestampedTableTimeColumn}" DATETIME NOT NULL
);
CREATE TABLE "{MultiTimeTable}" (
"{MultiTimeFirstColumn}" DATETIME NOT NULL,
"{MultiTimeValueColumn}" REAL NULL,
"{MultiTimeSecondColumn}" TIMESTAMP NULL
);
""").ConfigureAwait(false);
// Separate statements: these names must be quoted by the seed itself, so keep them off the
@@ -0,0 +1,132 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Gitea #551: <see cref="TwinCATTagDefinitionFactory.Inspect"/> is <b>accept-gate-first</b> — it asks
/// <see cref="TwinCATTagDefinitionFactory.FromTagConfig"/> for the verdict and only explains a refusal.
/// See <c>ModbusInspectAcceptGateTests</c>'s remarks for why both directions (never stricter, never
/// silent) are pinned. TwinCAT's uncovered rejections were an absent <c>symbolPath</c> and the
/// unsupported <c>Structure</c> data type — a VALID enum name that the mapper still refuses.
/// </summary>
public sealed class TwinCATInspectAcceptGateTests
{
private const string RawPath = "cell/twincat/dev1/T1";
/// <summary>Blobs the mapper ACCEPTS — the gate must say nothing about any of them.</summary>
public static TheoryData<string> Accepted() =>
[
"""{"symbolPath":"MAIN.bStart","dataType":"Bool"}""",
"""{"symbolPath":"MAIN.nCount"}""",
"""{"symbolPath":"MAIN.aData","dataType":"DInt","isArray":true,"arrayLength":8}""",
// A stale arrayLength behind a cleared isArray is scalar, not a rejection.
"""{"symbolPath":"MAIN.nCount","dataType":"DInt","arrayLength":8}""",
];
/// <summary>Blobs the mapper REJECTS — the gate must produce at least one warning for each.</summary>
public static TheoryData<string> Rejected() =>
[
"""{"dataType":"Bool"}""", // no symbolPath
"""{"symbolPath":"","dataType":"Bool"}""", // blank symbolPath
"""{"symbolPath":42}""", // symbolPath not a string
"""{"symbolPath":"MAIN.x","dataType":"Booll"}""", // typo'd dataType
"""{"symbolPath":"MAIN.udt","dataType":"Structure"}""", // valid NAME the driver still refuses
];
[Theory]
[MemberData(nameof(Accepted))]
public void A_config_the_mapper_accepts_draws_no_warning(string tagConfig)
{
TwinCATTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeTrue("fixture must be accepted");
TwinCATTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty();
}
[Theory]
[MemberData(nameof(Rejected))]
public void A_config_the_mapper_rejects_is_never_passed_over_in_silence(string tagConfig)
{
TwinCATTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeFalse("fixture must be rejected");
TwinCATTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty();
}
[Fact]
public void An_absent_symbolPath_is_named()
=> TwinCATTagDefinitionFactory.Inspect("""{"dataType":"Bool"}""")
.ShouldContain(w => w.Contains("'symbolPath'"));
/// <summary>A rejection an enum-spelling check can never see: the NAME parses, the driver still refuses.</summary>
[Fact]
public void A_Structure_typed_tag_is_named()
=> TwinCATTagDefinitionFactory.Inspect("""{"symbolPath":"MAIN.udt","dataType":"Structure"}""")
.ShouldContain(w => w.Contains("Structure"));
[Fact]
public void A_blank_reference_draws_nothing()
=> TwinCATTagDefinitionFactory.Inspect(" ").ShouldBeEmpty();
[Fact]
public void Unparseable_json_names_the_json()
=> TwinCATTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON"));
// ---- Gitea #556 item 1: leading whitespace no longer defeats the framing pre-filter ----
/// <summary>
/// Padding a blob with leading whitespace. A driver is handed whatever authoring persisted, and
/// <c>FromTagConfig</c> passes it straight to <c>JsonDocument.Parse</c>, which skips leading
/// whitespace — so the MAPPER's verdict is identical padded or not, and the gate's must be too.
/// </summary>
private const string Pad = " \t\r\n ";
/// <summary>
/// Never stricter, under padding: a padded blob the mapper still accepts draws nothing. (This
/// direction already held before #556 — the pre-filter returned empty for everything padded — and
/// is kept so a future "just trim everywhere" change cannot start warning about a served tag.)
/// </summary>
/// <param name="tagConfig">An accepted fixture, padded below.</param>
[Theory]
[MemberData(nameof(Accepted))]
public void Leading_whitespace_leaves_an_accepted_config_silent(string tagConfig)
{
var padded = Pad + tagConfig;
TwinCATTagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
.ShouldBeTrue("the mapper's JsonDocument.Parse skips leading whitespace");
TwinCATTagDefinitionFactory.Inspect(padded).ShouldBeEmpty();
}
/// <summary>
/// Never silent, under padding — the #556 item 1 defect itself. The pre-filter tested
/// <c>reference[0] != '{'</c>, so EVERY padded blob returned empty and a padded config the driver
/// refuses deployed clean. Asserting equality with the untrimmed inspection (rather than merely
/// "not empty") also pins that padding changes no message.
/// </summary>
/// <param name="tagConfig">A rejected fixture, padded below.</param>
[Theory]
[MemberData(nameof(Rejected))]
public void Leading_whitespace_draws_exactly_the_warnings_the_untrimmed_blob_draws(string tagConfig)
{
var padded = Pad + tagConfig;
TwinCATTagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
.ShouldBeFalse("the mapper's verdict is unchanged by padding");
TwinCATTagDefinitionFactory.Inspect(padded).ShouldBe(TwinCATTagDefinitionFactory.Inspect(tagConfig));
}
/// <summary>
/// The other half of the same fix, and the one it would be easy to lose: trimming widens the gate
/// ONLY for JSON objects. A reference that is still not object-framed after the trim is not an
/// equipment-tag TagConfig at all — the pre-existing convention shared by all eleven inspectors —
/// so it keeps drawing nothing even though the mapper refuses it.
/// </summary>
/// <param name="reference">A padded, non-object reference.</param>
[Theory]
[InlineData(" just a string")]
[InlineData(" [1,2,3]")]
[InlineData(" \t\r\n 42")]
[InlineData(" \"a\"")]
[InlineData(" ")]
public void A_non_object_reference_still_draws_nothing_after_trimming(string reference)
{
TwinCATTagDefinitionFactory.FromTagConfig(reference, RawPath, out _).ShouldBeFalse();
TwinCATTagDefinitionFactory.Inspect(reference).ShouldBeEmpty();
}
}
@@ -0,0 +1,393 @@
using System.Reflection;
using System.Runtime.ExceptionServices;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
/// <summary>
/// Pins that a superseded open of the browse modal can never write its device's browse context under
/// a different device's displayed modal.
/// </summary>
/// <remarks>
/// <para>
/// Blazor does not serialize <c>OnParametersSetAsync</c> against its own outstanding task, and the
/// modal's <c>_open</c> bool cannot tell two opens apart. Its resolve awaits the merged probe
/// config and the target group context, so without a post-await staleness re-check a
/// close-reopen onto another device lets the first open's late continuation install its
/// <c>_driverType</c> / <c>_mergedConfig</c> / <c>_groupPrefix</c> — and "Connect &amp; browse"
/// would then silently dial the wrong controller and commit under the wrong group. Same race, same
/// fix as the two sibling modals (<c>RawTagModal</c>, <c>RawManualTagEntryModal</c>, 2026-08-05).
/// </para>
/// <para>
/// AdminUI has no bUnit, so the component is driven directly as a class: its injected properties,
/// its <c>[Parameter]</c>s (BL0005 forbids setting those from outside the component, and this
/// project treats warnings as errors) and its private resolve state are reached by reflection.
/// Unlike the siblings this modal calls <c>StateHasChanged</c> on the open path, which throws
/// unless the component holds a render handle — hence the inert renderer below.
/// </para>
/// </remarks>
// BL0006 warns off Microsoft.AspNetCore.Components.RenderTree, which is right for production code and
// unavoidable here: subclassing Renderer is the only way to hand a component a render handle without
// taking a bUnit dependency, and Renderer's abstract render method is typed in that namespace. The surface
// used is that one parameter — no RenderTree type is inspected, stored or asserted on.
#pragma warning disable BL0006
public sealed class RawBrowseModalStaleOpenTests : IDisposable
{
private const string ConfigA = """{"endpoint":"10.0.0.1:44818","slot":0}""";
private const string ConfigB = """{"endpoint":"10.0.0.2:44818","slot":1}""";
private readonly List<InertRenderer> _renderers = new();
[Fact]
public async Task A_stale_open_never_feeds_the_browser_another_devices_config()
{
var deviceAResolve = new TaskCompletionSource();
var svc = new FakeRawTreeService
{
OnLoadMergedProbeConfigAsync = async deviceId =>
{
if (deviceId == "dev-A") { await deviceAResolve.Task; return ("AbCip", ConfigA); }
return ("TwinCAT", ConfigB);
},
};
var modal = NewModal(svc, new FakeBrowserSessionService(), NullExportReader());
// Quick close-reopen onto another device while dev-A's resolve is still in flight.
SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: null);
var staleOpen = OpenAsync(modal);
SetParameters(modal, visible: false, deviceId: "dev-A", tagGroupId: null);
await OpenAsync(modal);
SetParameters(modal, visible: true, deviceId: "dev-B", tagGroupId: null);
await OpenAsync(modal);
deviceAResolve.SetResult();
await staleOpen;
Field<string>(modal, "_mergedConfig").ShouldBe(ConfigB);
Field<string>(modal, "_driverType").ShouldBe("TwinCAT");
}
[Fact]
public async Task A_stale_group_resolve_never_retargets_the_displayed_open()
{
var dbName = SeedGroup("TG-A", deviceId: "dev-A", name: "GroupA");
var groupResolve = new TaskCompletionSource();
var svc = new FakeRawTreeService
{
OnLoadMergedProbeConfig = deviceId =>
deviceId == "dev-A" ? ("AbCip", ConfigA) : null,
};
var modal = NewModal(
svc,
new FakeBrowserSessionService(),
new RawTagCsvExportReader(new GatedDbContextFactory(dbName, groupResolve.Task)));
// The merged-config resolve completes inline here, so this open suspends inside the GROUP
// resolve — the second await, which a fix that guards only the first one leaves unprotected.
SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: "TG-A");
var staleOpen = OpenAsync(modal);
SetParameters(modal, visible: false, deviceId: "dev-A", tagGroupId: "TG-A");
await OpenAsync(modal);
// Re-opened with no device and no target group: the displayed open commits at a device root it
// has not resolved yet, so both writes the stale continuation makes are observable.
SetParameters(modal, visible: true, deviceId: "", tagGroupId: null);
await OpenAsync(modal);
groupResolve.SetResult();
await staleOpen;
Field<string?>(modal, "_groupPrefix").ShouldBeNull();
Field<string>(modal, "_effectiveDeviceId").ShouldBe("");
}
[Fact]
public async Task A_stale_open_completing_late_never_clears_the_live_opens_loading_state()
{
var deviceAResolve = new TaskCompletionSource();
var deviceBResolve = new TaskCompletionSource();
var svc = new FakeRawTreeService
{
OnLoadMergedProbeConfigAsync = async deviceId =>
{
if (deviceId == "dev-A") { await deviceAResolve.Task; return ("AbCip", ConfigA); }
await deviceBResolve.Task;
return ("TwinCAT", ConfigB);
},
};
var modal = NewModal(svc, new FakeBrowserSessionService(), NullExportReader());
SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: null);
var staleOpen = OpenAsync(modal);
SetParameters(modal, visible: false, deviceId: "dev-A", tagGroupId: null);
await OpenAsync(modal);
SetParameters(modal, visible: true, deviceId: "dev-B", tagGroupId: null);
var liveOpen = OpenAsync(modal);
// The stale run finishes while the live one is still resolving. Its tail state — the spinner
// flag and the render it forces — belongs to an open that is no longer displayed: letting the
// `finally` run would drop the live open's spinner and paint the not-yet-resolved
// "Browsing unavailable" panel over a device that is still loading.
deviceAResolve.SetResult();
await staleOpen;
Field<bool>(modal, "_resolving").ShouldBeTrue();
deviceBResolve.SetResult();
await liveOpen;
Field<bool>(modal, "_resolving").ShouldBeFalse();
}
[Fact]
public async Task A_stale_resolve_failure_never_paints_its_error_over_the_live_open()
{
var deviceAResolve = new TaskCompletionSource();
var svc = new FakeRawTreeService
{
OnLoadMergedProbeConfigAsync = async deviceId =>
{
if (deviceId == "dev-A") { await deviceAResolve.Task; return ("AbCip", ConfigA); }
return ("TwinCAT", ConfigB);
},
};
var modal = NewModal(svc, new FakeBrowserSessionService(), NullExportReader());
SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: null);
var staleOpen = OpenAsync(modal);
SetParameters(modal, visible: false, deviceId: "dev-A", tagGroupId: null);
await OpenAsync(modal);
SetParameters(modal, visible: true, deviceId: "dev-B", tagGroupId: null);
await OpenAsync(modal);
// A superseded open that FAILS is the same defect wearing the catch block: its diagnostic names
// a device the operator is no longer looking at, and disables browsing for the one they are.
deviceAResolve.SetException(new InvalidOperationException("dev-A unreachable"));
await staleOpen;
Field<bool>(modal, "_canBrowse").ShouldBeTrue();
Field<string?>(modal, "_disabledReason").ShouldBeNull();
}
/// <inheritdoc/>
public void Dispose()
{
foreach (var renderer in _renderers) { renderer.Dispose(); }
}
/// <summary>Builds a modal with its three injected dependencies set and a render handle attached.</summary>
private RawBrowseModal NewModal(
IRawTreeService svc, IBrowserSessionService browser, RawTagCsvExportReader exportReader)
{
var modal = new RawBrowseModal();
Inject(modal, "Svc", svc);
Inject(modal, "BrowserService", browser);
Inject(modal, "ExportReader", exportReader);
var renderer = new InertRenderer();
_renderers.Add(renderer);
renderer.Attach(modal);
return modal;
}
/// <summary>An export reader over a factory that fails loudly — for tests that must never reach it.</summary>
private static RawTagCsvExportReader NullExportReader()
=> new(new GatedDbContextFactory(dbName: null, gate: Task.CompletedTask));
/// <summary>Seeds a lone tag-group row into a fresh InMemory database and returns its name.</summary>
private static string SeedGroup(string tagGroupId, string deviceId, string name)
{
var dbName = $"rawbrowse-{Guid.NewGuid():N}";
using var db = RawTreeTestDb.CreateNamed(dbName);
db.TagGroups.Add(new TagGroup { TagGroupId = tagGroupId, DeviceId = deviceId, Name = name });
db.SaveChanges();
return dbName;
}
/// <summary>
/// Sets the modal's <c>[Parameter]</c>s. By reflection because BL0005 forbids assigning component
/// parameters from outside the component, and this project treats warnings as errors.
/// </summary>
private static void SetParameters(
RawBrowseModal modal, bool visible, string deviceId, string? tagGroupId)
{
Set("Visible", visible);
Set("DeviceId", deviceId);
Set("TagGroupId", tagGroupId);
void Set(string name, object? value)
{
var property = typeof(RawBrowseModal).GetProperty(name);
property.ShouldNotBeNull($"the [Parameter] {name} was renamed or removed.");
property!.SetValue(modal, value);
}
}
/// <summary>Sets one of the <c>@inject</c>-generated private properties.</summary>
private static void Inject(RawBrowseModal modal, string name, object value)
{
var property = typeof(RawBrowseModal).GetProperty(
name,
BindingFlags.NonPublic | BindingFlags.Instance);
property.ShouldNotBeNull($"the injected {name} property was renamed or removed.");
property!.SetValue(modal, value);
}
/// <summary>Drives the open path — the lifecycle method the resolve hangs off.</summary>
private static async Task OpenAsync(RawBrowseModal modal)
{
var method = typeof(RawBrowseModal).GetMethod(
"OnParametersSetAsync",
BindingFlags.NonPublic | BindingFlags.Instance);
method.ShouldNotBeNull("the open path moved off OnParametersSetAsync — retarget this driver.");
await (Task)method!.Invoke(modal, null)!;
}
/// <summary>Reads one of the modal's private resolve-state fields.</summary>
private static T Field<T>(RawBrowseModal modal, string name)
{
var field = typeof(RawBrowseModal).GetField(name, BindingFlags.NonPublic | BindingFlags.Instance);
field.ShouldNotBeNull($"the modal's {name} field was renamed or removed.");
return (T)field!.GetValue(modal)!;
}
/// <summary>
/// An <see cref="IDbContextFactory{TContext}"/> whose context creation can be held open, so a
/// test can suspend a resolve inside <c>ResolveGroupContextAsync</c> and interleave a second open.
/// A null database name means "must never be called".
/// </summary>
private sealed class GatedDbContextFactory(string? dbName, Task gate)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <inheritdoc/>
public OtOpcUaConfigDbContext CreateDbContext()
{
dbName.ShouldNotBeNull("the group-context resolve was reached by a test that forbids it.");
return RawTreeTestDb.CreateNamed(dbName!);
}
/// <inheritdoc/>
public async Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default)
{
await gate;
return CreateDbContext();
}
}
/// <summary>
/// Browse gate stand-in: every driver+config can browse, so a test asserting that a stale run did
/// not disable browsing has a true baseline to be falsified against. Nothing else is reachable —
/// these tests never open a session.
/// </summary>
private sealed class FakeBrowserSessionService : IBrowserSessionService
{
/// <inheritdoc/>
public bool CanBrowse(string driverType, string configJson) => true;
/// <inheritdoc/>
public Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
=> throw new NotImplementedException();
/// <inheritdoc/>
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct)
=> throw new NotImplementedException();
/// <inheritdoc/>
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct)
=> throw new NotImplementedException();
/// <inheritdoc/>
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct)
=> throw new NotImplementedException();
/// <inheritdoc/>
public Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct)
=> throw new NotImplementedException();
/// <inheritdoc/>
public bool CanRequestRebirth(Guid token) => false;
/// <inheritdoc/>
public Task CloseAsync(Guid token) => throw new NotImplementedException();
}
/// <summary>
/// The smallest renderer that gives a component a working render handle: <c>StateHasChanged</c>
/// throws without one, and this modal calls it on the open path. Renders are built and then
/// discarded — the tests read the modal's state, not its markup.
/// </summary>
private sealed class InertRenderer()
: Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance)
{
/// <inheritdoc/>
public override Dispatcher Dispatcher { get; } = new InlineDispatcher();
/// <summary>Assigns the component a render handle, as a root component would receive.</summary>
public void Attach(IComponent component) => AssignRootComponentId(component);
/// <inheritdoc/>
protected override void HandleException(Exception exception)
=> ExceptionDispatchInfo.Capture(exception).Throw();
/// <inheritdoc/>
protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask;
}
/// <summary>
/// Runs everything on the calling thread. The real dispatcher would refuse a
/// <c>StateHasChanged</c> raised from a test thread (or from a resolve continuation), which is
/// exactly what these tests do.
/// </summary>
private sealed class InlineDispatcher : Dispatcher
{
/// <inheritdoc/>
public override bool CheckAccess() => true;
/// <inheritdoc/>
public override Task InvokeAsync(Action workItem)
{
workItem();
return Task.CompletedTask;
}
/// <inheritdoc/>
public override Task InvokeAsync(Func<Task> workItem) => workItem();
/// <inheritdoc/>
public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem)
=> Task.FromResult(workItem());
/// <inheritdoc/>
public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem) => workItem();
}
}
@@ -99,4 +99,25 @@ public sealed class AbCipTagConfigModelTests
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
}
/// <summary>
/// Gitea #549: the LOAD is lenient — an unrecognised name silently becomes <c>DInt</c> — but
/// <c>AbCipTagDefinitionFactory</c> reads <c>dataType</c> with <c>TryReadEnumStrict</c> and rejects
/// the whole tag, so the save gate must judge the value the blob actually carries rather than the
/// one the load substituted.
/// </summary>
[Fact]
public void Validate_refuses_a_dataType_the_driver_would_reject()
=> AbCipTagConfigModel.FromJson("""{"tagPath":"Motor1.Status","dataType":"DIntt"}""")
.Validate().ShouldNotBeNull().ShouldContain("DIntt");
/// <summary>The other direction, which matters just as much: the check is the runtime's rule and not a
/// stricter one — a valid name in any casing, an absent key, and a present-but-NON-STRING token (which
/// <c>TryReadEnum</c> treats as absent) are all served by the driver and so must all save.</summary>
[Theory]
[InlineData("""{"tagPath":"Motor1.Status","dataType":"real"}""")]
[InlineData("""{"tagPath":"Motor1.Status"}""")]
[InlineData("""{"tagPath":"Motor1.Status","dataType":2}""")]
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
=> AbCipTagConfigModel.FromJson(json).Validate().ShouldBeNull();
}
@@ -129,4 +129,25 @@ public sealed class AbLegacyTagConfigModelTests
m.Validate().ShouldBeNull();
}
/// <summary>
/// Gitea #549: the LOAD is lenient — an unrecognised name silently becomes <c>Int</c> — but
/// <c>AbLegacyTagDefinitionFactory</c> reads <c>dataType</c> with <c>TryReadEnumStrict</c> and
/// rejects the whole tag, so the save gate must judge the value the blob actually carries rather
/// than the one the load substituted.
/// </summary>
[Fact]
public void Validate_refuses_a_dataType_the_driver_would_reject()
=> AbLegacyTagConfigModel.FromJson("""{"address":"N7:0","dataType":"Intt"}""")
.Validate().ShouldNotBeNull().ShouldContain("Intt");
/// <summary>The other direction, which matters just as much: the check is the runtime's rule and not a
/// stricter one — a valid name in any casing, an absent key, and a present-but-NON-STRING token (which
/// <c>TryReadEnum</c> treats as absent) are all served by the driver and so must all save.</summary>
[Theory]
[InlineData("""{"address":"N7:0","dataType":"float"}""")]
[InlineData("""{"address":"N7:0"}""")]
[InlineData("""{"address":"N7:0","dataType":2}""")]
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
=> AbLegacyTagConfigModel.FromJson(json).Validate().ShouldBeNull();
}

Some files were not shown because too many files have changed in this diff Show More