Unseals the Sparkplug branch of the MQTT address-picker browser that Task 10
deliberately sealed shut, and adds the first (and only) sanctioned publish on a
browse session.
- MqttBrowseSession serves Group -> EdgeNode -> [Device] -> Metric in Sparkplug
mode, decoded from observed NBIRTH/DBIRTH certificates (the only Sparkplug
messages that name their metrics). Node-level metrics hang directly under
their edge node -- no synthesised device folder, because the authored address
tuple genuinely has deviceId = null for them. Metric node ids use a '::'
separator: a metric name may contain '/', so slash-joining would make a
node-level metric indistinguishable from a device folder.
- AttributesAsync is purely birth-derived in Sparkplug mode. The plain path's
UTF-8 inference / payload-snippet machinery does not run and no payload bytes
are retained: a Sparkplug body is protobuf and would be reported as
"(N bytes, binary)" for every metric in the plant. A datatype ToDriverDataType
cannot map is reported as the Sparkplug type name, never coerced.
- RequestRebirthAsync(scope) is the one publishing member, reached only by an
explicit operator action. It routes through the counted PublishAsync
chokepoint via a private RebirthTransport adapter, so PublishCountForTest
still proves that OpenAsync/RootAsync/ExpandAsync/AttributesAsync/Observe/
DisposeAsync publish nothing -- now in both modes. A device or metric scope
resolves up to its owning edge node (NCMD is node-addressed); group scope
enumerates observed edge nodes and is refused whole past
MaxGroupRebirthNodes = 32.
- BrowserSessionService.RequestRebirthAsync gates on the same DriverOperator
policy that gates the picker, evaluated server-side where the action happens
rather than only at render time, fails closed, and Info-logs the scope and the
published count. Exposed through the new IRebirthCapableBrowseSession contract
so "does this session write?" stays a type question.
- ToBrowseOptions' Last-Will landmine re-verified: MqttDriverOptions still
carries nothing will-shaped (an NDEATH is a broker-published will and would be
invisible to PublishCountForTest), now pinned by a reflection guard.
Falsifiability: injecting a publish into RootAsync reddens the Sparkplug and
plain passivity tests; double-publishing per target reddens all four
one-NCMD-per-node assertions. 572/572 MQTT tests, 24/24 AdminUI browsing tests.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Task 9. Adds MqttDriverFactoryExtensions (DriverTypeName = DriverTypeNames.Mqtt,
direct deserialization of MqttDriverOptions — no intermediate DTO, mirroring
OpcUaClientDriverFactoryExtensions), the DriverTypeNames.Mqtt constant, and both
host wiring sites: the factory in DriverFactoryBootstrap.Register and the probe in
AddOtOpcUaDriverProbes (the admin-node path Program.cs calls in its hasAdmin
block — a probe wired only on driver nodes makes Test-connect silently dead).
Carried-forward items:
- Converges every MQTT config-parsing seam onto ONE JsonSerializerOptions —
MqttJson.Options, in .Contracts alongside MqttDriverOptions. It replaces the
probe's internal JsonOpts and the browser's separate private copy; the factory,
the probe, MqttDriver.ParseOptions and MqttDriverBrowser now all parse through
it. .Contracts is the only assembly all four consumers reference, and the
browser's reference to the runtime .Driver project is a documented layering
exception scheduled for removal — anchoring the options there would resurrect
the duplicate the day it goes away.
- Replaces the "Mqtt" literals in MqttDriverProbe, MqttDriverBrowser and
MqttDriver with the constant (string value unchanged).
- Tightens MqttDriverProbeTests.ProbeAsync_EnumAsName: it asserted only
Ok == false + non-empty message, which is also exactly what a JSON-parse
failure produces — so it stayed green under the very regression it names.
It now asserts the probe got past the parse and reached the network.
Falsifiability: deleting JsonStringEnumConverter from MqttJson.Options reddens 9
tests across 4 suites, including the tightened probe test (message becomes
"Config JSON is invalid: The JSON value could not be converted to
MqttProtocolVersion") — which the pre-fix assertions would have passed.
Also references the MQTT driver from Core.Abstractions.Tests so
DriverTypeNamesGuardTests' reflective bin scan discovers the new factory and the
constant/factory parity check stays honest.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Three review findings, all "unbounded work/memory from untrusted broker input"
rather than correctness bugs - but this points at a live plant broker.
1. InferPayload decoded the WHOLE payload on MQTTnet's dispatcher thread before
trimming to 64 chars. The node cap never engages here: a multi-MB blob on an
already-known topic creates no node, it just updates one, so the cost repeats
per message forever. Now at most PayloadInspectMaxBytes (512) are examined.
A blind slice can cut a multi-byte sequence, which the strict decoder would
report as "binary" - TrimToUtf8Boundary backs the window off to the last whole
sequence so good UTF-8 is never mislabelled. A clipped payload is String by
construction rather than inferred from a partial view.
2. The node cap bounded COUNT, not bytes. MQTT topics run to ~65KB, so 50k nodes
near that ceiling is a multi-GB tree. Topics now bounded at 1024 chars and
segments at 256, rejected whole (a truncated topic is a DIFFERENT topic the
picker would commit as a tag address) and surfaced via an __oversized__ marker
kept distinct from __truncated__ - the operator's remedy differs.
3. Control characters in a snippet would break the picker's single-line row;
Trim() only strips the ends. Now scrubbed to spaces.
Each guard was falsified independently by neutering it and confirming exactly one
test reddens. That found a real gap: the oversized-topic test was passing via the
SEGMENT bound, leaving the whole-topic bound untested - the test now uses many
short segments so only the topic bound can reject it.
Also records the .Driver project reference as a known, deliberate exception to the
.Browser -> .Contracts pattern, with its cost (AdminUI gains Core + Polly + Serilog)
and the clean fix (a leaf transport project; NOT a move into .Contracts, which is
deliberately transport-free).
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
A will is published by the BROKER, so it is invisible to PublishCountForTest.
MqttDriverOptions carries none today, but Sparkplug NDEATH is a will message -
once P2 adds it, an ungraceful browse disconnect would fire NDEATH under the
plant's own edge-node identity. ToBrowseOptions is where it must be cleared.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
MQTT has no discovery protocol, so the AdminUI address picker learns the topic
space the only way there is: subscribe to the wildcard and accumulate what
arrives. MqttBrowseSession serves that observation as a segment tree (split on
'/'); MqttDriverBrowser opens it with a distinct "{clientId}-browse-{guid8}"
identity under a 5-30 s clamped budget.
Browsing publishes NOTHING - the load-bearing safety property, since the picker
runs against a live plant broker. Every outbound message must route through the
single PublishAsync seam that counts into PublishCountForTest; P2's operator-
triggered Sparkplug rebirth is the one intended exception. Sparkplug mode fails
fast rather than serving a raw spBv1.0 topic tree that looks like a metric tree.
Deviation from the plan's ref list: the project also references .Driver so the
browse CONNECT reuses MqttConnection.BuildClientOptions (TLS posture, CA-pin
chain validator, credentials). A second copy would be a security divergence.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW