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
@@ -0,0 +1,80 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Validates an operator-typed Sparkplug rebirth scope for the raw browse modal (Gitea #514).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a typed scope exists at all.</b> The Sparkplug browse tree is <i>birth-driven</i>, and
/// births are never retained. On a quiet plant — every edge node birthed long before the browse
/// window opened, and is now publishing only NDATA/DDATA — the tree comes up empty. The
/// Request-rebirth scope was derived solely from a tree selection, so the one action that would
/// populate the tree could not be armed: the action needed the tree, and the tree needed the action.
/// </para>
/// <para>
/// <b>This is advisory, not the gate.</b> <c>MqttBrowseSession.ResolveRebirthTargets</c> re-resolves
/// the scope and refuses an unusable one <em>before any message leaves</em>, and the
/// <c>DriverOperator</c> policy is enforced server-side in <c>BrowserSessionService</c>. This exists
/// so the modal does not let an operator arm a two-click confirm that cannot succeed. Being the
/// looser of the two is therefore safe; being the stricter one would block a scope the session
/// accepts, which is the failure mode the repo's authoring surfaces avoid by convention.
/// </para>
/// <para>
/// <b>A group-only scope is rejected on purpose.</b> The session resolves a bare group by enumerating
/// the edge nodes it has <i>observed</i> — of which an empty tree has none — so it could not help the
/// case this input exists for, and on a populated tree it would fan one click across the whole group.
/// The tree-selection path still offers a group scope, with its own confirm-time cap and warning.
/// </para>
/// </remarks>
public static class SparkplugRebirthScope
{
/// <summary>MQTT wildcards. A rebirth addresses one edge node exactly; a wildcard cannot name one.</summary>
private static readonly char[] Wildcards = ['+', '#'];
/// <summary>
/// Describes why <paramref name="raw"/> is not a usable <c>{group}/{edgeNode}</c> rebirth scope.
/// </summary>
/// <param name="raw">The operator-typed scope, untrimmed; <see langword="null"/> is treated as blank.</param>
/// <returns>The operator-facing error text, or <see langword="null"/> when the scope is usable.</returns>
public static string? Describe(string? raw)
{
var scope = (raw ?? "").Trim();
if (scope.Length == 0) { return "Enter the edge node as 'group/edgeNode'."; }
if (scope.IndexOfAny(Wildcards) >= 0)
{
return "An MQTT wildcard (+ or #) cannot address an edge node; name one exactly.";
}
var segments = scope.Split('/');
if (segments.Length != 2 || Array.Exists(segments, string.IsNullOrWhiteSpace))
{
return "Use exactly 'group/edgeNode' — a group on its own would ask every edge node in it to "
+ "republish, which this window cannot enumerate while the tree is empty.";
}
return null;
}
/// <summary>True when <paramref name="raw"/> is a usable rebirth scope.</summary>
/// <param name="raw">The operator-typed scope.</param>
/// <returns><see langword="true"/> when <see cref="Describe"/> finds no fault.</returns>
public static bool IsUsable(string? raw) => Describe(raw) is null;
/// <summary>
/// Splits a scope already accepted by <see cref="Describe"/> into its two segments.
/// </summary>
/// <param name="raw">A scope for which <see cref="IsUsable"/> is <see langword="true"/>.</param>
/// <returns>The trimmed <c>(group, edgeNode)</c> pair.</returns>
/// <exception cref="ArgumentException">The scope is not usable.</exception>
public static (string GroupId, string EdgeNodeId) Split(string? raw)
{
if (Describe(raw) is { } error)
{
throw new ArgumentException($"'{raw}' is not a usable rebirth scope: {error}", nameof(raw));
}
var segments = raw!.Trim().Split('/');
return (segments[0].Trim(), segments[1].Trim());
}
}