Closes the pin skew against ScadaBridge (which already pins 0.2.1). Pure pin
bump — no code change was required:
- `RegisterReplicated(string)` still delegates to `baselineExistingRows: false`,
so `LocalDbSetup.OnReady` is byte-identical in behaviour.
- Everything 0.2.x added is additive (`DeregisterReplicated`, `MaxBatchBytes`,
the `baselineExistingRows` overload); none of it is called here.
- The wire protocol is unchanged (0.1.x peers still sync) and the on-disk
bookkeeping schema v1 -> v2 upgrades in place on open, so no rig, volume or
operator action is needed.
The one thing that DID need reconciling is documentation. 0.2.0 made batching
byte-budgeted: `MaxBatchBytes` (2 MB default, under the 4 MB gRPC cap) now
bounds the wire message, and `MaxBatchSize` is demoted to a secondary row cap
plus the DB read-page size. The rig's `MaxBatchSize = 16` was chosen as a
hand-computed byte proxy ("row-count-only against gRPC's 4 MB cap"), and that
rationale is now wrong everywhere it appeared — appsettings, Configuration.md,
the pair-replication runbook, and both docker-dev site-a services.
The VALUE stays 16, for a different and still-real reason: OplogStore /
SnapshotStreamer materialise a whole read page in memory *before* the
byte-budget split runs, so 16 x ~171 KB artifact chunk rows bounds that
transient allocation at ~2.7 MB. Documented as such, with `MaxBatchBytes`
added to both config tables.
CLAUDE.md records the pin and two deliberate non-adoptions: this repo does not
take ScadaBridge's conditional-registration + boot-time `DeregisterReplicated`
self-heal (OnReady registers all four tables unconditionally, and
LocalDbSetupTests pins that set), and consequently does not use
`baselineExistingRows` either — so enabling replication on a long-running
default-OFF node converges on subsequent writes, not on rows already in the
file. Both are behaviour changes, out of scope for a pin bump.
Verification: solution builds clean; full suite run (57 projects). All 66
LocalDb tests in Host.IntegrationTests green. One unrelated failure,
RoslynVirtualTagEvaluatorTests.Evaluate_racing_ClearCompiledScripts_never_
fails_with_disposed — a Roslyn evaluator disposal race with no LocalDb in its
path, whose own comment documents that its "at most one clear per evaluation"
assumption depends on thread-pool pressure. It passed 3/3 re-runs in isolation
and fails only under full-solution parallel load; pre-existing test-design
fragility, not a regression from this bump.
The /admin/secrets link was already in the side rail, but ungated — every
authenticated user saw it, and a Designer/Operator clicking it was bounced by
the page's own [Authorize(Policy = SecretsAuthorization.ManagePolicy)]. Hide
the advertised dead end.
Gated on that same policy constant rather than Roles="Administrator". The two
are equivalent today (the RCL's AdminRole is "Administrator", read off the same
ClaimTypes.Role claim FleetAdmin uses), so this is not about reach — it is that
the rail cannot drift if the RCL ever changes its policy.
Live-gated on docker-dev, both directions, because the positive half alone
proves nothing here: the link rendered for an admin BEFORE this change too, so
"admin still sees it" is satisfied equally by a working gate and by an inert
one. Negative control — gate swapped to Roles="NoSuchRoleFalsifyProbe", rebuilt,
item went to 0 while /role-grants and /apikeys stayed at 1 (the item is hidden,
not the rail broken). Restored build hashed to the same image ID as the
pre-falsification one, proving the restore was byte-exact. This also rules out
AuthorizeView being inert inside ThemeShell's <Nav> slot under this host's
static router, which would have silently hidden the link from everyone.
SecretsNavLinkTests covers it in the AdminUI's actual idiom — source scan +
metadata cross-check, NOT bUnit (the bunit PackageVersion pin has zero
PackageReferences repo-wide). The load-bearing assertion is index ordering
(AuthorizeView open < NavRailItem < close): a scan that merely finds both tags
in the file passes when they are not nested, which is the ungated state.
Scope: Secrets only. /role-grants and /apikeys are FleetAdmin (Administrator-
only) too and stay visible to everyone, so the rail is now inconsistent —
deliberate, as hiding those is a UX call for the whole rail rather than
something to smuggle into this change. Noted in the markup.
Family-wide task from the scadaproj index session (ScadaBridge ee193cd2,
ignitionoee 3627bb8, HistorianGateway 58b6418, mxgw 87d575d).
Gitea #561 ("production writes a 0-byte log") is closed as not-a-defect. The
production log on wonder-app-vd03 listed as 0 bytes with LastWriteTime ==
CreationTime; opening it showed 4,658,893 bytes of correct output, written
continuously since service start.
On Windows the directory entry's size and last-write time are not refreshed while
the writing process holds the file open, so Get-ChildItem, dir and FileInfo.Length
all report size-at-open for the life of the handle. A live, healthy log is
indistinguishable from a dead sink by metadata alone — which is what made the
false reading survive six weeks of investigation.
That same trap was in the 29a202f5 instrumentation: CheckFileSinkGrowth measured
with FileInfo.Length, so it would have reported "self-check FAILED: the sink did
not grow" at every boot on a perfectly healthy Windows service — and a
self-check's false positive reads as confirmation of the very bug it was added to
find. MeasureFamilyBytes now reads Length through an opened FileStream:
FileShare.ReadWrite to open it at all while the sink holds its write handle, and
FileShare.Delete so the probe never blocks a roll or retention delete.
Both instruments are kept. A sink that cannot write its file is still a real and
otherwise-invisible failure — a sibling repo reproduced it via handle contention,
where RollingFileSink logs the lock-out to SelfLog and falls through to _001. Only
the two doc claims that are now false are corrected: the class summary's
"production ran that way for six weeks" and the warning text's "#561 failure mode".
Tests: A_working_file_sink_passes already measured while Serilog held the file
open — precisely the broken case — and passed throughout, because it only ever ran
on macOS, where the length is honest. That platform blindness is now recorded in
the class summary, alongside two tests pinning the sharing contract the fix
depends on.
Four-minor jump 0.2.3 → 0.6.2. The release line exists because a deploy
renaming the app directory took the MxGateway credential store with it
(2026-08-09, two silent days): Secrets:SqlitePath must now be absolute AND
outside the content root, rejected at boot rather than relocated. 0.6.2 over
0.6.1 is message-only (the rejection text now teaches the mounted-volume
case and forecloses the override hunt) — the live rig evidence below was
gathered at 0.6.1 and the validator behavior is identical.
Adoption changes beyond the pins:
- appsettings.json drops the relative "otopcua-secrets.db" (now boot-fatal);
dev gets the library's absolute per-user default, deployments supply their
own (wonder already pins an absolute out-of-root path in the service env).
- Program.cs's pre-host ${secret:} expander now calls the FOUR-argument
AddZbSecrets with builder.Environment.ContentRootPath. This call site is
the actual fix, not the pin: the throwaway container has no
IHostEnvironment, and without the explicit root the under-content-root
rule is skipped at exactly the moment the migrator creates the store file
(the 0.6.0 defect). Do NOT simplify back to the 3-argument overload — it
compiles and silently reopens the gap. Pinned by a Program.cs source-scan
test AND a runtime falsification control that RUNS the migration and
demonstrably creates the store at the rejected path under the 3-arg form.
- docker-dev: Secrets__SqlitePath moves /app/data → /data via a SECOND mount
of the same per-node volume (same file at the volume root, so seeded
stores carry over) because /app is the container's content root and the
new rule correctly rejects it. LocalDb stays on /app/data (secrets-only
rule).
- SecretsStorePathValidationTests: positive-control probes, artifact-first
(relative and in-content-root paths must FAIL leaving no file; outside-
root passes; the no-explicit-root control proves the file assertions can
fail) — a clean boot is equally consistent with the validator never being
wired.
Live-verified on the six-node docker-dev rig (at 0.6.1): all nodes boot
under the new validation with stores at /data; an AdminUI-written secret
converged into BOTH central nodes' independent stores; site-a held only its
own pair's secrets (Phase 6 pair-local isolation intact). Replicator wire
behavior unchanged 0.3.0–0.6.x per CHANGELOG; registration-graph pins and
the full suite pass.
The modal's existence was a function of _rows (FindSourceScript() as the
visibility gate), so any render where the row is momentarily unresolvable —
a future reload nulling or replacing the list — unmounts the subtree and
disposes the Close handler's binding id mid-click (GetRequiredEventBindingEntry).
Latent here (_rows is written once, in OnInitializedAsync) but the identical
shape bit ScadaBridge live the moment a page refresh landed, and it presents
as an unattributable intermittent once one does.
ScadaBridge's shape, ported by the family sweep: intent (_sourceScriptId)
gates existence, the resolve drives only content; an unresolvable script
renders a notice with the frame and Close still mounted, and closing remains
the operator's action rather than a side effect of list state.
Production ran six weeks with a healthy process and a 0-byte log because
Serilog isolates per-sink failures: a file sink whose every write fails is
invisible from anywhere but SelfLog, which was not enabled. Two instruments:
- LogPipelineDiagnostics.EnableSelfLog (before the first logger builds):
SelfLog to logs/serilog-selflog.txt (rotated at 5 MB) + stderr — the only
channel that carries the exception the sink swallows.
- CheckFileSinkGrowth (after the startup banner): verifies each configured,
unbuffered file sink's rolled-file family physically grew across a marker
event; a silent sink is reported at boot via SelfLog/stderr and the
console sink instead of discovered months later. Growth-across-marker,
not existence — a previous run's bytes must not mask a silent sink (the
false pass an existence check would have given on wonder-app-vd03).
Never throws: both instruments swallow their own failures — a diagnostics
channel that can fail startup is worse than the defect it watches for.
The load-bearing test is the failure case (a sink that writes nothing IS
reported); a check that can only pass is indistinguishable from no check.
The page ran members, the driver-instances heading, and every cluster group
together inside one panel; driver name and id slammed inline; one long driver
exception blew the table out sideways (Controls scrolled out of reach); and the
inline #546 expander row dumped a full-width panel into the table.
- One panel per cluster group, node chips in the panel head.
- Driver id renders on its own muted mono line under the name.
- Last error abbreviated to 60 chars (DisplayText.Abbreviate — #504 rule),
full text on the tooltip and in the Controls dialog.
- DriverStatusPanel moved from an inline expander row to a modal (one at a
time); the dialog re-resolves its row from the current groups each render so
the 5 s rebuild can't strand it, and the Enabled-in-key recycle discipline
carries over unchanged. HostsPanelWiringTests re-pinned to the dialog markup.
Live-verified on docker-dev: separated cards, in-viewport table, dialog opens
with live breaker/status data and grouped Reconnect|Restart, closes cleanly.
Sweep task 5 (Shared/Drivers + Acls):
- btn-group the Save/Cancel pair in DriverFormShell (all 13 driver forms),
CollectionEditor foot + per-row Edit/Delete, DriverConfigModal /
DeviceModal / DriverTagPicker / AclScopePickerModal feet,
DriverStatusPanel Reconnect+Restart and its inline restart confirm,
and the Refresh+Close browse toolbars on the FOCAS/AbCip/TwinCAT pickers.
- Replace the no-op inline border-color:var(--alert) tint with .notice-alert
in DriverFormShell, DriverConfigModal, DeviceModal, MqttDriverForm,
MTConnectDriverForm, MqttDeviceForm.
- Replace inline style="width:18px" expander sizing with .tree-twist in
DriverBrowseTree and AclScopeTree.
Markup only — every @onclick, binding, disabled and arm->confirm flow is
unchanged.
Group related action buttons into btn-group/btn-group-sm on Alerts, Certificates,
ApiKeys and ScriptEdit; promote the RoleGrants Delete from btn-link to
btn-outline-danger. Replace the two inline style="border-color:var(--alert)"
no-ops with the .notice-alert class, and the undefined --surface-2 token in
Scripts with the theme's shipped --zebra-bg.
Delete unconditional documentation prose now covered by docs/ (Redundancy,
Telemetry, AlarmTracking, security, Reservations, AlarmHistorian, ScriptEditor),
keeping state banners and live captions. Correct the LDAP config key on
RoleGrants to Security:Ldap:GroupToRole (LdapOptions.SectionName) and drop the
same wrong key from Account with its deleted block; reword the stale
"Engine emit (F8/F9) is pending" script-log empty state.
UI sweep task 4 (Shared/Raw). Markup-only; no behavior change.
- Wrap the Cancel+primary modal-footer pairs in a Bootstrap btn-group across
RawNameDialog, RawDriverTypeDialog, RawConfirmDialog, RawTagModal,
RawManualTagEntryModal, RawCsvImportModal, RawBrowseModal and
RawCsvExportModal (the last mixes a <button> with the download <a>, which
is valid btn-group membership; the download attribute is unchanged).
- RawBrowseModal: the rebirth confirm pair (Publish rebirth request + Cancel)
becomes a btn-group btn-group-sm in place of the d-flex gap-2 wrapper.
- RawCsvImportModal: drop the py-0 padding hack from the per-row remove
button; .btn-sm sizing covers it.
- RawTree: replace the expander's inline style="width:18px" (and the matching
leaf placeholder) with the shared .tree-twist class.
The CSV import all-or-nothing commit gating and RawTree's tree logic are
untouched.
Foundation task of the AdminUI UI sweep. CSS/link/doc only — no behavior change
beyond restoring styles that were built but never served.
- App.razor: link the RCL's scoped-CSS bundle
(_content/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.bundle.scp.css).
It is produced on every compile and served by the Host, but nothing referenced
it, so ContextMenu.razor.css was dead at runtime and context menus rendered
in-flow with no position:fixed.
- site.css: three additions.
* .btn / .btn-sm sizing as Bootstrap CSS-VARIABLE overrides (never box
properties — .btn-group depends on Bootstrap's own .btn rules for its seams
and end-cap radii). Theme 0.3.1 ships no .btn rule, so Bootstrap's 1rem
applied against the theme's 0.9rem body. Upstreaming candidate, same as the
ignitionoee router's app.css.
* .notice-alert — red variant of the kit's amber .notice, on the --bad/
--bad-bg/--bad-border tokens. The 13 existing call sites hack this with
style="border-color:var(--alert)", and no --alert token exists anywhere, so
the tint silently no-ops today. Call-site migration is a separate task.
* .tree-twist — shared 18px expander column for the tree components, replacing
repeated inline style="width:18px". Rows are d-flex, so flex-basis is what
actually holds the column. Adoption is a separate task.
- ContextMenu.razor.css: correct the header comment — the kit is light-only
(no dark block in Theme 0.3.1, site.css, or this file). No rule changes.
- docs/Telemetry.md: record that driver-health is keyed per driver instance
across the cluster, not per Akka member (the wire record carries no per-member
identity). The fact previously lived only in Hosts.razor page copy.
- docs/Configuration.md: new audit-log subsection recording that EventId gives
cross-restart dedup, enforced by the filtered unique index
UX_ConfigAuditLog_EventId, so a retried apply inserts no duplicate row;
CorrelationId joins a row to its originating workflow. Previously only in the
historical v2 rebuild plan and ClusterAudit page copy.
Task 1 of the AdminUI UI sweep. Behavior-preserving: only wrappers, classes
and prose change — every @onclick, disabled, href and arm->confirm flow is
untouched.
Buttons -> Bootstrap btn-group:
- ClusterOverview page-head Edit cluster + Deployments pair
- Edit-form feet (Save+Cancel grouped; ms-auto Delete stays outside):
ClusterEdit, NodeEdit, NewCluster, AclEdit
- ClusterRedundancy confirm-failover Confirm+Cancel (ms-2 dropped)
- AclEdit permission-bundle presets: btn-link + middot separators become an
outline-secondary btn-group
Inline style="border-color:var(--alert)" (a no-op — that token is defined
nowhere) replaced by the .notice-alert class: NodeEdit, AclEdit, ClusterEdit.
Prose (unconditional documentation deleted; state banners/legends kept):
- ClusterRedundancy: removed the stale RedundancyStateActor "on each admin
node" block (cluster-scoped singleton on driver nodes since mesh Phase 6)
which also hyperlinked an unserved repo path; trimmed the mesh-split
explanation to its data-provenance first sentence
- ClusterAcls: removed the Security:Acl:Enabled mode walkthrough and the
grant/principal explanation (both in docs/security.md); kept the
unresolved-chip decoder and trimmed the empty state to one sentence
- AclEdit: removed its near-duplicate of that walkthrough
- ClusterAudit: dropped the stale (F3) label and the EventId/CorrelationId
dedup sentence; kept the page-size + Details-JSON caption
- ClusterNamespaces / NamespaceEdit: retired-in-v3 placeholders reworded to
past tense — Batches 2/3 shipped, /raw and /uns are live
Driver.Mqtt.Contracts-004/-005/-006/-007.
-004: retainSeed was the last behavioural key read leniently, so "retainSeed":"false"
— what a CSV import produces — silently became the default TRUE, the INVERSE of the
authored intent. Both mappers now read it through TryReadBoolStrict (the qos
absent/valid/present-but-invalid split) and DescribeRejection names it. The authoring
side needed no hand-mirrored rule: MqttTagConfigModel.Validate already consults this
same parse through TagConfigParseGate, so the save is blocked with the inspector's
wording — pinned by a new AdminUI parity theory pair.
-005: the seven [Range] attributes on MqttDriverOptions/MqttPlainOptions never ran —
the options are bound by JsonSerializer, and nothing in the four MQTT projects calls
Validator.TryValidateObject. Two of them CONTRADICTED the real authority (KeepAlive's
[Range(1,…)] forbade the zero Normalize admits as legal MQTT; MaxPayloadBytes' forbade
the non-positive value its own remarks document as falling back to 1 MiB), so wiring
them up would have broken deliberate behaviour. Deleted, with each property naming
where its bound actually lives. The one escape they papered over is closed: an
out-of-range defaultQos rode into the SUBSCRIBE verbatim, and MqttSubscriptionManager's
constructor now clamps it exactly as the AdminUI driver form already persists.
-006: the DateTime write arm cast a negative long to ulong, so 1969-12-31 published a
timestamp ~584 million years in the future; it is now refused as ValueNotRepresentable.
An Unspecified kind was handed to ToUniversalTime(), which reads it as LOCAL — the same
authored instant encoded differently on the two nodes of a redundant pair; it is now
read as UTC.
-007: Format/FormatState enforced only null/empty, and nothing at all on deviceId, so
the type was not round-trip-closed. They now apply the parser's IsValidSegment (plus
'/', which only the format side can see). MqttWriteEncoder's own guards stay — TryEncode
returns an error rather than throwing, and that contract does not change.
IWritable shipped behind write.enabled and docs/drivers/MTConnect.md told the operator to
set it, but MTConnectDriverForm — the only editor DriverFormMaps maps for this type, with
no raw-JSON escape hatch and no driver-instance API — exposed no write section, so the
documented enablement path was a hand edit of DriverInstance.DriverConfig in the config DB.
- Write-back section: switch + form text stating it is observation injection, that the
Agent's AllowPut is an independent second gate, and that it does not by itself make a
node writable over OPC UA (Tag.AccessLevel still composes CurrentWrite).
- Write added to the mirrored MTConnectConfigDto and to the form model, written in BOTH
states — an omitted block would leave a previously-authored true standing, so the switch
could never turn writing off again.
- The false 'MTConnect is read-only' header comment is gone; the doc names the new section.
No bUnit here, so the model/serialization seam is unit-tested both sides: the AdminUI test
pins the emitted bytes, and MTConnectFactoryTests parses that same document and asserts
MTConnectDriverOptions.Write.Enabled. Live page verification remains a rig step.
The two value-bearing fields were read asymmetrically and nothing said why.
whereValue went through ReadScalar (string/number/boolean — the design doc's own
wide-row example authors it as the number 7); keyValue went through the string-only
ReadString, so a numerically-authored key refused the tag outright. Nothing downstream
justified the split: both are BOUND as DbParameters and both are matched back as text —
SqlPollReader.IndexByKey stringifies the key cell precisely so an authored key can face
an INTEGER key column.
The refusal was also mis-explained. Inspect branched on ReadString(root, "keyValue") is
null, so a numerically-authored key drew "Sql KeyValue tag has no 'keyValue'" — telling
the operator the field is missing when it is right there in the blob, the #549 shape of
an authoring surface disagreeing with the runtime about WHY.
keyValue now uses ReadScalar; Inspect mirrors it with HasScalar and says what a usable
key is; SqlTagConfigModel loads it with the shared TagConfigJson.GetScalarText (which
also replaces SqlRowSelectorModel's private copy), so the editor is not left stricter
than the parser. An object/array/null key is still refused. docs/drivers/Sql.md updated.
RED first: 4 failing in Driver.Sql.Tests.
TagConfigJson.ClassifyEnumText classified via Enum.TryParse(text, ignoreCase: true),
which accepts a numeric STRING for any enum: "1" returns the member at that ordinal
and an out-of-range "99" parses just as happily. Every driver contract's strict enum
read inherits that, so a Sql tag authored "type":"99" was ACCEPTED, carried
(DriverDataType)99, and SqlPollReader.TryCoerce fell through its switch to the String
arm — a stringified cell published Good under a type nothing can map.
Refuse a digit- or sign-leading spelling before the parse (judged on the TRIMMED text,
because Enum.TryParse trims), and require Enum.IsDefined — which covers what the
leading-character check cannot, since TryParse also ORs a comma-separated list for a
non-[Flags] enum. Mirrors RawBrowseCommitMapper.ParseNativeType and
MTConnectTagDefinitionFactory.TryParseDataType, which already guard their own parses.
Three AdminUI restatements of the same rule now ask ClassifyEnumText instead of
Enum.TryParse (TagConfigJson.GetEnum, SqlTagConfigModel.ReadEnumStrict,
MqttTagConfigModel.DescribeInvalidEnum/GetEnumNullable) — leaving them would have opened
the #549 direction the moment the runtime tightened: a blob the modal accepts and the
driver skips.
RED first: 10 failing in Core.Abstractions.Tests, 4 in Driver.Sql.Tests, and 10 of the
11 new fleet-wide parity cases (re-confirmed by reverting the Core fix).
RawBrowseCommitMapper.MapLeaf typed a browse-committed raw tag's OPC UA node
from the leaf's probe-inferred DriverDataType, but its BuildTagConfig MTConnect
branch emitted `new MTConnectTagConfigModel { FullName = address }` — and that
model's DataType defaults to String with ToJson() unconditionally writing
`dataType`. A non-blank authored type wins the driver's coercion precedence, so
the Agent-probe inference step was never consulted and every browsed SAMPLE
published text into a Double/Int64 node. Silent: the blob is perfectly valid, so
neither the authoring gate nor the deploy gate had anything to say.
Same defect class as #536, which is why the AbCip / TwinCAT / FOCAS branches
forward the browsed native type via NativeType(addressFields, …). MapLeaf now
passes driverDataType into BuildTagConfig, whose MTConnect branch uses it as the
NativeType fallback — a stated native `dataType` address field still wins, so
the branch reads like its siblings; the difference is only that MTConnect's
fallback is the leaf's own inference rather than a fixed model default. Its
coercion type IS a DriverDataType, the very enum the leaf carries, so nothing is
re-derived. NativeType's numeric-string and unparseable-name guards are shared
via the extracted ParseNativeType, so an unusable name still keeps String.
Tests assert the PAIR (row OPC UA type and blob coercion type agree with the
inference) — the blob alone would pass against a mapper that stopped typing the
row — plus the parity control that a leaf inferred (or reported) as String still
commits `"dataType":"String"`, so the forward cannot be implemented as a
deletion. Observed red first: expected "Float64" but was "String".
`OpcUaClientTagDefinitionFactory.TryReadNodeId` still honours a legacy PascalCase
`NodeId` when the v3 camelCase `nodeId` is wholly absent, but
`OpcUaClientTagConfigModel` read only `nodeId` — so a legacy blob was SERVED by
the driver yet opened with an empty reference field and could not be saved
untouched. Authoring stricter than the reader, which #554 names as never
legitimate: the editor was blocking config the runtime honours.
Fixed with the #550 pattern (read both spellings, normalise on save), NOT by
retiring the spelling from the reader — that would break every legacy blob the
driver currently serves at the next deploy, a data-plane regression traded for
an editor asymmetry.
The precedence is PRESENCE-shadows, not first-non-blank. The reader's
`TryGetProperty("nodeId", out n) || TryGetProperty("NodeId", out n)`
short-circuits on the mere PRESENCE of the camelCase key and then checks
string-and-non-blank ONCE, so a blank / non-string / JSON-null `nodeId` beside a
valid legacy `NodeId` is a REJECTED blob, not a fallback. Copying MTConnect's
`FirstNonBlank` walk here would have made authoring WEAKER than the reader —
the same defect in mirror image.
- `NodeIdKeys` drives both halves, so the read and the drop cannot drift.
- `ToJson` writes `nodeId` and drops `NodeId`; the TagModal history-merge keys
(`isHistorized` / `historianTagname`) are disjoint and untouched.
- `Validate`'s required-reference fast path is now precedence-aware by
construction, so it agrees with the #554 parse consult across the whole
rejection surface.
- The `TagConfigNonEnumParityTests` exemption for OpcUaClient is REMOVED — its
anti-vacuity guard `Every_deliberately_stricter_entry_is_still_exactly_that`
went red on the fix, which is this change's falsification control. The legacy
shapes moved into the corpus, where both fleet theories now cover them.
Tests: a nine-row precedence theory driving the authoring verdict against the
READER's verdict per shape, on the original blob AND on the `ToJson()` output;
no-retype (legacy loads showing its reference, saves canonical, same node id
before and after); no-drift (output never carries `NodeId`). Each half was
reverted in isolation to confirm it reddens tests of its own: read-only-revert
=> 6 red incl. the fleet parity theory; drop-only-revert => 4 red.
Suites: AdminUI.Tests 1459/1459, ControlPlane.Tests 261/261,
Driver.OpcUaClient.Tests 143/143, Driver.OpcUaClient.Browser.Tests 10/10.
Two guards enumerated the same RawBrowseCommitMapper.BuildTagConfig seam against
two hand-authored lists, which can only drift. The older one's list was also
misnamed: DriverDispatchMapParityTests.NonBrowsableDriverTypes really meant
"types that produce the generic key" — AbLegacy is genuinely non-browsable yet
correctly absent from it, because it has a typed branch.
Retire the older fact. RawBrowseCommitMapperParityTests.GenericKeyExemptions is
now the single hand-authored list for this seam: named for what it holds, one
written reason per entry, and checked against a DI-derived browsable set rather
than a second list. The one assertion the retired fact made that nothing here
covered — set EQUALITY, i.e. an exemption must still DESCRIBE the mapper — moves
across as Every_generic_key_exemption_still_commits_to_the_generic_key. Without
it, a driver that gained a typed branch would keep a silently inert exemption.
Item 2: OpcUaClientDriverBrowser and GalaxyDriverBrowser returned string
literals ("OpcUaClient", "GalaxyMxGateway") where Sql/Mqtt use DriverTypeNames.
The values matched, and nothing guarded that. Repoint both at the constants —
DriverTypeNames.Galaxy carries the identical value — and add
Every_registered_browser_is_keyed_by_a_declared_driver_type_name over the same
AddAdminUI() read-back. Compared ORDINALLY: BrowserSessionService indexes on the
exact persisted string, and case-only drift (TwinCat/TwinCAT) is what
DriverTypeNames exists to end. Browser projects are out of reach of
DriverTypeNamesGuardTests, which pins the driver FACTORIES' names.
Falsified, four ways, each red naming the offender and green on revert:
- #555 re-run: disabling the Sql branch reddens the two enumeration facts
("...no dedicated BuildTagConfig branch...: Sql"), the same catch the
retired fact made.
- giving Modbus a typed branch reddens ONLY the new stale-exemption fact,
proving it is not redundant with the four that were already there.
- SqlDriverBrowser.DriverType => "SQL" and Galaxy => "GalaxyMXGateway" each
redden the new browser-key guard by name — case-only typos, which an
OrdinalIgnoreCase compare would have waved through.
#549/#551 made every typed editor judge its driver's enum KEYS against the runtime's
own reader and stopped there. Every other reason a reader refuses a tag met no gate
until deploy, or until the driver silently skipped the tag: an absent Modbus `region`,
an `"address":"notanumber"`, an S7 `stringLength` of 300, a TwinCAT `dataType:
"Structure"` (a VALID enum name the reader refuses), a Sql blob the parser cannot
recognise as Sql at all, an MQTT `"qos":null`.
Rather than hand-mirroring N more rules into N models — the drift #551 closed on the
deploy side — each `<Driver>TagConfigModel.Validate()` now asks its driver's own parse
whether it would read the AS-LOADED blob, via the new `TagConfigParseGate`. One
authority for the verdict AND the wording.
Two cautions are load-bearing and are pinned by tests:
- The gate is the PARSE VERDICT, never "the inspector said something". FOCAS, MQTT and
Calculation inspectors emit advisories for blobs their reader ACCEPTS, so a
non-empty-inspection gate would make the modal stricter than the runtime and leave
rows nobody can save (#520/#535). Inspect is consulted only to word a refusal that
already happened.
- It judges the blob the model was LOADED FROM, not the typed fields — the lenient load
("address":"notanumber" ⇒ 0) is what hides the defect. Same capture-the-source shape
as S7's `_dataTypeMissingInSource`.
Deliberately-stricter editor rules were surveyed and KEPT, not aligned: AbLegacy's
arrayLength > 256 (reader clamps, but the un-clamped value still sets ArrayDimensions),
FOCAS's unparseable address (#540), MQTT wildcard topics + Sparkplug segment chars, and
Calculation's trigger/50 ms floors. They are now recorded fleet-wide in a table the
tests assert is still accurate.
Behaviour change: Modbus `{}` is now refused. Modbus was the one mapped editor with no
required field, and `{}` is exactly what the modal persists for a config nobody touched
— the driver skips it and it reads BadNodeIdUnknown forever with the deploy green. Its
pinned test asserted the old, weaker behaviour and is rewritten with the rationale.
Falsification: neutering only the Modbus consult turns 14 tests red, all Modbus-scoped;
every #549 enum test stays green.
RawBrowseCommitMapper.BuildTagConfig's unknown-driver fallback claimed to be
guarded by RawBrowseCommitMapperParityTests, which did not exist anywhere under
tests/. The claim it made — "every BROWSABLE driver is handled above" — is the
exact claim that stopped being true when SqlDriverBrowser registered (deferment.md
G-6), so the comment was asserting the invariant instead of pinning it.
The guard now exists and enumerates DriverTypeNames.All:
- Browsability's bespoke arm is DERIVED from production, not hand-listed: the test
runs AddAdminUI() and reads back every registered IDriverBrowser's DriverType —
the same set BrowserSessionService.CanBrowse indexes. A driver that gains or
loses a bespoke browser moves the guard with it.
- The universal arm (DiscoveryDriverBrowser -> ITagDiscovery) is NOT derivable from
the AdminUI assembly: it references driver Contracts/Browser projects, never the
runtime drivers, and IDriverFactory is composed in the Host. Covered instead by
requiring every declared driver type to be typed or explicitly exempted with a
reason (Modbus, Calculation), with a third fact mechanically refuting any
exemption the derived arm contradicts — the Sql failure mode.
Falsified three ways, each red naming the offender: disabling the Sql branch trips
the derived-arm fact; disabling the MTConnect branch trips the enumeration fact
(the universal-arm half, which no bespoke registration would have caught); adding
Sql to the exemption list trips the contradiction fact.
Comment corrected to state what the test actually asserts, including the residual
trust the universal arm leaves.
`RawBrowseCommitMapper.BuildSqlTagConfig` emitted a WideRow blob carrying only
table + columnName. A wide-row tag must also say WHICH row it reads, so both
`SqlEquipmentTagParser` (the runtime authority) and `SqlTagConfigModel.Validate`
(the authoring gate) refused it — a browse-committed Sql tag was never readable,
first silently as BadNodeIdUnknown and, since #551 put the gate on the import,
loudly at commit.
`SqlBrowseSession` now states a fourth address field: the table's preferred
date/time-typed column, computed from the columns its attributes call ALREADY
read (no extra catalog round-trip). Candidacy is decided by
`ISqlDialect.MapColumnType` reporting `DriverDataType.DateTime` — never by column
name, which would order a wide-row read by a string column that merely looks like
a timestamp. Multiple candidates tie-break on FIRST IN CATALOG ORDER (every
dialect's column SQL orders by ordinal position), so the pick is stable across
calls and servers; it is a visible, editable default the typed editor round-trips.
No candidate (a table with no date/time column) is an operator-actionable refusal,
not an invented selector: the only remaining selector is a whereColumn/whereValue
pair whose value is data, not schema, so `DescribeUncommittableLeaf` refuses that
leaf by name, names the table, and points at the tag editor.
The session and the mapper now agree via `SqlBrowseAddressFields` constants rather
than two independently spelled string literals — a disagreement there is silent,
and is exactly the shape this defect had.
`RawTagConfigGateTests`' Sql leg flips from "records the gap" to "browse-commit
output passes the gate", with the no-candidate case pinned as its own test.
ModelBackedCsvDriverTagMap.ApplyTypedColumns round-tripped the row's free-form
TagConfigJson cell through a driver model's FromJson/ToJson to fold the typed
columns in. That load is deliberately lenient — it has to be, so a poisoned blob
can be opened and repaired — so a typo'd value under a key the model owns
("dataType":"Intt16") came back out as the model's DEFAULT, before the #551
import gate or any later gate could judge it. The row imported clean and the tag
the operator described was gone. Only keys the model did not own survived to be
judged, which is why RawTagConfigGateTests looked like it covered this.
MTConnect carried the same defect from the export end. It is the one driver whose
blob accepts several spellings per field, and the runtime PREFERS driverDataType
over the dataType key the MtDataType column writes. The export residual carried
that alias (the map owned only the canonical key), so a re-imported row let it
out-rank the very column the operator had just edited — accepted, then silently
ignored at the driver — while the exported cell printed the spelling the driver
was ignoring, or nothing at all.
The fix preserves evidence; it does not move the verdict:
- ApplyTypedColumns writes the base cell's own tokens back over the model's
serialisation for every key no non-blank typed column overrode. The model still
stamps defaults for keys the row declares nowhere, so fragment cells compose
exactly as before.
- CsvTypedColumn gains ReadKeys / OwnedJsonKeys: the full ordered set of spellings
the RUNTIME reads for that column, mirroring TryFromTagConfig's FirstNonBlank
pairs. A firing column clears the out-ranking aliases; the export residual drops
them; the export cell shows the EFFECTIVE value.
- The review grid's override provenance reads the whole owned set, so an
overridden driverDataType is announced as an overridden dataType would be.
Nothing here decides which values are legal. The verdict stays with
TagConfigValidator on the assembled blob and with IRawTreeService.ImportTagsAsync
behind it — the deliberate reason NOT to follow the issue's own "run the validator
over the pre-merge cell" direction, which would have imposed completeness on a
cell that is legitimately a fragment and false-rejected every typed-column row.
Semantics chosen for the override contradiction: a cell token that a non-blank
column overrides is ACCEPTED with the column winning, and reported by name in
TypedOverrodeFallback. Typed-column-wins is the map's documented contract, the
override is announced rather than silent, and erroring would remove the only way
to repair a poisoned tag through the CSV surface. Every token that reaches the
stored blob is judged; a token the operator overrode is discarded openly.
Falsification: neutering the restore loop + alias handling turns exactly 7 of the
15 new tests red, while RawTagConfigGateTests (#551), RawTagCsvRoundTripTests and
CsvColumnMapReflectionTests all stay green — layering, not replacement.
AdminUI 1248, MTConnect 534, Modbus 373, S7 285, TwinCAT 205 — all green.
Gitea #551.
1. CSV import ran no driver-typed config gate. Its typed columns were always
strictly parsed, but the free-form TagConfigJson column reached the database
unread, so a CSV could author a tag the driver rejects — one that deploys
green and reads BadNodeIdUnknown forever. RawTagCsvMapper.Parse now names the
offending row in the staged review grid, and RawTreeService enforces the same
gate on CreateTag/UpdateTag/ImportTags: a form cannot be the guarantee (the
API and any future caller reach none of them, and this repo has no bUnit).
A rejected import row is an ERROR — the batch inserts nothing and the row is
named — never a skip.
Note the brief's premise that manual entry rides ImportTagsAsync is wrong:
RawManualTagEntryModal commits row-by-row via CreateTagAsync, which is why
the gate had to land on all three write paths.
2/3. The seven pre-#549 Inspect methods ran a hand-list of DescribeInvalidEnum
checks, so every rejection cause not on the list was invisible. They are now
ACCEPT-GATE-FIRST (the MTConnect/Sql/OpcUaClient/Calculation shape): ask the
driver's own reader for the verdict, then explain the refusal, with a
catch-all so "the parse rejects but Inspect said nothing" is unreachable.
That closes S7's absent dataType, AbCip members[], Modbus stringByteOrder and
the type-dependent guards, plus causes the issue did not name (absent
region/address/tagPath/symbolPath/topic, TwinCAT Structure, MQTT's Sparkplug
tuple). FOCAS and MQTT keep advisories about ACCEPTED blobs, so they
diagnose rather than return empty.
Authoring half: ModbusTagConfigModel gains stringByteOrder;
AbCipTagConfigModel asks the new AbCipTagDefinitionFactory.DescribeInvalidMember;
S7TagConfigModel captures whether the SOURCE blob declared dataType and asks
S7TagDefinitionFactory.DataTypeKey/DeclaresDataType. One authority per rule.
4. Doc sweep: the six factory Inspect summaries, EquipmentTagConfigInspector's
class remarks and AdminOperationsActor's comment all claimed invalid enums are
"silently defaulted by the lenient runtime" — false since R2-11 Phase C made
the runtime reads strict. They describe rejections now.
MTConnectTagDefinitionFactory.TryFromTagConfig resolves the coercion type as
FirstNonBlank(blob.DriverDataType, blob.DataType) — driverDataType OUT-RANKS
dataType — while MTConnectTagConfigModel read and wrote dataType alone. That
broke in both directions at once:
* a blob carrying "driverDataType": "Strng" was accepted at authoring while
the runtime rejected the tag (the residual slice of #549), and
* a VALID driverDataType silently overrode whatever type the editor displayed
and saved, so the editor showed a lie and a save RETYPED the tag.
Both halves land together, mirroring the existing IdKeys pattern:
* FromJson reads DataTypeKeys = [driverDataType, dataType] in the runtime's
precedence, with the runtime's own per-key semantics — absent/JSON-null and
present-but-blank fall through, a non-string token stops the walk (the
runtime deserialise throws JsonException on it and rejects the whole tag),
and the raw TOKEN is captured so Validate() judges what the driver judges.
* ToJson drops every non-canonical DataTypeKeys entry and writes dataType as
the enum NAME, so the alias can never out-rank the key the editor writes on
the next load.
* Validate() now names the offending spelling rather than always saying
"dataType".
Doing only one half silently retypes existing tags on their next save; the
falsification runs recorded in the PR confirm each half-fix reddens a distinct
test.
MTConnectTagConfigModel read `dataType` with TagConfigJson.GetEnum, which falls
back to String on ANY unrecognised value. A misspelled name ("Strng") from a
hand-authored blob, a CSV import or the API was therefore accepted at authoring
and silently rewritten, while the driver's strict parse rejected it and the tag
died with BadNodeIdUnknown. Found by the G-7 parity tests' falsifiability
control, which had to use the digits-only shape to have a case that failed.
The rule is not restated in the AdminUI. MTConnectTagDefinitionFactory exposes
its parse as `IsAcceptedDataTypeName`, documented as the one authority the
driver, the deploy gate and the authoring model all consult; the model's
Validate() asks it about the raw value the blob carries. The old digits-only
message is NOT retained as a special case: the runtime draws no distinction
between a bare number and a typo — one predicate, one Inspect warning — so a
second message here would be a second statement of a rule this model does not
own. The raw value is captured as the JSON TOKEN, because the runtime
deserialises the key into a `string?` DTO field and an unquoted number throws
there too.
The survey found the same asymmetry at six more editors, contrary to the
assumption that those runtimes silently default: R2-11 Phase C moved every
driver's TagConfig enum read to TagConfigJson.TryReadEnumStrict, which REJECTS
the tag on a present-but-invalid string, while the editors kept loading them
with a silent fallback. Modbus (region/dataType/byteOrder), S7, AbCip, AbLegacy,
TwinCAT and FOCAS now check their enums in Validate() through the runtime's own
TagConfigJson.DescribeInvalidEnumText — added alongside a public
ClassifyEnumText that TryReadEnum now delegates to, so the JSON-token door and
the string door reach one implementation. Mqtt and Sql already carried
equivalent strict checks and are untouched.
Care taken NOT to become stricter than the runtime: a present-but-non-string
token stays accepted for those six (TryReadEnum calls it absent), as does an
absent key, each pinned by a test.
Also adds a direct ProjectReference to Core.Abstractions from the AdminUI — the
save gate now leans on a type it was consuming only transitively.
G-7 wired OpcUaClientTagDefinitionFactory.Inspect into the deploy-time TagConfig
gate, which gave ControlPlane a ProjectReference to
Driver.OpcUaClient.Contracts — and with it, transitively, the whole OPC UA client
stack (5 OPCFoundation packages) into every host that loads the control plane.
NamespaceMap was the only reason Contracts carried
OPCFoundation.NetStandard.Opc.Ua.Client: its entire public surface is
ISession/NamespaceTable/NodeId-typed. It moves to a new leaf project,
ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session, referenced by the runtime .Driver
and by .Browser. Contracts is left with Core.Abstractions and NOTHING else —
the same shape as Calculation.Contracts.
The namespace stays ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient, so no consumer's
`using` changed. The .Session -> .Contracts direction is deliberate: the blob
read NamespaceMap needs is OpcUaClientTagDefinitionFactory.TryReadNodeId, the
one authority it shares with the deploy gate, and that must stay in the
dependency-light half.
Why a new project rather than folding NamespaceMap into the runtime .Driver and
having .Browser reference it (the Driver.Mqtt.Browser #512-style deliberate
exception): the AdminUI project-references .Browser and NOT .Driver, so that
shape would pull the OpcUaClient driver runtime — plus Core,
Opc.Ua.Configuration and Secrets.Abstractions — into the AdminUI's build/deploy
graph. That is exactly the violation #512 spent two extractions removing from
Mqtt.Browser; re-introducing it to save one csproj is a bad trade.
Guard: ContractsIsDependencyLightTests (Driver.OpcUaClient.Tests) asserts the
Contracts manifest names no Opc.Ua* assembly, plus a counterpart asserting
.Session does. An ABSENCE assertion is sound on the manifest where a presence
one is not — the compiler elides an unused reference, but it MUST emit one the
moment Opc.Ua-typed code is added. Falsified by temporarily re-adding the
package plus an Opc.Ua.NodeId-typed member: red on Opc.Ua.Types, green again on
removal.
Evidence — `dotnet list ControlPlane package --include-transitive | grep -i opc`:
before: OPCFoundation.NetStandard.Opc.Ua.{Client,Configuration,Core,
Security.Certificates,Types} 1.5.378.106
after: none (and 0 Opc.Ua*.dll in ControlPlane's output directory)