feat(mqtt): Sparkplug birth-driven browser tree + scoped Request-rebirth action

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
This commit is contained in:
Joseph Doherty
2026-07-24 22:51:16 -04:00
parent 6d7a458c4d
commit 64ec7aa43a
7 changed files with 1263 additions and 95 deletions
@@ -35,3 +35,47 @@ public interface IBrowseSession : IAsyncDisposable
/// <returns>The attributes of the node.</returns>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
}
/// <summary>
/// An <see cref="IBrowseSession"/> whose protocol offers an explicit, operator-triggered
/// <b>re-announce</b> action: a request that the remote peer republish its self-description so the
/// observation window can fill without waiting for the next natural announcement. Implemented
/// today only by the MQTT/Sparkplug browser, whose tree is built from observed NBIRTH/DBIRTH
/// certificates.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the only interface in the browse contract that causes an outbound message.</b>
/// Every other browse member is strictly read-only, and for the MQTT browser that read-only
/// property is load-bearing: a picker opened by an operator runs against a live production
/// broker. It is a separate interface, rather than an optional member on
/// <see cref="IBrowseSession"/>, precisely so "does this session write?" stays a type
/// question a caller cannot forget to ask.
/// </para>
/// <para>
/// <b>Callers must authorize before invoking it.</b> The AdminUI routes it through
/// <c>BrowserSessionService.RequestRebirthAsync</c>, which enforces the same
/// <c>DriverOperator</c> policy that gates the picker's Browse affordance — an implementation
/// cannot check that itself, since it holds no user identity.
/// </para>
/// </remarks>
public interface IRebirthCapableBrowseSession : IBrowseSession
{
/// <summary>
/// Asks the addressed remote peer(s) to re-announce themselves.
/// </summary>
/// <param name="scope">
/// The protocol-specific target. For Sparkplug: a browse <c>NodeId</c> from this session's own
/// tree (group, edge node, device or metric — resolved up to the owning edge node), or a bare
/// <c>{group}/{edgeNode}</c> pair for a node that has not been observed yet.
/// </param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The number of request messages published.</returns>
/// <exception cref="ArgumentException"><paramref name="scope"/> is empty or unusable.</exception>
/// <exception cref="InvalidOperationException">
/// The scope resolves to no target, or to more targets than the implementation will fan out to
/// in one action. Nothing is published in either case.
/// </exception>
/// <exception cref="NotSupportedException">This session's mode has no re-announce action.</exception>
Task<int> RequestRebirthAsync(string scope, CancellationToken cancellationToken);
}