Skip to content

new Device() ctor auto-generates a random Uuid that re-rolls on every construction, silently violating the XSD "for its entire life" identity contract when a caller does not overwrite it #136

@ottobolyos

Description

@ottobolyos

Summary

MTConnect.NET-Common's Device default constructor assigns Uuid = Guid.NewGuid().ToString()a freshly-generated random GUID on every construction. The value is a public setter that a caller's object-initializer can overwrite, but there is no observable difference between "I explicitly set this" and "I relied on the default, and the library rolled a fresh GUID that nothing in my code generated". The leak is therefore hidden.

Consequence in production: every consumer that doesn't set Uuid explicitly registers a different Uuid on every connector restart, because the ctor re-rolls the GUID. Downstream stores (dashboards, historians, bridges) that key on Device.Uuid — the canonical identity MTConnect has defined since v1.0 (2008) — end up with one row per connector restart under the same Device.Name. Observed live at Tempco 2026-04-23: 4 rows for a single Furnace1West device, 2 for Furnace2East, accumulated over a handful of pre-merge restarts of our connector.

This draft is narrowly about the Uuid slot because it is the only one of the three ctor-leaked values whose spec contract is directly violated by random-per-construction generation. The related Id and Name fixture defaults (Id = "4A1GF40513", Name = "dev") are a separate library-quality concern tracked in a sibling draft.

Verified by reproduction — 2026-04-23

Reproduced live with MTConnect.NET-Common as referenced by MTConnect.NET-Applications-Agents v6.9.0.2 on net9.0 Linux x64:

var d = new Device();
// observed: Uuid='e6b40112-99a2-40d8-9bed-a5bbbdf16177'
// second construction: Uuid='b7c3e820-…' — different every time

Two sequential new Device() calls produce Devices with different Uuid values. Setting Uuid in the object initializer (new Device { Uuid = "F1" }) correctly produces Uuid = "F1" — the setter has precedence — but this requires the caller to remember to set every single Device they construct.

Environment

  • MTConnect.NET-Common via MTConnect.NET-Applications-Agents v6.9.0.2 (current latest release, published 2025-10-16; same assembly ships in Docker image trakhound/mtconnect.net-agent:6.9.0).
  • net9.0 target framework, Linux x64 (reproduces identically on Debug and Release builds).
  • No agent / broker / transport involved — the leak is in the POCO constructor invoked by application code.

Reproduction

Minimum rig — two xUnit facts against the library assembly (no agent required):

using FluentAssertions;
using MTConnect.Devices;

public class DeviceCtorUuidRoll
{
    [Fact] public void Default_ctor_auto_generates_a_Uuid()
    {
        var d = new Device();
        d.Uuid.Should().NotBeNullOrEmpty();  // library-authored — caller did not ask for this
    }

    [Fact] public void Default_ctor_re_rolls_Uuid_per_construction()
    {
        var a = new Device();
        var b = new Device();
        a.Uuid.Should().NotBe(b.Uuid);       // passes — each ctor re-rolls
    }
}

Both assertions pass against v6.9.0.2.

Authority

XSD — DeviceType.uuid is use='required' in every MTConnect release since v1.0 (2008)

Byte-verified against the upstream canonical schemas at https://schemas.mtconnect.org/schemas/ for v2.1 and v2.7, and against the cppagent/schemas/ mirror for v1.0 through v1.7.

DeviceType declaration (from MTConnectDevices_2.7.xsd, identical structural wording in v2.1 and byte-verified identical in v2.0):

<xs:complexType name='DeviceType'>
    <xs:complexContent>
        <xs:extension base='ComponentType'>
            ...
            <xs:attribute name='uuid' type='UuidType' use='required'>
                <xs:annotation>
                    <xs:documentation>
                        The components universally unique id. This can be composed of the
                        manufactures id or name and the serial number.
                    </xs:documentation>
                </xs:annotation>
            </xs:attribute>
            <xs:attribute name='name' type='NameType' use='required'>
                ...
            </xs:attribute>
        </xs:extension>
    </xs:complexContent>
</xs:complexType>

UuidType simple-type declaration (verbatim identical in every XSD from v1.0 through v2.7):

<xs:simpleType name='UuidType'>
    <xs:annotation>
        <xs:documentation>
            A universally unique id that uniquely identifies the element for
            it's entire life
        </xs:documentation>
    </xs:annotation>
    <xs:restriction base='xs:string'/>
</xs:simpleType>

Version history — Device.uuid across every published MTConnect release

Version Release DeviceType.uuid DeviceType.uuid annotation UuidType annotation
v1.0 2008 use='required' (none) "A universally unique id that uniquely identifies the element for it's entire life"
v1.1 required (none) same
v1.2 required (none) same
v1.3 required (none) same
v1.4 required (none) same
v1.5 required first annotation added: "The unique identifier for an XML element." same
v1.6 required "The unique identifier for an XML element." same
v1.7 ~2019 required rewritten: "The components universally unique id. This can be composed of the manufactures id or name and the serial number." same
v2.0 required same as v1.7 same
v2.1 2023-01-12 required same as v1.7 same
v2.5 / 2.6 / 2.7 2026-02-22 required same as v1.7 same

Device.uuid has been use='required' since the very first MTConnect release (v1.0, 2008). It has never been optional in any published version. The UuidType "entire life" annotation has been in place since v1.0 — verbatim identical across 18 years and eleven major/minor releases. The attribute-level annotation was added in v1.5 and rewritten once, in v1.7, to the wording that has since persisted unchanged through v2.7.

SysML — MTConnect v2.7 normative model (https://model.mtconnect.org/Version2.7/DeviceInformationModel/Device/)

The Device class description opens with the normative prose:

A Device MUST have a Device::name and Device::uuid to identify itself.

Device's property table:

Name Type Int Dep Multiplicity Description
uuid UUID 1.0 1 universally unique identifier for the element.

The Component parent class carries uuid UUID 1.0 0..1 universally unique identifier for the «abstract» Component — i.e. 0..1 (optional) at the Component level. Device tightens the cardinality to exactly one.

The Device class also carries a normative OCL well-formedness constraint:

Components::Devices::Device::allInstances()->iterate(device; devicecount: Real = 0 |
  if device.id->size() = 1 and
     device.name->size() = 1 and
     device.uuid->size() = 1 and
     (device.observes->size() > 0 or device.hasReference->size() > 0 or device.hasComponent->size() > 0)
  then devicecount + 1
  else devicecount + 0 endif
) = Components::Devices::Device::allInstances()->size()

i.e. every Device instance in the model must have exactly one id, name, and uuid. The SysML layer is the MTConnect standard's normative model; XSD annotations are informal per the XML Schema 1.0/1.1 spec but mirror it.

What follows

Two claims about a library-level new Device() are grounded in the normative text above:

  1. UuidType is an identity claim "for it's entire life" (XSD, since v1.0) uniquely identifying the element (SysML v2.7 description). Guid.NewGuid() invoked inside the library's constructor generates a different value on every construction, so two new Device() calls in the same process produce Devices that are not the same "element" by uuid — and two new Device() calls across process restarts likewise produce different uuids for what the operator may intend as the same semantic Device. If the consumer forgets to overwrite Uuid, the wire identity is reset on every restart, observably violating the "entire life" semantics at the application boundary.

  2. The DeviceType.uuid annotation (since v1.7) says the value "can be composed of the manufactures id or name and the serial number". That is, the documentation describes an identity composed from identifying information, not an opaque random token. A Guid.NewGuid() has no manufacturer / serial-number semantics by construction, so it is not a plausible default for this slot under the documented intent.

Suggested fix

Stop assigning Uuid in Device..ctor():

// MTConnect.NET-Common/.../Devices/Device.cs
public class Device : Component
{
    public Device()
    {
        // Remove:
        //   Uuid = Guid.NewGuid().ToString();
        // Leave Uuid at its language default (null) and let the caller set it.
    }
    ...
}

If library-level code paths hard-depend on Uuid being non-null before a caller has had a chance to set it, the cleanest path is to surface that constraint as a validation step at Agent.AddDevice() time (or at serialisation time): throw or log a descriptive error when a Device with Uuid = null is added, rather than hiding the absence behind a random value.

A weaker middle ground, if there is a loud constituency for the current auto-Uuid behaviour: add an opt-in Device.CreateWithAutoUuid() static factory and make the plain ctor empty. Existing code that wants the current behaviour migrates explicitly; new call-sites avoid the leak by default.

Dime-side workaround — no library change required to unblock us

Our dime-connector@c71a8f6 overwrites Uuid with the operator-declared deviceName at the point where Builder.Build materialises a new Device from an inline xpath that lacks an explicit uuid= predicate. Three xUnit tests pin the behaviour:

  • Build_device_without_uuid_predicate_defaults_to_deviceName
  • Build_device_without_uuid_predicate_is_stable_across_builds
  • Build_device_uuid_predicate_wins_over_deviceName_default

A follow-up commit (11e356b) additionally logs an NLog.Warn when the fallback is invoked, so operators can see which of their xpaths omits the spec-required uuid= and fix the config at leisure. Our Probe envelopes are now restart-stable regardless of what the ctor does; the underlying ctor leak remains for every other consumer of MTConnect.NET-Common.

Impact — observed

Operator deployment at Tempco (2026-04-23): the data.device table of a downstream store that keys on Device.Uuid accumulated 4 rows under Device.Name = "Furnace1West" and 2 rows under Device.Name = "Furnace2East" over a handful of pre-merge restarts of the connector. Each row carried the same name and a different uuid. The ctor-rolled GUID is the direct cause — the operator's inline xpath configuration did not always supply an explicit uuid= predicate, and the random ctor seed registered under each restart's fresh GUID.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions