fix(mqtt-contracts): strict retainSeed, real bounds instead of dead [Range]s, DateTime edges, closed topic formatting

Driver.Mqtt.Contracts-004/-005/-006/-007.

-004: retainSeed was the last behavioural key read leniently, so "retainSeed":"false"
— what a CSV import produces — silently became the default TRUE, the INVERSE of the
authored intent. Both mappers now read it through TryReadBoolStrict (the qos
absent/valid/present-but-invalid split) and DescribeRejection names it. The authoring
side needed no hand-mirrored rule: MqttTagConfigModel.Validate already consults this
same parse through TagConfigParseGate, so the save is blocked with the inspector's
wording — pinned by a new AdminUI parity theory pair.

-005: the seven [Range] attributes on MqttDriverOptions/MqttPlainOptions never ran —
the options are bound by JsonSerializer, and nothing in the four MQTT projects calls
Validator.TryValidateObject. Two of them CONTRADICTED the real authority (KeepAlive's
[Range(1,…)] forbade the zero Normalize admits as legal MQTT; MaxPayloadBytes' forbade
the non-positive value its own remarks document as falling back to 1 MiB), so wiring
them up would have broken deliberate behaviour. Deleted, with each property naming
where its bound actually lives. The one escape they papered over is closed: an
out-of-range defaultQos rode into the SUBSCRIBE verbatim, and MqttSubscriptionManager's
constructor now clamps it exactly as the AdminUI driver form already persists.

-006: the DateTime write arm cast a negative long to ulong, so 1969-12-31 published a
timestamp ~584 million years in the future; it is now refused as ValueNotRepresentable.
An Unspecified kind was handed to ToUniversalTime(), which reads it as LOCAL — the same
authored instant encoded differently on the two nodes of a redundant pair; it is now
read as UTC.

-007: Format/FormatState enforced only null/empty, and nothing at all on deviceId, so
the type was not round-trip-closed. They now apply the parser's IsValidSegment (plus
'/', which only the format side can see). MqttWriteEncoder's own guards stay — TryEncode
returns an error rather than throwing, and that contract does not change.
This commit is contained in:
Joseph Doherty
2026-08-10 08:18:30 -04:00
parent eddf5f57a5
commit 93f70504bd
11 changed files with 441 additions and 31 deletions
@@ -65,6 +65,14 @@ public sealed class MqttSubscriptionManager
/// </summary>
public const int DefaultMaxPayloadBytes = 1024 * 1024;
/// <summary>
/// Subscription QoS applied when neither the tag nor the driver's <c>Plain</c> section authored
/// one. Mirrors <see cref="MqttPlainOptions.DefaultQos"/>' own default — duplicated rather than
/// referenced because that value is an instance property, and this is the fallback for the case
/// where the whole section is absent.
/// </summary>
public const int DefaultQos = 1;
// OPC UA status codes (values verified against Opc.Ua.StatusCodes, not recalled).
private const uint StatusGood = 0x00000000u;
@@ -137,7 +145,13 @@ public sealed class MqttSubscriptionManager
MaxPayloadBytes = maxPayloadBytes > 0 ? maxPayloadBytes : DefaultMaxPayloadBytes;
_mode = options.Mode;
_defaultQos = options.Plain?.DefaultQos ?? 1;
// Clamped here for the same reason MaxPayloadBytes is: this constructor is the single
// enforcement point for the driver options the manager captures, and MQTT defines exactly three
// QoS levels — an out-of-range value would otherwise ride into BuildFilters and out onto the
// SUBSCRIBE verbatim. Clamp rather than fall back to the default, matching what
// MqttDriverFormModel.Normalize already persists, so the authoring surface and the driver cannot
// reach different answers for the same authored number.
_defaultQos = Math.Clamp(options.Plain?.DefaultQos ?? DefaultQos, 0, 2);
_driverId = driverId;
_transport = transport;
_logger = logger;