fix(inspect): trim before the framing check; stop advisories swallowing the backstop (#556); pin the MQTT payloadFormat mode contract (#557 item 2)

#556 item 1 — leading whitespace defeated every Inspect's pre-filter. Seven
factories (Modbus/S7/AbCip/AbLegacy/TwinCAT/FOCAS/Mqtt) tested
`reference[0] != '{'`, so a padded blob returned EMPTY and a tag the driver
refuses deployed clean. The check now trims first, matching the idiom
Sql/Calculation/MTConnect/OpcUaClient already used. The raw reference is still
what is handed to the mapper — the driver reads the stored blob, not a trimmed
copy — so the gate keeps mirroring the runtime rather than becoming lenient.
Both directions are pinned: a padded blob the mapper accepts still draws
nothing, and one that is not object-framed AFTER trimming still draws nothing
(it is not an equipment-tag TagConfig at all).

Six of the seven mappers hand the blob to a whitespace-tolerant
JsonDocument.Parse, so their verdict is unchanged by padding. MQTT is the
exception: both its readers carry the same framing gate, so a padded blob is
genuinely refused — which is why the untrimmed pre-filter was a real defect
there and not a cosmetic mismatch.

#556 item 2 — FOCAS and MQTT appended advisories unconditionally and then gated
the catch-all rejection message on `warnings.Count == 0`, so an advisory (which
fires on ACCEPTED blobs too, and says nothing about a refusal) silently
swallowed the only message naming a refusal nothing else can. The two classes
now accumulate in SEPARATE lists and the backstop gates on the rejection count
alone — structural, not string matching. MQTT's swallow was reachable in
production (a padded blob its readers refuse while every describe branch finds
the parsed document well-formed); FOCAS's backstop is currently unreachable by
audit, so its guard is protective.

#557 item 2 — decided and pinned: KEEP the union verdict. Evidence:
FromSparkplugTagConfig hard-codes PayloadFormat=Json and never consults the key;
only MqttSubscriptionManager (the Plain ingest path) decodes by it; and
MqttTagConfigModel.ToJson deliberately omits the key entirely in Sparkplug mode
(#513). So a Sparkplug driver genuinely serves the tag and rejecting it would be
stricter than the runtime. The cost — served behaviour depends on the driver's
MqttMode, which no tag row records — is paid by an ADVISORY naming the key and
the mode dependency, not by changing the verdict, so both gates keep agreeing.
The same invalid value on a blob the union REFUSES stays a rejection; the class
is decided by the accept gate, never by the key.

Falsified: reverting Modbus's trim reddens the padded-rejection theory;
neutering MQTT's backstop guard to the pre-#556 shape reddens the
advisory-plus-unnameable-rejection test; disabling the #557 advisory reddens the
contract pin; and for FOCAS, removing a describe branch alone stays green (the
backstop covers it) while additionally neutering the guard goes red.
This commit is contained in:
Joseph Doherty
2026-08-10 03:07:18 -04:00
parent ca3968cc31
commit f5475868f7
14 changed files with 752 additions and 56 deletions
@@ -212,9 +212,15 @@ public static class AbCipTagDefinitionFactory
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
// drawing nothing.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') return warnings;
// The accept gate: whatever the driver reads draws nothing.
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
try
@@ -148,9 +148,15 @@ public static class AbLegacyTagDefinitionFactory
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
// drawing nothing.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') return warnings;
// The accept gate: whatever the driver reads draws nothing.
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
try
@@ -122,16 +122,37 @@ public static class FocasTagDefinitionFactory
/// </list>
/// Empty for a clean, accepted blob, for a blank one, or for one that is not an equipment-tag
/// object. Never throws.
/// <para>
/// The distinction is <b>structural</b>, not a matter of wording (Gitea #556 item 2): the two
/// classes accumulate in separate lists, and the catch-all backstop — the only thing that
/// reports a refusal neither <see cref="DescribeRejection"/> branch can name — fires on "no
/// REJECTION was produced", never on "no warning at all". Gated on the total, an advisory
/// (which fires on an accepted blob too) would silently swallow it.
/// </para>
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 2: the two classes live in SEPARATE lists, and the backstop below gates on the
// REJECTION list alone. Sharing one list meant an advisory (which fires on an accepted blob too)
// could satisfy a `warnings.Count == 0` guard and silently swallow the catch-all rejection message
// — the only thing that reports a refusal neither DescribeRejection branch can name. The split is
// structural: nothing string-matches a message to work out which class it is.
var rejections = new List<string>();
var advisories = new List<string>();
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
// drawing nothing.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
return rejections;
// The accept gate: only a blob the driver REFUSES gets a rejection diagnosis. The advisories below
// apply either way — see the summary.
// apply either way — see the summary. The RAW reference is handed to the mapper: the driver reads
// the stored blob, not a trimmed copy of it.
var accepted = FromTagConfig(reference, InspectionRawPath, out _);
try
@@ -140,27 +161,43 @@ public static class FocasTagDefinitionFactory
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("FOCAS TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
rejections.Add("FOCAS TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return rejections;
}
if (!accepted) DescribeRejection(root, warnings);
DescribeAdvisories(root, warnings);
if (!accepted) DescribeRejection(root, rejections);
DescribeAdvisories(root, advisories);
}
catch (JsonException)
{
warnings.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
rejections.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return rejections;
}
if (!accepted && warnings.Count == 0)
// Backstop: the mapper refused for a reason no branch above names. Gated on the REJECTION count,
// never on the total — an advisory says nothing about why the tag was refused.
if (!accepted && rejections.Count == 0)
{
warnings.Add(
rejections.Add(
"FOCAS TagConfig was rejected by the driver's tag reader — the driver will skip this tag "
+ "and it will read BadNodeIdUnknown.");
}
return warnings;
return Combine(rejections, advisories);
}
/// <summary>
/// Flattens the two warning classes into the single list <see cref="Inspect"/> returns, rejections
/// first — the order the pre-split implementation produced, so no caller sees a reordering.
/// </summary>
/// <param name="rejections">The rejection-class warnings.</param>
/// <param name="advisories">The advisory-class warnings.</param>
/// <returns>The combined list.</returns>
private static List<string> Combine(List<string> rejections, List<string> advisories)
{
if (advisories.Count == 0) return rejections;
rejections.AddRange(advisories);
return rejections;
}
/// <summary>
@@ -168,21 +205,22 @@ public static class FocasTagDefinitionFactory
/// same order. Runs only after the mapper has already refused, so it can never invent strictness.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="warnings">The list to append warnings to.</param>
private static void DescribeRejection(JsonElement root, List<string> warnings)
/// <param name="rejections">The REJECTION-class list to append to (never the advisory list — see
/// <see cref="Inspect"/>'s backstop).</param>
private static void DescribeRejection(JsonElement root, List<string> rejections)
{
if (!root.TryGetProperty("address", out var addr) || addr.ValueKind != JsonValueKind.String)
{
warnings.Add(
rejections.Add(
"FOCAS TagConfig has no string 'address' — the driver will skip this tag and it will read "
+ "BadNodeIdUnknown.");
}
else if (string.IsNullOrWhiteSpace(addr.GetString()))
{
warnings.Add("FOCAS TagConfig has a blank 'address' — the tag will not resolve (BadNodeIdUnknown).");
rejections.Add("FOCAS TagConfig has a blank 'address' — the tag will not resolve (BadNodeIdUnknown).");
}
if (TagConfigJson.DescribeInvalidEnum<FocasDataType>(root, "dataType") is { } w) warnings.Add(w);
if (TagConfigJson.DescribeInvalidEnum<FocasDataType>(root, "dataType") is { } w) rejections.Add(w);
}
/// <summary>
@@ -190,8 +228,9 @@ public static class FocasTagDefinitionFactory
/// "advisories" bullet on <see cref="Inspect"/>.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="warnings">The list to append warnings to.</param>
private static void DescribeAdvisories(JsonElement root, List<string> warnings)
/// <param name="advisories">The ADVISORY-class list to append to. These fire whether or not the mapper
/// accepted, so they must never reach the rejection list — see <see cref="Inspect"/>'s backstop.</param>
private static void DescribeAdvisories(JsonElement root, List<string> advisories)
{
// Gitea #540: an address the driver cannot parse is skipped at init and reads BadNodeIdUnknown
// forever, while driver health stays Healthy with zero errors. The AdminUI modal rejects it
@@ -202,13 +241,13 @@ public static class FocasTagDefinitionFactory
&& !string.IsNullOrWhiteSpace(address)
&& FocasAddress.TryParse(address) is null)
{
warnings.Add(
advisories.Add(
$"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
"PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " +
"this tag at startup and it will read BadNodeIdUnknown.");
}
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
warnings.Add("FOCAS wire is read-only — an authored 'writable:true' still fails at the wire with BadNotWritable.");
advisories.Add("FOCAS wire is read-only — an authored 'writable:true' still fails at the wire with BadNotWritable.");
}
}
@@ -160,9 +160,15 @@ public static class ModbusTagDefinitionFactory
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
// drawing nothing, which is the pre-existing convention shared by all eleven inspectors.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') return warnings;
// The accept gate: whatever the driver reads draws nothing. See the remarks.
// The accept gate: whatever the driver reads draws nothing. See the remarks. The RAW reference is
// handed to the mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
try
@@ -60,6 +60,14 @@ public static class MqttTagDefinitionFactory
/// for it. Rejecting it at runtime would turn an authoring mistake into a silent
/// <c>BadNodeIdUnknown</c> with no stated cause.
/// </para>
/// <para>
/// ⚠️ <b><c>payloadFormat</c> is the one key whose verdict differs between the two readers</b>
/// (Gitea #557 item 2). It is read STRICTLY here, so a typo takes the tag dark in Plain mode;
/// <see cref="FromSparkplugTagConfig"/> never reads it at all, so the same blob is served
/// happily in Sparkplug mode. That asymmetry is intentional — see the contract note there —
/// and <see cref="Inspect"/> reports it as an ADVISORY because no tag-level gate can see which
/// mode the tag's driver runs.
/// </para>
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
@@ -139,6 +147,26 @@ public static class MqttTagDefinitionFactory
/// validated — a blob retyped in the editor keeps them, and rejecting the tag for a stale
/// leftover key would take a correctly-authored Sparkplug tag dark with no stated cause.
/// </para>
/// <para>
/// <b>The <c>payloadFormat</c> contract (Gitea #557 item 2), decided and pinned.</b> An invalid
/// <c>payloadFormat</c> is ACCEPTED here and REFUSED by <see cref="FromTagConfig"/>, and the
/// union verdict <see cref="Inspect"/> and the AdminUI's <c>MqttTagConfigModel</c> both use
/// therefore admits it. That is the intended contract, on three pieces of evidence: this reader
/// hard-codes <see cref="MqttPayloadFormat.Json"/> and never consults the key; the Sparkplug
/// ingest path (<c>SparkplugIngestor</c> → this method) never reads
/// <see cref="MqttTagDefinition.PayloadFormat"/> — only <c>MqttSubscriptionManager</c>, the
/// PLAIN path, decodes by it; and <c>MqttTagConfigModel.ToJson</c> deliberately omits the key
/// entirely in Sparkplug mode (Gitea #513). So in Sparkplug mode the tag genuinely works, and a
/// gate that rejected it would be stricter than the runtime — the one thing these gates must
/// never be.
/// </para>
/// <para>
/// ⚠️ The cost is that the served behaviour depends on the DRIVER's <see cref="MqttMode"/>,
/// which no tag row records. <see cref="Inspect"/> closes that with an ADVISORY naming the key
/// and the mode dependency, so the operator is told without the gate over-claiming. The
/// authoring gate keeps the same verdict (accept) by construction — it consults the identical
/// union — and its own Plain-mode half already refuses the value outright.
/// </para>
/// </remarks>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
@@ -221,12 +249,22 @@ public static class MqttTagDefinitionFactory
/// tuple. Such a tag is skipped by the driver and reads <c>BadNodeIdUnknown</c> forever.
/// </item>
/// <item>
/// <b>Advisory</b> — a <b>wildcard</b> tag topic (<c>+</c> / <c>#</c>). The mapper
/// deliberately ACCEPTS it (ambiguous, not unparseable), so this runs either way.
/// <b>Advisories</b> — a <b>wildcard</b> tag topic (<c>+</c> / <c>#</c>), which the mapper
/// deliberately ACCEPTS (ambiguous, not unparseable) so this runs either way; and a
/// present-but-invalid <c>payloadFormat</c> on an ACCEPTED blob, which can only mean the
/// Sparkplug reader took it and the Plain reader would not (Gitea #557 item 2).
/// </item>
/// </list>
/// Empty for a clean, readable blob, for a blank one, or for one that is not an equipment-tag
/// TagConfig object. Never throws.
/// <para>
/// The two classes are kept in <b>separate lists</b> (Gitea #556 item 2), and the catch-all
/// backstop fires on "no REJECTION was produced", never on "no warning at all" — otherwise an
/// advisory would silently swallow the only message that reports a refusal nothing can name.
/// For MQTT that shape is reachable in production: a leading-whitespace blob is refused by both
/// mappers' own framing gates while every rejection branch finds the parsed document perfectly
/// well-formed.
/// </para>
/// </summary>
/// <remarks>
/// <b>The accept gate is the UNION of the two mappers</b> (Gitea #551), because <c>Inspect</c> is
@@ -239,9 +277,26 @@ public static class MqttTagDefinitionFactory
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 2: the two classes live in SEPARATE lists, and the backstop below gates on the
// REJECTION list alone. Sharing one list meant an advisory (which fires on an accepted blob too)
// could satisfy a `warnings.Count == 0` guard and silently swallow the catch-all rejection message
// — the only thing that reports a refusal neither DescribeRejection branch can name, which for
// MQTT is a REACHABLE shape, not a theoretical one: see the leading-whitespace note below.
var rejections = new List<string>();
var advisories = new List<string>();
// Gitea #556 item 1: the framing check TRIMS FIRST, so a blob whose only sin is leading whitespace
// is no longer waved through in silence. MQTT is the one factory of the seven whose RUNTIME
// mappers carry the same `[0] != '{'` gate, so — unlike its siblings, whose mappers hand the blob
// to a whitespace-tolerant JsonDocument.Parse — " {…}" is genuinely REFUSED by this driver. Which
// is exactly why the untrimmed check was a defect: the tag read BadNodeIdUnknown forever and the
// deploy gate said nothing. A reference that still does not start with '{' after trimming is not
// an equipment-tag TagConfig at all and keeps drawing nothing.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{')
return rejections;
// The RAW reference is handed to the mappers — the driver reads the stored blob, not a trimmed
// copy of it, so the verdict here is the verdict the driver will reach.
var accepted = FromTagConfig(reference, InspectionRawPath, out _)
|| FromSparkplugTagConfig(reference, InspectionRawPath, out _);
@@ -251,35 +306,66 @@ public static class MqttTagDefinitionFactory
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("Mqtt TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
rejections.Add("Mqtt TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return rejections;
}
if (!accepted) DescribeRejection(root, warnings);
// Advisory — the mappers accept a wildcard topic on purpose; see the summary.
var topic = ReadString(root, MqttTagConfigKeys.Topic);
if (topic.IndexOfAny(TopicWildcards) >= 0)
{
warnings.Add(
$"value '{topic}' for 'topic' contains an MQTT wildcard (+ or #); a tag's topic must be " +
"concrete, or the tag will be fed by every matching topic.");
}
if (!accepted) DescribeRejection(root, rejections);
DescribeAdvisories(root, accepted, advisories);
}
catch (JsonException)
{
warnings.Add("Mqtt TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
rejections.Add("Mqtt TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
return rejections;
}
if (!accepted && warnings.Count == 0)
// Backstop: neither mapper read the blob and no branch above names why. Gated on the REJECTION
// count, never on the total — an advisory says nothing about why the tag was refused.
if (!accepted && rejections.Count == 0)
{
warnings.Add(
rejections.Add(
"Mqtt TagConfig was rejected by the driver's tag reader — the driver will skip this tag "
+ "and it will read BadNodeIdUnknown.");
}
return warnings;
if (advisories.Count == 0) return rejections;
rejections.AddRange(advisories); // rejections first — the order the pre-split shape produced
return rejections;
}
/// <summary>
/// Warnings about a blob at least one mapper reads (or would read but for another fault) that the
/// driver still cannot serve as authored — see the "advisories" bullet on <see cref="Inspect"/>.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="accepted">Whether the union accept gate read the blob.</param>
/// <param name="advisories">The ADVISORY-class list to append to. These must never reach the rejection
/// list — see <see cref="Inspect"/>'s backstop.</param>
private static void DescribeAdvisories(JsonElement root, bool accepted, List<string> advisories)
{
// The mappers accept a wildcard topic on purpose (ambiguous, not unparseable), so this runs
// whether or not the blob was accepted.
var topic = ReadString(root, MqttTagConfigKeys.Topic);
if (topic.IndexOfAny(TopicWildcards) >= 0)
{
advisories.Add(
$"value '{topic}' for 'topic' contains an MQTT wildcard (+ or #); a tag's topic must be " +
"concrete, or the tag will be fed by every matching topic.");
}
// Gitea #557 item 2 — the ONE key whose verdict depends on the DRIVER'S mode, reported as an
// advisory because no tag-level gate can see that mode. A present-but-invalid payloadFormat is
// fatal to the Plain parse and invisible to the Sparkplug one, so a union accept here can only
// have come from Sparkplug. Promoting it to a rejection would be stricter than the runtime for
// every Sparkplug deployment (the tag IS served); staying silent would hide that the same row
// goes dark the moment its driver is Plain. See the contract note on FromSparkplugTagConfig.
if (accepted && TagConfigJson.DescribeInvalidEnum<MqttPayloadFormat>(root, "payloadFormat") is { } pf)
{
advisories.Add(
pf + " — this tag is accepted only because the Sparkplug B reader ignores 'payloadFormat' "
+ "entirely. If this device's driver runs Plain mode the tag is REJECTED and reads "
+ "BadNodeIdUnknown; either fix the value or remove the key.");
}
}
/// <summary>
@@ -288,17 +374,22 @@ public static class MqttTagDefinitionFactory
/// already refused, so it can never invent strictness.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="warnings">The list to append warnings to.</param>
private static void DescribeRejection(JsonElement root, List<string> warnings)
/// <param name="rejections">The REJECTION-class list to append to (never the advisory list — see
/// <see cref="Inspect"/>'s backstop).</param>
private static void DescribeRejection(JsonElement root, List<string> rejections)
{
foreach (var w in new[]
{
// payloadFormat appears in BOTH classes, and which one it lands in is decided by the accept
// gate, not by the key: here it is a genuine rejection cause (both mappers refused, so the
// driver serves the tag in neither mode), while on an ACCEPTED blob the same invalid value is
// the mode-dependent advisory in DescribeAdvisories (Gitea #557 item 2).
TagConfigJson.DescribeInvalidEnum<MqttPayloadFormat>(root, "payloadFormat"),
TagConfigJson.DescribeInvalidEnum<DriverDataType>(root, "dataType"),
DescribeInvalidQos(root),
})
{
if (w is not null) warnings.Add(w);
if (w is not null) rejections.Add(w);
}
// Address: Plain needs a topic, Sparkplug needs the whole binding tuple. Neither is satisfied, or
@@ -309,7 +400,7 @@ public static class MqttTagDefinitionFactory
&& !string.IsNullOrWhiteSpace(ReadString(root, MqttTagConfigKeys.MetricName));
if (!hasTopic && !hasSparkplugTuple)
{
warnings.Add(
rejections.Add(
"Mqtt TagConfig names no address — a Plain-mode tag needs a 'topic', a Sparkplug B tag "
+ "needs 'groupId' + 'edgeNodeId' + 'metricName'. The driver will skip this tag and it "
+ "will read BadNodeIdUnknown.");
@@ -161,9 +161,15 @@ public static class S7TagDefinitionFactory
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
// drawing nothing.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') return warnings;
// The accept gate: whatever the driver reads draws nothing.
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
try
@@ -132,9 +132,15 @@ public static class TwinCATTagDefinitionFactory
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
// drawing nothing.
if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') return warnings;
// The accept gate: whatever the driver reads draws nothing.
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
try