fix(mqtt): close #509, #510, #512, #513, #514

Five MQTT tracker items — four AdminUI/authoring defects plus the .Browser
layering exception. No runtime driver behaviour changes.

#513 payloadFormat in a Sparkplug blob. MqttTagConfigModel.ToJson wrote the key
unconditionally. Sharper than the issue states: PayloadFormat is the one Plain
field with a NON-NULLABLE typed backing, so the write did not preserve a retyped
tag's leftover — it INVENTED "payloadFormat":"Json" in a Sparkplug tag that never
had one. Now Plain-only; preserve-unknown-keys is pinned unchanged.

#509 slashed Sparkplug metric names. The mapper already carried metricName
verbatim; only the tag Name (a RawPath segment) tripped RawPaths.ValidateSegment,
which refused the batch whole — including Node Control/Rebirth, mandatory on
every Sparkplug edge node. ToRawSegment coerces the Name and leaves the address
untouched. Coercion is not injective, so DescribeNameCollisions refuses a
colliding set BY NAME: silently merging two metrics into one tag would be worse
than the refusal it replaces. Applies to every driver, not just MQTT — the
argument is about what a Name is, not about who produced it. The sanitiser is
pinned against RawPaths.ValidateSegment rather than a copied charset.

#510 stale rebirth affordance. _canRebirth was probed once at browse-open and
sessions are reaped for idleness, so a modal left open offered an action whose
token no longer resolved. Re-probed on arm AND on confirm (the reap can land
between the two, and confirm is the one path that publishes to a live broker);
the affordance retires with an explanation instead of vanishing or erroring.

#514 rebirth on a quiet plant. The tree is birth-driven and births are never
retained, so a plant that birthed before the window opened showed an empty tree —
and the scope came only from a tree selection, so the one action that would fill
the tree could not be armed. The session already accepted a bare {group}/{edgeNode}
("the prime rebirth target"); this adds the affordance. Edge-node scope only, not
a group broadcast. The rule is extracted to SparkplugRebirthScope so it is
testable — this repo has no bUnit — and is advisory: the session re-resolves and
refuses before publishing.

#512 .Browser -> .Driver layering. TWO extractions were needed, not the one the
issue anticipated: it costed this as "the client-options builder", but the browse
session decodes births itself, so moving only that would have left the reference
exactly where it was.
  - MqttClientOptionsFactory (TLS, PEM CA-pin validator, credentials, protocol
    mapping) -> new leaf .Transport project. NOT .Contracts: that is deliberately
    transport-free and MqttClientOptions is an MQTTnet type, so .Contracts would
    hand MQTTnet to ControlPlane. This is the fix the .Browser csproj comment had
    already prescribed, against the issue's suggestion.
  - The Sparkplug protocol layer (SparkplugTopic/MessageType, SparkplugCodec,
    RebirthRequester) -> .Contracts, beside the Tahu wire schema it encodes.
Still exactly one copy of the TLS/CA-pin logic; MqttBrowserLayeringTests guards
the graph both ways.

Measured, not assumed: Driver.Mqtt.dll, Polly.Core and Serilog are now absent
from the AdminUI's output. Core.dll remains, because Driver.Sql.Browser has the
SAME violation, undocumented — the old comment blamed this browser for Core and
was wrong. Filed separately rather than fixed in passing.

Full suite green apart from the 7 known pre-existing failures (4 AbLegacy
integration, compose profiles not up; 3 Host.IntegrationTests).

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 06:17:18 -04:00
parent 3207089f01
commit 7f53bd96ec
23 changed files with 1029 additions and 269 deletions
@@ -126,9 +126,34 @@
@if (_rebirthTarget is null)
{
@* Gitea #514: the tree is BIRTH-driven, and births are never retained. On a
quiet plant — every edge node birthed long before this window opened and is
now publishing only NDATA/DDATA — the tree is empty, so a tree-selected
scope can never be armed and the one action that would fill the tree is
unreachable. Naming an edge node directly breaks that cycle. Deliberately
an EDGE NODE rather than a group broadcast: a group rebirth is a
self-inflicted broker storm on a large plant. *@
<div class="small text-muted mt-1">
Click a group, edge node, device or metric in the tree above to choose the scope.
Click a group, edge node, device or metric in the tree above to choose the
scope &mdash; or, if the tree is empty because every edge node birthed
before this window opened, name one directly:
</div>
<div class="input-group input-group-sm mt-1">
<span class="input-group-text">Edge node</span>
<input class="form-control mono" placeholder="group/edgeNode"
aria-label="Sparkplug edge-node scope"
value="@_manualScope"
@oninput="@(e => _manualScope = e.Value?.ToString() ?? "")" />
<button type="button" class="btn btn-outline-secondary"
disabled="@(ManualScopeError is not null)"
@onclick="ApplyManualScope">
Use this scope
</button>
</div>
@if (!string.IsNullOrWhiteSpace(_manualScope) && ManualScopeError is { } manualErr)
{
<div class="small text-danger mt-1">@manualErr</div>
}
}
else
{
@@ -179,6 +204,17 @@
}
</div>
}
else if (_rebirthExpired && _canOperate)
{
@* Gitea #510: the affordance is retired rather than left to fail on click. It is
drawn from a capability probed at browse-open, and sessions are reaped for
idleness — so a modal left open past the reap used to offer an action whose
token no longer resolved. Say what happened instead of silently vanishing. *@
<div class="alert alert-warning py-2 mt-2 mb-0 small">
This browse session expired while idle, so a rebirth can no longer be requested
through it. Close and re-open the browser to request one.
</div>
}
<div class="mt-2 small">
<strong>@_selected.Count</strong> tag@(_selected.Count == 1 ? "" : "s") selected.
@@ -276,6 +312,18 @@
private string? _rebirthOutcome;
private string? _rebirthError;
/// <summary>
/// Set when a re-probe finds the session's rebirth capability gone (#510), so the retired
/// affordance can be explained rather than silently disappearing.
/// </summary>
private bool _rebirthExpired;
/// <summary>
/// Operator-typed <c>{group}/{edgeNode}</c> rebirth scope, for the birth-driven-tree deadlock
/// (#514). Seeded with the driver's configured group so only the edge node has to be typed.
/// </summary>
private string _manualScope = "";
private readonly Dictionary<string, SelectedLeaf> _selected = new(StringComparer.Ordinal);
private readonly HashSet<string> _selectedIds = new(StringComparer.Ordinal);
@@ -316,6 +364,8 @@
_disabledReason = null;
ResetRebirthState();
_canRebirth = false;
_rebirthExpired = false;
_manualScope = "";
_resolving = true;
StateHasChanged();
@@ -373,8 +423,16 @@
{
_token = result.Token;
// Capability, not authorization: a plain-MQTT window (and every non-MQTT browser) has no
// re-announce action at all, so the affordance must not be drawn for it.
// re-announce action at all, so the affordance must not be drawn for it. Re-probed per
// click as well — see RebirthStillAvailable (#510).
_canRebirth = BrowserService.CanRequestRebirth(_token);
_rebirthExpired = false;
// Only the edge node should have to be typed (#514): the group is the driver's entire
// subscription scope, so it is already in the config this browser was opened with.
if (_canRebirth && _manualScope.Length == 0 && TryReadConfiguredGroupId() is { } gid)
{
_manualScope = gid + "/";
}
}
else { _openError = result.Message; }
}
@@ -502,9 +560,64 @@
{
_rebirthOutcome = null;
_rebirthError = null;
// #510 — the capability was probed once at browse-open; re-probe before offering to act on it.
if (!RebirthStillAvailable()) { return; }
_rebirthConfirming = true;
}
/// <summary>
/// Re-probes the open session's rebirth capability, retiring the affordance when it has gone
/// (Gitea #510).
/// </summary>
/// <returns><see langword="true"/> when a rebirth can still be requested through this session.</returns>
/// <remarks>
/// <c>CanRequestRebirth</c> is a cheap, non-throwing, no-I/O probe that is false for a reaped or
/// unknown token, so re-running it per click costs nothing. It is a <b>capability</b> probe, not
/// an authorization check — the <c>DriverOperator</c> gate stays server-side on
/// <c>RequestRebirthAsync</c> and fails closed there, and <c>_canOperate</c> remains the
/// defence-in-depth half of the render condition.
/// </remarks>
private bool RebirthStillAvailable()
{
if (BrowserService.CanRequestRebirth(_token)) { return true; }
_canRebirth = false;
_rebirthExpired = true;
ResetRebirthState();
return false;
}
/// <summary>
/// Describes why <see cref="_manualScope"/> is not a usable edge-node scope, or <c>null</c> when
/// it is. Mirrors the session's own <c>{group}/{edgeNode}</c> rule (Gitea #514).
/// </summary>
/// <remarks>
/// Advisory only: <c>MqttBrowseSession.ResolveRebirthTargets</c> re-validates and refuses an
/// unresolvable scope <b>before publishing anything</b>, so this exists to keep the operator from
/// arming a confirm step that cannot succeed — not as the gate. A group-only scope is rejected on
/// purpose: the session would fan out to every observed edge node, and on a tree that is empty
/// (the case this input exists for) there are none to fan to.
/// </remarks>
private string? ManualScopeError => SparkplugRebirthScope.Describe(_manualScope);
/// <summary>Adopts the typed <c>{group}/{edgeNode}</c> as the rebirth scope (Gitea #514).</summary>
private void ApplyManualScope()
{
if (!RebirthStillAvailable()) { return; }
if (!SparkplugRebirthScope.IsUsable(_manualScope)) { return; }
var (groupId, edgeNodeId) = SparkplugRebirthScope.Split(_manualScope);
_rebirthTarget = new RebirthTarget(
$"{groupId}/{edgeNodeId}",
$"edge node {edgeNodeId}",
$"Edge node '{edgeNodeId}' in group '{groupId}' republishes its whole metric set. "
+ "This node was named directly, so it need not have appeared in the tree.",
IsGroup: false);
_rebirthConfirming = false;
_rebirthOutcome = null;
_rebirthError = null;
}
/// <summary>Disarms the confirm step without publishing anything.</summary>
private void CancelRebirth() => _rebirthConfirming = false;
@@ -516,6 +629,9 @@
private async Task ConfirmRebirthAsync()
{
if (_rebirthTarget is null) { return; }
// #510 again, at the last moment before publishing: the reap can land between arming the confirm
// step and clicking it, and this is the one path that reaches a live broker.
if (!RebirthStillAvailable()) { return; }
_rebirthBusy = true;
_rebirthOutcome = null;
_rebirthError = null;
@@ -558,6 +674,34 @@
ResetRebirthState();
}
/// <summary>
/// Reads the driver's configured Sparkplug group id out of the merged config, or <c>null</c> when
/// it is absent or the blob is unreadable. Purely a convenience seed for <see cref="_manualScope"/>
/// — never a gate, so any failure is simply "no seed".
/// </summary>
/// <returns>The configured group id, or <c>null</c>.</returns>
private string? TryReadConfiguredGroupId()
{
try
{
// Case-insensitive: the merged blob is assembled from the driver + device configs and this
// is a display convenience, not a binding — it must not depend on which one won.
var root = System.Text.Json.Nodes.JsonNode.Parse(_mergedConfig)?.AsObject();
var sparkplug = root?
.FirstOrDefault(p => string.Equals(p.Key, "Sparkplug", StringComparison.OrdinalIgnoreCase))
.Value?.AsObject();
var groupId = sparkplug?
.FirstOrDefault(p => string.Equals(p.Key, "GroupId", StringComparison.OrdinalIgnoreCase))
.Value?.GetValue<string>();
return string.IsNullOrWhiteSpace(groupId) ? null : groupId.Trim();
}
catch
{
return null;
}
}
/// <summary>Clears every rebirth-panel field (modal re-open, browser close).</summary>
private void ResetRebirthState()
{
@@ -586,12 +730,22 @@
.ToList();
if (_commitErrors.Count > 0) { return; }
var rows = _selected.Values
var selected = _selected.Values.ToList();
var rows = selected
.Select(s => RawBrowseCommitMapper.MapLeaf(
_driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType,
_groupPrefix, s.FolderPath, _createGroups, s.AddressFields))
.ToList();
// A leaf name is coerced into a legal RawPath segment (#509), and that coercion is not
// injective — 'A/B' and 'A_B' both land on 'A_B'. Refuse the colliding SET by name here:
// letting it through would silently merge two distinct metrics into one tag bound to
// whichever address survived, which is worse than the outright refusal #509 replaced.
_commitErrors = RawBrowseCommitMapper
.DescribeNameCollisions(selected.Select(s => s.Name).ToList(), rows)
.ToList();
if (_commitErrors.Count > 0) { return; }
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
if (outcome.Errors.Count > 0)
{
@@ -613,6 +767,8 @@
var t = _token;
_token = Guid.Empty;
_canRebirth = false;
_rebirthExpired = false;
_manualScope = "";
ResetRebirthState();
Visible = false;
_open = false;