Files
UA-.NETStandard/docs/WoTNodeSetConversion.md
Marc SchierandCopilot fb80bfdc82 [WotCon] Track WoT Connectivity/Binding 1.1 and add projections (Section 12) (#4182)
Follow-up to #4128, which merged the WoT Connectivity 1.1 work against
**draft2** of the companion specifications. Both drafts have moved
since, and this branch tracks them through to **draft3**, adds the
projection feature they introduced, and closes the gaps a bidirectional
re-audit found.

The specifications are the private
[`OPCF-Members/spec-drafts`](https://github.com/OPCF-Members/spec-drafts)
drafts of *OPC UA — WoT Connectivity 1.1* and *OPC UA — WoT Binding
1.1*. They are still drafts, which is why this is opened as a draft PR —
see [Status](#status) at the bottom.

## What changed in the specifications

Two revisions landed upstream while #4128 was in flight.

**draft2** replaced the `eventGroup` / `actionGroup` grouping constructs
with a single **projection / view** concept, added fifteen Section 6
vocabulary terms, and made two identifier rules normative. **draft3**
([spec PR #8](https://github.com/OPCF-Members/spec-drafts/pull/8))
un-deprecated the incorporated OPC 10100-1 v1.02 surface, restructured
the conformance clause into three profiles, and tightened two security
obligations.

The draft3 NodeSet is **structurally identical** to draft2 — I diffed
all 286 nodes node-by-node. Every change is metadata: 96
`ReleaseStatus="Deprecated"` attributes removed, `PublicationDate`
advanced, and `WOTC-ProjectionMaterialization` claimed by three Type
Nodes that previously implemented it in name only.

## Projections (WoT Binding Section 12)

The substantial addition. A projection document selects affordances from
other Thing Descriptions or Thing Models and resolves to a document of
its own; materialized, it becomes an OPC UA `View` that **creates no
affordance Node** and only `Organizes` what its sources already
materialized.

| Piece | Where |
|---|---|
| Document model and validation | `Opc.Ua.Types/Wot/WotProjection.cs`,
`WotProjectionModel.cs` |
| Four-stage resolution engine |
`Opc.Ua.Types/Wot/WotProjectionResolver.cs` |
| Materialization plan |
`Opc.Ua.WotCon.Server/Materialization/WotViewProjectionPlan.cs`,
`WotProjectionViewBuilder.cs` |
| Live address-space host | `WotProjectionViewNodeManager.cs`,
`LifecycleWotViewProjectionHost.cs`, `IWotViewProjectionHost.cs` |
| Node lookup seam | `IWotMaterializedNodeIndex.cs` |

Groups reached by `ua:Organizes` become organizational Objects rather
than nested Views, only the outermost materialization is a `View`, and a
member whose source is served by another server is omitted and reported
rather than recreated — the federation case the clause calls out.

### How this is validated

The specification publishes twenty worked examples, two of which are a
**golden pair**: a projection document and the resolved view it is
defined to produce. `WotSpecExampleTests` embeds all twenty and runs the
pair through the resolver, asserting against *the specification's own
expected output* rather than against my reading of the prose. That
single document exercises all three selection forms, the bulk naming
rule, the security closure naming and the provenance term.

This is the strongest evidence in the PR, and it is worth more than the
unit tests around it: it is the same check the upstream
`validate_local.py` performs.

## Other spec work

- **Section 6 vocabulary** — fifteen missing model and platform terms
with their validation rules (`WotNodeSetConverter.ModelVocabulary.cs`).
- **Portable identifiers** — the two rules release 1.1 made normative,
with `WotNodeSetConverterOptions.AllowNonPortableIdentifiers` as a
documented downgrade-to-warning switch for documents authored against
v1.00. Defaults to `false`.
- **xRegistry §6.9 identifier construction** (`XRegistryIdentifier.cs`)
— reverse-authority symbolic identifiers; all seven published worked
examples are test cases.

## Gaps found by re-auditing against draft3

I audited in both directions — every normative requirement to code, and
every `uav:` term in code back to a spec that defines it. Three
findings, all fixed:

1. **`Wot-Con 1.02` accepted anything well-formed.** draft3 makes format
validation part of the unit, because *WoT-Con Minimal* is one unit wide
and materialization projects a *valid* document. The 1.02 upload path
only deserialized into the `ThingDescription` POCO, which happily
accepts JSON that is not a WoT document at all — every member of that
type is optional, so `{}` deserialized and was materialized. It now
parses via `WotDocument.Parse` and requires the document to identify
itself by a non-empty `name` or `title`, returning `Bad_DecodingError`
and materializing nothing otherwise. The rule lives in
`ThingDescriptionFormatValidator` and is shared with
`CreateAssetForEndpoint`, whose auto-generated Thing Descriptions §11
also treats as untrusted input.
2. **Two `WoTFile` operations were ungated.** draft3 §11 names `Write`
and `CloseAndUpdate` alongside the five management methods; both reached
the materializer without an access check. `EnforceManagementAccess` is
now wired into `WotAssetFileManager`.
3. **Two invented `uav:` terms.** `uav:NodeSet2Preservation` was
declared and never used — removed. `uav:eventFields` is read by
`OpcUaBindingPlanner` and defined in *neither* spec, while a comment
claimed it came from OPC 10101. It still works for compatibility but is
now documented as a non-standard extension at both sites rather than
misattributed.

The SSRF prohibition is satisfied by construction: there is no HTTP
client anywhere in these assemblies, and all resolution goes through the
injected `IWotThingResolver`.

## Two ambiguities fixed in the specifications themselves

The audit turned up two places where a conforming implementer had to
invent an answer. Rather than pick silently, I took both upstream — they
are pushed to [spec-drafts PR
#8](https://github.com/OPCF-Members/spec-drafts/pull/8) (all checks
green) and are now normative in *Binding* §12.6 and *Connectivity*
§7.13:

- **`ViewVersion`** had one sentence saying only *that* it changes. It
now changes iff the resolved membership changes, is updated at commit
time alongside the `NodeVersion` stamping, and is a deterministic
function of the membership alone in a canonical order — so two servers
that resolved the same membership agree, which a per-server counter
would not across a redundant pair. Explicitly **not monotonic**: compare
for inequality only.
- **"Type-level view"** was used once in §12.2 and defined nowhere,
while every other statement of materialization was universal. §12.6 now
*defines* it rather than contradicting §12.2: the NodeClass is `View` in
both cases.

Both were already what this code did, so neither changed behaviour — but
the order-insensitivity the clause added was **not** covered by any
test, and `ViewVersionIsUnchangedWhenOnlyTheOrderOfTheMembershipChanges`
now holds it. It is mutation-verified: removing the ordinal sort turns
it red.

`docs/WotBindings.md` no longer lists these as points "the specification
leaves open".

## Validation

| Suite | Result |
|---|---|
| `Opc.Ua.Types.Tests` | 8525 ✅ (8518 on net48) |
| `Opc.Ua.WotCon.Tests` | 1057 ✅ (net10.0 and net48) |
| `Opc.Ua.WotCon.Bindings.Tests` | 559 ✅ |
| `Opc.Ua.XRegistry.Tests` | 196 ✅ |
| `Opc.Ua.Server.Tests` | 4027 ✅ |
| `Opc.Ua.SourceGeneration.Core.Tests` | 3771 ✅ |
| `Opc.Ua.WotCon.Samples.Tests` | 9 ✅ |

The full `UA.slnx` builds with **0 errors and 0 warnings attributable to
this PR** on all 6 TFMs (`TreatWarningsAsErrors` is on).
`Opc.Ua.Server.Tests` and the source-generation suites are listed
because this PR now also touches `MasterNodeManager` and the WoT-Con
NodeSet.

## Compatibility

No public API is removed or broken. Everything added is new surface
using `ArrayOf<T>` / `ByteString` per the repo conventions, wired for DI
with a direct-construction fallback, and the one behavioural tightening
(`Wot-Con 1.02` format validation) rejects input that was never valid to
begin with.

One pre-existing issue is **not** addressed here because it came in with
#4128 and is out of scope: `WotAssetFileManager.OnCloseAndUpdate` uses
`.GetAwaiter().GetResult()`, which the repo rules ban. Worth a
follow-up.

## Landed since this PR was opened

- **DI upgraded to 1.05.0.** `ConnectsTo` was declared a subtype of
`HierarchicalReferences`, contradicting OPC 10000-100 §5.5 Table 48.
That is an upstream error in every official DI NodeSet up to 1.04,
corrected by the OPC Foundation in 1.05.0, so the fixture is replaced
with the verbatim 1.05.0 file rather than hand-patched. A guard test
pins the corrected form. This also closed a second gap: six DI types the
WoT model was missing are 1.05 additions.
- **The sample Pump has the type definition OPC 40223 declares for it.**
`Pump1.Identification` carried DI's `FunctionalGroupType` instead of
`PumpIdentificationType`. Every test, the sample documents and the
README agreed with each other about it, which is why it survived.
- **`WotPumpAddressSpaceComparisonTests`** compares this server against
`PumpDeviceIntegrationServer`, which builds the same OPC 40223 Pump from
generated NodeSets and hand-written wiring. An independent oracle rather
than a restatement of the same assumptions - it is what found the defect
above.
- **Error containment in `MaterializeProjectionViewsAsync`.** An
exception mid-loop discarded the applied View handles before they were
published, leaving View Nodes in the address space that nothing could
remove.
- **`HasWoTProjection`** is now created; it was documented and plumbed
but never wired.
- **The default in-memory View host** no longer deletes the View it has
just applied.
- **An undeclared NodeSet alias now says so.** It previously surfaced as
`IdentifierMissing`, naming neither the value nor the fact that an alias
was expected.
- **`ViewVersion` follows the algorithm the specification now states.**
My earlier spec text was rejected on review, correctly: it named neither
the function nor the canonical order it demanded, ignored that OPC
10000-3 §5.4 requires a value greater than zero, and its "when, and only
when" is unsatisfiable for a 32-bit value. §12.6 now specifies the
algorithm outright and this implementation follows it exactly, pinned by
an independently computed test vector. Implementing it surfaced a
further defect in the clause - joining members on U+000A is not
injective, because a NodeId string identifier may itself contain U+000A
- which is fixed in the specification by length-prefixing each member.
- **An auto-generated Thing Description is treated as untrusted input.**
§11 requires it; `CreateAssetForEndpoint` validated the endpoint but
handed whatever the discovery provider returned straight to the
materializer. The `Wot-Con 1.02` format rule now gates both paths from
one place.
- **Document bytes moved out of the registry snapshot** into a
content-addressed store, so a commit no longer rewrites the whole corpus
to edit one document. Writes stage and are promoted during the commit,
which preserves both durability-first ordering and the fail-closed rule
that a blob directory without a manifest means operator recovery.
Restoring streaming digest verification along the way closed a real gap:
content integrity had degenerated to a length comparison.
- **External references now reach a NodeManager registered later.** A
reference to a Node that does not exist yet is dropped rather than
queued, and the dynamic-registration path had no second phase to apply
them in, so registering a NodeSet before the one it points into lost the
forward edge permanently. Review of that change then found a state leak
in its rollback path, fixed with a red-to-green regression test.
- **`WOTC-Legacy` is renamed `Wot-Con 1.02`** in the specification and
here. Editorial: the NodeSet diff is `<Category>` values only.

## Status

Open for review. Two things may still move:

1. **The specifications are unpublished drafts.** Anything here can
change if the working group decides differently. The `ViewVersion`
definition has since been settled - §12.6 specifies the algorithm and
this follows it - but the same could happen to any other clause tracked
here.
2. **`uav:eventFields`** is kept as a documented non-standard extension.
That is a judgement call the WG may want to reverse, either by adopting
the term or removing it.

Two known items are recorded rather than fixed:

- `Pump1` has no hierarchical parent, so it is reachable by NodeId but
not by browsing down from `Objects`. The cross-NodeSet ordering defect
this was originally attributed to is fixed above, and cross-manager
references are now symmetric in both registration orders. The Pump
itself is still blocked on a different cause found while verifying that
fix: with the reference added, `DeviceSet` exists but the Pump has
**zero** inverse references, so the edge is dropped at *import* - before
any external-reference machinery runs. That is a separate defect in the
materialization path, not the one that was fixed, and it is left
recorded rather than guessed at.
- `WotAssetFileManager.OnCloseAndUpdate` uses
`.GetAwaiter().GetResult()`, which the repo rules ban. Pre-existing from
#4128 and out of scope here.

Review of the projection resolver and the materialization host is the
most valuable thing to look at; the vocabulary and identifier commits
are mechanical by comparison.

---------

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

11 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 Root is a UAObject with HasTypeDefinition to BaseObjectType (i=58).
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 OPC UA BaseDataType (i=24). type: object maps to BaseObjectType (i=22) and emits an unsupported-schema warning; type: array currently falls back to BaseDataType with the same warning, which is a deliberately conservative but coarse default.
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.
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 uav:componentModel maps to HasComponent; other generic binding relations map to Organizes.
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:nameNamespace (Section 6.4) Default / Fails Absent: local names resolve against the model's own target namespace and nothing extra is recorded. Malformed (not an absolute IRI with a scheme): NonAbsoluteIri 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.

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.