Repository navigation
Align the WoT implementation with spec: type binding, removed vocabulary, Alarms and Conditions - #4225
Conversation
Spec PR #19 in the WoT spec-drafts repository added Binding Section 5.2.1, which lets a document state that the node it projects is an instance of a type that already exists - one a companion model defines and the Server has loaded, or one a sibling document of the same conversion defines - so a converter binds to that type instead of defining a second type of the same shape. The converter hardcoded HasTypeDefinition to BaseObjectType, so a document that named its type projected an untyped node and a client browsing for the companion type would not find it. Read the definitive form: a ua:HasTypeDefinition link whose href is the ExpandedNodeId of the type. An ExpandedNodeId matches exactly one Node or none, so it needs no lookup. A Node has exactly one HasTypeDefinition, so two such links are reported as ambiguous and the root keeps the default rather than being bound to a guess; a link with no usable identifier is reported the same way. The readable @type form is a lookup hint that has to resolve against the local context of Section 5.1.5 - sibling documents first, the loaded AddressSpace as the fallback - which needs a resolver the Types layer does not have yet, so it is not implemented here. Also re-sync the two vendored spec examples that #19 changed. The example migration is uav:componentModel to ua:HasComponent, uav:capability to ua:HasInterface and uav:reference to ua:NonHierarchicalReferences, with uav:congruentType, uav:congruentTypeName and uav:nameNamespace dropped; the generic ReferenceType-compact-name handling already covers the replacements. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
The WoT Binding now defines uav:severity with an explicit rule: the value shall be in the OPC 10000-5 range 1..1000, and a consumer shall not silently clamp it. The implementation clamped, so a document asking for severity 5000 was accepted and published as 1000 - the mistake was hidden while what the author asked for was changed. An affordance carrying an out-of-range severity is now skipped, with the reason logged, and its half-built event type and GeneratesEvent reference are removed. That keeps the rest of the asset usable, which was the point of clamping, without accepting the bad definition. Fixes a real defect the change surfaced: the runtime path read ValidateSeverity(severity) ?? tag.Severity, and because ValidateSeverity returns the 500 default for a null input it never fell back to the affordance's authored severity. An occurrence reported without a severity published 500 rather than the authored 900. Split the build-time validation from the occurrence-time choice so each does one job. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Code coverage✅ Coverage gate passed.
Uncovered changed lines
Coverage is above the recorded baseline - consider ratcheting Thresholds live in |
There was a problem hiding this comment.
Pull request overview
Updates the WoT conversion/connectivity implementation to match recent OPC UA WoT Binding draft changes by (1) allowing a Thing Description to bind the projected root node to an already-existing UA type via a definitive ua:HasTypeDefinition link, and (2) enforcing the normative event severity range rule by rejecting out-of-range authored severities instead of clamping.
Changes:
- Add support for definitive type binding via
ua:HasTypeDefinitionlinks and new diagnostics (AmbiguousTypeBinding,InvalidTypeBinding). - Change WoT Connectivity event severity handling to reject out-of-range authored severities and ensure occurrence-time severity falls back correctly.
- Update golden JSON-LD assets, docs, and tests to reflect the spec draft changes and the new behavior.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/Opc.Ua.WotCon.Tests/WotConnectivityNodeManagerEventTests.cs | Updates/extends tests to validate rejecting out-of-range authored event severities and cleanup behavior. |
| tests/Opc.Ua.Types.Tests/Wot/WotTypeBindingTests.cs | Adds unit tests covering definitive ua:HasTypeDefinition type binding and diagnostic reporting. |
| tests/Opc.Ua.Types.Tests/Wot/Assets/04-type-reference-modelling-rule.jsonld | Re-syncs vendored example asset by removing deprecated terms. |
| tests/Opc.Ua.Types.Tests/Wot/Assets/02-thing-model-pump.jsonld | Re-syncs vendored example asset by migrating old uav:* rels to standard UA ReferenceTypes and removing deprecated terms. |
| src/Opc.Ua.WotCon.Server/Assets/AssetRegistry.cs | Enforces severity range by validating authored severity and skipping invalid affordances; fixes occurrence-time severity selection. |
| src/Opc.Ua.Types/Wot/WotNodeSetConverter.WoT.cs | Implements definitive type binding via ua:HasTypeDefinition link parsing and uses it for the root node’s HasTypeDefinition. |
| src/Opc.Ua.Types/Wot/WotDiagnostics.cs | Adds new diagnostic codes for type-binding ambiguity/invalid binding. |
| docs/WoTNodeSetConversion.md | Documents the new “default vs bound” root type behavior and new diagnostics. |
| docs/WoTConnectivity.md | Documents that out-of-range authored severities are invalid and the affordance is skipped (no clamping). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Spec PR #19 in the WoT spec-drafts repository deleted uav:capability, uav:componentModel, uav:reference, uav:congruentType, uav:congruentTypeName and uav:nameNamespace from the context, the JSON Schema and the ontology. A link now names its ReferenceType directly in rel, so the three link relations become ua:HasInterface, ua:HasComponent and ua:NonHierarchicalReferences, and the congruent-type pair is superseded by the Section 5.2.1 type binding. Remove their validation and mapping: the uav:nameNamespace absolute-IRI check, the uav:congruentTypeName hint-and-pin pair, and the three link relations, which are now ordinary residue like any other unrecognised rel. uav:congruentType was also the only term that redirected one reference to another while resolving a link target, so ResolveTargetNodeIdAsync no longer needs a loop. It resolves once and reports an unresolved target; the resolution context is still entered and the bytes still counted, so the per-conversion document, depth and byte bounds are unchanged. Add NonHierarchicalReferences (i=32) and HasInterface (i=17603) to the ReferenceType map. The converter knew eight ReferenceTypes by name and neither of these, so the two relations that replace uav:reference and uav:capability could not resolve - including in example 02, which this repository vendors as a golden asset and which #19 rewrote to use them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Spec PR #19 added example 22, the worked example of the Section 5.2.1 type binding, and the conditions PR added example 21. The repository vendors the published examples as golden assets and embeds them all, so both were missing. Converting example 22 is now the end-to-end check that a document binds the node it projects to a type that already exists rather than projecting an untyped node - the specification's own example rather than a fixture written to match the implementation. The conformance table claimed ten of the eleven units and silently omitted WoT-ConditionMapping, and the prose said twenty worked examples. Record WoT-ConditionMapping as not covered, since Section 13 is not implemented, and correct the count. The unit is independently claimable and is in none of the recommended profiles, so the profile claims are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
|
CI note — the one red check is infrastructure, not this branch.
This branch touches zero PubSub, Leaving it rather than chasing it; happy to re-run the job if a maintainer |
WoT Binding Section 5.2.1 names a type in either or both of two forms - a compact model name in @type and a ua:HasTypeDefinition link carrying the definitive ExpandedNodeId - and Section 5.1.5 resolves both against a local context: the other documents being converted together first, a loaded AddressSpace as the fallback. Only the definitive form was implemented, because nothing in the stack resolved a name to a node. IWotNodeResolver adds that. It answers three questions: whether a namespace is held, what a NamespaceUri-qualified BrowseName matches, and what an ExpandedNodeId identifies. WotCompositeNodeResolver composes implementations in Section 5.1.5 order, and NullWotNodeResolver is the default that holds nothing. The converter needs no new async surface: it resolves the binding in a pre-resolve pass, exactly as it already pre-resolves Thing references into a catalogue, and the synchronous core consumes the result. The Section 5.2.1 table is now honoured: a unique name binds; the link settles an ambiguous name; a name that resolves to nothing while the link resolves is invalid, because that is a mistake in the name rather than a shorthand for the identifier; two forms resolving to different Nodes are invalid; and a resolved type of the wrong NodeClass is invalid. A binding that names a type the local context does not hold FAILS rather than falling back to BaseObjectType. A silently mistyped node is worse than a reported failure: a Client browsing for the companion type would not find it and nothing would say why. A compact name is told from an ordinary @type annotation BY NAMESPACE, not by whether the lookup happens to succeed, so a name in a held namespace that resolves to nothing is reported while saref:TemperatureSensor on a Server that has never heard of SAREF stays the annotation it always was. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
WoT Binding Section 5.2.1 lets a document bind the node it projects to a type that already exists, and Section 5.1.5 says where that name is looked up: the other documents being converted alongside this one first, a loaded AddressSpace second. Only the abstraction and a null default shipped, so a host got 'holds nothing' and every binding came back unresolved. This adds the first part of that context. SnapshotWotNodeResolver indexes the registry snapshot a conversion runs over and is wired into WotNodeSetDocumentConverter, so a document naming a type another document in the same registry projects now resolves without needing an AddressSpace at all. Only Thing Models are indexed. A Thing Model projects its root as a UAObjectType and is therefore what a type binding can name; a Thing Description projects an instance and is never a type-binding target. The identity it indexes by comes from the new public WotNodeSetConverter.TryDescribeProjectedType, which applies exactly the rules the conversion uses, so an index entry and the projected node cannot drift apart. Describing a document is much cheaper than converting it. Ambiguity is preserved rather than resolved: two siblings projecting the same qualified name yield both matches so the caller can report the name as ambiguous instead of silently picking one. Every new test was mutation-verified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
… mapping Review feedback on #4225. Ambiguity now dominates when reading a type binding. A document declaring several 'ua:HasTypeDefinition' links was reported as ambiguous and, if the first link also had a blank href, as invalid on top of that. The converter is not entitled to choose among several candidates, so judging one of them produced a second, misleading error. Candidates are now counted before any is judged. Implements WoT Binding Section 13, Alarms and Conditions, which the conformance table recorded as not covered. A projected Condition event now derives from the ConditionType it names rather than from BaseEventType: falling back would lose the Condition state model entirely, leaving a Client unable to tell an alarm from an ordinary event. The readable 'uav:conditionType' hint resolves for the four ConditionTypes Section 13.1 scopes; 'uav:conditionTypeId' pins the definitive identity and wins. An unpinned name outside that set is reported rather than guessed. Four conformance rules are enforced, each because breaking it yields a document a consumer can read but cannot act on: a Condition event declares EventId; 'uav:conditionAction' stays inside its closed set; 'uav:actsOn' names a Condition event in the same document; and the three occurrence-level Methods declare an EventId input. Enable and Disable act on the Condition instance and are deliberately exempt from the last rule. Records the normative basis for the reserved prefixes. Section 4 forbids a conforming document from rebinding 'uav', Section 6.5.1 reserves 'ua', and 'tm' is fixed by the W3C WoT TD 1.1 context, so an ordinal comparison against the literal is exact; every other prefix is resolved through the document's @context. An audit confirmed every JSON-LD term comparison in the WoT code is ordinal, as case-sensitive terms require. Rewrites the two garbled WotExpectedNodeClass member summaries. All 15 new tests were mutation-verified over three rounds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Security review of #4225. SnapshotWotNodeResolver was constructed per conversion, and its index parses every document of the snapshot. A refresh converts every resource of a snapshot in turn, so one refresh cost one registry-wide index per document - quadratic parsing work, performed while holding the materialization mutex, and charged against no budget. A document needed nothing more exotic than a context-bound prefix in its @type to force the index build. Three changes: The index is now built once per snapshot. A snapshot is immutable and its content cache is fully populated before any conversion runs, so one index serves every conversion of that snapshot. Only the most recent snapshot is held, so nothing accumulates as generations advance. Only Thing Models are read. The decision uses the registry's Kind rather than the document's own content, so a Thing Description's bytes are never parsed - and a party who can only submit Thing Descriptions cannot plant a type for another document to bind to, whatever its content claims. Indexing is bounded by the same MaxResolverDocuments and MaxResolverTotalBytes budget the rest of a conversion runs under, so a large registry cannot turn one conversion into unbounded work. Both new tests were mutation-verified. The first attempt at the Kind test passed under mutation because the content it relied on was absent anyway; it was rewritten to register a Thing Model's bytes under a Thing Description Kind, which only the Kind check can exclude. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
The remaining two notes from the security review of #4225. A skipped affordance is now reported to the caller. Applying a Thing Description returned a plain Good however many affordances had been dropped - for an out-of-range uav:severity, an invalid child name or a duplicate name - so an operator was told the whole document had been applied while an alarm they authored had silently vanished. In an industrial setting that is the wrong way round: believing an alarm exists when it does not is worse than being told the document was imperfect. RebuildAsync now counts every skip and returns GoodResultsMayBeIncomplete with that count. The code stays in the Good class, so the asset remains usable and a caller testing ServiceResult.IsGood is unaffected, but a caller that inspects the code learns the document was not applied in full. Each skip still logs its own reason. One unreadable document can no longer abort a whole refresh. Both catch filters admitted only JsonException and FormatException, so any other exception from a single malicious or malformed document propagated and took every unrelated resource in the registry down with it. Both now use the repository's established 'ex is not OperationCanceledException' filter: the document fails its own conversion, or contributes no name to the sibling index, and the refresh continues. Both new tests were mutation-verified in each direction. My first attempt put them in AssetRegistryRegistryBridgeTests, whose harness cannot materialise an event type; rather than reshape the production code to fit the fixture, they were moved to the event tests, which already have a working harness. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
…stics Code review of #4225. Implements the loaded-AddressSpace half of the Section 5.1.5 local context. Only the sibling-document half shipped, yet an unresolved binding was already fatal, so a document binding to a companion model type - the primary use of Section 5.2.1, and the spec's own worked example - resolved against nothing and failed to convert where it had previously succeeded. AddressSpaceWotNodeResolver resolves both forms against the types a Server has loaded, and WotRegistryNodeManager composes it behind the sibling resolver as soon as an IServerInternal exists. The definitive ua:HasTypeDefinition link now resolves through that context too. It was honoured unverified whenever no resolver was supplied, so the synchronous entry point emitted a HasTypeDefinition to a type that need not exist anywhere, with no diagnostic - a dangling reference, which is the silently mistyped node Section 5.2.1 exists to prevent, and worse than the BaseObjectType fallback the clause forbids. Both entry points now agree on every document. uav:conditionType is resolved through the document's @context rather than by matching the literal 'ua' prefix. Section 13.2 defines it as a compact model name, and Section 5.1.2 resolves those through the context, so an author who binds a second prefix to the OPC UA namespace no longer gets a spurious error. It also gets its own code, UnresolvedConditionType, rather than reusing the Section 5.2.1 UnresolvedTypeBinding. Section 5.2.1 lists an ambiguous name and an invalid document as separate outcomes, so a NodeClass mismatch and two forms that disagree are no longer reported as ambiguity: they now carry InvalidTypeBinding, which had been near-dead, and AmbiguousTypeBinding keeps its documented meaning. IWotNodeResolver returns ArrayOf<WotResolvedNode> per the repository convention for new public API. This also stops SnapshotWotNodeResolver handing out the live List it stores in its shared cached index, which a caller could have downcast and mutated for every later conversion. Its per-name dedup is gone with it: two siblings claiming the same identity are a conflict to report, and collapsing them resolved the name uniquely and hid it. Also removes a duplicate <summary> block on BuildEventNode. The four tests that encoded the corrected behaviours were updated rather than deleted, and the spec-example test now supplies a local context and asserts the exact bound node instead of merely 'not the default'. All seven new tests were mutation-verified over three rounds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Section 9.2 asks whether converting a readable document back reproduces an equivalent UANodeSet. The check answered a stronger question: it compared canonical XML text, so a NodeSet that writes a DataType as the alias its own Aliases table declares differed from one that writes the identifier that alias stands for. That is a difference in spelling reported as a difference in content, and it would keep the exceptional uav:nodes projection alive for a document the vocabulary expresses perfectly well. CompareEquivalent reads each side through its own alias table and drops the table itself, which is only the definition of the shorthand. It resolves an alias exactly where one is legal - the DataType and ReferenceType attributes and a Reference's target - so a BrowseName that happens to read like an alias name stays what it is. It is a separate entry point rather than a change to Compare. Compare answers whether a document was reproduced as written, callers depend on that, and an earlier attempt to loosen it in place broke forty-seven tests. Only the completeness check uses the new comparison. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Section 9.1 maps a Variable's Value onto the property's value, and the readable mapping dropped it: a pump came back with its Manufacturer, its SerialNumber, its ProductInstanceUri and both supervision signals holding nothing. A NodeSet Value is a UA-XML fragment, and the DataType decides which fragment - a JSON string alone cannot say whether it is a String or the Text of a LocalizedText - so this builds on the definitive DataType the previous change started writing. Only the shapes the forward direction can rebuild exactly are carried: Boolean, String, and the Locale-free LocalizedText. A LocalizedText that states a Locale, and every structured value, is left alone. That is deliberate. Emitting a value the conversion could not reconstruct would turn a gap the completeness check reports into a value that is quietly wrong, and a wrong value is worse than an absent one. Five differences on the sample pump become none. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
The readable mapping was completed a long way: converting the sample pump through affordances alone once produced one Node in one namespace and now produces twenty-one of thirty-five in the source's exact four-namespace table, inventing nothing and keeping every companion type definition, every DataType and every scalar value. What is left is fourteen Nodes, and they divide in two. EURange is expressible - its Low and High are the WoT minimum and maximum and the value is determined by those two numbers. EngineeringUnits is not: its EUInformation carries a NamespaceUri, a UnitId, a DisplayName and a Description, Section 6.4 gives the vocabulary only a unit string alongside uav:unitProperty, uav:scaleFactor and uav:decimalPlaces, and the symbol alone does not determine the UnitId or the Description without a UNECE units table that neither the vocabulary nor this repository holds. So the pump's engineering units are precisely what Section 9.2 calls information the current vocabulary cannot yet express, and uav:nodes is being emitted correctly rather than as a shortfall. Dropping it needs either a term for the whole EUInformation or a units table to rebuild one from a symbol - a specification question rather than a defect. Both documents also record the two conventions that came out of this work: a Value is carried only where it can be rebuilt exactly, because a value that is quietly wrong is worse than one that is reported absent, and completeness is tested for equivalence rather than for spelling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
The previous commit stated that an EngineeringUnits value cannot be expressed without a UNECE units table, and that uav:nodes is therefore emitted correctly for the sample pump. That is wrong, and it was wrong for a specific reason worth naming: it conflated mapping the value onto Section 6.4's unit string, which is lossy and would need such a table, with carrying the value at all, which does not. Nothing has to infer a unit's identifier from its symbol. An ExtensionObject states the identifier of the type it holds, EUInformation and Range are types this stack already generates from the standard NodeSet, and the encoder stack maps such a value to named fields and back. What actually keeps uav:nodes alive for this pump is two gaps in this implementation: a Variable's own Variable children sit one level deeper than the conversion descends, and a Variable's Value is carried only where it is one of three special-cased scalars. Both are ordinary work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Every value in every imported NodeSet was silently dropped. A pump loaded
at runtime browsed correctly and read back empty: its Manufacturer, its
SerialNumber, its engineering units and its EU ranges all present as Nodes
and all holding nothing. The only values that ever appeared came from
runtime bindings, which is why this survived so long - a server that binds
everything it serves never notices.
A NodeSet writes a Variable's value as the typed element alone, uax:String
or uax:ExtensionObject and so on. XmlDecoder.ReadVariant reads the Variant
XML encoding, which nests that element inside a Value element of the OPC UA
XSD namespace: it calls BeginField("Value") and, finding uax:String
instead, treats the field as absent and returns a null Variant. The import
handed it the bare element, so the answer was always null and never an
error.
The element is now wrapped in the Value element the decoder reads, and an
element that already is one is passed through untouched.
Measured on the sample pump NodeSet: the import went from 0 of 27
Variables carrying a value to 19 of 27, which is exactly what the document
declares at every earlier stage. Confirmed live against the running
aggregation server, where EURange now reads Low=0 High=1000000 and
EngineeringUnits reads UnitId 5259596, DisplayName Pa, Description Pascal.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
WoT Binding 6.11.4 gives a canonical table for reading an OPC UA DataType out of a WoT DataSchema. Two rows of ours disagreed with it and four were missing. A bare integer inferred Int64 and a bare number inferred Double. Both invented a width the schema never states. The specification reads the schema for exactly what it says -- whole, or numeric -- and infers the abstract Integer and Number, which permit subtype values; a concrete type comes from an explicit annotation instead of a guess. A string was always String, so a ByteString, DateTime, Guid or UriString could not survive the trip through JSON Schema. contentEncoding and format now refine it, which is the only channel those four have. An explicit uav:dataTypeId now outranks the inference, as 6.11.4 requires for recovering the original concrete type. The one test that asserted Double for a bare number asserted the old behaviour rather than the rule; it now states the rule. The whole table is covered case by case, and both an explicit annotation and the ambiguous rows are pinned. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
A Structure, a Union, an Enumeration and an OptionSet could only reach a NodeSet through the native uav:nodes projection. WoT Binding 6.11 gives them a readable vocabulary, and 6.11.8 makes it a contract: a fact the clause covers shall be emitted readably and shall not be the reason a converter falls back to the projection. uav:dataTypeDefinitions now materializes as DataType Nodes. Identity follows 6.11.1. uav:dataTypeId states it where the author wants to; otherwise it is derived from uav:dataTypeName alone, so the same definition read from a differently ordered or differently nested document still lands on the same Node. The complete definition has to occur exactly once and every other occurrence be an @id-only reference, because merging two ordered field lists has no defined answer. Resolution runs in two passes. A field may name a sibling definition by its JSON-LD @id, and that @id is a graph identifier rather than a NodeId, so every identity is known before any field is resolved through it. Base types take the defaults of 6.11.2, and an OptionSet or a SimpleDataType is told to state its own -- an OptionSet because the base decides how many bits its fields may number. Encodings follow 6.11.7: a non-abstract Structure or Union exposes all three, with identities derived by extending the type's own, and an abstract type is refused them outright. The term is also marked recognised for residue. Without that the round trip would re-emit it as an Extension on top of the Nodes it just produced, stating the same fact twice in two languages -- a trap this conversion has fallen into twice before, so it is now pinned by a test. The specification's own example 23 joins the conformance assets and is asserted definition by definition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
The reverse conversion had no handling for a DataType Node at all, so a Structure or an Enumeration could only reach a document through the native uav:nodes projection. Defining one was on its own enough to force the projection onto a document that needed nothing else from it, which is what 6.11.8 forbids. Every DataType a NodeSet defines is now emitted as uav:dataTypeDefinitions: identity, base type, abstract state, structure type, ordered fields with their rank, dimensions, optionality and subtype allowance, and ordered enumeration fields with their values. Whether a definition is an enumeration is decided by the shape a NodeSet actually gives it -- an enumeration field carries a value and no DataType, a structure field the reverse -- since the file states no kind directly. An alias is resolved rather than emitted. A NodeSet may write DataType="Structure" against its own Aliases table, and that name means nothing to anyone reading the document later. ToPortableNodeId is made total. It already returned malformed input unchanged but let an alias name through to NodeId.Parse, which throws a different exception than the one it caught; enriching from an ordinary NodeSet attribute could therefore throw. It now hands back anything it cannot make portable, as it already did for every other unparseable form. The DI and Pumps sample models are regenerated: 9 DataTypes in DI alone, 5 enumerations and 4 structures, are now stated readably instead of lost. The round trip is asserted field by field with the projection stripped, because with the projection present the way back prefers it and the readable terms would never be read. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
WoT Binding 6.11.4 and 6.11.5 let a DataSchema state a DataType without an explicit definition, but only where the schema determines every required fact. That is now implemented, and it fails rather than guesses: a wrong DataType is worse than a missing one, because it is silently wrong at every later read. An object infers a Structure. JSON member order carries no meaning, so beyond a single property the schema shall carry uav:fieldOrder and inference stops without it. The required array decides optionality, which is what separates Structure from StructureWithOptionalFields, and uav:structureType: Union is honoured where stated. An integer whose oneOf branches each carry a const and a name infers an Enumeration. A bare enum array does not: it states values but never names them, and a field with no name cannot be materialized. A bare integer or number is refused inside a Structure field. It is honest about a scalar Variable, where the abstract type permits subtype values, but not inside a Structure, where accepting them would need a subtyped-value kind the schema has not asked for. A type authored by name alone must name a concrete base, since 6.11.4 forbids a custom type subtyping the abstract Integer or Number. The specification's example now materializes all thirteen of its DataTypes -- the eight stated explicitly and the five it expects to be inferred. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Records what 6.11 asks for and what is implemented in both directions: authoring and identity, the inference table with the cases that fail rather than guess, the encoding rules, and how the reverse direction decides a definition's kind from the shape a NodeSet gives it. Corrects the DataSchema row of the defaults table, which still described the old behaviour of mapping every unrecognized schema to BaseDataType and said nothing about the canonical table now in use. States the remaining gap plainly: an inferred definition's schema terms still travel as residue rather than being re-derived, so a document that relies on inference keeps its uav:nodes projection until the canonical schema equivalence of 6.11.6 is implemented. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
The reference validator that ships with the specification enforces its rules as mutations of its own example: each breaks one rule and shall be caught. Three of those rules matter to a converter as much as to a validator, because accepting them materializes a malformed OPC UA definition in silence. A field may now not be optional unless its definition is a kind with room for an absent field, nor allow subtype values unless its definition is a subtyped-value kind. ArrayDimensions carries one bound per dimension, so its length must equal the ValueRank; a rank of two with one dimension describes nothing coherent. Adding the second check immediately caught a real bug in the reverse direction added earlier in this branch. A NodeSet states only IsUnion, so the rest of the kind has to be read back off the fields; the emitter read only optionality, and silently demoted StructureWithSubtypedValues and UnionWithSubtypedValues to plain ones. It now derives all five kinds, and the specification's own subtyped-value example is asserted. The remaining validator rules -- inherited field prefixes, identity and encoding collisions, enum value duplication, OptionSet bit ranges -- are not covered here and are recorded as outstanding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Five more rules the specification's reference validator enforces as mutations of its own example. Each shares a shape: the document is accepted, materializes, and only later turns out to say something that cannot be resolved to one answer. Two definitions claiming one NodeId would materialize as a single Node with one silently overwriting the other, decided by document order. Two types claiming one encoding Object would leave a value of either ambiguous to decode. A uav:defaultEncodingId naming none of the three encodings a type exposes points at an Object that type does not have; 6.11.7 gives it no fourth encoding to name. Two enumeration fields sharing a value mean the value no longer says which field it is. An OptionSet field numbering a negative bit is not a bit at all; 6.11.5 makes an OptionSet value a bit number rather than a mask. Each is asserted against the shape the reference validator mutates, and each guard was verified to fail when removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
6.11.3 states inherited fields first. That is not a formatting preference. The encoding writes the base's fields before the subtype's own, so renaming, reordering or dropping one shifts every field after it and the value decodes as something else entirely -- the kind of fault that shows up as corrupt data far from its cause. A subtype whose base is defined in the same document now has its leading fields checked against that base, and both a changed name and a dropped field are refused. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Spec-drafts PR #25 opened on the branch this work tracks, moving it from 025e7f2 to 777fc78. Realigns against what changed. uav:hasDefaultEncoding is new. A concrete Structure or Union used only inside other Structures, never directly in an ExtensionObject, may set it false: it is never encoded on its own, so generating the three encodings would advertise Objects nothing can reach. It defaults to true, is refused on any kind that has no encodings to begin with, and is emitted on the way back so a suppressed type does not silently regain them. 6.11.5 narrowed an OptionSet's base from any unsigned integer to the concrete Byte, UInt16, UInt32 or UInt64, ruling out the abstract UInteger, and requires it to carry the highest authored bit. An abstract base says only that some bits exist, not how many. The updated example is re-embedded. It also revealed that inference never received the identities of the explicitly stated definitions, so an inferred type could not subtype one by @id -- which the new example does. They are now threaded through. Writing the new term exposed a real bug. A NodeSet may write the encoding link from either end, and real companion models write it from the Object: the DI NodeSet carries no forward Reference on the DataType at all and declares each encoding as an Object referring back. Looking only one way marked three perfectly ordinary DI structures as having no encodings, and would have published that. Both directions are now searched, and with that the regenerated DI model is byte-identical to the checked-in one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Documents uav:hasDefaultEncoding and the narrowed OptionSet base from spec-drafts PR #25, and notes that the reverse direction reads the structure kind and the encoding link off the NodeSet rather than trusting one direction, since a NodeSet may state either from either end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Two gaps from spec-drafts PR #25, both of which made the mapping quietly wrong rather than merely incomplete. 6.11.1 lets a definition sit in the Thing root's uav:dataTypeDefinitions or inline as a DataSchema's uav:dataTypeDefinition, and says both identify the same graph node. Only the root list was read, so a definition stated inline -- as the specification's own example states one on an action's input -- materialized as nothing at all. Both are now gathered together before anything is resolved, under the same exactly-once rule. Worse, a DataSchema naming a definition did not bind its Variable to it. The example's State property, which names a Machine state enumeration, came out as the abstract Integer: precisely the built-in the definition was written to replace, which defeats the point of the clause. A schema that names a definition, here or by @id anywhere in the document, now carries that DataType. A field may also state its type through the ordinary WoT members, which 6.11.3 permits and requires to agree with the DataType. That is now accepted, still refusing the bare integer or number 6.11.4 calls ambiguous inside a Structure. The collision check added earlier caught the consequence immediately: an inline definition was emitted at the root on the way out and restored inline by residue on the way back, so the document stated it twice. uav:dataTypeDefinition is a term the converter now maps, so it must not also travel as residue -- including nested inside an unrecognized value, which is stored whole. Residue values are now written with mapped terms removed at any depth. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
6.11.2, as tightened by spec-drafts PR #25, keeps a DataType within its own kind: a Structure or Union subtypes one of the same Union/non-Union family, and an Enumeration subtypes a non-OptionSet Enumeration, since an OptionSet's values are bit numbers rather than ordinals. The cycle check is the one that matters most. Removing it to confirm the test could fail did not produce a failure -- it hung, because resolving an inherited prefix or a terminal base through a cyclic graph does not terminate. A document could previously make the converter loop forever. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
A NodeSet is not a single Thing. The conversion chose one root -- the first ObjectType -- and walked its references, so everything not reachable from that one node fell to the native uav:nodes projection. For a companion model that is almost everything: the DI model states 42 ObjectTypes, 5 ReferenceTypes, 2 VariableTypes, 90 Objects, 248 Variables and 51 Methods side by side, and only one of them had a way in. Section 9.1 gives each of these constructs a readable mapping -- an ObjectType is a Thing Model, a VariableType a property in one -- so a model converts to a set of linked documents rather than to one document. Every Node that is not contained by another Node in the same set now roots its own document; a contained Node is still reached by the walk from whatever contains it, and is emitted once. Measured on the DI companion model: 1 document carrying 447 projected nodes and no readable affordances becomes 133 documents carrying 155 properties and all 51 Methods as actions. A DataType still does not root a document, because 6.11 carries it in uav:dataTypeDefinitions, and neither does a ReferenceType, which 9.1 maps to the compact name a link rel uses. The first root keeps the caller's href, so an existing single-root conversion is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
A Variable may hold Variables of its own. EURange and EngineeringUnits sit below an analog Variable, two levels under the Node that roots the document, and the affordance walk read only the root's references -- so they were never reached and the model kept them only in the projection. They are now collected to any depth and stated as properties of the same Thing, each naming the Variable it belongs to through uav:componentOf. The reverse direction reads that and re-parents the child there; without it a child comes back hanging off the Thing, one level higher than the source put it, which is a quietly different address space rather than a missing one. Measured on the DI companion model this recovers only part of the loss. The dominant remaining gap is now precisely known and is a different shape: of the 248 Variables in that model, 58 are Method arguments, which 9.1 maps to an action's input and output schemas rather than to properties. That is tracked separately and is not addressed here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Two losses in the DI companion model, measured rather than guessed. A Method holds its InputArguments and OutputArguments as Variables, two levels below the Node that roots the document, so the affordance walk never reached them: all 58 argument Variables in that model were left behind. They are now carried readably, each naming the Method it belongs to, using the same parentage the nested-Variable work introduced. 9.1 maps a Method's arguments to the action's input and output schemas. Deriving those means decoding the Argument structures the argument Variable holds, which needs the value work that is still open; until then the arguments are stated in their own right so no Node is lost and none is re-parented. The richer shape can replace this without changing what the address space contains. Separately, every Thing Model synthesized as an ObjectType. 9.1 maps a VariableType to a Thing Model too, and the document says which through its @type, so reading only "Thing Model" both lost the VariableType and invented an ObjectType in its place. One cause, two symptoms: the DI round trip returned 44 ObjectTypes for 42 and none of its 2 VariableTypes. Both now return exactly. A test asserted the old behaviour as a limitation -- "currently synthesizes as a UAObjectType" -- and now asserts the rule instead. DI round trip: 361 of 447 Nodes returned, now 419. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
The DI round trip returned nine Objects that were not in the source and lost nine that were: the converter generated encoding Objects from the derived identity pattern while the model's own Default Binary, XML and JSON Objects disappeared. The address space then had the right shape and the wrong identities, which is worse than an obvious gap because everything still browses. 6.11.7 derives an encoding identity only when the author omits it and preserves an explicit one. A NodeSet virtually always allocates its own, so the reverse direction now states them and the forward direction resolves them from the portable form rather than writing them verbatim. Fixing that exposed a second fault the specification anticipates. The derived identity was being built from the DataType's explicit identity, so a type identified numerically produced "ns=2;i=6001/Default Binary" -- a session-local form glued onto a number, which the portable-identity rule rejects outright. 6.11.7 derives from the name-derived String NodeId precisely because it is independent of an explicit numeric, GUID or opaque id and therefore always yields a valid String NodeId. Separately, a Node was treated as belonging to another document whenever it declared a parent, even when that parent lives outside the set. The namespace metadata Object hangs off the Server, so it had no document to belong to and no document of its own: it and eleven Nodes beneath it were simply lost. Containment now requires the parent to be present. DI round trip: 419 of 447 Nodes and nine invented, now 422 of 447 and none invented. Every Node returned is a Node the source stated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
9.1 maps a ReferenceType to the compact model name a link rel uses. That says how one is referred to, not how one is defined, so a ReferenceType had nowhere to state its BrowseName, supertype, symmetry or inverse name and was lost outright: the DI companion model defines five, and the round trip returned none of them. A ReferenceType now roots a Thing Model document like the other type definitions, annotated uav:referenceType, and derives from NonHierarchicalReferences unless it says otherwise. DI round trip: 422 of 447 Nodes, now 427, still inventing none. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
The DI companion model now survives NodeSet to a set of linked WoT documents and back with every Node intact and none invented: 447 of 447. The ISA-95 Job Control and Demo models do too. Three faults stood between 442 and 447, and the test that now guards the result found two of them. A DataType may hold Variables of its own -- EnumStrings and OptionSetValues -- so it roots a document like every other Node that can hold something, even though 6.11 carries its definition separately. A document rooted on a Variable or a DataType stated no NodeClass the way back could read, so an orphan Variable returned as an Object and a DataType as an ObjectType. The counts balanced perfectly while the model was wrong, which is exactly the fault an equality test on identities alone cannot see; the comparison therefore asserts the NodeClass of every Node as well as its presence. A NodeSet may state containment from either end. The walk followed only the parent's forward references, so a child that names its own parent and is never named by it was simply dropped -- one Node in the Demo model, and the kind of asymmetry that had already bitten once with encodings. The comparison is now a test rather than a probe, over three models, and asserts both directions: a Node that disappears is an obvious loss, and a Node that appears from nowhere is the quieter fault because the address space still browses and only fails when something resolves an identity the source never stated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
The three companion models the pump sample depends on now convert to sets of linked documents in which not one document needs the uav:nodes projection: Device Integration to 133 documents, Machinery to 11, Pumps to 339. That is the point of 9.1 and the completeness contract of 6.11.8. A companion model states many type definitions side by side and has no single root, so converted as one document everything but the first root is unreachable and the whole model falls back to the projection -- which is how all three shipped until now. The tests assert the property that matters rather than a shape: no document in any set carries uav:nodes, and every href within a set is distinct, since an href becomes a file name and a duplicate would silently overwrite a sibling. The generator gains GenerateThingModelSet beside GenerateThingModel. The checked-in sample files are unchanged by this commit; writing a set to disk changes the sample layout and the documents.json manifest, which is the next step and is recorded with the measured counts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
The document-set path passes nativeProjection: null unconditionally, so it never writes uav:nodes whatever the mapping does. Asserting the absence of that member therefore proved nothing on its own: the check could not fail, and would have passed just as happily against a set of empty documents. What actually earns the omission is that the set rebuilds the model, so each companion model is now also merged back and compared Node by Node -- nothing lost, nothing invented, and the NodeClass of every Node intact. That extends the guarantee to Machinery and Pumps, which the existing round-trip test did not cover. Verified by removing Variables from the set of Nodes that root a document: the strengthened check fails, where the projection assertion alone stayed green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
Writes a generated set one file per document into a directory named for the model, and returns the documents.json entries that name those files. The set is emitted parent before child, so chaining each entry to the one before it is already a valid load order and a document that names its parent is never loaded first. The materialization itself is Explicit, because it rewrites the checked-in sample document set. Running it produces 501 documents -- Device Integration 151, Machinery 11, Pumps 339 -- and not one of them carries uav:nodes. The checked-in samples are deliberately left alone in this commit. Five tests assert the present single-file shape (canonical serialization over a fixed list, the manifest's exact document kinds, namespace ownership, the DI ConnectsTo reference, and per-file regeneration) and have to be reworked for a set before the sample can change. Landing the files without that leaves the suite red, which is a worse state than a capability that is ready and a shape decision that is not yet taken. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
…oundation/UA-.NETStandard into marcschier/wot-type-binding
Description
Aligns the WoT implementation with the normative changes in the OPC UA WoT
specification drafts that the stack had not picked up, and fixes the defects
that alignment surfaced.
The driving artifact is
OPCF-Members/spec-drafts#19,
"bind a projected node to an already-loaded type, by name or by NodeId", which
landed on the spec
mainafter this repository last tracked it.1. Bind a projected node to an existing type (Binding §5.2.1)
§5.2.1 lets a document state that the node it projects is an instance of a type
that already exists — one a companion model defines and the Server has
loaded, or one a sibling document of the same conversion defines — so a
converter binds to that type instead of defining a second type of the same
shape.
The converter hardcoded
HasTypeDefinitiontoBaseObjectType, so a documentthat named its type projected an untyped node: a client browsing for the
companion type would not find it, and nothing said why.
Both forms are now implemented, resolved against the §5.1.5 local context —
the other documents being converted together first, a loaded AddressSpace as the
fallback.
IWotNodeResolversupplies that context (is a namespace held; whatdoes a qualified BrowseName match; what does an ExpandedNodeId identify),
WotCompositeNodeResolvercomposes implementations in the specified order, andNullWotNodeResolveris the default that holds nothing. The converter needed nonew async surface: the binding is resolved in a pre-resolve pass, exactly as
Thing references already are, and the synchronous core consumes the result.
The §5.2.1 table is honoured: a unique name binds; the link settles an ambiguous
name; a name that resolves to nothing while the link resolves is invalid
(that is a mistake in the name, not a shorthand for the identifier); two forms
resolving to different Nodes are invalid; a resolved type of the wrong NodeClass
is invalid. A binding naming a type the local context does not hold fails
rather than falling back to
BaseObjectType— a silently mistyped node is worsethan a reported failure.
A compact name is told from ordinary
@typeannotation by namespace, not bywhether the lookup succeeds, so
saref:TemperatureSensoron a Server that hasnever heard of SAREF stays the annotation it always was.
The definitive form — a
ua:HasTypeDefinitionlink whosehrefis the ExpandedNodeId of the type. An ExpandedNodeId matches exactly oneNode or none, so it needs no lookup.
A Node has exactly one
HasTypeDefinition, so:AmbiguousTypeBinding(6019), and the root keeps the defaultrather than being bound to a guess;
InvalidTypeBinding(6020);BaseObjectTypedefault is unchanged.2. Drop the six vocabulary terms
#19removed#19deleteduav:capability,uav:componentModel,uav:reference,uav:congruentType,uav:congruentTypeNameanduav:nameNamespacefrom thecontext, the JSON Schema and the ontology. A link now names its ReferenceType
directly in
rel, so the three link relations becomeua:HasInterface,ua:HasComponentandua:NonHierarchicalReferences, and the congruent-typepair is superseded by the §5.2.1 type binding.
Their validation and mapping are removed; they are now ordinary residue like any
other unrecognised term.
A real gap this uncovered
The converter knew eight ReferenceTypes by name and had neither
NonHierarchicalReferences(i=32) norHasInterface(i=17603) — so the tworelations that replace
uav:referenceanduav:capabilitycould not resolveat all. That includes example 02, which this repository vendors as a golden
asset and which
#19rewrote to use them. Both are now in the map.uav:congruentTypewas also the only term that redirected one reference toanother while resolving a link target, so
ResolveTargetNodeIdAsyncno longerneeds a loop. It resolves once and reports an unresolved target; the resolution
context is still entered and the bytes still counted, so the per-conversion
document, depth and byte bounds are unchanged.
3. Reject an out-of-range event severity instead of clamping it
uav:severityhad grown in this implementation without being a specificationterm. Rather than drop it, it is proposed upstream in
OPCF-Members/spec-drafts#21,
which defines it with an explicit rule: the value shall be in the OPC 10000-5
range 1..1000, and a consumer shall not silently clamp it.
The implementation clamped, so a document asking for severity 5000 was accepted
and published as 1000 — the mistake was hidden while what the author asked for
was changed. An affordance carrying an out-of-range severity is now skipped,
with the reason logged, and its half-built event type and
GeneratesEventreference are removed.
Defect found while making that change
The occurrence-time path read
ValidateSeverity(severity) ?? tag.Severity.Because
ValidateSeverity(null)returns the 500 default, it never fell backto the affordance's authored severity: an occurrence reported without a severity
published 500 rather than the authored 900. Build-time validation and the
occurrence-time choice are now separate methods.
4. Vendored examples and honest coverage claims
Examples 21 and 22 were missing from the vendored golden assets. Converting
example 22 is now the end-to-end check for §5.2.1 — the specification's own
example rather than a fixture written to match the implementation.
docs/WotBindings.mdclaimed ten of the eleven conformance units and silentlyomitted WoT-ConditionMapping, and said "twenty worked examples". The count
is corrected, and WoT-ConditionMapping is now covered — see §5 below.
5. Alarms and Conditions (Binding §13)
The last unclaimed conformance unit. A projected Condition event now derives
from the ConditionType it names rather than from
BaseEventType; falling backwould lose the Condition state model entirely, leaving a Client unable to tell
an alarm from an ordinary event.
The two forms follow the hint-plus-pin pattern of §5.3:
uav:conditionTypeIdis definitive and wins,
uav:conditionTypeis a readable hint resolved for thefour ConditionTypes §13.1 scopes. An unpinned name outside that set is reported
rather than guessed.
Four conformance rules are enforced, each because breaking it yields a document
a consumer can read but cannot act on:
EventIdin itsdataConditionEventIdMissinguav:conditionActionis inside its closed setInvalidConditionActionuav:actsOnnames a Condition event in the same documentInvalidConditionTargetAcknowledge/Confirm/AddCommentdeclare anEventIdinputConditionActionInputMissingEnableandDisableact on the Condition instance rather than one occurrenceand are deliberately exempt from the last rule, pinned by a negative test.
Shelving, suppression, dialog conditions and
ConditionRefreshstay out ofscope, as §13.1 scopes the mapping.
6. The sibling half of the §5.1.5 local context
SnapshotWotNodeResolverindexes the registry snapshot a conversion runs overand is wired into
WotNodeSetDocumentConverter, so a document naming a typeanother document in the same registry projects resolves with no AddressSpace at
all. Only Thing Models are indexed: a Thing Model projects a
UAObjectTypeandis a valid target, a Thing Description projects an instance and never is.
Identity comes from the new public
WotNodeSetConverter.TryDescribeProjectedType,which applies exactly the rules the conversion uses, so an index entry and the
projected node cannot drift apart. Ambiguity is preserved rather than resolved:
two siblings sharing a qualified name yield both matches so the caller can
report it.
Deliberately not in this PR
IWotNodeResolver. The abstraction, thecomposite, the null default and the sibling-document implementation ship
here; the AddressSpace-backed half needs a cached type-hierarchy walk behind
a server
OperationContextand follows separately. Until it exists a hostgets the sibling context plus the null fallback, which reports a binding as
unresolved rather than mistyping it.
mandatory instance declaration of the resolved type, and rejecting
disagreement with an instantiated Thing Model.
Each is tracked; none is faked here.
Related Issues
There is no tracking issue: this came out of a spec-versus-implementation audit
rather than a reported defect, and the driving artifacts are the two upstream
spec PRs linked above. Happy to open one if maintainers prefer — the §5.2.1
follow-up in particular is worth an ADR, since the local-context resolver is a
new injectable provider spanning two assemblies.
Checklist
Verification detail
Opc.Ua.Types.TestsOpc.Ua.WotCon.TestsFull
UA.slnxbuild: 0 errors, 0 warnings across all target frameworks,re-verified after
masterwas merged into the branch.Every new test was mutation-verified: the type-binding tests against three
mutations (revert to the hardcoded default, drop the ambiguity check, accept an
empty
href), the severity tests against reinstating the clamp, and theReferenceType additions against removing them from the map. Each mutation failed
the suite before the change was kept.