Files
UA-.NETStandard/docs/WoTNodeSetConversion.md
T
Marc Schier a7c776e8d4 Align the WoT implementation with spec: type binding, removed vocabulary, Alarms and Conditions (#4225)
# 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](https://github.com/OPCF-Members/spec-drafts/pull/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](https://github.com/OPCF-Members/spec-drafts/pull/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

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] 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.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
2026-08-14 07:55:33 -07:00

19 KiB

WoT / NodeSet conversion

Opc.Ua.Wot.WotNodeSetConverter converts OPC UA NodeSet2 documents to WoT Thing Models / Thing Descriptions and materializes WoT documents back to NodeSet2. The converter preserves a byte-exact uav:nodeSet envelope when requested, uses the structured uav:nodes projection when the readable vocabulary is not complete, and otherwise synthesizes NodeSet2 from the readable WoT terms.

WoT to NodeSet defaults

The table below lists the defaults the current WoT-to-NodeSet materializer applies when the WoT input lacks information needed for a NodeSet2 projection. Rows marked Fails emit an error diagnostic and ToNodeSet throws. Rows marked Default substitute the listed value; warnings or informational diagnostics are noted where the code emits them.

Missing WoT input Behaviour Materialized value
Convertible content: no uav:nodeSet, no uav:nodes, and neither a Thing Model nor a Thing Description kind Fails NoConvertibleContent error; no NodeSet is returned.
Model namespace: root uav:id has no nsu=<NamespaceUri>;... part and document id is absent Default NamespaceUris = ["urn:opcua:wot:synthesized"] and Models[0].ModelUri = "urn:opcua:wot:synthesized". This is intentionally synthetic and should be replaced by an authored namespace in portable documents.
Root local name: root uav:browseName is absent Default Sanitized title (letters, digits, _, -) when non-empty; otherwise Thing.
Root uav:id Default Deterministic NodeId ns=1;s=<rootLocal> and an informational GeneratedNodeId diagnostic.
Root uav:browseName Default 1:<rootLocal>.
Root title Default Root DisplayName becomes <rootLocal>.
Thing Model root event annotation uav:eventType Default Root is a non-abstract UAObjectType with inverse HasSubtype to BaseObjectType (i=58). If the event annotation is present, the default supertype is BaseEventType (i=2041).
Thing Description root type information Default / Bound / Fails Absent: root is a UAObject with HasTypeDefinition to BaseObjectType (i=58). Present: a ua:HasTypeDefinition link (WoT Binding Section 5.2.1) whose href is the ExpandedNodeId of the type, and/or a compact model name in @type, binds the root to that type so the converter reuses the existing type rather than defining a second one. The two forms are resolved against the Section 5.1.5 local context — the sibling documents of the conversion first, a loaded AddressSpace as the fallback — supplied through IWotNodeResolver. A binding that names a type the local context does not hold fails (UnresolvedTypeBinding) rather than falling back to BaseObjectType; two bindings that disagree, an ambiguous name with nothing to settle it, or a resolved type of the wrong NodeClass fail as AmbiguousTypeBinding. A compact name is a binding when its namespace is one the local context holds; any other @type member is ordinary annotation and is retained as residue.
Root description Default No Description field is materialized.
Property affordance uav:browseName Default The affordance map key is used as the local name and BrowseName 1:<key>.
Property affordance uav:id Default Deterministic NodeId ns=1;s=<rootLocal>/<propertyLocal>.
Property DataSchema type or an unrecognized type Default The canonical table of WoT Binding §6.11.4: boolean → Boolean, integer → the abstract Integer (i=27), number → the abstract Number (i=26), string → String, refined by contentEncoding: base64 → ByteString, format: date-time → DateTime, format: uuid → Guid, format: uri → UriString. An explicit uav:dataTypeId or uav:mapToType outranks the inference. Anything unrecognized falls back to BaseDataType (i=24). A bare integer or number is deliberately abstract: the schema states only that the value is whole or numeric, and a concrete width is recovered from an annotation rather than guessed.
Property readOnly and writeOnly Default Missing flags mean read/write access (`CurrentRead
Property title Default No DisplayName field is materialized for the variable.
Property description Default No Description field is materialized for the variable.
Property type definition Default HasTypeDefinition to BaseDataVariableType (i=63).
Action affordance uav:browseName Default The affordance map key is used as the local name and BrowseName 1:<key>.
Action affordance uav:id Default Deterministic NodeId ns=1;s=<rootLocal>/<actionLocal>.
Action title Default No DisplayName field is materialized for the method.
Event affordance uav:browseName Default The affordance map key is used as the local name and BrowseName 1:<key>.
Event affordance uav:id Default Deterministic NodeId ns=1;s=<rootLocal>/<eventLocal>.
Event title Default No DisplayName field is materialized for the event type.
Event type abstraction/supertype Default Event affordances materialize as non-abstract UAObjectType nodes with inverse HasSubtype to BaseEventType (i=2041) and a root GeneratesEvent reference, unless the affordance carries uav:conditionType or uav:conditionTypeId. A Condition event derives from the named ConditionType instead (WoT Binding Section 13.2).
uav:modellingRule on a property or action Default No HasModellingRule reference is materialized.
uav:hasComponent / uav:componentOf entry has no matching typed ReferenceType link Default HasComponent is used for the component reference.
Binding link has no resolvable model-name relation and no uav:refId Default The link maps to Organizes. Spec PR #19 removed uav:componentModel, uav:capability and uav:reference; a link now names its ReferenceType directly in rel (ua:HasComponent, ua:HasInterface, ua:NonHierarchicalReferences).
Reference link points to another Thing by URI and no resolver is supplied or the resolver cannot find uav:id Default The reference is omitted and a warning diagnostic is emitted; no placeholder NodeId is generated.
Invalid namespace-qualified uav:id / uav:browseName syntax or unbound compact-name prefix Fails An error diagnostic is emitted; ToNodeSet throws even though synthesis continues far enough to collect diagnostics.
Event affordance says @type: uav:eventType and uav:isEvent: false Fails EventAnnotationConflict error; the two terms must not contradict each other.
uav:isComposite (Section 6.1) Default / Fails Absent: the type is treated as atomic and no HasComponent walk is forced. Malformed (non-boolean): InvalidModelVocabularyValue error and ToNodeSet throws. Present and valid: the flag has no distinct readable NodeSet structure, so it is carried verbatim through the uav:nodes residue and restored on the reverse conversion.
uav:contains (Section 6.3) Default / Fails Absent: sub-components come from links only. Malformed (not an array, or an entry that does not match a link uav:refName declared on the same type): InvalidContainment error. Present and valid: preserved via residue.
uav:containedIn (Section 6.3) Default / Fails Absent: no parent is recorded. Malformed (not a non-empty string, or naming the type itself, which is a cycle): InvalidContainment error. The reciprocal "the named composite exists" check is cross-document and out of scope for the single-document converter, which validates range and self-cycle only. Present and valid: preserved via residue.
uav:unitProperty (Section 6.5) Default / Fails Absent: the value is treated as already in engineering units and no EngineeringUnits pointer is recorded. Malformed (not a non-empty RFC 6901 JSON Pointer resolving, within the same document, to a string property): InvalidUnitPointer error. Present and valid: preserved via residue.
uav:scaleFactor (Section 6.5) Default / Fails Absent: identity scaling (factor 1). Malformed (not a non-zero number): InvalidModelVocabularyValue error. Present and valid: preserved via residue.
uav:decimalPlaces (Section 6.5) Default / Fails Absent: no rounding is recorded. Malformed (not an integer greater than or equal to zero; 2.0 is rejected as a non-integer literal): InvalidModelVocabularyValue error. Present and valid: preserved via residue.
uav:semanticId (Section 6.7) Default / Fails Absent: no semantic reference is recorded. Malformed (not an absolute IRI with a scheme): NonAbsoluteIri error. Present and valid: preserved via residue.
uav:metadata (Section 6.7) Default Absent: nothing is recorded. Present: opaque; carried verbatim through residue, never validated and never a reason to reject the document (Section 6.7).
uav:propertyConfiguration (Section 6.7) Default Absent: nothing is recorded. Present: opaque per-affordance configuration; carried verbatim through residue and never validated.
uav:actionConfiguration (Section 6.7) Default Absent: nothing is recorded. Present: opaque per-affordance configuration; carried verbatim through residue and never validated.
uav:eventConfiguration (Section 6.7) Default Absent: nothing is recorded. Present: opaque per-affordance configuration; carried verbatim through residue and never validated.
uav:includeInherited (Section 6.8) Default / Fails Absent: no inheritance-span flag is recorded. Malformed (non-boolean): InvalidModelVocabularyValue error. Present and valid: preserved via residue.
uav:additionalProperties (Section 6.8) Default / Fails Absent: no open-content flag is recorded. Malformed (non-boolean): InvalidModelVocabularyValue error. Present and valid: preserved via residue.
uav:browsePathAnchor (Section 5.1.4) Default / Fails Absent: a relative uav:browsePath resolves against the nearest enclosing uav:id. Malformed (not an ExpandedNodeId): ValidationError error; the session-local ns=<index> form is reported NonPortableIdentity (an error unless AllowNonPortableIdentifiers is set). Present and valid: preserved via residue.

Consumer-visible compatibility notes

Two WoT-to-NodeSet behaviours are intentionally silent because they follow the current WoT Binding vocabulary, but both can change what a consumer observes in the materialized NodeSet.

Pre-PR #19 reference vocabulary is residue

Spec PR #19 removed six uav: terms from the Binding vocabulary: uav:capability, uav:componentModel, uav:reference, uav:congruentType, uav:congruentTypeName and uav:nameNamespace. A document authored against the earlier vocabulary that still carries those terms is not rejected. The terms are now unmapped JSON and are carried through the Section 9 uav:nodes residue mechanism, with no diagnostic.

That preserves the source document for WoT → NodeSet → WoT round-trip, but it no longer creates the References that earlier conversions produced. A consumer that browses the projected NodeSet therefore will not see those legacy references even though conversion succeeds.

Use the current vocabulary instead:

Earlier term Current form
uav:componentModel link rel names ua:HasComponent directly
uav:capability link rel names ua:HasInterface directly
uav:reference link rel names ua:NonHierarchicalReferences directly
uav:congruentType + uav:congruentTypeName Section 5.2.1 type binding with @type and a ua:HasTypeDefinition link

See Resolving a type binding: the local context for the Section 5.2.1 binding rules.

Condition events derive from their ConditionType

A projected event normally derives from BaseEventType (i=2041). If the event affordance carries uav:conditionType or uav:conditionTypeId, Section 13.2 instead makes the event type derive from the named ConditionType. The Condition mapping is summarized in Alarms and Conditions.

This changes event-filter behaviour. A client filtering for BaseEventType subtypes still matches the projected Condition event, but a client that assumed the exact immediate supertype was BaseEventType, or that selects Condition-specific fields, observes a different type hierarchy and field set.

DataType definitions (Section 6.11)

WoT Binding §6.11 gives Structures, Unions, Enumerations, OptionSets and SimpleDataTypes a readable vocabulary, so that defining one is no longer on its own a reason to fall back to the native uav:nodes projection. §6.11.8 states that as a completeness contract: a fact the clause covers shall be emitted readably and shall not be the reason a converter adds uav:nodes.

Both directions are implemented.

Authoring. uav:dataTypeDefinitions on the Thing root holds the definitions. Each is a JSON-LD node with an @id, a @type of uav:StructureDefinition, uav:EnumDefinition or uav:SimpleDataType, and a uav:dataTypeName. The complete definition occurs in exactly one place and every other occurrence is an @id-only reference; two occurrences that each contribute properties are rejected rather than merged, because merging two ordered field lists has no defined answer.

Identity. uav:dataTypeId states the NodeId where the author wants to. Otherwise it is derived from uav:dataTypeName alone as nsu=<NamespaceUri>;s=DataTypes/<Name>, so the same definition read from a differently ordered or differently nested document still lands on the same Node. A field may name a sibling definition by its JSON-LD @id; that @id is a graph identifier and is resolved to the sibling's NodeId, never read as a NodeId itself.

Inference. A DataSchema with no explicit definition infers one, but only where it determines every required fact — inference fails rather than guesses:

Schema Result
type: object with one property A Structure.
type: object with more than one property Requires uav:fieldOrder; JSON member order carries no meaning, so without it the encoding order is unknowable and inference fails.
required Decides IsOptional; all required gives Structure, otherwise StructureWithOptionalFields.
uav:structureType: Union A Union.
type: integer with oneOf branches carrying const and uav:enumName An Enumeration.
type: integer with a bare enum array Not an Enumeration: it states values but never names them.
A bare integer or number inside a Structure field Fails. It is honest about a scalar Variable, where the abstract type permits subtype values, but inside a Structure accepting them would need a subtyped-value kind the schema has not asked for.
A custom type named without uav:dataTypeSubtypeOf Fails. §6.11.4 forbids a custom type subtyping the abstract Integer or Number.

Encodings. A non-abstract Structure or Union exposes Default Binary, Default XML and Default JSON, with identities derived by appending /Default Binary and so on to the type's own identity. An abstract type is refused encoding identities outright. A concrete type used only inside other Structures — never directly in an ExtensionObject — may set uav:hasDefaultEncoding: false, and then no encodings are generated for it; the term is refused on any kind that has no encodings to begin with.

An OptionSet states a base of Byte, UInt16, UInt32 or UInt64 wide enough for its highest authored bit. The abstract UInteger is not legal: the base has to say how many bits exist, and an abstract type says only that there are some.

NodeSet to WoT. Every DataType a NodeSet defines is emitted back into uav:dataTypeDefinitions. 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 — because the file states no kind directly. The same is true of the structure kind: only IsUnion is recorded, so optionality and subtype allowance are read back off the fields. An encoding link is searched from both ends, because a NodeSet may write it from either and real companion models write it from the Object. An alias is resolved rather than emitted, since a name like DataType="Structure" means nothing outside the document that defines it.

Known gap. An inferred definition's own DataSchema terms (uav:fieldOrder, properties, required, oneOf) still travel as residue rather than being re-derived from the definition, so a document that relies on inference does not yet reproduce byte-identically and keeps its uav:nodes projection. Closing it is the canonical-schema equivalence of §6.11.6: derive the schema from the definition, normalize both, and require the two semantic normal forms to be equal.

Model and platform vocabulary (Section 6)

The WoT Binding Section 6 model- and platform-vocabulary terms (composition, containment, naming, units and scaling, semantics, inheritance) and the anchored browse-path term of Section 5.1.4 are readable annotations: they record OPC UA model facts but have no distinct structure that this converter materializes into the readable NodeSet (it does not model, for example, AnalogUnitType, EngineeringUnits, or HasDictionaryEntry structures). The converter therefore handles them in one direction with full round-trip fidelity:

  • WoT to NodeSet. Each term is validated during synthesis against the per-term domain and range table of Section 7. A malformed value is an error (Section 7 requires a consumer to treat the document as invalid rather than repair it), so ToNodeSet throws and ToNodeSetResult reports the diagnostic. A well-formed value is carried unchanged through the generic uav:nodes residue mechanism.
  • NodeSet to WoT (reverse). These readable terms are not synthesized from a plain NodeSet. When a NodeSet carries a structure that the readable vocabulary cannot yet express, the complete uav:nodes native projection preserves it losslessly, and any residue previously captured for a term is re-applied by JSON Pointer.
  • Round-trip. A document carrying these terms survives WoT → NodeSet → WoT unchanged. Affordance-level terms (uav:scaleFactor, uav:decimalPlaces, uav:unitProperty, uav:semanticId) are preserved under the affordance's projected local name, so an affordance that also carries uav:browseName round-trips under that browse name's local part rather than its original map key.

The opaque terms uav:metadata, uav:propertyConfiguration, uav:actionConfiguration and uav:eventConfiguration are never validated and never cause rejection; they are carried verbatim.