Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -433,32 +433,31 @@ public void StartAgent(IAgentApplicationConfiguration configuration, bool verbos
var freshlyConstructed = (existingAgentInformation == null);
var agentInformation = existingAgentInformation ?? new MTConnectAgentInformation();

// Apply explicit AgentUuid config override (if set). This
// pins the Agent meta-device UUID across restarts without
// requiring agent.information.json to be present on disk,
// and takes precedence over any UUID previously stored in
// that file. See MTConnect v2.7 XSD UuidType ("for its
// entire life") vs Header.instanceId (per-boot).
if (!string.IsNullOrEmpty(configuration.AgentUuid))
{
agentInformation.Uuid = configuration.AgentUuid;
}

// When no operator config override and no persisted state file,
// derive a deterministic UUID v5 (RFC 4122 §4.3, DNS namespace,
// SHA-1) from ServiceName:port so the Agent meta-device satisfies
// UuidType's "for it's entire life" annotation across every restart
// in the ephemeral-container deployment path. Mirrors cppagent's
// name_generator prior art. Port is 0 (sentinel) because
// IAgentApplicationConfiguration does not surface a listener-port
// property; the seed is still unique per ServiceName.
if (freshlyConstructed && string.IsNullOrEmpty(configuration.AgentUuid))
{
agentInformation.Uuid = DeterministicAgentUuid.Derive(
configuration.ServiceName,
System.Environment.MachineName,
port: 0);
}
// Resolve the Agent meta-device UUID via the shared three-path
// algorithm in AgentUuidResolver so this application and the
// test fixtures exercise the same code and cannot silently
// drift. Path 1 (validated operator override) wins, else Path 2
// (validated persisted state), else Path 3 (deterministic
// UUID v5 derivation from ServiceName). Malformed input on
// either the override or the persisted path logs a warning
// and falls through — silently forwarding non-UUID content
// would break MTConnect Part 1's wire-XSD validation on every
// typed enum/decimal DataItem.
agentInformation.Uuid = AgentUuidResolver.Resolve(
operatorSuppliedUuid: configuration.AgentUuid,
persistedUuid: freshlyConstructed ? null : agentInformation.Uuid,
agentName: configuration.ServiceName,
hostname: System.Environment.MachineName,
warn: message => _applicationLogger?.Warn(message));

// Happy-path visibility: operators reading the startup log after
// a UUID-related field-support ticket should see which UUID the
// agent adopted without having to reproduce the resolver's
// three-path decision from configuration state.
_applicationLogger?.Info(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"Agent meta-device UUID resolved: {0}",
agentInformation.Uuid));

// Create Observation File Buffer
if (configuration.Durable)
Expand Down Expand Up @@ -583,7 +582,6 @@ public void StartAgent(IAgentApplicationConfiguration configuration, bool verbos
}
}


// Initilialize Processors
_processors = new MTConnectAgentProcessors(configuration);
_processors.ProcessorLoaded += ProcessorLoaded;
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Configuration for an MTConnect Http Agent Application

| Key | C# property | Type | Description |
| --- | --- | --- | --- |
| `agentUuid` | `AgentUuid` | `string` | Optional static UUID to assign to the Agent meta-device. When set, this value overrides the per-boot <c>Guid.NewGuid()</c> default applied by <see cref="MTConnect.Agents.MTConnectAgentInformation"/>'s parameterless constructor and survives restarts without relying on <c>agent.information.json</c> being present on disk. Corresponds to <c>AgentDeviceUUID</c> in the cppagent reference implementation. Per MTConnect v2.7 XSD <c>UuidType</c>, the uuid identifies the element "for its entire life" — <c>Header.instanceId</c> is the per-boot discriminator. |
| `agentUuid` | `AgentUuid` | `string` | Optional static UUID to assign to the Agent meta-device. Must parse as an RFC 4122 UUID — any format <c>Guid.TryParse</c> accepts (hyphenated <c>xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</c>, braced, parenthesized, bare-hex, hex-braced) is normalized to the canonical hyphenated form. Surrounding whitespace is trimmed. Malformed, all-zero (<c>Guid.Empty</c> / RFC 4122 nil), or unparseable values are rejected on startup with a warning that reports only the value's length (never the raw content), and the resolver falls through to the next path: (1) validated override, else (2) validated <c>agent.information.json</c> persisted state, else (3) a deterministic UUID v5 derived from <c>ServiceName</c> + machine name so the meta-device UUID survives restarts even in ephemeral-container deployments. Corresponds to <c>AgentDeviceUUID</c> in the cppagent reference implementation. Per MTConnect v2.7 XSD <c>UuidType</c>, the uuid identifies the element "for its entire life" — <c>Header.instanceId</c> is the per-boot discriminator. |
| `configurationFileRestartInterval` | `ConfigurationFileRestartInterval` | `int` | Gets or Sets the minimum time (in seconds) between Agent restarts when MonitorConfigurationFiles is enabled |
| `devices` | `Devices` | `string` | The Path to look for the file(s) that represent the Device Information Models to load into the Agent. The path can either be a single file or a directory. The path can be absolute or relative to the executable's directory |
| `durable` | `Durable` | `bool` | Gets or Sets whether the Agent buffers are durable and retain state after restart |
Expand Down Expand Up @@ -169,7 +169,7 @@ Configuration for an MTConnect Shdr > Http Agent

| Key | C# property | Type | Description |
| --- | --- | --- | --- |
| `agentUuid` | `AgentUuid` | `string` | Optional static UUID to assign to the Agent meta-device. When set, this value overrides the per-boot <c>Guid.NewGuid()</c> default and survives restarts without relying on <c>agent.information.json</c> being present on disk. Corresponds to <c>AgentDeviceUUID</c> in the cppagent reference implementation. |
| `agentUuid` | `AgentUuid` | `string` | Optional static UUID to assign to the Agent meta-device. Must parse as an RFC 4122 UUID (any <c>Guid.TryParse</c>-accepted format, normalized to the canonical hyphenated form after trimming surrounding whitespace). Malformed, all-zero (<c>Guid.Empty</c> / RFC 4122 nil), or unparseable values log a length-only warning and the resolver falls through to persisted <c>agent.information.json</c> state or a deterministic UUID v5 derived from <c>ServiceName</c> + machine name. Corresponds to <c>AgentDeviceUUID</c> in the cppagent reference implementation. |
| `configurationFileRestartInterval` | `ConfigurationFileRestartInterval` | `int` | Gets or Sets the minimum time (in seconds) between Agent restarts when MonitorConfigurationFiles is enabled |
| `devices` | `Devices` | `string` | The Path to look for the file(s) that represent the Device Information Models to load into the Agent. The path can either be a single file or a directory. The path can be absolute or relative to the executable's directory |
| `durable` | `Durable` | `bool` | Gets or Sets whether the Agent buffers are durable and retain state after restart |
Expand Down
156 changes: 156 additions & 0 deletions libraries/MTConnect.NET-Common/Agents/AgentUuidResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
// TrakHound Inc. licenses this file to you under the MIT license.

using System;
using System.Globalization;

namespace MTConnect.Agents
{
/// <summary>
/// Resolves the Agent meta-device UUID from the three canonical sources —
/// operator-supplied config override, persisted <c>agent.information.json</c>
/// state, and a deterministic UUID v5 derivation — with RFC 4122 validation
/// applied uniformly on both the override and the persisted paths.
///
/// <para>
/// Shared between <c>MTConnectAgentApplication.StartAgent</c> and the test
/// fixtures so the boot-time resolution is exercised by the same code in
/// both places; the tests cannot silently drift from production semantics.
/// </para>
///
/// <para>
/// Resolution order:
/// </para>
/// <list type="number">
/// <item>
/// <b>Path 1 — operator-supplied override.</b> If
/// <paramref name="operatorSuppliedUuid"/> parses as an RFC 4122 UUID
/// (via <see cref="DeterministicAgentUuid.TryValidate"/>), the canonical
/// hyphenated form wins. Malformed input logs a warning via
/// <paramref name="warn"/> and falls through to Path 2 / Path 3.
/// </item>
/// <item>
/// <b>Path 2 — persisted state.</b> If
/// <paramref name="persistedUuid"/> parses as an RFC 4122 UUID, the
/// canonical form wins. Malformed persisted state (e.g. a pre-hardening
/// agent version wrote a non-UUID string, or the file was hand-edited)
/// logs a warning and falls through to Path 3 so a spec-conformant UUID
/// always reaches the wire.
/// </item>
/// <item>
/// <b>Path 3 — deterministic derivation.</b>
/// <see cref="DeterministicAgentUuid.Derive"/> over
/// <c>(agentName ?? hostname, hostname, port: 0)</c>. Port is <c>0</c>
/// because <c>IAgentApplicationConfiguration</c> does not surface a
/// listener-port property; the seed is still unique per agent name.
/// </item>
/// </list>
///
/// <para>
/// Spec rationale — MTConnect Part 1 types the <c>uuid</c> attribute as
/// the <c>UUID</c> DataType (RFC 4122) and mandates that value remain
/// stable and unique for the agent's entire lifetime. Silently forwarding
/// a non-UUID string from any source diverges from that prose contract and
/// from the cppagent reference implementation, which rejects malformed
/// input at ingress; the current wire XSD types <c>uuid</c> only as
/// <c>xs:string</c>, so validation would silently pass a non-UUID value,
/// but downstream consumers that trust the DataType annotation would then
/// mis-key their aggregation and history stores.
/// </para>
/// </summary>
public static class AgentUuidResolver
{
/// <summary>
/// Resolves the Agent meta-device UUID per the three-path algorithm.
/// </summary>
/// <param name="operatorSuppliedUuid">
/// Raw value from <c>AgentApplicationConfiguration.AgentUuid</c>; may
/// be <see langword="null"/>, empty, or malformed.
/// </param>
/// <param name="persistedUuid">
/// Raw value from <c>MTConnectAgentInformation.Read().Uuid</c>, or
/// <see langword="null"/> when no <c>agent.information.json</c> exists
/// (freshly constructed lifecycle). May itself be malformed if a prior
/// agent boot wrote non-UUID content.
/// </param>
/// <param name="agentName">
/// The logical agent name (typically <c>configuration.ServiceName</c>).
/// Passed verbatim to <see cref="DeterministicAgentUuid.Derive"/>,
/// which falls back to <paramref name="hostname"/> when this is
/// <see langword="null"/> or empty.
/// </param>
/// <param name="hostname">
/// The machine host name (typically
/// <see cref="Environment.MachineName"/>). Used by
/// <see cref="DeterministicAgentUuid.Derive"/> as both the fallback
/// seed component and the deterministic derivation input.
/// </param>
/// <param name="warn">
/// Optional delegate invoked with a human-readable message when Path 1
/// or Path 2 rejects malformed input. Kept as a plain
/// <see cref="Action{T}"/> so <c>MTConnect.NET-Common</c> does not
/// take a hard dependency on any logging framework; the caller adapts
/// it to NLog, Serilog, or <c>Microsoft.Extensions.Logging</c>. The
/// message reports only the <c>length</c> of the rejected value — the
/// raw string is never echoed, so a mis-pasted API key, bearer token,
/// or other secret in the <c>AgentUuid</c> config slot cannot leak
/// into the log archive, and CR/LF or other control characters in the
/// rejected value cannot forge additional log lines.
/// </param>
/// <returns>
/// The canonical hyphenated RFC 4122 UUID string that the agent must
/// adopt for its meta-device.
/// </returns>
public static string Resolve(
string operatorSuppliedUuid,
string persistedUuid,
string agentName,
string hostname,
Action<string> warn = null)
{
// Path 1 — validated operator override wins.
if (DeterministicAgentUuid.TryValidate(operatorSuppliedUuid, out var normalizedOverride))
{
return normalizedOverride;
}

// Hoist Path 2 validity + normalization so Path 1's rejection
// warning can label the fallback kind without a second parse of
// the persisted value.
var persistedIsValid = DeterministicAgentUuid.TryValidate(persistedUuid, out var normalizedPersisted);

// Path 1 rejected but operator supplied something → warn (length only).
// Message wording is intentionally broad: TryValidate rejects unparseable
// input AND the RFC 4122 nil UUID (Guid.Empty), which does parse but
// would collide across every misconfigured agent — so "not acceptable"
// covers both causes without leaking which one the operator hit.
if (!string.IsNullOrEmpty(operatorSuppliedUuid))
{
var fallbackKind = persistedIsValid ? "persisted" : "derived";
warn?.Invoke(string.Format(
CultureInfo.InvariantCulture,
"AgentUuid override (length={0}) is not an acceptable RFC 4122 UUID (must be non-empty, parseable, and not the all-zero nil UUID); falling back to {1} UUID.",
operatorSuppliedUuid.Length,
fallbackKind));
}

// Path 2 — validated persisted state wins over derivation.
if (persistedIsValid)
{
return normalizedPersisted;
}

// Path 2 rejected but persisted state carried something → warn (length only).
if (!string.IsNullOrEmpty(persistedUuid))
{
warn?.Invoke(string.Format(
CultureInfo.InvariantCulture,
"Persisted AgentUuid in agent.information.json (length={0}) is not an acceptable RFC 4122 UUID (must be non-empty, parseable, and not the all-zero nil UUID); falling back to derived UUID.",
persistedUuid.Length));
}

// Path 3 — deterministic derivation.
return DeterministicAgentUuid.Derive(agentName, hostname, port: 0);
}
}
}
Loading
Loading