Skip to content

Align the WoT implementation with spec: type binding, removed vocabulary, Alarms and Conditions - #4225

Merged
marcschier merged 55 commits into
masterfrom
marcschier/wot-type-binding
Aug 14, 2026
Merged

marcschier merged 55 commits into
masterfrom
marcschier/wot-type-binding

Conversation

@marcschier

@marcschier marcschier commented Aug 10, 2026 •

Copy link
Copy Markdown
Collaborator

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 main after 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 HasTypeDefinition to BaseObjectType, so a document
that 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. IWotNodeResolver supplies that context (is a namespace held; what
does a qualified BrowseName match; what does an ExpandedNodeId identify),
WotCompositeNodeResolver composes implementations in the specified order, and
NullWotNodeResolver is the default that holds nothing. The converter needed no
new 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 worse
than a reported failure.

A compact name is told from ordinary @type annotation by namespace, not by
whether the lookup succeeds, so saref:TemperatureSensor on a Server that has
never heard of SAREF stays the annotation it always was.

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 → AmbiguousTypeBinding (6019), and the root keeps the default
    rather than being bound to a guess;
  • a link with no usable identifier → InvalidTypeBinding (6020);
  • no binding at all → the BaseObjectType default is unchanged.

2. Drop the six vocabulary terms #19 removed

#19 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 §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) nor HasInterface (i=17603) — so the two
relations that replace uav:reference and uav:capability could not resolve
at all. That includes example 02, which this repository vendors as a golden
asset and which #19 rewrote to use them. Both are now in the map.

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.

3. Reject an out-of-range event severity instead of clamping it

uav:severity had grown in this implementation without being a specification
term. 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 GeneratesEvent
reference 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 back
to 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.md claimed ten of the eleven conformance units and silently
omitted 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 back
would 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:conditionTypeId
is definitive and wins, uav:conditionType is a readable hint resolved for the
four 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:

Rule Section Diagnostic
A Condition event declares EventId in its data 13.3 ConditionEventIdMissing
uav:conditionAction is inside its closed set 13.2 InvalidConditionAction
uav:actsOn names a Condition event in the same document 13.4 InvalidConditionTarget
Acknowledge/Confirm/AddComment declare an EventId input 13.4 ConditionActionInputMissing

Enable and Disable act on the Condition instance rather than one occurrence
and are deliberately exempt from the last rule, pinned by a negative test.
Shelving, suppression, dialog conditions and ConditionRefresh stay out of
scope, as §13.1 scopes the mapping.

6. The sibling half of the §5.1.5 local 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 resolves with no AddressSpace at
all. Only Thing Models are indexed: a Thing Model projects a UAObjectType and
is 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

  • The AddressSpace-backed IWotNodeResolver. The abstraction, the
    composite, the null default and the sibling-document implementation ship
    here; the AddressSpace-backed half needs a cached type-hierarchy walk behind
    a server OperationContext and follows separately. Until it exists a host
    gets the sibling context plus the null fallback, which reports a binding as
    unresolved rather than mistyping it.
  • Two §5.2.1 obligations that need the address space: not duplicating a
    mandatory instance declaration of the resolved type, and rejecting
    disagreement with an instantiated Thing Model.
  • Connectivity §7.3 parent resolution.

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

  • I have signed the CLA and read the CONTRIBUTING doc.
  • I have added tests that prove my fix is effective or that my feature works and increased code coverage.
  • I have added all necessary documentation.
  • I have verified that my changes do not introduce (new) build or analyzer warnings.
  • I ran all tests locally using the UA.slnx solution against at least .net framework and .net 10, and all passed.
  • I fixed all failing and flaky tests in the CI pipelines and all CodeQL warnings.
  • I have addressed all PR feedback received.

Verification detail

net10.0 net48
Opc.Ua.Types.Tests 8573 / 8573 8555 / 8555
Opc.Ua.WotCon.Tests 1075 / 1075 1075 / 1075

Full UA.slnx build: 0 errors, 0 warnings across all target frameworks,
re-verified after master was 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 the
ReferenceType additions against removing them from the map. Each mutation failed
the suite before the change was kept.

marcschier and others added 2 commits August 10, 2026 06:57
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
@marcschier
marcschier marked this pull request as ready for review August 10, 2026 06:11
Copilot AI lite review requested due to automatic review settings August 10, 2026 06:11
@github-actions

github-actions Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

Code coverage

✅ Coverage gate passed.

Check Result Threshold
✅ Project line rate 86.53% (218157/252104 lines) >= 70.00%
✅ Project branch rate 76.29% >= 60.00%
✅ Patch coverage 84.26% (2452/2910 changed lines) >= 75.00% (> 100 changed lines)
ℹ️ Baseline delta (advisory) +12.93 pp 73.60% recorded
Uncovered changed lines
  • src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotViewProjectionHost.cs: 88
  • src/Opc.Ua.Types/Wot/WotVocabulary.cs: 127, 128
  • src/Opc.Ua.WotCon.Server/Materialization/WotProjectionViewBuilder.cs: 358, 373, 384, 408, 421, 422
  • src/Opc.Ua.WotCon.Server/Materialization/SnapshotWotNodeResolver.cs: 105, 165, 196, 217
  • src/Opc.Ua.WotCon.Server/Assets/AssetRegistry.cs: 574, 597, 612, 620, 874, 875, 876, 877, 898, 899, 900, 901, 902, 903, 980, 995, 1016, 1017, 1019, 1020, 1028, 1030, 1031, 1032, 1034, 1036, 1038, 1040
  • src/Opc.Ua.Types/Schema/NodeSetComparer.cs: 203, 207, 225, 227, 230
  • src/Opc.Ua.WotCon.Server/Materialization/AddressSpaceWotNodeResolver.cs: 101, 130, 157, 228, 236, 272, 276, 299, 342, 343
  • src/Opc.Ua.Types/Wot/IWotNodeResolver.cs: 182, 192, 243, 264, 272, 274, 275, 276, 277, 279, 283, 284
  • src/Opc.Ua.WotCon.Server/Materialization/WotProjectionViewNodeManager.cs: 82, 83, 110
  • src/Opc.Ua.Types/Wot/WotDocumentNodeResolver.cs: 64, 66, 68, 70, 72, 79, 80, 90, 91, 93, 95, 96, 98, 100, 103, 111, 126, 127, 128, 130, 132, 137, 142, 144, 146, 147, 148, 149, 150, 152, 154, 156, 158, 160, 162, 163, 179, 180, 181, 182, 184, 186, 187, 188, 190, 192, 194, 195, 196, 200, 202, 203, 204, 208, 209, 210, 211, 212, 213, 214, 216, 225, 226, 230, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 246, 248, 249, 255, 257, 259, 260, 262, 263, 264, 266, 269, 270, 272, 274, 275, 277, 279, 280, 281, 282, 283, 284, 286, 288, 289, 290, 292, 293, 295, 296, 300, 302, 304, 306, 308, 309, 311, 314, 316, 317, 321, 322, 323, 324, 325, 326, 327, 332, 334, 336, 338, 340, 343, 348, 349, 350, 351, 352, 355, 356, 358, 359, 361
  • src/Opc.Ua.Types/Wot/WotNodeSetConverter.DataTypes.cs: 204, 206, 207, 208, 209, 210, 211, 212, 214, 218, 219, 220, 221, 222, 223, 224, 233, 329, 375, 395, 586, 591, 592, 593, 594, 595, 596, 604, 605, 606, 607, 608, 609, 610, 611, 646, 647, 648, 649, 650, 651, 672, 673, 674, 675, 676, 677, 678, 695, 696, 698, 700, 701, 702, 707, 712, 810, 811, 812, 813, 814, 815, 816, 817, 854, 855, 856, 857, 858, 859, 910, 962, 966, 978, 979, 981, 983, 985, 986, 987, 988, 989, 990, 992, 1044, 1045, 1046, 1047, 1048, 1049, 1112, 1113, 1114, 1115, 1116, 1117, 1229, 1230, 1232, 1246, 1247, 1248, 1249, 1250, 1251, 1252, 1253, 1254, 1261, 1262, 1263, 1264, 1265, 1266, 1267, 1268, 1398, 1469, 1640, 1732, 1790, 1868, 1898, 1956, 1993, 1998, 2049, 2051, 2053, 2100
  • src/Opc.Ua.Types/Wot/WotNodeSetConverter.Conditions.cs: 223
  • src/Opc.Ua.Types/Wot/WotNodeSetConverter.WoT.cs: 327, 328, 330, 333, 334, 335, 336, 337, 338, 561, 718, 719, 720, 1048, 1139, 1140, 1144, 1151, 1153, 1155, 1156, 1157, 1158, 1159, 1171, 1382, 1392, 1394, 1399, 1410, 1530, 1909, 1910, 2021, 2022, 2023, 2024, 2025, 2026, 2696, 2697, 2698, 3216, 3221, 3228, 3229, 3230, 3231, 3232, 3233, 3234
  • src/Opc.Ua.Types/Wot/WotJsonResidue.cs: 611
  • src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs: 173, 176, 178, 240, 241, 242, 243, 244, 263, 265, 266, 268, 271, 272
  • src/Opc.Ua.Types/Wot/WotNodeSetConverter.NodeSet.cs: 705, 729, 749, 751, 753, 755, 1323, 1329
  • src/Opc.Ua.Types/Wot/WotNodeSetConverter.DocumentSet.cs: 139, 140, 178, 182, 191, 192, 193, 194, 195, 225, 227, 229, 231, 275, 393, 411, 412, 414, 542
  • src/Opc.Ua.Types/Schema/UANodeSetHelpers.cs: 996
  • src/Opc.Ua.Types/Wot/WotProjectionResolver.cs: 865, 1568, 1569, 1571, 1572, 1573, 1617, 1618, 1656, 1658, 1660, 1662, 1664, 1666, 1673, 1674
  • src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs: 858, 859, 860, 861, 862

Coverage is above the recorded baseline - consider ratcheting coverage-thresholds.json.

Thresholds live in coverage-thresholds.json. Whole report before exclusions: line 85.80%, branch 75.43%.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:HasTypeDefinition links 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.

Comment thread src/Opc.Ua.Types/Wot/WotNodeSetConverter.WoT.cs
@codecov

codecov Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.97251% with 641 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.55%. Comparing base (64d2834) to head (f31dc38).

Files with missing lines Patch % Lines
.../Opc.Ua.Types/Wot/WotNodeSetConverter.DataTypes.cs 81.04% 129 Missing and 64 partials ⚠️
src/Opc.Ua.Types/Wot/WotDocumentNodeResolver.cs 0.00% 141 Missing ⚠️
src/Opc.Ua.Types/Wot/WotNodeSetConverter.WoT.cs 87.00% 51 Missing and 28 partials ⚠️
...pc.Ua.Types/Wot/WotNodeSetConverter.DocumentSet.cs 74.33% 19 Missing and 29 partials ⚠️
src/Opc.Ua.WotCon.Server/Assets/AssetRegistry.cs 66.66% 28 Missing and 7 partials ⚠️
...ver/Materialization/AddressSpaceWotNodeResolver.cs 82.64% 10 Missing and 11 partials ⚠️
src/Opc.Ua.Types/Wot/WotProjectionResolver.cs 57.50% 15 Missing and 2 partials ⚠️
src/Opc.Ua.Types/Wot/IWotNodeResolver.cs 44.82% 12 Missing and 4 partials ⚠️
...rc/Opc.Ua.Types/Wot/WotNodeSetConverter.NodeSet.cs 88.81% 8 Missing and 8 partials ⚠️
...on.Server/Materialization/IWotDocumentConverter.cs 63.41% 14 Missing and 1 partial ⚠️
... and 10 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4225      +/-   ##
==========================================
- Coverage   80.84%   80.55%   -0.29%     
==========================================
  Files        1816     1824       +8     
  Lines      249346   252104    +2758     
  Branches    43313    43913     +600     
==========================================
+ Hits       201577   203078    +1501     
- Misses      32654    33798    +1144     
- Partials    15115    15228     +113     
Flag Coverage Δ
actions 80.55% <77.97%> (-0.29%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/Opc.Ua.Types/Wot/WotDiagnostics.cs 81.25% <ø> (ø)
...a.Types/Wot/WotNodeSetConverter.ModelVocabulary.cs 86.06% <ø> (-0.69%) ⬇️
src/Opc.Ua.Types/Wot/WotTypeBinding.cs 100.00% <100.00%> (ø)
...erver/Materialization/IWotMaterializedNodeIndex.cs 77.08% <100.00%> (ø)
...n.Server/Materialization/IWotViewProjectionHost.cs 76.74% <100.00%> (+5.31%) ⬆️
src/Opc.Ua.WotCon.Server/Providers/WotActionTag.cs 50.00% <100.00%> (+25.00%) ⬆️
src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs 79.73% <100.00%> (+0.08%) ⬆️
src/Opc.Ua.Types/Schema/UANodeSetHelpers.cs 71.21% <80.00%> (+0.18%) ⬆️
src/Opc.Ua.Types/Wot/WotJsonResidue.cs 88.97% <95.45%> (+1.26%) ⬆️
.../Materialization/LifecycleWotViewProjectionHost.cs 89.55% <77.77%> (+6.21%) ⬆️
... and 17 more

... and 55 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

marcschier and others added 2 commits August 10, 2026 08:59
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
@marcschier marcschier changed the title Bind a projected WoT node to an existing type, and stop clamping event severity Align the WoT implementation with spec PR #19: bind to an existing type, drop the removed vocabulary Aug 10, 2026
@marcschier
marcschier marked this pull request as draft August 10, 2026 07:25
@marcschier

Copy link
Copy Markdown
Collaborator Author

CI note — the one red check is infrastructure, not this branch.

build-and-push-image (pubsubclient, ./samples/ConsoleReferencePubSubClient/Dockerfile)
fails at the Setup Docker buildx step, i.e. before any repository code is
fetched or built.

This branch touches zero PubSub, samples/ or Dockerfile paths — the full
change set is src/Opc.Ua.Types/Wot/**, tests/Opc.Ua.Types.Tests/Wot/** and
three files under docs/, so it cannot influence that job. The same workflow is
green on master (most recently on e05a820c9, which is merged into this
branch).

Leaving it rather than chasing it; happy to re-run the job if a maintainer
prefers.

@marcschier marcschier changed the title Align the WoT implementation with spec PR #19: bind to an existing type, drop the removed vocabulary Align the WoT implementation with spec: bind to an existing type, drop the removed vocabulary Aug 10, 2026
marcschier and others added 2 commits August 10, 2026 12:59
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
Comment thread docs/WotBindings.md Outdated
Comment thread src/Opc.Ua.Types/Wot/IWotNodeResolver.cs Outdated
Comment thread src/Opc.Ua.Types/Wot/WotJsonResidue.cs
Comment thread src/Opc.Ua.Types/Wot/WotNodeSetConverter.NodeSet.cs
@marcschier
marcschier marked this pull request as ready for review August 10, 2026 13:34
@marcschier
marcschier marked this pull request as draft August 10, 2026 13:43
… 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
@marcschier marcschier changed the title Align the WoT implementation with spec: bind to an existing type, drop the removed vocabulary Align the WoT implementation with spec: type binding, removed vocabulary, Alarms and Conditions Aug 10, 2026
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
@marcschier
marcschier marked this pull request as ready for review August 10, 2026 17:10
@marcschier marcschier added the ready Ready to merge once CI Passes label Aug 10, 2026
marcschier and others added 2 commits August 10, 2026 19:28
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
@marcschier
marcschier marked this pull request as draft August 10, 2026 19:34
…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
marcschier and others added 28 commits August 12, 2026 14:00
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
@marcschier
marcschier merged commit a7c776e into master Aug 14, 2026
198 checks passed
@marcschier
marcschier deleted the marcschier/wot-type-binding branch August 14, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready Ready to merge once CI Passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants