Implement nested log redaction (Telemetry-002)

RedactionEnricher now projects each property into a mutable view the ILogRedactor
can edit: scalars stay as their CLR value, while StructureValue/SequenceValue/
DictionaryValue become nested IDictionary<string,object?>/IList<object?> the
redactor descends into recursively. A field nested inside a destructured {@Object}
can now be masked or removed — closing the gap documented as a limitation.

- Project/Rebuild round-trip preserves StructureValue.TypeTag and original
  dictionary keys; redactor-synthesised plain dicts/lists are rebuilt too.
- Untouched properties are not reallocated: structural ValueEquals skips write-back
  unless a property actually changed. Scalar fast path and no-redactor/no-property
  short-circuits retained.
- +5 nested-reach tests (mask/remove a field, sequence element, dictionary value,
  two-levels-deep); the old 'cannot reach' limitation test replaced. Serilog 34, 0 warnings.
- ILogRedactor XML doc + library README updated to document the recursive reach.
This commit is contained in:
Joseph Doherty
2026-06-01 12:12:26 -04:00
parent ae0ccc9a3a
commit 05cc62aab3
4 changed files with 464 additions and 71 deletions
+13 -7
View File
@@ -55,13 +55,19 @@ Trace↔log correlation is automatic: `TraceContextEnricher` reads `Activity.Cur
log event and attaches `trace_id` and `span_id`, so log events produced inside a traced request
carry the same span identity as the trace backend.
**Redaction reach.** A registered `ILogRedactor` may **remove** or **replace** any top-level
property, and `RedactionEnricher` honours both (a removed key is dropped from the event). The seam
sees the unwrapped value of scalar properties only — a destructured `{@Object}` property is exposed
as its raw Serilog `StructureValue` wrapper, so a redactor can replace/remove the whole structured
property but **cannot** mask a field nested inside it. To protect a sensitive field of a logged
object, log it as its own scalar property (do not destructure it) or remove the whole property by
key. See the `ILogRedactor` XML doc for the full contract.
**Redaction reach.** A registered `ILogRedactor` may **remove** or **replace** any value, and
`RedactionEnricher` honours both (a removed key is dropped from the event). Scalar properties appear
as their unwrapped CLR value; **destructured** properties are projected into mutable views the
redactor can descend into — a `{@Object}` is an `IDictionary<string, object?>` of its fields, a
logged collection an `IList<object?>`, a logged dictionary an `IDictionary<string, object?>` — all
recursively, so a field **nested inside** a destructured object can be masked or removed:
```csharp
if (properties["command"] is IDictionary<string, object?> command) command["apiKey"] = "***";
```
Structure type tags and dictionary keys are preserved on rebuild, and untouched properties are left
intact (not reallocated). See the `ILogRedactor` XML doc for the full contract.
---
@@ -14,16 +14,16 @@ public interface ILogRedactor
/// Both removing a key (the property is dropped from the event) and replacing its value are
/// honoured by <see cref="RedactionEnricher"/>.
/// <para>
/// <b>Reach — scalar top-level properties only.</b> Each entry's value is the unwrapped scalar
/// of a Serilog <c>ScalarValue</c> property (so simple string/number/etc. properties such as
/// <c>{apiKey}</c> can be read and masked directly). <b>Destructured / structured properties are
/// not unwrapped:</b> a <c>{@Object}</c> property arrives as the raw Serilog
/// <c>StructureValue</c> wrapper (and a sequence/dictionary as <c>SequenceValue</c>/
/// <c>DictionaryValue</c>). A redactor can therefore replace or remove the <i>whole</i>
/// top-level property, but it cannot reach a field <i>nested inside</i> a destructured object to
/// mask it selectively. To protect a sensitive field of a logged object, do not destructure it
/// (log the field as its own scalar property), or remove/replace the entire structured property
/// by key.
/// <b>Reach — top-level and nested.</b> A scalar property (e.g. <c>{apiKey}</c>) appears as its
/// unwrapped CLR value, which you can read and replace directly. A <b>destructured</b> property is
/// projected into a mutable view you can descend into: a <c>{@Object}</c> arrives as an
/// <c>IDictionary&lt;string, object?&gt;</c> of its fields, a logged collection as an
/// <c>IList&lt;object?&gt;</c>, and a logged dictionary as an <c>IDictionary&lt;string, object?&gt;</c>
/// keyed by the string form of each key — all recursively. You can therefore mask or remove a field
/// nested inside a destructured object, for example:
/// <code>if (properties["command"] is IDictionary&lt;string, object?&gt; command) command["apiKey"] = "***";</code>
/// The structure's type tag and a dictionary's original keys are preserved when the event is
/// rebuilt, and properties you do not touch are left intact.
/// </para>
/// </summary>
/// <param name="properties">The mutable property dictionary for the current log event.</param>
@@ -31,26 +31,28 @@ public sealed class RedactionEnricher : ILogEventEnricher
/// <summary>
/// Hands the log event's properties to the registered <see cref="ILogRedactor"/> and reconciles
/// the result back onto the event: values the redactor changed are rewritten via
/// <c>AddOrUpdateProperty</c>, and keys the redactor removed are deleted via
/// <c>RemovePropertyIfPresent</c>. No-op when no redactor is registered or the event carries no
/// the result back onto the event. No-op when no redactor is registered or the event carries no
/// properties.
/// <para>
/// The redactor sees the unwrapped value of each <see cref="ScalarValue"/> property; structured
/// values (<see cref="StructureValue"/> from <c>{@Object}</c>, <see cref="SequenceValue"/>,
/// <see cref="DictionaryValue"/>) are passed through as their raw Serilog wrapper. A redactor can
/// therefore replace or remove a whole structured top-level property, but cannot reach a field
/// nested inside one — see <see cref="ILogRedactor"/> for the seam's documented reach.
/// Each property is projected into a mutable view the redactor can edit: a scalar is its
/// unwrapped value, and a structured value (<see cref="StructureValue"/> from <c>{@Object}</c>,
/// <see cref="SequenceValue"/>, <see cref="DictionaryValue"/>) becomes a nested
/// <see cref="IDictionary{TKey,TValue}"/> / <see cref="IList{T}"/> the redactor can descend into —
/// recursively — so a field nested inside a destructured object can be masked or removed. After
/// redaction each property is rebuilt and written back only when it actually changed; the
/// structure's type tag and a dictionary's original keys are preserved on rebuild, and keys the
/// redactor removed are deleted via <c>RemovePropertyIfPresent</c>. Properties the redactor does
/// not touch are left intact and are not reallocated.
/// </para>
/// </summary>
/// <param name="logEvent">The log event to redact.</param>
/// <param name="propertyFactory">Factory used to materialize replacement properties.</param>
/// <param name="propertyFactory">Unused; structured values are rebuilt directly into Serilog values.</param>
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
ArgumentNullException.ThrowIfNull(logEvent);
ArgumentNullException.ThrowIfNull(propertyFactory);
var redactor = ResolveRedactor();
var redactor = _redactor.Value;
if (redactor is null)
{
return;
@@ -62,12 +64,13 @@ public sealed class RedactionEnricher : ILogEventEnricher
return;
}
var snapshot = new Dictionary<string, object?>(logEvent.Properties.Count);
// Project every property into a mutable view. Scalars stay as their CLR value (zero extra
// allocation); structured values become nested dictionaries/lists carrying enough metadata
// (node kind, type tag, original dictionary keys) to be rebuilt faithfully.
var snapshot = new Dictionary<string, object?>(logEvent.Properties.Count, StringComparer.Ordinal);
foreach (var property in logEvent.Properties)
{
snapshot[property.Key] = property.Value is ScalarValue scalar
? scalar.Value
: property.Value;
snapshot[property.Key] = Project(property.Value);
}
// Capture the original key set so we can honour deletions: any key the redactor drops from
@@ -78,10 +81,12 @@ public sealed class RedactionEnricher : ILogEventEnricher
foreach (var entry in snapshot)
{
if (HasChanged(logEvent, entry.Key, entry.Value))
// Rebuild the (possibly redacted) value and write it back only when it differs from what
// the event already holds, so an untouched property is never needlessly reallocated.
var rebuilt = Rebuild(entry.Value);
if (!logEvent.Properties.TryGetValue(entry.Key, out var existing) || !ValueEquals(existing, rebuilt))
{
logEvent.AddOrUpdateProperty(
propertyFactory.CreateProperty(entry.Key, entry.Value));
logEvent.AddOrUpdateProperty(new LogEventProperty(entry.Key, rebuilt));
}
}
@@ -96,17 +101,238 @@ public sealed class RedactionEnricher : ILogEventEnricher
}
}
private ILogRedactor? ResolveRedactor() => _redactor.Value;
private static bool HasChanged(LogEvent logEvent, string key, object? newValue)
/// <summary>
/// Projects an immutable Serilog value into a mutable view the redactor can edit. Scalars unwrap
/// to their CLR value; structures/sequences/dictionaries become nested mutable wrappers that
/// remember their kind (and a structure's type tag / a dictionary's original keys) for rebuild.
/// </summary>
private static object? Project(LogEventPropertyValue value)
{
if (!logEvent.Properties.TryGetValue(key, out var existing))
switch (value)
{
// Redactor added a brand-new property.
return true;
case ScalarValue scalar:
return scalar.Value;
case StructureValue structure:
var projectedStructure = new ProjectedStructure(structure.TypeTag, structure.Properties.Count);
foreach (var property in structure.Properties)
{
projectedStructure[property.Name] = Project(property.Value);
}
return projectedStructure;
case SequenceValue sequence:
var projectedSequence = new ProjectedSequence(sequence.Elements.Count);
foreach (var element in sequence.Elements)
{
projectedSequence.Add(Project(element));
}
return projectedSequence;
case DictionaryValue dictionary:
var projectedDictionary = new ProjectedDictionary(dictionary.Elements.Count);
foreach (var pair in dictionary.Elements)
{
var key = pair.Key.Value?.ToString() ?? NullKey;
projectedDictionary[key] = Project(pair.Value);
projectedDictionary.OriginalKeys[key] = pair.Key;
}
return projectedDictionary;
default:
// Unknown future LogEventPropertyValue subtype — pass the wrapper through untouched.
return value;
}
}
/// <summary>
/// Rebuilds a (possibly redacted) projected value back into an immutable Serilog value. The
/// inverse of <see cref="Project"/>; also accepts plain dictionaries/lists a redactor synthesised
/// and leaf CLR values it substituted.
/// </summary>
private static LogEventPropertyValue Rebuild(object? projected)
{
switch (projected)
{
case ProjectedStructure structure:
return new StructureValue(RebuildProperties(structure), structure.TypeTag);
case ProjectedDictionary dictionary:
var pairs = new List<KeyValuePair<ScalarValue, LogEventPropertyValue>>(dictionary.Count);
foreach (var entry in dictionary)
{
var key = dictionary.OriginalKeys.TryGetValue(entry.Key, out var original)
? original
: new ScalarValue(entry.Key);
pairs.Add(new KeyValuePair<ScalarValue, LogEventPropertyValue>(key, Rebuild(entry.Value)));
}
return new DictionaryValue(pairs);
case ProjectedSequence sequence:
return new SequenceValue(RebuildElements(sequence));
case IDictionary<string, object?> injected:
// A redactor synthesised a new structure (plain dictionary) — rebuild as a StructureValue.
return new StructureValue(RebuildProperties(injected));
case IList<object?> injectedList:
return new SequenceValue(RebuildElements(injectedList));
case LogEventPropertyValue raw:
// An unknown subtype passed through by Project, or a value the redactor set directly.
return raw;
default:
return new ScalarValue(projected);
}
}
private static List<LogEventProperty> RebuildProperties(IDictionary<string, object?> source)
{
var properties = new List<LogEventProperty>(source.Count);
foreach (var entry in source)
{
properties.Add(new LogEventProperty(entry.Key, Rebuild(entry.Value)));
}
var existingValue = existing is ScalarValue scalar ? scalar.Value : existing;
return !Equals(existingValue, newValue);
return properties;
}
private static List<LogEventPropertyValue> RebuildElements(IList<object?> source)
{
var elements = new List<LogEventPropertyValue>(source.Count);
foreach (var element in source)
{
elements.Add(Rebuild(element));
}
return elements;
}
/// <summary>
/// Structural equality between two Serilog values, used to skip writing back a property the
/// redactor left unchanged. Compares scalars by value and structures/sequences/dictionaries by
/// their contents (recursively); unknown kinds fall back to reference equality.
/// </summary>
private static bool ValueEquals(LogEventPropertyValue left, LogEventPropertyValue right)
{
switch (left)
{
case ScalarValue scalar when right is ScalarValue otherScalar:
return Equals(scalar.Value, otherScalar.Value);
case StructureValue structure when right is StructureValue otherStructure:
if (structure.TypeTag != otherStructure.TypeTag
|| structure.Properties.Count != otherStructure.Properties.Count)
{
return false;
}
foreach (var property in structure.Properties)
{
var match = FindProperty(otherStructure, property.Name);
if (match is null || !ValueEquals(property.Value, match.Value))
{
return false;
}
}
return true;
case SequenceValue sequence when right is SequenceValue otherSequence:
if (sequence.Elements.Count != otherSequence.Elements.Count)
{
return false;
}
for (var i = 0; i < sequence.Elements.Count; i++)
{
if (!ValueEquals(sequence.Elements[i], otherSequence.Elements[i]))
{
return false;
}
}
return true;
case DictionaryValue dictionary when right is DictionaryValue otherDictionary:
if (dictionary.Elements.Count != otherDictionary.Elements.Count)
{
return false;
}
foreach (var pair in dictionary.Elements)
{
var match = FindDictionaryValue(otherDictionary, pair.Key);
if (match is null || !ValueEquals(pair.Value, match))
{
return false;
}
}
return true;
default:
return ReferenceEquals(left, right);
}
}
private static LogEventProperty? FindProperty(StructureValue structure, string name)
{
foreach (var property in structure.Properties)
{
if (property.Name == name)
{
return property;
}
}
return null;
}
private static LogEventPropertyValue? FindDictionaryValue(DictionaryValue dictionary, ScalarValue key)
{
foreach (var pair in dictionary.Elements)
{
if (Equals(pair.Key.Value, key.Value))
{
return pair.Value;
}
}
return null;
}
private const string NullKey = "";
/// <summary>A destructured object projected to a mutable dictionary; preserves its type tag.</summary>
private sealed class ProjectedStructure : Dictionary<string, object?>
{
public ProjectedStructure(string? typeTag, int capacity)
: base(capacity, StringComparer.Ordinal) => TypeTag = typeTag;
public string? TypeTag { get; }
}
/// <summary>A logged dictionary projected to a mutable dictionary; preserves the original keys.</summary>
private sealed class ProjectedDictionary : Dictionary<string, object?>
{
public ProjectedDictionary(int capacity)
: base(capacity, StringComparer.Ordinal) =>
OriginalKeys = new Dictionary<string, ScalarValue>(capacity, StringComparer.Ordinal);
public Dictionary<string, ScalarValue> OriginalKeys { get; }
}
/// <summary>A logged collection projected to a mutable list.</summary>
private sealed class ProjectedSequence : List<object?>
{
public ProjectedSequence(int capacity) : base(capacity)
{
}
}
}
@@ -34,26 +34,115 @@ public sealed class RedactionTests
public void Redact(IDictionary<string, object?> properties) => properties.Remove(_key);
}
private sealed class StructuredFieldRedactor : ILogRedactor
/// <summary>Masks a field nested inside a destructured <c>{@command}</c> object.</summary>
private sealed class NestedFieldRedactor : ILogRedactor
{
// Attempts to mask a nested field of a destructured ({@Object}) property by mutating the
// value the seam exposes. Documents that the seam reaches scalar top-level properties only.
public void Redact(IDictionary<string, object?> properties)
{
if (properties.TryGetValue("command", out var value) && value is StructureValue)
if (properties.TryGetValue("command", out var value)
&& value is IDictionary<string, object?> command
&& command.ContainsKey("ApiKey"))
{
// The seam exposes the raw StructureValue wrapper, not a mutable dictionary of the
// object's fields, so a project redactor cannot reach inside to mask a nested field.
properties["command"] = Masked;
command["ApiKey"] = Masked;
}
}
}
/// <summary>Removes a field nested inside a destructured <c>{@command}</c> object.</summary>
private sealed class NestedRemovingRedactor : ILogRedactor
{
public void Redact(IDictionary<string, object?> properties)
{
if (properties.TryGetValue("command", out var value)
&& value is IDictionary<string, object?> command)
{
command.Remove("ApiKey");
}
}
}
/// <summary>Masks an element inside a destructured <c>{@items}</c> sequence.</summary>
private sealed class SequenceElementRedactor : ILogRedactor
{
public void Redact(IDictionary<string, object?> properties)
{
if (properties.TryGetValue("items", out var value) && value is IList<object?> items)
{
for (var i = 0; i < items.Count; i++)
{
if (items[i] is string s && s.StartsWith("secret", StringComparison.Ordinal))
{
items[i] = Masked;
}
}
}
}
}
/// <summary>Masks a value inside a destructured <c>{@map}</c> dictionary.</summary>
private sealed class DictionaryValueRedactor : ILogRedactor
{
public void Redact(IDictionary<string, object?> properties)
{
if (properties.TryGetValue("map", out var value)
&& value is IDictionary<string, object?> map
&& map.ContainsKey("token"))
{
map["token"] = Masked;
}
}
}
/// <summary>Masks a field two levels deep inside a destructured object graph.</summary>
private sealed class DeepFieldRedactor : ILogRedactor
{
public void Redact(IDictionary<string, object?> properties)
{
if (properties.TryGetValue("payload", out var value)
&& value is IDictionary<string, object?> payload
&& payload.TryGetValue("Inner", out var innerValue)
&& innerValue is IDictionary<string, object?> inner
&& inner.ContainsKey("ApiKey"))
{
inner["ApiKey"] = Masked;
}
}
}
// Named types so a destructured StructureValue's TypeTag is deterministic in assertions.
private sealed record Command(string Name, string ApiKey);
private sealed record Envelope(Inner Inner);
private sealed record Inner(string ApiKey, string Note);
private static string? ScalarOrNull(LogEvent logEvent, string propertyName) =>
logEvent.Properties.TryGetValue(propertyName, out var value) && value is ScalarValue scalar
? scalar.Value?.ToString()
: null;
/// <summary>
/// Builds a logger wired with <paramref name="redactor"/>, logs one event, and returns it —
/// the shared harness for the redaction tests.
/// </summary>
private static LogEvent LogWith(ILogRedactor redactor, string template, params object?[] args)
{
var serviceProvider = new ServiceCollection()
.AddSingleton<ILogRedactor>(redactor)
.BuildServiceProvider();
var sink = new InMemorySink();
var options = new ZbTelemetryOptions { ServiceName = "mxgateway" };
var loggerConfig = new LoggerConfiguration();
ZbSerilogConfig.Apply(loggerConfig, options, serviceProvider);
using Logger logger = loggerConfig.WriteTo.Sink(sink).CreateLogger();
logger.Information(template, args);
return Assert.Single(sink.LogEvents);
}
[Fact]
public void Registered_redactor_masks_sensitive_property()
{
@@ -122,36 +211,108 @@ public sealed class RedactionTests
}
/// <summary>
/// Telemetry-002/003: the redaction seam reaches scalar top-level properties only. A
/// destructured ({@Object}) property is exposed to the redactor as the raw Serilog
/// <see cref="StructureValue"/> wrapper, so a project redactor cannot mask a field nested
/// inside the object — it can only replace/remove the whole top-level property. This test
/// pins that documented limitation (see ILogRedactor XML doc and the shared contract).
/// Telemetry-002: a redactor can reach a field nested inside a destructured ({@Object})
/// property and mask it, leaving sibling fields and the structure's type tag intact.
/// </summary>
[Fact]
public void Redactor_cannot_reach_a_field_inside_a_destructured_object()
public void Redactor_masks_a_field_inside_a_destructured_object()
{
var serviceProvider = new ServiceCollection()
.AddSingleton<ILogRedactor>(new StructuredFieldRedactor())
.BuildServiceProvider();
var logEvent = LogWith(
new NestedFieldRedactor(),
"dispatching {@command}",
new Command("Write", "mxgw_secret"));
var sink = new InMemorySink();
var options = new ZbTelemetryOptions { ServiceName = "mxgateway" };
var structure = Assert.IsType<StructureValue>(logEvent.Properties["command"]);
var fields = structure.Properties.ToDictionary(p => p.Name, p => p.Value);
Assert.Equal(Masked, (fields["ApiKey"] as ScalarValue)?.Value?.ToString());
Assert.Equal("Write", (fields["Name"] as ScalarValue)?.Value?.ToString());
Assert.Equal(nameof(Command), structure.TypeTag);
}
var loggerConfig = new LoggerConfiguration();
ZbSerilogConfig.Apply(loggerConfig, options, serviceProvider);
using Logger logger = loggerConfig.WriteTo.Sink(sink).CreateLogger();
/// <summary>
/// Telemetry-002: a redactor can remove a field nested inside a destructured object; the
/// field is dropped from the rebuilt structure while siblings survive.
/// </summary>
[Fact]
public void Redactor_removes_a_field_inside_a_destructured_object()
{
var logEvent = LogWith(
new NestedRemovingRedactor(),
"dispatching {@command}",
new Command("Write", "mxgw_secret"));
var command = new { Name = "Write", ApiKey = "mxgw_secret" };
logger.Information("dispatching {@command}", command);
var structure = Assert.IsType<StructureValue>(logEvent.Properties["command"]);
var names = structure.Properties.Select(p => p.Name).ToList();
Assert.DoesNotContain("ApiKey", names);
Assert.Contains("Name", names);
}
var logEvent = Assert.Single(sink.LogEvents);
Assert.True(logEvent.Properties.TryGetValue("command", out var value));
/// <summary>Telemetry-002: a redactor can mask an element nested inside a destructured sequence.</summary>
[Fact]
public void Redactor_masks_an_element_inside_a_destructured_sequence()
{
var logEvent = LogWith(
new SequenceElementRedactor(),
"items {@items}",
(object)new[] { "keep", "secret-token" });
// The property was destructured into a StructureValue and exposed to the redactor as that
// wrapper. The redactor recognized it and replaced the whole top-level property with the
// mask — confirming the seam can only act at top-level granularity for structured values.
Assert.Equal(Masked, (value as ScalarValue)?.Value?.ToString());
var sequence = Assert.IsType<SequenceValue>(logEvent.Properties["items"]);
var elements = sequence.Elements.Select(e => (e as ScalarValue)?.Value?.ToString()).ToList();
Assert.Equal(new[] { "keep", Masked }, elements);
}
/// <summary>Telemetry-002: a redactor can mask a value nested inside a destructured dictionary.</summary>
[Fact]
public void Redactor_masks_a_value_inside_a_destructured_dictionary()
{
var map = new Dictionary<string, string> { ["user"] = "alice", ["token"] = "secret" };
var logEvent = LogWith(new DictionaryValueRedactor(), "map {@map}", map);
var dictionary = Assert.IsType<DictionaryValue>(logEvent.Properties["map"]);
var pairs = dictionary.Elements.ToDictionary(
e => (string)e.Key.Value!,
e => (e.Value as ScalarValue)?.Value?.ToString());
Assert.Equal("alice", pairs["user"]);
Assert.Equal(Masked, pairs["token"]);
}
/// <summary>Telemetry-002: nested reach is recursive — a field two levels deep is reachable.</summary>
[Fact]
public void Redactor_masks_a_field_two_levels_deep()
{
var logEvent = LogWith(
new DeepFieldRedactor(),
"payload {@payload}",
new Envelope(new Inner("secret", "keep")));
var outer = Assert.IsType<StructureValue>(logEvent.Properties["payload"]);
var innerValue = outer.Properties.Single(p => p.Name == "Inner").Value;
var inner = Assert.IsType<StructureValue>(innerValue);
var fields = inner.Properties.ToDictionary(p => p.Name, p => p.Value);
Assert.Equal(Masked, (fields["ApiKey"] as ScalarValue)?.Value?.ToString());
Assert.Equal("keep", (fields["Note"] as ScalarValue)?.Value?.ToString());
}
/// <summary>
/// A structured property the redactor does not touch must survive redaction unchanged
/// (siblings, values, and type tag intact) even while a scalar property is masked.
/// </summary>
[Fact]
public void Untouched_structured_property_survives_redaction_intact()
{
var logEvent = LogWith(
new FakeRedactor(),
"dispatching {apiKey} {@command}",
"mxgw_secret",
new Command("Write", "open"));
Assert.Equal(Masked, ScalarOrNull(logEvent, "apiKey"));
var structure = Assert.IsType<StructureValue>(logEvent.Properties["command"]);
var fields = structure.Properties.ToDictionary(p => p.Name, p => p.Value);
Assert.Equal("Write", (fields["Name"] as ScalarValue)?.Value?.ToString());
Assert.Equal("open", (fields["ApiKey"] as ScalarValue)?.Value?.ToString());
Assert.Equal(nameof(Command), structure.TypeTag);
}
[Fact]