docs(mqtt): Sparkplug P2 live-verified; MQTT driver complete

Task 26 — the P2 milestone gate. Ran the live /run verification on an isolated
docker-dev rig (project otopcua-mqtt, ports 9210/4850-4851/14350, image built
from this branch) against the real Mosquitto TLS+auth broker and the C#
Sparkplug edge-node simulator on 10.100.0.35, and recorded the result.

The gate found three defects. Two are the same defect class the P1 gate found
twice — a hand-maintained AdminUI surface left behind by a driver-side feature —
and the third is a pre-existing cross-driver bug that only a Sparkplug flow
could surface.

  1. CRITICAL, FIXED — MqttDriverForm still shipped its P1 Sparkplug PLACEHOLDER.
     Switching Mode to SparkplugB rendered a "not available yet" notice and NO
     Group ID field, so with Sparkplug ingest fully shipped there was still no
     way to author a Sparkplug driver from the AdminUI at all. Sparkplug.GroupId
     is the driver's entire subscription filter (spBv1.0/{GroupId}/#): blank ⇒
     connected, Healthy, ingesting nothing. Now authors all five Sparkplug keys,
     MERGES over the existing sub-object rather than replacing it (so a key a
     newer driver adds inside it survives an older AdminUI), leaves it untouched
     in Plain mode, and validates the group id as the topic segment it is.
     Pinned by 8 MqttDriverFormModelTests cases, verified falsifiable — 8 RED
     with the fix stubbed out.

  2. PRE-EXISTING + CROSS-DRIVER, FIXED — every node label in the shared
     DriverBrowseTree was an <a href="#">. In a Blazor Web App, blazor.web.js's
     enhanced-navigation click interceptor resolves a bare "#" against
     <base href="/">, so clicking ANY browse-tree node label navigated the whole
     AdminUI to "/", tore down the circuit, and destroyed the hosting modal —
     losing the browse session AND the tag selection. @onclick:preventDefault
     does not help: it suppresses the browser's default action, not Blazor's own
     interceptor. Labels are now <button type="button">. Dates to the component's
     introduction (2026-05-28) and affects EVERY driver's picker; it surfaced
     only now because choosing a rebirth scope is the first flow that requires
     clicking a label rather than the ▶ toggle (always a button, always fine).

  3. FIXED (mitigation) — the browse tree rendered exactly once, at open, and
     never again, while OpenAsync returns as soon as the SUBSCRIBE is granted.
     On Sparkplug that means it is almost always empty forever, because births
     are never retained. Added a Refresh button that re-reads the root against
     the SAME session (nothing reconnects, nothing observed is lost, the tag
     selection is kept, only the armed rebirth scope is cleared).

Live gate result (all against the real broker + simulator, group OtOpcUaSim):

  - driver authored end-to-end through the fixed MqttDriverForm; blob is
    Mode:SparkplugB + Sparkplug.GroupId:OtOpcUaSim with enums as names
  - browse: BROWSER OPEN chip, Sparkplug-only rebirth panel, tree renders
    Group → EdgeNode → [Device] → Metric with the device folder Filler1 and
    node-level metric leaves SIDE BY SIDE, and "Node Control/Rebirth" as ONE
    leaf (the metric-name-is-not-a-topic-segment rule holding)
  - rebirth: group scope names the group + the 32-node cap, Cancel published
    NOTHING (simulator log unchanged); edge-node scope → "1 command published"
    and the simulator logged "rebirth NCMD #1 received; republishing metadata";
    group scope → "2 commands published", both edge nodes re-birthed; a device
    and a metric both resolve UP to "edge node EdgeA"; the panel does NOT render
    for a Plain MQTT device
  - browse-commit wrote bindable tuples with no topic/address key —
    {"groupId","edgeNodeId","metricName","deviceId"} for the device metric and
    deviceId ABSENT (not blank) for the node-level one, datatypes inherited from
    the birth (Int64 / Float)
  - deployed (Sealed), and both nodes served live changing values through OPC UA
    at the 2 s cadence — Good 0x00000000, no BadNodeIdUnknown
  - death→STALE: stopping the simulator drove both nodes to 0x80000000 with an
    empty value ~2 s later, and restarting it recovered them to Good on the new
    birth (FillCount back to its birth-declared 1000, then counting)
  - rediscovery fired exactly TWICE (once per authored scope: OtOpcUaSim/EdgeA
    7 metrics, OtOpcUaSim/EdgeA/Filler1 3 metrics) and was then gated across
    many subsequent births/rebirths — the anti-storm change gate working, and
    EdgeB correctly filtered out as an unauthored scope
  - tag editor: opens in SparkplugB (mode inference on reopen) with all four
    descriptor fields populated; blank group → "A Sparkplug group ID is
    required."; "Plant/1" rejected; "Node Control/Rebirth" ACCEPTED and saved
    with the slash whole; dataType override persists and its key is ABSENT again
    after selecting "(from birth certificate)"

Two things are NOT verified, and are recorded rather than claimed:

  - Rediscovery is INERT in v3 and this is a platform gap, not an MQTT one:
    nothing subscribes to IRediscoverable.OnRediscoveryNeeded and
    DriverHostActor.HandleDiscoveredNodes hard-returns. A DBIRTH introducing a
    metric does NOT change the OPC UA tree; redeploy. Verified from the driver's
    log line only, exactly as far as it can be verified.
  - A metric name containing "/" cannot be BROWSE-COMMITTED: the derived raw tag
    Name is a RawPath segment and may not contain "/", so the spec-mandatory
    "Node Control/Rebirth" is refused ("Row 3: Name must not contain '/'"). The
    refusal is loud and all-or-nothing, never a silently mis-bound tag, and
    Manual entry is the working path. Fixing it needs a name-sanitisation policy
    with a collision answer, so it was recorded rather than guessed at.

Also left open + documented: an empty browse tree cannot arm a rebirth (the
scope comes from a tree click, and the session side already accepts a bare
{group}/{edgeNode} — only a UI path is missing); _canRebirth is captured at
browse-open; the tag editor injects the Plain-only payloadFormat key into a
Sparkplug blob (cosmetic — the factory routes on the tuple).

Suites: offline 1528 passed / 0 failed (581 Driver.Mqtt + 809 AdminUI + 138
Core.Abstractions); live 15/15 against the broker + simulator.

Docs: docs/drivers/Mqtt.md brought fully up to date for P2 (Sparkplug config
sub-object, both tag shapes, the ingest state machine, rediscovery, browse +
rebirth + Refresh, a Known-gaps table); Mqtt-Test-Fixture.md gains the simulator
and drops its "Sparkplug has no fixture" claims; infra/README.md §3 gains the
simulator row; CLAUDE.md gains the simulator endpoint facts; the tracking doc
marks MQTT/Sparkplug COMPLETE. docker-dev/docker-compose.mqtt.yml is the
isolated-rig overlay the gate ran on.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-25 01:11:56 -04:00
parent 8d9155682d
commit d5bd4226ee
11 changed files with 685 additions and 84 deletions
@@ -141,17 +141,27 @@
{
<span style="width:18px"></span>
}
@* BUTTONS, NOT <a href="#">. This is a Blazor Web App: blazor.web.js installs a
document-level click interceptor for enhanced navigation, and it resolves a bare "#"
against <base href="/"> rather than the current URL — so clicking a node label
navigated the whole AdminUI to "/", tore down the circuit, and destroyed whatever modal
the tree was hosted in (losing the browse session AND the tag selection with it).
@onclick:preventDefault does not help: it suppresses the BROWSER's default action, not
Blazor's own interceptor. A <button> is never a navigation candidate, so the handler is
the only thing that runs. Found by the MQTT/Sparkplug P2 live gate — the Request-rebirth
scope is chosen by clicking a FOLDER label, which is the first feature that ever
required clicking a label rather than the ▶ toggle (a button, which always worked). *@
@if (isMultiLeaf)
{
<input type="checkbox" class="form-check-input" checked="@IsLeafSelected(item.Node.NodeId)"
@onchange="@(() => ToggleLeafAsync(item, ancestorPath))" />
<a href="#" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))" @onclick:preventDefault
class="text-decoration-none mono small">@item.Node.DisplayName</a>
<button type="button" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))"
class="btn btn-link btn-sm p-0 text-decoration-none mono small text-start">@item.Node.DisplayName</button>
}
else
{
<a href="#" @onclick="@(() => SelectAsync(item, ancestorPath))" @onclick:preventDefault
class="text-decoration-none mono small">@item.Node.DisplayName</a>
<button type="button" @onclick="@(() => SelectAsync(item, ancestorPath))"
class="btn btn-link btn-sm p-0 text-decoration-none mono small text-start">@item.Node.DisplayName</button>
}
@if (item.Node.Kind == BrowseNodeKind.Leaf)
{
@@ -8,8 +8,11 @@
pure MqttDriverFormModel — enums must round-trip by NAME (this repo's systemic driver-enum bug).
The Sparkplug sub-object (groupId / hostId / actAsPrimaryHost / requestRebirthOnGap /
birthObservationWindowSeconds) is P2 (Task 21+): the placeholder below mirrors how MqttTagConfigEditor
stubs its Sparkplug branch, and any existing sparkplug keys are preserved untouched. *@
birthObservationWindowSeconds) is authored by the Mode == SparkplugB branch below. It is MERGED over
whatever the inbound blob had rather than replacing it, and in Plain mode it is not touched at all —
see MqttDriverFormModel.ToJson. Until the P2 live gate this branch was a "not implemented yet"
placeholder, which left the group id — the driver's entire subscription filter — unauthorable from
the UI after Sparkplug ingest had shipped. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.Mqtt
@@ -171,7 +174,7 @@
<option value="@v">@v</option>
}
</InputSelect>
<div class="form-text">Plain = topic-bound tags. Sparkplug B is not implemented yet.</div>
<div class="form-text">Plain = topic-bound tags. Sparkplug B = birth-described metrics under one group.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-max-payload">Max payload bytes</label>
@@ -201,12 +204,66 @@
}
else
{
@* Sparkplug B. The group id is the ONE mandatory field: it is the driver's entire
subscription filter (spBv1.0/{GroupId}/#), so a blank one leaves the driver connected,
Healthy, and ingesting nothing. Validate() surfaces that inline; like every other knob
on this form it is ADVISORY — DriverConfigModal saves regardless — which is why the
model clamps on serialize instead of relying on the operator reading the notice. *@
<div class="col-md-3">
<label class="form-label" for="mqtt-spb-group">Group ID</label>
<InputText id="mqtt-spb-group" @bind-Value="_form.SparkplugGroupId" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="Plant1" />
<div class="form-text">
Subscribes <code class="mono">spBv1.0/@(string.IsNullOrWhiteSpace(_form.SparkplugGroupId) ? "{GroupId}" : _form.SparkplugGroupId)/#</code>.
No <code>/</code>, <code>+</code>, or <code>#</code>.
</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-spb-window">Birth observation window (seconds)</label>
<InputNumber id="mqtt-spb-window" @bind-Value="_form.SparkplugBirthObservationWindowSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 15 s. How long browse/discovery collects births before calling the metric set stable.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-spb-host">Host ID (optional)</label>
<InputText id="mqtt-spb-host" @bind-Value="_form.SparkplugHostId" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="scada-primary" />
<div class="form-text">Sparkplug Host Application identity. Receive-only in this version.</div>
</div>
<div class="col-md-3 d-flex flex-column justify-content-center">
<div class="form-check form-switch">
<InputCheckbox id="mqtt-spb-rebirth" @bind-Value="_form.SparkplugRequestRebirthOnGap" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="mqtt-spb-rebirth">Request rebirth on sequence gap</label>
</div>
<div class="form-text mb-2">A detected seq gap asks the edge node to re-announce (NCMD), rather than running on stale metric state.</div>
<div class="form-check form-switch">
<InputCheckbox id="mqtt-spb-primary" @bind-Value="_form.SparkplugActAsPrimaryHost" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="mqtt-spb-primary">
Act as Primary Host
<span class="badge text-bg-warning">not implemented</span>
</label>
</div>
</div>
@if (_form.SparkplugActAsPrimaryHost)
{
@* Shown rather than the flag being hidden: an operator who needs a primary host must
learn it is absent HERE, not from a plant that never sees a STATE message. The
driver logs the same warning at startup so the two cannot drift. *@
<div class="col-12">
<div class="alert alert-warning py-2 px-3 mb-0 small" role="alert">
<strong>Primary-host STATE publishing is not implemented.</strong> This driver is
receive-only: it will not publish <code class="mono">spBv1.0/STATE/@(string.IsNullOrWhiteSpace(_form.SparkplugHostId) ? "{HostId}" : _form.SparkplugHostId)</code>,
so edge nodes will not treat it as their primary host. The driver logs a warning at
startup rather than being silently inert. Leave this off unless you are staging
configuration ahead of that feature.
</div>
</div>
}
<div class="col-12">
<div class="alert alert-info py-2 px-3 mb-0 small" role="alert">
Sparkplug B settings (group id, host id, primary-host role, rebirth-on-gap, birth
observation window) are not available yet — they land with Sparkplug ingest. Switch back
to <strong>Plain</strong> to author a topic-bound driver. Any existing Sparkplug keys on
this driver are preserved untouched.
Sparkplug tags bind by the <code class="mono">group / edge node / device / metric</code>
tuple, not by topic — author them with <strong>Browse device…</strong> on a device under
this driver, which builds its tree from observed birth certificates.
<strong>MQTT tags are read-only</strong> (write-through is not implemented).
</div>
</div>
}
@@ -46,11 +46,21 @@ public sealed class MqttDriverFormModel
private const string RawTagsKey = nameof(MqttDriverOptions.RawTags);
/// <summary>
/// The <see cref="MqttDriverOptions"/> property name for the P2 Sparkplug sub-object; never
/// authored here and preserved verbatim from the inbound blob.
/// The <see cref="MqttDriverOptions"/> property name for the Sparkplug sub-object. Authored here
/// in <see cref="MqttMode.SparkplugB"/> mode and otherwise preserved verbatim from the inbound
/// blob — see <see cref="ToJson"/> for why the mode decides which of the two happens.
/// </summary>
private const string SparkplugKey = nameof(MqttDriverOptions.Sparkplug);
/// <summary>
/// Characters that cannot appear in a Sparkplug group id. It is a literal MQTT topic segment
/// inside the driver's one subscription filter <c>spBv1.0/{GroupId}/#</c>, so a <c>/</c> would
/// silently widen the filter and a <c>+</c>/<c>#</c> is not even legal mid-segment. Mirrors
/// <c>MqttTagConfigModel</c>'s identical rule on the tag side, so the driver and the tags bound
/// to it cannot disagree about what a group id may be.
/// </summary>
private static readonly char[] SparkplugGroupIdIllegalChars = ['/', '+', '#'];
// --- Broker connection ----------------------------------------------------------------------
/// <summary>Broker hostname or IP address.</summary>
@@ -122,9 +132,37 @@ public sealed class MqttDriverFormModel
/// <summary>Plain mode: default subscription QoS applied when a tag's own <c>qos</c> is unset.</summary>
public int DefaultQos { get; set; } = 1;
// --- Sparkplug B ----------------------------------------------------------------------------
/// <summary>
/// The inbound blob's keys, retained so the P2 <c>Sparkplug</c> sub-object and any key a newer
/// driver adds survive a load→save.
/// Sparkplug mode: the group id. <b>Required</b> — it is the driver's entire subscription scope
/// (<c>spBv1.0/{GroupId}/#</c>), so a blank one subscribes to nothing and the driver ingests
/// nothing while still reporting a healthy broker connection.
/// </summary>
public string SparkplugGroupId { get; set; } = "";
/// <summary>
/// Sparkplug mode: the Host Application identity. Optional, and <b>receive-only</b> in this
/// version — see <see cref="SparkplugActAsPrimaryHost"/>.
/// </summary>
public string SparkplugHostId { get; set; } = "";
/// <summary>
/// Sparkplug mode: claim the Primary Host Application role. <b>Not implemented</b> — STATE
/// publishing is out of scope, and the driver logs a warning rather than being silently inert
/// when this is set. Surfaced (rather than hidden) so an operator who needs it learns that from
/// the form instead of from a quiet plant.
/// </summary>
public bool SparkplugActAsPrimaryHost { get; set; }
/// <summary>Sparkplug mode: answer a detected sequence-number gap with a rebirth request (NCMD).</summary>
public bool SparkplugRequestRebirthOnGap { get; set; } = true;
/// <summary>Sparkplug mode: how long browse/discovery collects births before calling the set stable.</summary>
public int SparkplugBirthObservationWindowSeconds { get; set; } = 15;
/// <summary>
/// The inbound blob's keys, retained so any key a newer driver adds survives a load→save.
/// </summary>
private JsonObject _bag = new();
@@ -172,6 +210,16 @@ public sealed class MqttDriverFormModel
MaxPayloadBytes = o.MaxPayloadBytes,
TopicPrefix = o.Plain?.TopicPrefix ?? "",
DefaultQos = o.Plain?.DefaultQos ?? new MqttPlainOptions().DefaultQos,
SparkplugGroupId = o.Sparkplug?.GroupId ?? "",
SparkplugHostId = o.Sparkplug?.HostId ?? "",
SparkplugActAsPrimaryHost = o.Sparkplug?.ActAsPrimaryHost ?? false,
// Defaulted from the record, not from a literal, so the form shows the driver's own default
// for a blob that has no Sparkplug sub-object at all.
SparkplugRequestRebirthOnGap =
o.Sparkplug?.RequestRebirthOnGap ?? new MqttSparkplugOptions().RequestRebirthOnGap,
SparkplugBirthObservationWindowSeconds =
o.Sparkplug?.BirthObservationWindowSeconds
?? new MqttSparkplugOptions().BirthObservationWindowSeconds,
_bag = bag,
};
}
@@ -181,8 +229,9 @@ public sealed class MqttDriverFormModel
/// range <see cref="MqttDriverOptions"/> declares, so an operator who ignores
/// <see cref="Validate"/> and saves anyway still cannot persist a driver-bricking blob — a
/// <c>connectTimeoutSeconds: 0</c> is exactly the operator-authorable brick this repo has hit
/// before. <see cref="MqttDriverOptions.Sparkplug"/> stays <c>null</c> and
/// <see cref="MqttDriverOptions.RawTags"/> stays empty; both are handled by <see cref="ToJson"/>.
/// before. <see cref="MqttDriverOptions.RawTags"/> stays empty (the deploy artifact owns it) and
/// <see cref="MqttDriverOptions.Sparkplug"/> is emitted only in
/// <see cref="MqttMode.SparkplugB"/> mode; both are finished by <see cref="ToJson"/>.
/// </summary>
/// <returns>The clamped, driver-legal options record.</returns>
public MqttDriverOptions ToOptions() => new()
@@ -208,23 +257,45 @@ public sealed class MqttDriverFormModel
TopicPrefix = TopicPrefix.Trim(),
DefaultQos = Math.Clamp(DefaultQos, 0, 2),
},
// Null in Plain mode so ToJson leaves whatever the inbound blob had; see its remarks.
Sparkplug = Mode == MqttMode.SparkplugB
? new MqttSparkplugOptions
{
GroupId = SparkplugGroupId.Trim(),
HostId = SparkplugHostId.Trim(),
ActAsPrimaryHost = SparkplugActAsPrimaryHost,
RequestRebirthOnGap = SparkplugRequestRebirthOnGap,
BirthObservationWindowSeconds = Math.Max(1, SparkplugBirthObservationWindowSeconds),
}
: null,
};
/// <summary>
/// Serialises the authored fields over the preserved key bag and returns the <c>DriverConfig</c>
/// JSON. Keys are PascalCase and enums are names, because the serialisation runs through
/// <see cref="MqttJson.Options"/> — see the type remarks. <c>Sparkplug</c> is left exactly as the
/// inbound blob had it (P2 authors it) and <c>RawTags</c> is removed (the deploy artifact owns it).
/// <see cref="MqttJson.Options"/> — see the type remarks. <c>RawTags</c> is removed (the deploy
/// artifact owns it).
/// </summary>
/// <remarks>
/// <b><c>Sparkplug</c> is merged, never replaced, and only in Sparkplug mode.</b> In
/// <see cref="MqttMode.Plain"/> the sub-object is left exactly as the inbound blob had it, so an
/// operator who flips a Sparkplug driver to Plain to look at it and flips back does not lose the
/// group id. In <see cref="MqttMode.SparkplugB"/> the five fields this form owns are written
/// <i>over</i> the existing sub-object rather than replacing it, so a key a newer driver adds
/// inside <c>Sparkplug</c> survives an older AdminUI — the same preserve-what-you-do-not-author
/// discipline the top-level bag applies.
/// </remarks>
/// <returns>The serialised <c>DriverConfig</c> JSON string.</returns>
public string ToJson()
{
var typed = JsonSerializer.SerializeToNode(ToOptions(), MqttJson.Options)!.AsObject();
// Never let this form's (always-null / always-empty) placeholders overwrite what it does not own.
typed.Remove(SparkplugKey);
// Never let this form's always-empty placeholder overwrite what it does not own.
typed.Remove(RawTagsKey);
// Pulled out of the flat copy loop below: unlike every other key, this one merges.
var authoredSparkplug = TakeIgnoringCase(typed, SparkplugKey) as JsonObject;
foreach (var (key, value) in typed.ToList())
{
// Case-insensitive replace: MqttJson.Options binds a hand-edited camelCase blob happily, so
@@ -233,6 +304,24 @@ public sealed class MqttDriverFormModel
_bag[key] = value?.DeepClone();
}
if (authoredSparkplug is not null)
{
// Merge over whatever was already there, under the key name the bag already uses so a
// camelCase hand-edited blob does not end up with both "sparkplug" and "Sparkplug".
var existingKey = FindIgnoringCase(_bag, SparkplugKey) ?? SparkplugKey;
if (_bag[existingKey] is not JsonObject target)
{
target = new JsonObject();
_bag[existingKey] = target;
}
foreach (var (key, value) in authoredSparkplug.ToList())
{
RemoveIgnoringCase(target, key);
target[key] = value?.DeepClone();
}
}
RemoveIgnoringCase(_bag, RawTagsKey);
return TagConfigJson.Serialize(_bag);
}
@@ -262,6 +351,28 @@ public sealed class MqttDriverFormModel
}
if (MaxPayloadBytes < 1) { return "Max payload bytes must be at least 1."; }
if (DefaultQos is < 0 or > 2) { return "Default QoS must be 0, 1 or 2."; }
if (Mode == MqttMode.SparkplugB)
{
var groupId = SparkplugGroupId.Trim();
if (groupId.Length == 0)
{
return "A Sparkplug group ID is required — it is the driver's whole subscription scope "
+ "(spBv1.0/{GroupId}/#), so a blank one ingests nothing.";
}
if (groupId.IndexOfAny(SparkplugGroupIdIllegalChars) >= 0)
{
return $"Sparkplug group ID '{groupId}' contains a character ('/', '+', or '#') that "
+ "cannot appear in an MQTT topic segment.";
}
if (SparkplugBirthObservationWindowSeconds < 1)
{
return "Birth observation window must be at least 1 second.";
}
}
return null;
}
@@ -284,6 +395,29 @@ public sealed class MqttDriverFormModel
}
}
/// <summary>The actual key in <paramref name="o"/> matching <paramref name="name"/> case-insensitively.</summary>
/// <param name="o">The object to search.</param>
/// <param name="name">The key name to look for.</param>
/// <returns>The matching key as it is spelled in <paramref name="o"/>, or <c>null</c>.</returns>
private static string? FindIgnoringCase(JsonObject o, string name)
=> o.Select(p => p.Key)
.FirstOrDefault(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase));
/// <summary>Removes and returns the value of a case-insensitively matched key.</summary>
/// <param name="o">The object to mutate.</param>
/// <param name="name">The key name to take.</param>
/// <returns>The detached value, or <c>null</c> when the key was absent (or its value was null).</returns>
private static JsonNode? TakeIgnoringCase(JsonObject o, string name)
{
if (FindIgnoringCase(o, name) is not { } key) { return null; }
var value = o[key];
o.Remove(key);
// Detached before return: a node still parented to `typed` cannot be re-parented into the bag.
return value?.DeepClone();
}
/// <summary>Binds the blob through the shared options; <c>null</c> on blank/malformed input.</summary>
/// <param name="json">The raw <c>DriverConfig</c> JSON.</param>
/// <returns>The bound options, or <c>null</c>.</returns>