diff --git a/CHANGELOG.md b/CHANGELOG.md
index 989da62..a30798c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,10 @@
### 新增
+- 增加独立 SecsFrame-DiagnosticTrace/1 信封,为公共
+ HsmsDiagnostic 提供确定性受限字段快照导出与严格读取。
+- 诊断快照只保留代码、层级、操作、状态、计时器和可选协议标量,明确排除
+ 原异常与未解码帧;增加黄金向量、畸形字段、资源限制和敏感内容不泄漏测试。
- 增加默认关闭的 Trace 时序重放;按 allowlist 筛选后的 Sent 记录时间戳
计算间隔,支持显式速度倍率和缩放后单次等待封顶。
- 增加时序倒退预验证、筛选间隔、缩放/封顶、等待取消和默认零等待测试;
diff --git a/README.md b/README.md
index 67505aa..4a13fbf 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
| `SecsFrame` | SECS-II 数据模型、HSMS-SS 会话与事务基础 |
| `SecsFrame.Gem` | GEM 通用状态模型与能力服务 |
| `SecsFrame.Sml` | 动态消息的确定性 SML 调试读写 |
-| `SecsFrame.Trace` | 已解码消息的导出、结构化脱敏与受控重放 |
+| `SecsFrame.Trace` | 已解码消息与受限诊断快照的导出、结构化脱敏与受控重放 |
| `SecsFrame.Smn` | E173 SMN 日志、文档与消息表示 |
| `SecsFrame.Sedd` | E172 SEDD 设备接口数据字典 |
@@ -43,7 +43,8 @@ Equipment 应用发送策略的 Collection Event、报警目录查询、单报
不进入线上通讯路径。
SecsFrame.Trace 在该文本层上提供版本化的数据消息 Trace 信封、
严格的 Item 路径脱敏、显式 allowlist 重放,以及默认关闭的缩放/封顶时序
-重放;它不会接管连接事件消费者,也不会复用原事务标识。
+重放;独立诊断信封只保存稳定标量,不保存异常或原始帧。该包不会接管连接
+事件消费者,也不会复用原事务标识。
Item 用法与边界见
[docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见
[docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index a52e807..ec3af8e 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -17,6 +17,8 @@ E172 SEDD 与 E173 SMN 是可选元数据/表示层,不进入核心通讯路
解码的数据消息;它不订阅内部 transport 或创建第二个公共事件消费者。
Trace 的时序重放必须显式启用,只在允许发送记录之间等待,并继续通过公共
发送 API 创建新事务;默认重放不引入时间等待。
+结构化诊断使用独立只读快照信封:只复制公共诊断的稳定标量,明确排除原
+异常和未解码帧,也不能进入重放路径。
## 核心原则
@@ -166,6 +168,7 @@ HSMS 头 Session ID 与 System Bytes。没有 W-Bit 的出站消息在写出后
- `SecsFrame.Sml` 依赖 `SecsFrame`,不得依赖 GEM 或线上传输实现。
- `SecsFrame.Trace` 依赖 `SecsFrame.Sml` 与 `SecsFrame`,重放必须回到
公共发送 API,不得直接构造或写出保留旧事务标识的线上帧;可选时序只
- 控制相邻发送前的等待,不得绕过连接状态、T3 或显式 allowlist。
+ 控制相邻发送前的等待,不得绕过连接状态、T3 或显式 allowlist。诊断
+ 导出不得隐式包含 Exception 或 HsmsFrame。
- 核心包不得依赖 GEM、SML/SMN、依赖注入容器或具体日志实现。
- 状态机计时使用可替换的时间抽象,测试不得依赖真实长时间等待。
diff --git a/docs/HSMS-DIAGNOSTICS.md b/docs/HSMS-DIAGNOSTICS.md
index 691ffb2..01a306d 100644
--- a/docs/HSMS-DIAGNOSTICS.md
+++ b/docs/HSMS-DIAGNOSTICS.md
@@ -71,6 +71,14 @@ Session ID 与 System Bytes;拒绝诊断保留远端状态或原因字节。
工艺数据。库不自动格式化、写日志或上传它。应用在记录前应按自己的数据
分类策略做脱敏和长度限制;常规指标优先只使用代码、层级、操作和计时器。
+SecsFrame.Trace 提供独立的
+SecsFrame-DiagnosticTrace/1 受限字段快照信封。
+SecsTraceDiagnosticRecord.Create 只复制稳定代码、层级、操作、
+状态、计时器与可选协议标识/状态字节,不复制 Error 或
+Frame。这使结构化诊断可以确定性导出,但协议 Session ID、
+System Bytes 和远端状态字节仍属于运维元数据,导出文件仍应使用适当的
+访问控制与保留策略。具体格式和示例见 [TRACE.md](TRACE.md)。
+
## 标准边界
诊断名称描述 SecsFrame 已实现并测试的工程行为,不新增 SEMI 默认值,也
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 5a0b6d5..08cf76f 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -99,7 +99,8 @@ E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写
消息的版本化 Trace 导出、结构化脱敏与显式 allowlist 受控重放;重放通过
公共发送 API 创建新事务,不复用旧会话或 System Bytes。可选时序重放也已
完成:只使用允许发送记录的相邻时间戳,支持显式加速和缩放后单次等待封顶,
-默认重放仍不等待。
+默认重放仍不等待。结构化 HSMS 诊断现可通过独立受限信封导出;快照只包含
+稳定标量,明确排除异常和原始帧。
## 阶段 3:元数据与工具
@@ -108,11 +109,13 @@ E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写
- E172 SEDD 设备字典导入、校验与发现。
- [x] 已解码数据消息的 Trace 导出、结构化脱敏和受控重放基线。
- [x] 显式启用、可缩放且单次等待封顶的 Trace 重放时序。
-- 控制消息、结构化诊断和故障原始样本等互操作诊断扩展。
+- [x] 不包含异常与原始帧的受限结构化诊断 Trace 导出和严格读取。
+- 控制消息和故障原始样本等互操作诊断扩展。
当前 Trace 基线不接管连接事件流,也不把原始标识写回线上。时序重放默认
-关闭,并继续经过显式 allowlist 与公共发送 API。后续控制与故障诊断扩展
-必须先定义原始字节的数据分级、默认不导出策略和可验证脱敏边界。
+关闭,并继续经过显式 allowlist 与公共发送 API。诊断快照始终排除
+Error 与 Frame。后续控制与故障原始样本扩展必须先
+定义原始字节的数据分级、默认不导出策略和可验证脱敏边界。
E173 SMN 与 E172 SEDD 仍需先核对授权标准及允许分发的 Schema/表示形式。
## 发布门槛
diff --git a/docs/TRACE.md b/docs/TRACE.md
index 1640791..0a52ec5 100644
--- a/docs/TRACE.md
+++ b/docs/TRACE.md
@@ -7,7 +7,8 @@
- 确定性的 SecsFrame-Trace/1 文本导出和严格读取;
- 按 Stream、Function 与 Item List 路径执行的结构化替换脱敏;
- 只选择显式允许的本地发送记录,并通过正常公共发送 API 串行重放;
-- 可选按缩放且封顶的源时间间隔执行受控时序重放。
+- 可选按缩放且封顶的源时间间隔执行受控时序重放;
+- 不包含异常和原始帧的结构化 HSMS 诊断快照导出与严格读取。
当前切片不是原始网络抓包器,不记录 TCP 字节、transport Session generation、
控制帧或异常对象,也不会接管 HsmsConnection 的单消费者事件流。
@@ -54,6 +55,39 @@ Item、值和文本长度限制。解析错误使用包含记录索引与字符
该格式是 SecsFrame 的版本化诊断信封,不是 SEMI 标准、PCAP 或原始 HSMS
wire trace。协议标识只用于问题关联,不能作为新连接上的重放标识。
+## 结构化诊断信封
+
+SecsTraceDiagnosticRecord 从公共 HsmsDiagnostic
+创建只包含稳定标量的受限字段快照:
+
+~~~csharp
+if (connectionEvent.Diagnostic is { } diagnostic)
+{
+ var record = SecsTraceDiagnosticRecord.Create(
+ DateTimeOffset.UtcNow,
+ diagnostic);
+ var text = new SecsTraceDiagnosticCodec().Encode(new[] { record });
+}
+~~~
+
+独立信封固定使用 LF 与 11 个单空格分隔字段:
+
+~~~text
+SecsFrame-DiagnosticTrace/1
+Diagnostic 2026-08-29T05:00:00.0000000Z T3Timeout Transaction WaitForSecondary Selected T3 10 0x10203040 - -
+~~~
+
+字段依次为 UTC 时间、诊断代码、层级、操作、会话状态、可选计时器、协议
+Session ID、System Bytes、远端状态字节和被拒绝的 SType。枚举只接受当前
+已定义的精确名称;协议字节使用固定宽度大写十六进制。codec 同样限制记录数
+和总文本长度,并通过 SecsTraceParseException 报告记录索引与
+字符偏移。
+
+快照 API 不提供 Error 或 Frame 字段,创建快照时也
+不会读取或格式化它们,因此异常消息和未解码帧负载不会进入导出文本。该格式
+与 SecsFrame-Trace/1 消息信封相互独立,不能交给重放器。协议
+标识和状态字节仍是运维元数据;存储和共享文件时仍需执行访问控制与保留策略。
+
## 结构化脱敏
脱敏发生在不可变 Item 树上,而不是对 SML 文本做字符串替换:
@@ -143,4 +177,6 @@ var results = await new SecsTraceReplayer().ReplayWithTimingAsync(
资源限制、路径漂移、规则重叠、敏感值不进入导出文本、预验证零发送,以及
时序缩放/封顶、筛选间隔、时间倒退预验证、等待取消和默认零等待。真实 TCP
测试还验证重新分配 Session ID/System Bytes 并获得新 Secondary。它证明
-当前工具组合边界,不代表设备命令安全性或 SEMI 一致性认证。
+当前工具组合边界,不代表设备命令安全性或 SEMI 一致性认证。诊断信封另有
+黄金向量、可选字段往返、严格枚举/十六进制、资源限制及异常/帧内容不泄漏
+测试。
diff --git a/src/SecsFrame.Trace/SecsTraceDiagnosticCodec.cs b/src/SecsFrame.Trace/SecsTraceDiagnosticCodec.cs
new file mode 100644
index 0000000..9ab61f4
--- /dev/null
+++ b/src/SecsFrame.Trace/SecsTraceDiagnosticCodec.cs
@@ -0,0 +1,275 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Text;
+
+namespace SecsFrame.Trace;
+
+/// Encodes and decodes restricted-field structured-diagnostic trace files.
+public sealed class SecsTraceDiagnosticCodec
+{
+ /// The exact diagnostic trace format identifier.
+ public const string FormatIdentifier = "SecsFrame-DiagnosticTrace/1";
+
+ /// The default maximum number of records in one diagnostic trace.
+ public const int DefaultMaxRecordCount = SecsTraceCodec.DefaultMaxRecordCount;
+
+ /// The default maximum diagnostic trace text length.
+ public const int DefaultMaxTextLength = SecsTraceCodec.DefaultMaxTextLength;
+
+ /// Creates a diagnostic trace codec with explicit resource limits.
+ public SecsTraceDiagnosticCodec(
+ int maxRecordCount = DefaultMaxRecordCount,
+ int maxTextLength = DefaultMaxTextLength)
+ {
+ if (maxRecordCount <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxRecordCount), maxRecordCount, "The maximum record count must be positive.");
+ if (maxTextLength <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxTextLength), maxTextLength, "The maximum text length must be positive.");
+
+ MaxRecordCount = maxRecordCount;
+ MaxTextLength = maxTextLength;
+ }
+
+ /// Gets the maximum number of records in one diagnostic trace.
+ public int MaxRecordCount { get; }
+
+ /// Gets the maximum accepted or produced diagnostic trace length.
+ public int MaxTextLength { get; }
+
+ /// Encodes records in enumeration order using LF line endings.
+ public string Encode(IEnumerable records)
+ {
+ if (records is null)
+ throw new ArgumentNullException(nameof(records));
+
+ var text = new StringBuilder(FormatIdentifier);
+ text.Append('\n');
+ EnsureTextLength(text);
+
+ var recordCount = 0;
+ foreach (var record in records)
+ {
+ if (record is null)
+ throw new ArgumentException("The diagnostic trace sequence contains a null record.", nameof(records));
+ if (++recordCount > MaxRecordCount)
+ throw new InvalidOperationException($"The diagnostic trace record count exceeds the configured maximum {MaxRecordCount}.");
+
+ AppendRecord(text, record);
+ EnsureTextLength(text);
+ }
+
+ return text.ToString();
+ }
+
+ /// Strictly decodes one complete diagnostic trace file.
+ public IReadOnlyList Decode(string text)
+ {
+ if (text is null)
+ throw new ArgumentNullException(nameof(text));
+ if (text.Length > MaxTextLength)
+ throw new ArgumentOutOfRangeException(nameof(text), text.Length, $"The diagnostic trace text length cannot exceed {MaxTextLength} characters.");
+
+ return new Parser(text, this).Parse();
+ }
+
+ private static void AppendRecord(StringBuilder text, SecsTraceDiagnosticRecord record)
+ {
+ text.Append("Diagnostic ");
+ text.Append(record.Timestamp.UtcDateTime.ToString("O", CultureInfo.InvariantCulture));
+ text.Append(' ');
+ text.Append(record.Code);
+ text.Append(' ');
+ text.Append(record.Layer);
+ text.Append(' ');
+ text.Append(record.Operation);
+ text.Append(' ');
+ text.Append(record.State);
+ text.Append(' ');
+ text.Append(record.Timer.HasValue ? record.Timer.Value.ToString() : "-");
+ text.Append(' ');
+ text.Append(record.ProtocolSessionId.HasValue
+ ? record.ProtocolSessionId.Value.ToString(CultureInfo.InvariantCulture)
+ : "-");
+ text.Append(' ');
+ text.Append(FormatOptionalUInt32(record.SystemBytes));
+ text.Append(' ');
+ text.Append(FormatOptionalByte(record.PeerStatus));
+ text.Append(' ');
+ text.Append(FormatOptionalByte(record.RejectedMessageType));
+ text.Append('\n');
+ }
+
+ private static string FormatOptionalUInt32(uint? value)
+ => value.HasValue
+ ? "0x" + value.Value.ToString("X8", CultureInfo.InvariantCulture)
+ : "-";
+
+ private static string FormatOptionalByte(byte? value)
+ => value.HasValue
+ ? "0x" + value.Value.ToString("X2", CultureInfo.InvariantCulture)
+ : "-";
+
+ private void EnsureTextLength(StringBuilder text)
+ {
+ if (text.Length > MaxTextLength)
+ throw new InvalidOperationException($"The diagnostic trace text length exceeds the configured maximum {MaxTextLength}.");
+ }
+
+ private sealed class Parser
+ {
+ private readonly string _text;
+ private readonly SecsTraceDiagnosticCodec _options;
+ private int _index;
+ private int _recordIndex;
+
+ public Parser(string text, SecsTraceDiagnosticCodec options)
+ {
+ _text = text;
+ _options = options;
+ }
+
+ public IReadOnlyList Parse()
+ {
+ var headerOffset = _index;
+ var formatIdentifier = ReadLine();
+ if (!string.Equals(formatIdentifier, FormatIdentifier, StringComparison.Ordinal))
+ throw Error("The diagnostic trace format identifier is missing or unsupported", -1, headerOffset);
+
+ var records = new List();
+ while (!IsEnd)
+ {
+ if (_recordIndex >= _options.MaxRecordCount)
+ throw Error($"The diagnostic trace record count exceeds the configured maximum {_options.MaxRecordCount}", _recordIndex, _index);
+
+ records.Add(ParseRecord());
+ _recordIndex++;
+ }
+
+ return new ReadOnlyCollection(records);
+ }
+
+ private SecsTraceDiagnosticRecord ParseRecord()
+ {
+ var headerOffset = _index;
+ var fields = ReadLine().Split(new[] { ' ' }, StringSplitOptions.None);
+ if (fields.Length != 11 || !string.Equals(fields[0], "Diagnostic", StringComparison.Ordinal))
+ throw Error("A diagnostic record must contain exactly eleven single-space-separated fields", _recordIndex, headerOffset);
+
+ return new SecsTraceDiagnosticRecord(
+ ParseTimestamp(fields[1], headerOffset),
+ ParseEnum(fields[2], "diagnostic code", headerOffset),
+ ParseEnum(fields[3], "diagnostic layer", headerOffset),
+ ParseEnum(fields[4], "diagnostic operation", headerOffset),
+ ParseEnum(fields[5], "session state", headerOffset),
+ ParseOptionalEnum(fields[6], "timer", headerOffset),
+ ParseOptionalUInt16(fields[7], "protocol Session ID", headerOffset),
+ ParseOptionalUInt32(fields[8], "System Bytes", headerOffset),
+ ParseOptionalByte(fields[9], "peer status", headerOffset),
+ ParseOptionalByte(fields[10], "rejected message type", headerOffset));
+ }
+
+ private DateTimeOffset ParseTimestamp(string value, int offset)
+ {
+ if (!DateTimeOffset.TryParseExact(
+ value,
+ "O",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.RoundtripKind,
+ out var timestamp))
+ {
+ throw Error("The diagnostic timestamp must use the round-trip format", _recordIndex, offset);
+ }
+
+ return timestamp;
+ }
+
+ private TEnum ParseEnum(string value, string fieldName, int offset)
+ where TEnum : struct, Enum
+ {
+ if (!Enum.TryParse(value, ignoreCase: false, out TEnum parsed) ||
+ !Enum.IsDefined(typeof(TEnum), parsed) ||
+ !string.Equals(value, parsed.ToString(), StringComparison.Ordinal))
+ {
+ throw Error($"The {fieldName} is unknown", _recordIndex, offset);
+ }
+
+ return parsed;
+ }
+
+ private TEnum? ParseOptionalEnum(string value, string fieldName, int offset)
+ where TEnum : struct, Enum
+ => string.Equals(value, "-", StringComparison.Ordinal)
+ ? null
+ : ParseEnum(value, fieldName, offset);
+
+ private ushort? ParseOptionalUInt16(string value, string fieldName, int offset)
+ {
+ if (string.Equals(value, "-", StringComparison.Ordinal))
+ return null;
+ if (!ushort.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed))
+ throw Error($"The {fieldName} must be '-' or an unsigned 16-bit integer", _recordIndex, offset);
+ return parsed;
+ }
+
+ private uint? ParseOptionalUInt32(string value, string fieldName, int offset)
+ {
+ if (string.Equals(value, "-", StringComparison.Ordinal))
+ return null;
+ if (!HasExactHexForm(value, 8) ||
+ !uint.TryParse(value.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed))
+ {
+ throw Error($"The {fieldName} must be '-' or use the exact form 0xHHHHHHHH", _recordIndex, offset);
+ }
+
+ return parsed;
+ }
+
+ private byte? ParseOptionalByte(string value, string fieldName, int offset)
+ {
+ if (string.Equals(value, "-", StringComparison.Ordinal))
+ return null;
+ if (!HasExactHexForm(value, 2) ||
+ !byte.TryParse(value.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed))
+ {
+ throw Error($"The {fieldName} must be '-' or use the exact form 0xHH", _recordIndex, offset);
+ }
+
+ return parsed;
+ }
+
+ private static bool HasExactHexForm(string value, int digitCount)
+ {
+ if (value.Length != digitCount + 2 || value[0] != '0' || value[1] != 'x')
+ return false;
+
+ for (var index = 2; index < value.Length; index++)
+ {
+ var character = value[index];
+ if (!((character >= '0' && character <= '9') || (character >= 'A' && character <= 'F')))
+ return false;
+ }
+
+ return true;
+ }
+
+ private string ReadLine()
+ {
+ var start = _index;
+ while (!IsEnd && _text[_index] != '\n')
+ _index++;
+
+ var length = _index - start;
+ if (length > 0 && _text[start + length - 1] == '\r')
+ length--;
+ var line = _text.Substring(start, length);
+ if (!IsEnd)
+ _index++;
+ return line;
+ }
+
+ private bool IsEnd => _index >= _text.Length;
+
+ private static SecsTraceParseException Error(string message, int recordIndex, int offset)
+ => new(message, recordIndex, offset);
+ }
+}
diff --git a/src/SecsFrame.Trace/SecsTraceDiagnosticRecord.cs b/src/SecsFrame.Trace/SecsTraceDiagnosticRecord.cs
new file mode 100644
index 0000000..9643b46
--- /dev/null
+++ b/src/SecsFrame.Trace/SecsTraceDiagnosticRecord.cs
@@ -0,0 +1,103 @@
+namespace SecsFrame.Trace;
+
+///
+/// Describes a restricted-field, immutable snapshot of one structured HSMS diagnostic.
+///
+///
+/// The snapshot deliberately excludes the original exception and undecodable
+/// frame because either can contain environment or application data.
+///
+public sealed class SecsTraceDiagnosticRecord
+{
+ /// Creates a structured diagnostic trace record.
+ public SecsTraceDiagnosticRecord(
+ DateTimeOffset timestamp,
+ HsmsDiagnosticCode code,
+ HsmsDiagnosticLayer layer,
+ HsmsOperation operation,
+ HsmsSessionState state,
+ HsmsTimer? timer = null,
+ ushort? protocolSessionId = null,
+ uint? systemBytes = null,
+ byte? peerStatus = null,
+ byte? rejectedMessageType = null)
+ {
+ ValidateEnum(code, nameof(code));
+ ValidateEnum(layer, nameof(layer));
+ ValidateEnum(operation, nameof(operation));
+ ValidateEnum(state, nameof(state));
+ if (timer.HasValue)
+ ValidateEnum(timer.Value, nameof(timer));
+
+ Timestamp = timestamp.ToUniversalTime();
+ Code = code;
+ Layer = layer;
+ Operation = operation;
+ State = state;
+ Timer = timer;
+ ProtocolSessionId = protocolSessionId;
+ SystemBytes = systemBytes;
+ PeerStatus = peerStatus;
+ RejectedMessageType = rejectedMessageType;
+ }
+
+ /// Gets the UTC timestamp associated with the local observation.
+ public DateTimeOffset Timestamp { get; }
+
+ /// Gets the stable diagnostic code.
+ public HsmsDiagnosticCode Code { get; }
+
+ /// Gets the layer that produced the diagnostic.
+ public HsmsDiagnosticLayer Layer { get; }
+
+ /// Gets the operation associated with the diagnostic.
+ public HsmsOperation Operation { get; }
+
+ /// Gets the observed HSMS session state.
+ public HsmsSessionState State { get; }
+
+ /// Gets the associated timer, when present.
+ public HsmsTimer? Timer { get; }
+
+ /// Gets the protocol Session ID, when present.
+ public ushort? ProtocolSessionId { get; }
+
+ /// Gets the transaction System Bytes, when present.
+ public uint? SystemBytes { get; }
+
+ /// Gets the peer-provided status or reject-reason byte, when present.
+ public byte? PeerStatus { get; }
+
+ /// Gets the rejected HSMS SType byte, when present.
+ public byte? RejectedMessageType { get; }
+
+ ///
+ /// Creates a restricted-field snapshot without copying the diagnostic exception or frame.
+ ///
+ public static SecsTraceDiagnosticRecord Create(
+ DateTimeOffset timestamp,
+ HsmsDiagnostic diagnostic)
+ {
+ if (diagnostic is null)
+ throw new ArgumentNullException(nameof(diagnostic));
+
+ return new SecsTraceDiagnosticRecord(
+ timestamp,
+ diagnostic.Code,
+ diagnostic.Layer,
+ diagnostic.Operation,
+ diagnostic.State,
+ diagnostic.Timer,
+ diagnostic.ProtocolSessionId,
+ diagnostic.SystemBytes,
+ diagnostic.PeerStatus,
+ diagnostic.RejectedMessageType);
+ }
+
+ private static void ValidateEnum(TEnum value, string parameterName)
+ where TEnum : struct, Enum
+ {
+ if (!Enum.IsDefined(typeof(TEnum), value))
+ throw new ArgumentOutOfRangeException(parameterName, value, "Unknown diagnostic field value.");
+ }
+}
diff --git a/test/SecsFrame.Tests/SecsTraceDiagnosticTests.cs b/test/SecsFrame.Tests/SecsTraceDiagnosticTests.cs
new file mode 100644
index 0000000..849d387
--- /dev/null
+++ b/test/SecsFrame.Tests/SecsTraceDiagnosticTests.cs
@@ -0,0 +1,148 @@
+using System.Text;
+using SecsFrame.Trace;
+
+namespace SecsFrame.Tests;
+
+public sealed class SecsTraceDiagnosticTests
+{
+ [Fact]
+ public void Codec_produces_a_deterministic_restricted_diagnostic_vector()
+ {
+ var diagnostic = HsmsDiagnostic.Classify(
+ new HsmsDataTransactionTimeoutException(new HsmsDataMessage(
+ sessionId: 10,
+ systemBytes: 0x10203040,
+ new SecsMessage(6, 11, true))),
+ HsmsSessionState.Selected)!;
+ var timestamp = new DateTimeOffset(2026, 8, 29, 13, 0, 0, TimeSpan.FromHours(8));
+ var record = SecsTraceDiagnosticRecord.Create(timestamp, diagnostic);
+ var codec = new SecsTraceDiagnosticCodec();
+
+ var text = codec.Encode(new[] { record });
+
+ Assert.Equal(
+ "SecsFrame-DiagnosticTrace/1\n" +
+ "Diagnostic 2026-08-29T05:00:00.0000000Z T3Timeout Transaction WaitForSecondary Selected T3 10 0x10203040 - -\n",
+ text);
+ var decoded = Assert.Single(codec.Decode(text));
+ AssertRecordEqual(record, decoded);
+ }
+
+ [Fact]
+ public void Codec_round_trips_optional_peer_fields_and_empty_values()
+ {
+ var records = new[]
+ {
+ new SecsTraceDiagnosticRecord(
+ Epoch,
+ HsmsDiagnosticCode.ControlRejected,
+ HsmsDiagnosticLayer.Session,
+ HsmsOperation.Deselect,
+ HsmsSessionState.Selected,
+ peerStatus: 0x03,
+ rejectedMessageType: 0x07),
+ new SecsTraceDiagnosticRecord(
+ Epoch.AddSeconds(1),
+ HsmsDiagnosticCode.TransportFailure,
+ HsmsDiagnosticLayer.Transport,
+ HsmsOperation.Connect,
+ HsmsSessionState.Disconnected),
+ };
+ var codec = new SecsTraceDiagnosticCodec();
+
+ var decoded = codec.Decode(codec.Encode(records));
+
+ Assert.Equal(2, decoded.Count);
+ AssertRecordEqual(records[0], decoded[0]);
+ AssertRecordEqual(records[1], decoded[1]);
+ }
+
+ [Fact]
+ public void Snapshot_and_export_exclude_exception_and_frame_content()
+ {
+ const string secret = "SECRET-PROCESS-DATA";
+ var frame = new HsmsFrame(
+ HsmsMessageHeader.CreateData(23, 1, 2, false, 0xAABBCCDD),
+ Encoding.ASCII.GetBytes(secret));
+ var connectionEvent = HsmsConnectionEvent.DataMessageDecodeFailed(
+ frame,
+ new SecsProtocolException(secret));
+
+ var record = SecsTraceDiagnosticRecord.Create(Epoch, connectionEvent.Diagnostic!);
+ var text = new SecsTraceDiagnosticCodec().Encode(new[] { record });
+
+ Assert.Equal(HsmsDiagnosticCode.DataMessageDecodeFailed, record.Code);
+ Assert.Equal((ushort)23, record.ProtocolSessionId);
+ Assert.Equal(0xAABBCCDDu, record.SystemBytes);
+ Assert.DoesNotContain(secret, text, StringComparison.Ordinal);
+ Assert.DoesNotContain("Exception", text, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Codec_rejects_unknown_fields_noncanonical_hex_and_spacing()
+ {
+ const string valid =
+ "SecsFrame-DiagnosticTrace/1\n" +
+ "Diagnostic 1970-01-01T00:00:00.0000000Z ControlRejected Session Deselect Selected - - - 0x03 0x07\n";
+ var codec = new SecsTraceDiagnosticCodec();
+
+ Assert.Throws(() => codec.Decode(string.Empty));
+ Assert.Throws(() => codec.Decode(valid.Replace("DiagnosticTrace/1", "DiagnosticTrace/2")));
+ Assert.Throws(() => codec.Decode(valid.Replace("ControlRejected", "UnknownCode")));
+ Assert.Throws(() => codec.Decode(valid.Replace("ControlRejected", "10")));
+ Assert.Throws(() => codec.Decode(valid.Replace("Session", "session")));
+ Assert.Throws(() => codec.Decode(valid.Replace("0x03", "0x3")));
+ Assert.Throws(() => codec.Decode(valid.Replace("0x07", "0x0a")));
+ Assert.Throws(() => codec.Decode(valid.Replace("Diagnostic ", "Diagnostic ")));
+ }
+
+ [Fact]
+ public void Codec_and_record_enforce_resource_and_enum_boundaries()
+ {
+ var record = new SecsTraceDiagnosticRecord(
+ Epoch,
+ HsmsDiagnosticCode.TransportFailure,
+ HsmsDiagnosticLayer.Transport,
+ HsmsOperation.Connect,
+ HsmsSessionState.Disconnected);
+ var records = new[] { record, record };
+ var codec = new SecsTraceDiagnosticCodec();
+ var text = codec.Encode(records);
+
+ Assert.Throws(() => codec.Encode(null!));
+ Assert.Throws(() => codec.Decode(null!));
+ Assert.Throws(() => codec.Encode(new SecsTraceDiagnosticRecord[] { record, null! }));
+ Assert.Throws(() => new SecsTraceDiagnosticCodec(maxRecordCount: 1).Encode(records));
+ Assert.Throws(() => new SecsTraceDiagnosticCodec(maxRecordCount: 1).Decode(text));
+ Assert.Throws(() => new SecsTraceDiagnosticCodec(maxTextLength: 20).Encode(Array.Empty()));
+ Assert.Throws(() => new SecsTraceDiagnosticCodec(maxTextLength: 20).Decode(text));
+ Assert.Throws(() => new SecsTraceDiagnosticCodec(maxRecordCount: 0));
+ Assert.Throws(() => new SecsTraceDiagnosticCodec(maxTextLength: 0));
+ Assert.Throws(() => new SecsTraceDiagnosticRecord(
+ Epoch,
+ (HsmsDiagnosticCode)int.MaxValue,
+ HsmsDiagnosticLayer.Transport,
+ HsmsOperation.Connect,
+ HsmsSessionState.Disconnected));
+ Assert.Throws(() => SecsTraceDiagnosticRecord.Create(Epoch, null!));
+ }
+
+ private static DateTimeOffset Epoch
+ => new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ private static void AssertRecordEqual(
+ SecsTraceDiagnosticRecord expected,
+ SecsTraceDiagnosticRecord actual)
+ {
+ Assert.Equal(expected.Timestamp, actual.Timestamp);
+ Assert.Equal(expected.Code, actual.Code);
+ Assert.Equal(expected.Layer, actual.Layer);
+ Assert.Equal(expected.Operation, actual.Operation);
+ Assert.Equal(expected.State, actual.State);
+ Assert.Equal(expected.Timer, actual.Timer);
+ Assert.Equal(expected.ProtocolSessionId, actual.ProtocolSessionId);
+ Assert.Equal(expected.SystemBytes, actual.SystemBytes);
+ Assert.Equal(expected.PeerStatus, actual.PeerStatus);
+ Assert.Equal(expected.RejectedMessageType, actual.RejectedMessageType);
+ }
+}