Files
UA-.NETStandard/docs/Robotics.md
T
Marc Schier fa8d45f2cf Fix server/core validation, lifecycle and protocol regressions (#4477)
# Description

**Server/core audit fixes with regression tests. Latest review-fix
validation, merge validation and earlier acceptance results are
distinguished below.**

This addresses a 95-finding server/core report. Every finding was
reviewed with
the OPC UA specification knowledge base or, where the standard does not
prescribe a C# implementation, the corresponding ownership/configuration
contract. Incorrect or withdrawn subclaims remain qualified rather than
being
presented as additional fixes.

The original checkpoint covers session identity/certificate validation
and
revocation; certificate/key ownership and transactional updates; TCP
reconnect,
receive/admission and buffer lifetime; HTTP quotas; subscription
admission,
durability and pooled payloads; file-operation lifecycle; historian
aggregates;
fluent/state-machine behavior; and related utilities. It includes
targeted
regressions and directly related documentation.

The approved acceptance cleanup is separate in the commit history:

| Cleanup | Verified result |
| --- | --- |
| Invalid identity-policy and KeyCredential fixture inputs | Fixtures
explicitly advertise Username and use its generated policy ID.
Credential persistence uses valid clear mode inside the existing
encrypted admin context. Original permission, lockout and negative
encryption assertions remain. |
| Native GDS lifetime and concurrent connection failures | Retain
ApplicationInstance until client teardown. A deterministic native
reproducer proved the test-only channel cap of 10 was exhausted;
removing that override restores the existing bounded default of 1000. No
global serialization or production quota change. |
| Stress certificate ownership and close accounting | Balance two helper
references and the fake transport's server-certificate ownership.
Replace the invalid event-count bound with exactly-once physical
disposal, opens = closes, zero active entries and one logical entry
lifetime. |
| Participant deadlines | Use the injected TimeProvider with the
existing cross-framework WaitAsync overload. Tests pin one tick before,
exact expiry, terminal notification, recovery and timely completion. |
| Reproducibility and platform assertions | Preserve canonical WoT
bytes/hashes, explicit display-culture contracts and exact
numeric-vector assertions. Accept Apple's CryptographicException subtype
without weakening disposal balance. |
| Missing user-signature proof | Reject null/empty proofs before OS
crypto. Return BadUserSignatureInvalid per Part 4, 5.7.3.3. None-policy
and valid-signature controls remain. A CI-only test nullable-context
error was corrected without changing production behavior. |
| Durable sample restore and shared test storage | Dequeue waits for
actual batch residency instead of treating failed restore completion as
success, and restored files close before deletion. Five deterministic
regressions failed before the fix. The two fixtures that clear the
shared batch directory are isolated; all original 3,000-item assertions
remain unchanged. |

## Latest review follow-up

Commit `828856850219518970e8e2f580b8f94b3c1fc748` fixes the deferred
robotics
disposal reported in the latest review.
`RobotIntentNodeManager.DisposeAsync`
now joins a shared controller-host drain and then explicitly awaits base
node-manager disposal. A controller's bounded shutdown wait may defer
its
cleanup, but no longer lets the node-manager override finish early.
Concurrent
callers, including an asynchronous call after synchronous `Dispose`,
cannot
bypass the drain after the host list has been detached.

Synchronous disposal remains nonblocking for deferred work. Deferred
initialization failures are logged and retained, and base cleanup
failures
propagate to asynchronous callers. The existing controller-level bounded
disposal contract is unchanged; the node-manager contract is documented
in
`docs/Robotics.md`.

| Regression requirement | Evidence |
| --- | --- |
| Wait for deferred hosts; preserve synchronous compatibility and
concurrent joins | `NodeManagerAsyncDisposalWaitsForDeferredHostsAsync`:
four cases covering both entry-point orders and zero/positive host
shutdown waits. All four failed before the fix. |
| Wait through base cleanup and observe failures |
`NodeManagerDisposalWaitsForBaseCleanupAndReportsItsFailureAsync`: four
cases hold the actual base-resource semaphore, verify pending callers
after the host finishes, then check success or the same propagated
exception and exactly-once cleanup. |

The focused `IntentHostingCoverageTests` and
`IntentControllerHostTests.Dispose`
selection passed **30/30 on net10.0 and 30/30 on net48**, with no
failures or
skips. The final net10 solution build passed with zero errors and only
the four
previously documented CA2249 warnings in unchanged fuzz-test code. No
new
warnings, analyzer suppressions, dependencies, or weakened assertions
were added.
The review thread received a commit-specific reply and was explicitly
resolved.

This verification is scoped to the robotics feedback; the earlier full
Subscriptions-run limitation remains recorded in the merge section
below.


## Documentation review follow-up (2026-09-14)

Commit `f9a9686fcc5d3cfd19cbbc8c2d826e20f5c7e72f` addresses the six
follow-up
threads on documentation and implementation rationale. Each has an
in-thread
reply citing the commit and an explicit resolution.

Multiline XML documentation now covers the declarations introduced or
modified
throughout the entire PR, including internal contracts, samples, tests,
and
public members of related private helpers. A syntax-aware inventory
checked
**2,031 declarations across all 256 PR C# paths with zero documentation
gaps**.
A supplementary check also covers **75 previously unchanged public
members**
of private helpers involved in the changed code. Token comparison across
the
conditional-compilation branches proves this follow-up changes **no
executable
C# or test assertions**. Project-file changes are rationale comments
only.

The implementation questions are documented at their source:

| Question | Resolution |
| --- | --- |
| Certificate assembly friendship | Server Push configuration reuses the
internal explicit-subject CSR helper with the existing key; no public
crypto API was added. |
| Core.Types assembly friendship | Server builders use internal
transition callbacks/revision guards; Server.Tests checks the revision
deterministically without creating a public compatibility contract. |
| Always-single-reader notifications | One dequeuer preserves event
order; optional concurrency fans out within one event and joins before
the next event. |
| Asynchronous disposal completion | `DisposeAsync` already joins the
completion published after admitted operations and all cleanup attempts.
The compatibility `Dispose` remains nonblocking to avoid deadlocking an
admitted callback against its own drain. |

The requested detailed review disposition follows. It distinguishes
actual
fixes from confirmed policies and incorrect subclaims rather than
counting every
comment as a new runtime change.


### Detailed disposition of review 5191795284

The [SDK
review](https://github.com/OPCFoundation/UA-.NETStandard/pull/4477#pullrequestreview-5191795284)
was submitted against `1c4a9df6c`, before the subsequent merge and
feedback fixes.
Its **15 inline threads** have each received an action-specific reply
and an
explicit resolution. A fresh GraphQL query verified those replies and
resolution
states; the fixes in `b8a59990f` remain ancestors of the current branch.

| Review concern | Action and disposition | Concrete regression evidence
|
| --- | --- | --- |
| Injected certificate-store providers bypassed | **Fixed.**
Registration, configuration snapshots, and validation use the injected
providers. Explicit custom types remain authoritative; the built-in
Directory catch-all is deferred so native X509 paths and later custom
providers are not captured. Failed store opens dispose the created
store. | `InjectedProviderResolvesRegisteredAndConfiguredTrustStores`
covers default-provider ordering and both registration paths; provider
validation/open-failure cases and the unchanged
`TrustedCertificateInjectedViaIndependentX509StoreIsObservedAsync` cover
real use and independent-store visibility. |
| Unguarded timer-thread leadership callbacks | **Fixed.** Each
subscriber's exception is logged independently and cannot suppress
subsequent subscribers or fail lease operations. `IsLeader` reads the
deadline without invoking application callbacks. |
`ThrowingLeadershipSubscriberDoesNotFailAcquireOrDisposeAsync`,
`ExpiryNotifiesOtherSubscribersWhenOneThrowsAsync`, and
`IsLeaderDoesNotDispatchExpiryNotificationsAsync` were all demonstrated
failing before the fix. |
| Legacy pending-key provider accepts an unfinishable regenerated CSR |
**Fixed by explicit capability validation.** A provider implementing
only `IPendingCertificateKeyStore` receives `BadNotSupported` before key
generation or persistence when regeneration is requested. Existing-key
CSR/update remains supported. Regeneration requires matching-only
consumption; no unsafe destructive probing was introduced. |
`LegacyPendingProviderRejectsRegenerationBeforeCreatingOrSavingAKeyAsync`
checks no key or store mutation;
`LegacyPendingProviderCompletesExistingKeySigningAndUpdateAsync` proves
the supported flow completes. |
| Shutdown waits behind user deactivation | **Fixed.** Accepted
deactivations drain before the master's lifecycle gate is taken.
Configuration retains its binding until the shared drain completes,
including concurrent shutdown callers and direct master disposal. | Four
cases of `ShutdownDrainsDeactivationOutsideTheSessionClosingGateAsync`
exercise real configuration/binding/ServerInternalData/master reentry,
retained address space, exact session/subscription closure, and no
production cancellation shortcut. |
| Subclass cleanup runs under the operation-lifetime lock | **Fixed.**
Cleanup starts outside that lock. A pre-published completion joins all
disposers, and only the active asynchronous teardown context may use
guarded helpers. Captured contexts lose that privilege when the callback
exits. |
`SynchronousOwnedCleanupLetsSamplingWorkerReachClosedAdmissionAsync`,
`DisposeCoreCanUseGuardedMembersWithoutReopeningCallerAdmissionAsync`,
captured-context rejection, and exact cleanup-failure/ownership
assertions cover both synchronous and suspended cleanup. |
| Discovery requestId-zero chunk read after pool return | **Fixed by the
earlier merge.** Discovery validation precedes `SaveIntermediateChunk`;
no read occurs after that path can return the buffer. The ordering is
retained. | `DiscoveryChannelReadsTheFirstChunkBeforeItIsReturnedAsync`
uses a poisoning pool and the requestId-zero path. |
| T1.8 OPN cleanup misses post-decryption throws | **Fixed by the
earlier merge and pinned with new regressions.** Both sequence rejection
and certificate mismatch release the decrypted body and input ownership.
| `OpenSecureChannelRejectionAfterDecryptionReturnsEveryRentalAsync`
checks the exact audit status and pool conservation. Omitting either
corresponding release in an isolated snapshot made its case fail; no
mutation remains. |
| T1.2 mount-root protection depends on the provider | **Fixed at the
host boundary.** Separator-only aliases resolve to the protected root.
Traversal, empty/whitespace segments, backslashes, and drive/stream
qualifiers are rejected before arbitrary providers are invoked. Valid
nested paths and root destinations remain supported. | A deliberately
permissive provider is used by
`NonCanonicalObjectPathsCannotDeleteMoveOrCopyAsync` and invalid-target
cases; exact zero-call checks are paired with legitimate
nested-path/root-destination controls. |
| Inline trusted/issuer entries now face CRL checks | **Intentional
strict behavior, documented and tested.** A configured backing store
with strict unknown-status rejection requires current revocation
information. Explicit-only lists without a store retain their no-store
behavior. Actual revocation cannot be suppressed; a matching store
entry's policy takes precedence. |
`InlineIssuerWithStoreHonorsStrictUnknownRevocationPolicyAsync`,
`InlineIssuerRevocationIsNeverSuppressedAsync`, store-precedence cases,
and explicit-only controls preserve the missing-information versus
actual-revocation distinction in Part 4 6.1.3. |
| RSA minimum applied to unspecified ECC identifiers | **Fixed at the
application-certificate check.** The RSA minimum is compared only with
an actual RSA public key. An unspecified ECC identity is not replaced
simply because its key size is below an RSA threshold. |
`SilentCertificateCheckPreservesUnspecifiedEccIdentityAsync` checks DER,
thumbprint, store contents, and private-key retention; explicit RSA and
HTTPS minimum controls remain. |
| Failed directory refresh wedges corrective mutations | **Fixed.**
Recoverable automatic pre-refresh failures are logged and leave
reconciliation pending, but do not block corrective provider mutations.
Explicit refresh errors, capacity admission, caller cancellation, and
actual provider denials remain enforced. |
`CorrectiveDeleteRecoversAfterPersistentOverLimitRefreshFailureAsync`
converges an externally over-limit directory back to its exact allowed
contents. Capacity, cancellation/token, and six refresh-error controls
check negative paths. |
| Timed-transition rejection logs at 10 Hz | **Fixed.** Repeated
`(state, revision, status)` warnings are deduplicated without changing
the retry cadence. A different status or state entry permits another
warning. | `RepeatedTimedRejectionLogsOnceAndRecoversAsync` checks one
warning over eleven rejections and success on the next attempt.
Status-change and same/different-state reentry controls pin reset
behavior. |
| Bad unsubscribe strands retired-generation bookkeeping | **Fixed.**
Retired all-event bookkeeping completes even when unsubscribe reports
Bad; the first Bad is still returned to the caller. | Good and
BadCommunicationError cases of
`RetiredAllEventsDeletionCompletesBookkeepingAndReleasesGenerationAsync`
check event/snapshot removal, generation release, and exactly one
unsubscribe without finalization replay. |
| CSR subject parsing and fallback | **Fixed for existing and
regenerated keys.** Omitted subjects fall back to the actual stored
certificate. Regeneration resolves the public certificate's subject
before creating a key. Malformed or unresolvable subjects return
`BadInvalidArgument` before pending-key mutation. | Store-only fallback,
real-generator null/empty cases, and missing-subject/malformed-DN
controls verify the signed CSR, matching fresh pending key, unchanged
active identity, and no destructive side effects. |
| Committed RemoveUser change reported as failed | **Fixed for
RemoveUser and ModifyUser.** Accepted cleanup is still awaited and close
failures remain logged, but cannot replace the committed mutation
result. Precommit failures retain their original result and side
effects. |
`CommittedUserChangeKeepsResultAndPublishedUsersWhenCloseFailsAsync`
checks published Users, Good result, logging, and continued closes.
`FailedUserChangeKeepsPublishedUsersAndDoesNotCloseSessionsAsync` checks
BadNotFound with no publication or close. |
| Alleged pooled decoded-request aliasing | **No runtime redesign
needed; reviewer rebuttal confirmed.** The decoder copies the relevant
input data. Misleading ownership comments were corrected rather than
introducing a new dispatch model. |
`DecodedRequestRetainsValuesAfterInputBuffersAreReturnedAsync` checks
the exact decoded NodeId and ByteString after returned arrays are
poisoned. |

The current documentation-only follow-up rechecked **104 Core, 473
Server, and
4 Configuration cases** covering these concerns: **581 passed, zero
failed or
skipped**. The final net10 solution build also passed with **zero
warnings and
errors**. The complete earlier Core/Server/Configuration and net48
results remain
listed separately rather than being presented as rerun current-head
acceptance.

## Earlier review follow-up

Commit `b8a59990fe2e8e967a12ffd5d9158beed7831143` addresses all 19
review
threads. Each thread has an in-thread reply naming this commit and was
explicitly
resolved after verification; the final paginated query found 19 resolved
and
zero unresolved threads.

The follow-up fixes injected certificate-store selection (including
default
provider ordering and native X509 paths), actual-RSA minimum
enforcement, and
CSR capability/subject validation before key mutation. Node-manager
teardown
uses simpler scoped ownership and drains accepted user revocation
outside the
master gate; committed user results and retired-event cleanup remain
correct
when cleanup reports failure. File mutations can recover after failed
refresh
without bypassing capacity or cancellation, provider paths protect mount
roots,
and repeated timed rejections no longer flood logs. Leadership
subscriber
failures are isolated without callbacks from `IsLeader`.

Regressions also pin the already-merged OPN/discovery buffer fixes and
prove
decoded request values do not borrow returned input buffers. The strict
inline
issuer revocation policy is documented and tested, not weakened.
Existing tests
were changed only for the reviewed behavior: the incorrect
committed-user result
was corrected, and inline-issuer/root-alias controls were expanded.

### Review-fix validation

| Suite | Scope | Framework | Passed | Existing TRX NotExecuted |
| --- | --- | --- | ---: | ---: |
| Core | Complete project | net10.0 | 4,970 | 88 |
| Server | Complete project | net10.0 | 6,414 | 69 |
| Configuration | Complete project | net10.0 | 231 | 0 |
| Core | Certificate, lease and buffer compatibility slice | net48 | 153
| 0 |
| Server | Reviewed lifecycle, user, file, timer and CSR compatibility
slice | net48 | 692 | 0 |
| Configuration | Complete project | net48 | 231 | 0 |
| CSR provider regressions | Final portable-mock follow-up | net10.0 |
11 | 0 |

All runs exited successfully with normal concurrency and leak detection.
The Server console reports 5 skips; its TRX additionally records
existing
assumption/explicit cases, hence the 69-record count above. The
Framework build
caught two modern-only `ValueTask.CompletedTask` usages in new mock
setup; only
those expressions changed to the equivalent portable
`default(ValueTask)`.
The complete modern suites precede that test-only correction; all
production
files remained byte-identical, and the corrected fixture passed on both
targets.

The final net10 solution build succeeded with 0 errors and the same 4
CA2249
warnings in unchanged fuzz-test code. Diff checks and changed-line
whitespace
checks are clean; whole-file formatting still identifies 10 unchanged
baseline
lines, which were not reformatted outside the feedback scope. The
committed
35-file tree was hash-verified against the validation inputs.

This is the review-feedback verification scope, not a rerun or sign-off
of the
historical complete platform/native/soak acceptance campaign below.


## Latest upstream merge

Merge `9d9bf679f317164c5b2b90924cd3cdc27a86d451` integrates `master`
through `162fc5ebb` (#4486),
including the AddNodes child-name index, shared certificate-error
reporting,
CTT sample-data corrections, and the accompanying regressions and
documentation.

The CreateSession conflict preserves this branch's certificate
ownership,
required certificate, application-URI, and channel-certificate checks.
The
validation result now retains nested errors through `ThrowIfInvalid`,
allowing
the upstream shared reporting helper to mask trust, policy, and
revocation
failures consistently without losing audit detail.

The new indexed AddNodes regression initially attempted to reuse a
deterministic
NodeId after renaming its existing node. Renaming frees the BrowseName,
not that
NodeId. `IndexedBrowseNameReusePreservesExistingNodeIdentityAsync` now
proves
both contracts: the derived-ID collision leaves the original node
intact, then
a fresh explicitly requested ID can reuse the old name while the new
name
remains protected against duplicates. No production identity rule was
relaxed.
The incoming seeded random-index test uses the existing `UnsecureRandom`
wrapper
with the same seed and sequence; no new analyzer suppression was added.

| Local merge validation | Framework | Passed | Existing exclusions |
| --- | --- | ---: | ---: |
| Core, complete project | net10.0 | 5,081 | 88 |
| NodeState / BaseInstanceState | net10.0 | 579 | 0 |
| Server node management, certificate and session-security selection |
net10.0 | 112 | 0 |
| InformationModel node-management services | net10.0 | 29 | 1 |
| NodeState / BaseInstanceState | net48 | 578 | 0 |
| Core certificate validation, error reporting and TCP buffers | net48 |
111 | 0 |
| Server node management, certificate and session-security selection |
net48 | 112 | 0 |

All selected runs exited successfully at normal concurrency: **6,602
passing
test executions**. Modified test assemblies were explicitly rebuilt, and
the
combined name/identity regression was verified by name in both net10 and
net48
results. No skips or weakened assertions were introduced.

The final `dotnet build UA.slnx -c Release --no-restore -m:1` with
`CustomTestTarget=net10.0` succeeded with **zero warnings and zero
errors**.
The resolved source and adjusted tests pass whitespace verification, and
their
source hashes match the validation inputs.

This is merge-specific verification. Earlier review results and the
previously
reported incomplete full Subscriptions runs below remain scoped to their
own
commits; they were not reopened or relabeled as passing by this
operation.

## Previous upstream merge (2026-09-15 morning)

Merge `c4694e20ab089c555af233642042133ff3a03ac8` integrates `master`
through `484d6c887`, retaining
#4481's shared Like parser/GDS query fixes, #4488's RequestHandle
recovery in
ServiceFaults, and #4487's expired-session and durable-transfer
corrections.

Three conflict resolutions preserve both sides:

- Alias searches now use the shared `LikePattern` implementation,
including
upstream malformed-list validation. A timeout overload retains this
branch's
100 ms per-alias deadline and `BadTimeout` reporting without keeping a
second
wildcard translator. The shared matcher's original unlimited overload
remains
unchanged for existing GDS/ContentFilter callers. New regressions cover
bounded prefix/list scans, reuse after timeout, and the alias service
result.
- Expired activation closes outside the session-manager gate using
upstream's
once-only timeout claim, audit, and diagnostic accounting. This branch's
  original-channel and security-policy checks remain in place.
- TCP buffer tests retain this branch's ownership, poisoning-pool and
post-decryption checks alongside the upstream RequestHandle/fault
overload
  tests; neither set was discarded.

The upstream HTTPS binary behavior is intentionally retained: a decoder
`ServiceResultException` produces HTTP 200 with an OPC UA ServiceFault
carrying
the decoder status, response timestamp, and recoverable RequestHandle,
rather
than the previous HTTP 500 text response. Other exceptions retain their
existing HTTP error handling.

| Local validation | Framework | Passed | TRX NotExecuted |
| --- | --- | ---: | ---: |
| Core, complete project | net10.0 | 5,054 | 88 |
| Sessions, complete project | net10.0 | 780 | 610 |
| Server aliases, expiry/security and subscriptions | net10.0 | 288 | 0
|
| GDS database, paging and live directory calls | net10.0 | 263 | 0 |
| Shared Like patterns and bounded evaluation | net10.0 | 109 | 0 |
| Subscriptions transfer/durability/service selection | net10.0 | 23 | 1
|
| HTTPS WebApi, complete project | net10.0 | 346 | 0 |
| InformationModel aliases | net10.0 | 54 | 5 |
| Shared Like patterns and bounded evaluation | net48 | 109 | 0 |
| Core fault handles, TCP/HTTPS and filters | net48 | 268 | 0 |
| Server aliases, expiry/security and subscriptions | net48 | 288 | 0 |

These selected runs exited successfully with normal test concurrency:
**7,582 passing test executions**. Sessions' console reports 379 skips;
the
TRX also contains conditional/assumption exclusions, so the table uses
the
actual 610 NotExecuted records. No skips or weakened assertions were
added.
The new `EvaluationTimeoutBoundsWorkAndPreservesPatternReuse` and
`SharedPatternTimeoutRemainsAnAliasServiceError` tests passed on both
targets.

**Validation limitation:** two unfiltered Subscriptions runs did not
produce
a final result and were stopped, including a retry with a hang guard.
They
are not counted as passing. The isolated failover fixture passed 2/2,
and the
transfer/durability/service selection above passed. The stall's cause
was not
established; no unrelated code or test was changed to conceal it.

The final `dotnet build UA.slnx -c Release --no-restore -m:1` with
`CustomTestTarget=net10.0` succeeded with **0 warnings and 0 errors**.
Whitespace verification passed for all hand-reconciled files, and staged
source hashes match the validation inputs.

These are merge-specific checks, not a new full platform/native/soak
acceptance
sign-off. Earlier review and acceptance evidence below remains dated to
its
own commit.

## Previous upstream merge (2026-09-14)

Merge `c3e09553e4d1509502d4ba751960d58491bea2cf` integrates `master`
through `6313e38a0` (#4478),
including the CTT-driven Part 13 aggregate corrections and FindAlias
argument
validation. The upstream tests and CTT documentation are retained.

The single conflict in `AliasNameWildcardMatcher` is reconciled through
the
branch's shared regex compiler: upstream malformed-pattern rejection and
`IsValidPattern` coexist with OPC UA character escaping,
whole-string/newline
matching, the 100 ms timeout, and service-level error reporting. The
upstream
ReferenceTypeFilter checks and chronological aggregate behavior are
preserved.

Local merge verification:

| Suite / selection | Framework | Passed | Existing skips |
| --- | --- | ---: | ---: |
| Server aliases, aggregates and historian | net10.0 | 1,011 | 0 |
| Server aliases, aggregates and historian | net48 | 1,011 | 0 |
| InformationModel aliases | net10.0 | 54 | 5 |
| Features aliases | net10.0 | 11 | 0 |
| Client aliases | net10.0 | 28 | 0 |
| History aggregate/historical-access consumers | net10.0 | 265 | 0 |

All selected tests passed at normal concurrency. The final net10
solution
build passed with 0 errors and 0 warnings.
The resolved file's whitespace check and the staged diff check passed.
These
are merge-specific checks, not a new full platform/native/soak
acceptance claim.

## Previous upstream merges

Integrates `master` through `8e874d073`, including the Client, Types,
Core and
source-generator audit fixes in #4471, #4473, #4474 and #4475, plus the
event queue-size revision from #4480. The merge commits are `150ea0439`
(26 conflict resolutions) and `a0e17c07c` (the base update that arrived
during
validation).

The overlapping fixes are reconciled rather than replacing either
branch:

- **Rejected-certificate retention follows this branch, as explicitly
requested:**
zero means unlimited history; negative values disable new storage while
preserving existing backend trimming behavior (Directory clears prior
history;
shared key/value leaves it intact). The processor and manager policy are
not
  redefined, and documentation/tests describe the distinction.
- Preserve owned certificate snapshots and borrowed validation-core
lifetimes
  alongside upstream cache invalidation, store-first CRL checks and
  `BadCertificateRevocationUnknown` for unsupported revocation lookup.
- Combine exactly-once chunk ownership, receive-loop cancellation,
stale-epoch
rejection and detach-before-adoption with upstream
signature-before-padding
  verification and secret cleanup.
- Preserve pooled `HttpClient` per-request configuration together with
bounded
response reads and deadlines that remain active through body
consumption.
Regression cases exercise already-used clients and both timeout
precedence cases.
- Retain the binary-compatible channel-key constructor and normalize
omitted
profiles; preserve exact profile filtering and upstream binary-first
HTTPS/WSS
  endpoint ordering.
- Preserve monotonic lease expiry and stale-confirmation fencing while
adding
ordered, non-reentrant leadership notifications outside the state lock.

## Validation

Local validation of the conflict-resolution merge:

| Suite | Framework | Passed | Existing skips |
| --- | --- | ---: | ---: |
| Core | net10.0 | 4,940 | 88 |
| Client | net10.0 | 2,278 | 0 |
| Server | net10.0 | 6,088 | 5 |
| Types | net10.0 | 10,394 | 1 |
| SourceGeneration.Core | net10.0 | 3,940 | 8 |
| SourceGeneration | net10.0 | 185 | 0 |
| Core | net48 | 4,855 | 104 |

After the subsequent four-file #4480 update, the related Server slice
passed
811 tests with 5 existing skips, the real durable queue-size cases
passed 6/6,
and EventManager's 21 cases passed again after analyzer-only
expected-array
cleanup. The final `dotnet build UA.slnx -c Release` with
`CustomTestTarget=net10.0`
succeeded: 0 errors and 4 pre-existing CA2249 warnings in unchanged
fuzz-test
code. Whitespace verification passed for the resolved files and the
follow-up.

Merge-specific proof includes
`RequestTimeoutRemainsActiveWhileReadingTheOpenApiBodyAsync` on
already-used
shared clients with either the request or HttpClient timeout shorter,
the
existing ownership/expiry regressions from both branches, and both
branches'
retention tests adjusted only to the explicitly chosen zero/negative
contract.

Earlier acceptance at `1c4a9df6c` passed hosted Linux/macOS CI, CodeQL,
image
builds and the canonical coverage gate (87.15% line, 77.27% branch,
92.14% patch).
Both Framework project matrices, all three Windows native suites (183
cases),
and the unfiltered net10 stress project (230 cases, including full
60-minute
chaos and 30-minute memory soaks) also passed. Those are **pre-merge
results**,
not a claim that the full framework/platform/native/soak matrix has been
rerun
on this merge. The previous acceptance workflow remains separate from
this
review-feedback operation.

Security checks, meaningful assertions, leak detection, default native
concurrency, full soak durations and coverage thresholds remain intact.
The user-set ready-for-review state is unchanged. No architectural or
complete
merged-head acceptance sign-off is claimed.

## Related Issues

- Fixes #4476

## Checklist

- [ ] 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.
- [ ] 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.
- [x] I have addressed **all** PR feedback received.
2026-09-16 04:42:09 +00:00

95 KiB
Raw Blame History

OPC UA Robotics

The Robotics libraries implement the OPC UA Robotics companion specification (OPC 40010-1, version 1.02). Robotics builds on Industrial Automation (OPC 10000-200), which in turn builds on Device Integration (OPC 10000-100); all three models are source-generated from their released NodeSets and loaded in dependency order.

Status: draft companion model. The namespace http://opcfoundation.org/UA/RobotIntent/ and every NodeId in it are provisional. This implements the working-group draft OPC UA — Robot Intent. Do not deploy it on a production robot and expect the identifiers to survive.

OPC 40010 describes a robot in detail — its motion device system, its axes, its power trains, its controller, its safety states — and defines no motion verbs at all. Its whole actuation surface is Start, Stop and loading a named program. A conformant client can discover everything about a robot's construction and cannot ask it to move anywhere.

Robot Intent supplies the verbs, and only the verbs, so the two compose rather than compete:

  • Opc.Ua.Robotics carries the source-generated model, the executor contracts, and the Annex C pose maths.
  • Opc.Ua.Robotics.Server carries the execution engine, the address space builders and the hosting integration.
  • Opc.Ua.Robotics.Client carries discovery, the awaitable operation handle, command authority, missions and the fluent intent builders.

The NodeSet declares exactly one RequiredModel — the base OPC UA namespace — so a server can adopt Robot Intent without pulling in OPC 40010, OPC 10000-100 DI, or anything else.

Packages

Package Purpose
OPCFoundation.NetStandard.Opc.Ua.Robotics Source-generated OPC 40010/IA and draft Robot Intent models, generated NodeIds/DataTypes/ObjectType clients, ArrayOf<T>-based common contracts shared by client and server, the IIntentExecutor contract, IntentExecution, IIntentProgress, IntentOutcome, PoseMath and FrameTree.
OPCFoundation.NetStandard.Opc.Ua.Robotics.Server Stock Robotics node manager, Robot Intent node manager, model providers, hosting extensions (AddRobotics, AddRobotIntent, ConfigureRobotics, ConfigureRobotIntent), validated fluent topology builders, IntentControllerHost, safety binding, real-time channel declarations and facet calculation.
OPCFoundation.NetStandard.Opc.Ua.Robotics.Client Continuation-safe, subtype-aware discovery of Robotics instances over the DI client, Robotics type classification, Robot Intent discovery, the awaitable operation handle, command authority, real-time-channel leases, missions and RobotIntentBuilder.
graph TD
    Di["Opc.Ua.Di<br/>Device Integration base model"]
    Model["Opc.Ua.Robotics<br/>OPC 40010 + Robot Intent model"]
    Server["Opc.Ua.Robotics.Server<br/>node managers + builders + IntentControllerHost"]
    Client["Opc.Ua.Robotics.Client<br/>discovery + RobotIntentClient"]
    Mcp["Opc.Ua.Mcp.Robotics<br/>agent tools"]
    Executor["IIntentExecutor"]
    Safety["IRobotIntentSafetySource"]

    Di --> Model
    Model --> Server
    Model --> Client
    Client --> Mcp
    Executor -.->|"executes admitted intents"| Server
    Safety -.->|"guards admission"| Server

Generated OPC 40010 model types stay in the specification namespaces Opc.Ua.Robotics and Opc.Ua.IA; hand-written APIs compose the generated NodeStates, factories, enums, and ObjectType clients instead of replacing or inheriting from them.

The generated Opc.Ua.Robotics.Namespaces and Opc.Ua.IA.Namespaces classes expose the model namespace URIs, and RoboticsModel adds namespace-safe resolution and classification helpers over the generated ObjectTypeIds / ReferenceTypeIds classes, which remain the source of truth.

The generated Robot Intent types live in Opc.Ua.RobotIntent. The hand-written server APIs live in Opc.Ua.Robotics.Server and Opc.Ua.Robotics.Server.Builders; the hand-written client APIs live in Opc.Ua.Robotics.Client.Intent.

The Robot Intent client is also exposed as an MCP tool package for agent hosts: OPCFoundation.NetStandard.Opc.Ua.Mcp.Robotics registers discovery, monitoring, direct-control and mission tools over an already connected OPC UA Session. The tools are an adapter over RobotIntentClient and RobotIntentControllerClient; they do not implement an independent robot state machine.

Minimal hosted server

AddRobotics() registers the stock RoboticsNodeManager, the built-in DI/IA/ Robotics model provider, and the Robotics configuration pipeline. ConfigureRobotics(...) runs after the models are loaded and builds instances:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Opc.Ua.Robotics.Server;
using Opc.Ua.Robotics.Server.Builders;

HostApplicationBuilder host = Host.CreateApplicationBuilder(args);

host.Services
    .AddOpcUa()
    .AddServer(options =>
    {
        options.ApplicationName = "RobotCellServer";
        options.EndpointUrls.Add("opc.tcp://localhost:62830/RobotCellServer");
    })
    .AddRobotics(options =>
        options.InstanceNamespaceUri = "urn:example:robot-cell")
    .ConfigureRobotics(async context =>
    {
        await context.AddMotionDeviceSystemAsync("RobotCell", system =>
        {
            ISafetyStateBuilder safety = system.AddSafetyState("Safety");

            IMotionDeviceBuilder robot = system.AddMotionDevice("R1", device =>
                device.WithMotionDeviceCategory(
                    MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT));

            IDriveBuilder drive = robot.AddDrive(
                "Drive1",
                item => item.WithProductCode("DRV-1"));
            IPowerTrainBuilder train = robot.AddPowerTrain("PowerTrain1");
            IAxisBuilder axis = robot.AddAxis("A1", item => item
                .WithMotionProfile(AxisMotionProfileEnumeration.ROTARY)
                .WithActualPosition(0));

            train.AddMotor("Motor1", motor => motor.IsDrivenBy(drive));
            train.Moves(axis);
            axis.Requires(train);

            IControllerBuilder controller = system.AddController("Controller1");
            controller.AddSoftware("Runtime", software =>
                software.WithIdentification(data =>
                {
                    data.Manufacturer = new LocalizedText("Vendor");
                    data.Model = new LocalizedText("Robot Runtime");
                    data.SoftwareRevision = "1.0";
                }));
            ITaskControlBuilder taskControl = controller.AddTaskControl("Main");

            controller.Controls(robot).UsesSafetyState(safety);
            taskControl.Controls(robot);
        }, context.CancellationToken);
    });

await host.Build().RunAsync();

AddRobotics() owns the DI namespace and therefore cannot be combined with AddOpcUaDi(); both register the shared DiAddressSpaceOwnership marker and the second call throws with the name of the conflicting extension.

The two address-space shapes are separate but composable. OPC 40010 Robotics instances live below the DI DeviceSet; Robot Intent lives below Server/RobotIntent and exposes the command surface a client or MCP agent uses.

flowchart TD
    Objects["Objects"] --> DeviceSet["DeviceSet<br/>OPC 10000-100 DI"]
    DeviceSet --> Mds["MotionDeviceSystemType"]
    Mds --> C40010["Controllers"]
    Mds --> Motion["MotionDevices"]
    Mds --> Safety40010["SafetyStates"]
    Motion --> Axes40010["Axes"]
    Motion --> Power["PowerTrains"]

    ServerObj["Server"] --> Root["RobotIntent<br/>RobotIntentRootType"]
    Root --> IntentControllers["Controllers"]
    IntentControllers --> Controller["IntentControllerType"]
    Controller --> Frames["Frames"]
    Controller --> Tools["Tools"]
    Controller --> Locations["Locations"]
    Controller --> Axes["Axes"]
    Controller --> Intents["Intents<br/>IntentOperationType instances"]
    Controller --> Missions["Missions<br/>MissionType instances"]
    Controller --> Channels["RealTimeChannels"]
    Controller --> Capabilities["Capabilities<br/>SupportedIntents + facets"]
    Frames -.->|"HasFrameParent tree"| Frames
    Controller -.->|"optional HasIntentController"| Mds

Hosting API

Method Builder Purpose
AddRobotics(Action<RoboticsServerOptions>?) IOpcUaServerBuilder Registers the stock manager, built-in model provider, and configuration pipeline.
AddRoboticsModel<TProvider>() IOpcUaServerBuilder Adds an IRoboticsModelProvider that contributes further compiled models.
ConfigureRobotics(Action<IRoboticsBuildContext>) IOpcUaServerBuilder Synchronous configurator for the stock manager.
ConfigureRobotics(Func<IRoboticsBuildContext, ValueTask>) IOpcUaServerBuilder Asynchronous configurator.
ConfigureRobotics(Func<IRoboticsBuildContext, CancellationToken, ValueTask>) IOpcUaServerBuilder Asynchronous configurator with the hosting token.
ConfigureRobotics<TConfigurator>() IOpcUaServerBuilder Class-based, dependency-injected IRoboticsConfigurator.
ConfigureRoboticsFor<TNodeManager>(…) IOpcUaServerBuilder The same three lambda overloads, targeting an application-owned DiNodeManager.
ConfigureRoboticsFor<TNodeManager, TConfigurator>() IOpcUaServerBuilder Class-based configurator for an application-owned manager.

Configurators registered for the same node manager run in registration order and share one build context; the hosting pipeline calls Seal() once they have all completed. Every overload is additive, so a composition root can mix lambdas and classes.

RoboticsServerOptions.InstanceNamespaceUri selects the application-owned namespace used for dynamically created instances. It defaults to urn:opcua-netstandard:robotics:instances and is validated to be an absolute URI that is not the OPC UA, DI, IA, or Robotics model namespace.

Class-based code-behind

A class configurator is resolved from the container, so drivers, telemetry, file-system providers, and authorization services can be injected:

public sealed class RobotCell(IRobotBackend backend) : IRoboticsConfigurator
{
    public ValueTask ConfigureAsync(
        IRoboticsBuildContext context,
        CancellationToken cancellationToken)
    {
        return context.AddMotionDeviceSystemAsync("RobotCell", system =>
        {
            IMotionDeviceBuilder robot = system.AddMotionDevice("R1");
            robot.AddAxis("A1", axis => axis
                .WithMotionProfile(AxisMotionProfileEnumeration.ROTARY)
                .BindActualPosition(backend.ReadJoint1Async));
            // …
        }, cancellationToken);
    }
}

// builder.ConfigureRobotics<RobotCell>();

Model providers

IRoboticsModelProvider composes additional compiled models before instance configuration, which keeps optional models (for example the draft OpenUSD binding) out of Opc.Ua.Robotics.Server:

public sealed class OpenUsdModelProvider : IRoboticsModelProvider
{
    public int Order => 0;

    public ArrayOf<string> NamespaceUris => new[] { Opc.Ua.OpenUsd.Namespaces.OpenUSD };

    public void AddPredefinedNodes(NodeStateCollection nodes, ISystemContext context)
        => nodes.AddOpcUaOpenUsd(context);
}

// builder.AddRoboticsModel<OpenUsdModelProvider>();

Providers run in ascending Order. The built-in DI/IA/Robotics provider uses int.MinValue, so application providers run afterwards by default. A provider that replaces the built-in core provider must advertise both the IA and Robotics namespace URIs.

Build context

IRoboticsBuildContext is created once per manager startup and shared by every configurator of that manager:

Member Purpose
Manager The active DiNodeManager.
Context The active ISystemContext.
Nodes The single fluent INodeManagerBuilder owned by the context.
InstanceNamespaceIndex The resolved application-owned namespace index.
DeviceSet The DI DeviceSet node that Robotics systems are added below.
CancellationToken The hosting cancellation token.
GetRequiredService<T>() Narrow service resolution for code-behind.
Seal() Seals the fluent builder and starts configured simulations. Called by the hosting pipeline.

AddMotionDeviceSystemAsync(browseName, configure, cancellationToken) is the single entry point for topology. It configures the tree, validates it, verifies that every instance NodeId is unique, adds the semantic references, and then registers the completed generated state tree — rolling back the reservation if any step fails. Reservations for NodeIds and root BrowseNames are held per node manager, so concurrent configurators cannot collide.

Topology builders

Every builder implements IRoboticsNodeBuilder<TState>, which exposes the generated State, the owning BuildContext, a low-level Configure(Action<TState, ISystemContext>) escape hatch, and AsNode() for the fluent INodeBuilder<TState> view after registration.

Builder Adds
IMotionDeviceSystemBuilder AddController, AddMotionDevice, AddSafetyState.
IControllerBuilder AddSoftware, AddTaskControl, AddSystemOperation, AddPrograms, WithCurrentUser, AddAuxiliaryComponent, AddDrive, Controls, UsesSafetyState.
ISystemOperationBuilder WithInitialState, OnGetReady, OnStart, OnStop, OnStandDown, WithStopModes, OnTransition.
IProgramsBuilder UseFileSystem(provider) / UseFileSystem<TProvider>(), WithOptions.
IRoboticsUserBuilder The mandatory Controller CurrentUser child.
IRoboticsSoftwareBuilder Software identification (manufacturer, model, revision).
IMotionDeviceBuilder AddAxis, AddPowerTrain, AddDrive, AddAuxiliaryComponent, WithFlangeLoad, WithMotionDeviceCategory, speed-override binding, UsesTaskControl.
IAxisBuilder WithMotionProfile, AsVirtual, actual position/speed/acceleration, WithAdditionalLoad, Requires.
IPowerTrainBuilder AddMotor, AddGear, Moves, HasSlave.
IMotorBuilder Identification, motor temperature, brake-released, effective load rate, IsDrivenBy.
IGearBuilder Identification, WithGearRatio(int numerator, uint denominator), WithPitch.
IDriveBuilder / IAuxiliaryComponentBuilder Product code, asset id, component name.
ILoadBuilder WithMass, WithCenterOfMass, WithInertia.
ISafetyStateBuilder AddEmergencyStop, AddProtectiveStop, emergency-stop / operational-mode / protective-stop values and bindings.
ITaskControlBuilder AddTaskModule, AddTaskControlOperation, execution mode, task-program name and loaded flag, Controls.
ITaskControlOperationBuilder OnStart, OnStop, OnLoadByName, OnLoadByNodeId, OnUnloadByName, OnUnloadByNodeId, OnUnloadProgram, OnResetToProgramStart, WithMotionDevicesUnderControl.
ITaskModuleBuilder WithName, WithVersion, WithIsReferenced.

Instances are always materialised through the generated ISystemContext.CreateInstanceOf<Type> factories (for example CreateInstanceOfMotionDeviceSystemType), so each instance carries the full companion-type structure and its per-instance NodeIds, rather than only a type-definition reference on a bare BaseObjectState. Registration completes the node create lifecycle before the subtree enters the address space.

Binding live values

WithXxx seeds a static value; BindXxx attaches an asynchronous read (and, where the variable is writable, a write) handler:

robot.AddAxis("A1", axis => axis
    .WithMotionProfile(AxisMotionProfileEnumeration.ROTARY)
    .BindActualPosition(backend.ReadJoint1Async)
    .BindActualSpeed(backend.ReadJoint1SpeedAsync));

robot.BindSpeedOverride(
    backend.ReadSpeedOverrideAsync,
    backend.WriteSpeedOverrideAsync);

Read handlers are Func<CancellationToken, ValueTask<DataValue>>, so quality and timestamps stay under application control. SpeedOverride writes are validated to be finite and within 0–100 for every non-Bad status code.

Semantic references

The builders add the Robotics reference types with the correct forward/inverse semantics:

Call Reference
controller.Controls(motionDevice) Controls (optional).
controller.UsesSafetyState(safetyState) HasSafetyStates.
taskControl.Controls(motionDevice) Controls.
powerTrain.Moves(axis) Moves.
axis.Requires(powerTrain) Requires.
powerTrain.HasSlave(other) HasSlave, for non-1:1 kinematics.
motor.IsDrivenBy(drive) IsDrivenBy (optional).
builder.IsConnectedTo(other) IsConnectedTo, between any two Robotics nodes.

ITaskControlBuilder.Controls and IMotionDeviceBuilder.UsesTaskControl add the standard Controls relation. When the task control also has a TaskControlOperation (via AddTaskControlOperation), the motion device's TaskControlReference property is populated to point at that operation node.

Standard operations and programs

ControllerType.SystemOperation and TaskControlType.TaskControlOperation are optional facets carrying the two Part 16 state machines. The builders wire the methods to application handlers and move the machine only when a handler succeeds:

IControllerBuilder controller = system.AddController("Controller1");

controller.WithCurrentUser(user => user.WithName("operator"));

controller.AddSystemOperation(operation => operation
    .WithInitialState(RoboticsOperationState.Idle)
    .WithStopModes([RoboticsStopMode.Normal, RoboticsStopMode.Emergency],
        RoboticsStopMode.Normal)
    .OnGetReady((context, ct) => backend.GetReadyAsync(ct))
    .OnStart((context, ct) => backend.StartAsync(ct))
    .OnStop((request, ct) => backend.StopAsync(request.StopMode, ct))
    .OnStandDown((context, ct) => backend.StandDownAsync(ct)));

controller.AddPrograms(programs => programs
    .UseFileSystem<IRobotProgramStore>()
    .WithOptions(o => o.AllowDelete = false));

ITaskControlBuilder task = controller.AddTaskControl("Main");
task.AddTaskControlOperation(operation => operation
    .OnLoadByName((name, ct) => backend.LoadProgramAsync(name, ct))
    .OnStart((context, ct) => backend.RunAsync(ct))
    .OnStop((request, ct) => backend.HaltAsync(request.StopMode, ct))
    .WithMotionDevicesUnderControl([robot.State.NodeId]));

Causes follow the spec transitions: GetReady Idle→Ready, Start Ready→Executing, Stop Executing→Ready, StandDown and the unload verbs Ready→Idle, the load verbs Idle→Ready. A cause that is illegal from the current state returns BadInvalidState without invoking the handler. LastTransition, LastTransitionReason, PossibleStopModes, and ConfiguredDefaultStopMode are maintained automatically.

AddPrograms binds the optional Programs FileDirectoryType to the stack's existing IFileSystemProvider model through the shared Opc.Ua.Server.FileSystem.IFileDirectoryBinder, so any node manager — not just the dedicated file-system manager — can serve a Part 5 directory. Binding runs after the Robotics tree is registered, and is disposed if the build rolls back.

Validation

Registration fails with a ServiceResultException (BadConfigurationError) that reports every problem at once when the configured topology violates the companion specification:

  • a motion-device system without at least one controller, one motion device, and one safety state;
  • a controller without at least one SoftwareType and one TaskControlType instance — both are mandatory placeholders;
  • a controller without its mandatory CurrentUser child;
  • a motion device without at least one axis and one power train;
  • a non-virtual axis without a Requires link — mark an axis with AsVirtual() when it has no power train;
  • a power train without a motor, or a gear with a zero ratio denominator;
  • an emergency stop, protective stop, or task module without a Name.

Registration additionally rejects a descendant whose NodeId is null, outside the instance namespace, duplicated within the tree, or already indexed by the node manager. Duplicate BrowseNames raise BadBrowseNameDuplicated, both for siblings under one parent and for a MotionDeviceSystem BrowseName already reserved on the same node manager.

Because validation runs before registration, a failed build leaves no partial subtree in the address space.

Units follow the specification: a gear ratio numerator is a signed int and the denominator is a uint; WithPitch is millimetres of linear travel per output-side revolution and is a BaseDataVariableType without EngineeringUnits.

Custom node managers and non-DI hosting

The stock RoboticsNodeManager owns the DI address space and exactly one application instance namespace. Use it — through AddRobotics() — whenever Robotics is the only companion model the server adds. Reach for a custom node manager instead when any of the following applies:

  • the server composes additional models into the same manager (for example the OpenUSD binding, or RSL/GPOS positioning, alongside Robotics), so a single LoadPredefinedNodesAsync must return all of them in dependency order;
  • the server needs its own INodeIdFactory scheme (deterministic string NodeIds, an external asset registry, a sharded allocator);
  • the server already owns a DiNodeManager for its device model and Robotics is one facet of it.

An application that already owns a DiNodeManager keeps it and still gets the validated fluent builders:

builder.ConfigureRoboticsFor<MyDeviceNodeManager>(async context =>
{
    await context.AddMotionDeviceSystemAsync("RobotCell", system => { /* … */ },
        context.CancellationToken);
});

Outside the hosting pipeline entirely, load the models and create the context directly:

// Inside a DiNodeManager: load DI + IA + Robotics in dependency order.
predefinedNodes.AddRoboticsTypeSystem(context);

// After the models are loaded:
IRoboticsBuildContext buildContext =
    manager.CreateRoboticsBuildContext(new RoboticsServerOptions
    {
        InstanceNamespaceUri = "urn:example:robot-cell"
    });

CreateRoboticsBuildContext validates the DeviceSet, the loaded Robotics model, and the configured instance namespace before returning. The manager's Context.NodeIdFactory must implement IRoboticsNodeIdFactory; the allocator must be thread-safe, must reserve unique NodeIds for unregistered nodes, and must allocate Robotics instances in the configured instance namespace.

MinimalRobotServer is the example of the custom-manager route: it composes Robotics, IA, DI, the draft OpenUSD binding, and RSL/GPOS in one DiNodeManager subclass.

Vendor extensions

The Robotics packages are a base for robot vendors, not a closed set. A vendor ships their own package that references Opc.Ua.Robotics (and Opc.Ua.Robotics.Server when it needs fluent accessors), adds its own NodeSet2.xml declaring ObjectTypes derived from the companion types (for example an AcmeMotionDeviceType under MotionDeviceType), and runs the model source generator with default options:

<AdditionalFiles Include="Model\Acme.Robots.NodeSet2.xml">
  <ModelSourceGeneratorPrefix>Acme.Robots</ModelSourceGeneratorPrefix>
</AdditionalFiles>

The ModelDependencyAttribute emitted by Opc.Ua.Robotics makes the generator resolve the base state types from that assembly instead of re-emitting them, so AcmeMotionDeviceState derives from the shipped MotionDeviceState. The ModelFluentAccessorProviderAttribute on Opc.Ua.Robotics.Server does the same for the fluent accessors: the vendor assembly emits accessors only for its new types and inherits the Robotics ones. See Model Dependencies.

On the client side every discovery and classification call is subtype aware, so vendor specialisations are found and labelled without any client change.

Layering

The three packages follow the same layering as the Device Integration and Positioning trios:

Package References Generation
Opc.Ua.Robotics Opc.Ua.Di Robotics + IA models with ModelSourceGeneratorOmitFluentApi=true
Opc.Ua.Robotics.Server Opc.Ua.Robotics, Opc.Ua.Di.Server, Opc.Ua.Server fluent accessors only, with ModelSourceGeneratorFluentAccessorsOnly=true
Opc.Ua.Robotics.Client Opc.Ua.Robotics, Opc.Ua.Di.Client, Opc.Ua.Client —

The Opc.Ua.Robotics.Operations contracts ship in the model package so a client and a server can share them without either taking a dependency on the other.

The model package stays free of any server dependency because the generated fluent-accessor method bodies call into the Opc.Ua.Server fluent builders. Emitting them from the model package would force Opc.Ua.Robotics — and therefore Opc.Ua.Robotics.Client — to reference Opc.Ua.Server. The accessors are therefore generated once, in the server package, against the state types the model package already ships.

Common contracts

Opc.Ua.Robotics ships immutable ArrayOf<T>-based records that project a robot cell without a client or server dependency: RoboticsTopologySnapshot, MotionDeviceSystemSnapshot, ControllerSnapshot, MotionDeviceSnapshot, AxisSnapshot, AxisStateSnapshot, AxisLimits, AxisEngineeringOptions, LoadSnapshot, PowerTrainSnapshot, MotorSnapshot, GearSnapshot, DriveSnapshot, SafetyStateSnapshot, SafetyFunctionSnapshot, TaskControlSnapshot, TaskModuleSnapshot, RoboticsComponentIdentification, and RoboticsEngineeringValue.

Containment identifiers stay on the owning instance, while RoboticsRelationshipSnapshot (built from RoboticsRelationshipEntry values) is the authoritative projection of the semantic Robotics references listed above.

Client

Opc.Ua.Robotics.Client extends the Device Integration client: Robotics types derive from IA, which derives from OPC 10000-100, so RoboticsClient composes DiTopologyClient rather than reimplementing device navigation. It lets a generic client — a viewer, an OpenUSD connector, or a fleet manager — find, label, and drive robot cells without hard-coded NodeIds.

Registration

services.AddOpcUa()
    .AddClient(options => { /* endpoint and application options */ })
    .AddRoboticsClient();

AddRoboticsClient() also calls AddOpcUaDi(), and registers a Func<CancellationToken, Task<RoboticsClient>> factory bound to the managed session. The direct constructor new RoboticsClient(session, telemetry) remains available as the non-DI fallback.

API

Member Purpose
RoboticsClient(ISession, ITelemetryContext) Creates the client over a connected session.
session.Robotics(telemetry) Extension shorthand for the constructor.
Session / Telemetry The session and telemetry context the client was created with.
Topology The DiTopologyClient this client extends — use it to walk the DI device topology (DeviceSetId, NetworkSetId, DeviceTopologyId).
DiscoverMotionDeviceSystemsAsync(ct) Discovers every MotionDeviceSystem below the DI DeviceSet.
DiscoverMotionDeviceSystemsAsync(root, ct) Same, below an explicit root (for example the Objects folder).
EnumerateMotionDeviceSystemsAsync(ct) Streams systems as they are discovered.
DiscoverMotionDevicesAsync(root, ct) Discovers MotionDevices, typically below a system's MotionDevices folder.
DiscoverControllersAsync(root, ct) Discovers Controllers, typically below a system's Controllers folder.
DiscoverAxesAsync(root, ct) Discovers Axes, typically below a motion device's Axes folder.
ReadSystemAsync(system, ct) Reads a complete RoboticsTopologySnapshot, including the semantic RoboticsRelationshipSnapshot.
ReadControllerAsync / ReadMotionDeviceAsync / ReadAxisAsync / ReadSafetyStateAsync / ReadTaskControlAsync Per-node typed snapshots.
SystemOperation(controller) The standard SystemOperation state-machine client.
TaskControl(taskControl) The standard TaskControl state-machine client.
ProgramsAsync(controller, ct) A FileSystemClient rooted at the Controller Programs directory.
OperationsAsync(motionDevice, ct) The non-normative operation convention client.
ObserveAxisAsync / ObserveSafetyAsync Streaming telemetry over the subscription API.
GetRoboticsTypeNameAsync(typeDefinition, ct) Classifies a TypeDefinition against the server's type hierarchy, so vendor subtypes resolve to their closest standard Robotics type. Returns null when the node is not a Robotics type.
RoboticsClient.DiscoverMotionDeviceSystemsAsync(session, root, ct) (static) Session-only discovery for callers that do not hold a client instance.
RoboticsClient.TryGetRoboticsTypeName(typeDefinition, namespaceUris, out name) (static) Offline exact-match classification with no server round-trip.

Every discovery method returns ArrayOf<NodeId> and uses ManagedBrowseAsync, so a server that caps references per node cannot silently truncate the result. When the server does not expose the Robotics namespace, discovery returns an empty ArrayOf<NodeId> and classification returns null / false instead of throwing.

Walking a cell

using Opc.Ua.Robotics.Client;

var robots = new RoboticsClient(session, telemetry);

foreach (NodeId system in await robots.DiscoverMotionDeviceSystemsAsync(ct))
{
    foreach (NodeId device in await robots.DiscoverMotionDevicesAsync(system, ct))
    {
        foreach (NodeId axis in await robots.DiscoverAxesAsync(device, ct))
        {
            // Read ParameterSet/ActualPosition, subscribe, drive a twin, …
        }
    }
}

Classifying a discovered node

// Exact match, no server round-trip — use when you already resolved the
// namespace table and only care about the standard types.
if (RoboticsClient.TryGetRoboticsTypeName(
        typeDefinition, session.NamespaceUris, out string? typeName))
{
    // typeName is MotionDeviceSystem, MotionDevice, Axis, or Controller.
}

// Subtype aware — an AcmeMotionDeviceType instance is reported as MotionDevice.
string? kind = await robots.GetRoboticsTypeNameAsync(typeDefinition, ct);

Invoking robot operations

The Robotics methods themselves (task-control and system state-machine operations) are exposed through the source-generated ObjectType clients in Opc.Ua.Robotics, which give a typed result carrying a ServiceResult plus the declared outputs — for example LoadByName(string name) returning a Status Int32. The generated state-machine identifiers live in SystemOperationStateMachineTypeIds and TaskControlStateMachineTypeIds.

A higher-level verb façade over these generated proxies is documented under Operation conventions below. The standard state machines are reached through SystemOperation(...) and TaskControl(...).

Standard operations

RoboticsClient exposes the two OPC 40010 state machines directly:

SystemOperationClient system = robots.SystemOperation(controllerNodeId);
await system.GetReadyAsync(ct);       // Idle    -> Ready
await system.StartAsync(ct);          // Ready   -> Executing
await system.StopAsync(RoboticsStopMode.Normal, ct);   // Executing -> Ready
await system.StandDownAsync(ct);      // Ready   -> Idle

RoboticsOperationState state = await system.ReadStateAsync(ct);
await foreach (RoboticsOperationState s in system.ObserveStateAsync(ct))
{
    // Idle / Ready / Executing
}
TaskControlClient task = robots.TaskControl(taskControlNodeId);
await task.LoadByNameAsync("weld-seam-3", ct);
await task.StartAsync(ct);
await task.StopAsync(RoboticsStopMode.Normal, ct);
await task.ResetToProgramStartAsync(ct);
await task.UnloadProgramAsync(ct);

A verb that is illegal from the current state is rejected with BadInvalidState before the server-side handler runs, and a handler that returns a bad ServiceResult leaves the state machine where it was.

Programs

When the Controller exposes the optional Programs directory, it is a standard Part 5 FileDirectoryType, so it is read and written with the ordinary file services:

FileSystemClient programs = await robots.ProgramsAsync(controllerNodeId, ct);
await foreach (var entry in programs.EnumerateAsync("/", ct))
{
    // program files exposed by the controller
}

Observing telemetry

await foreach (AxisStateSnapshot axis in robots.ObserveAxisAsync(axisNodeId, ct))
{
    // ActualPosition / ActualSpeed / ActualAcceleration as they change
}

Operation conventions

OPC 40010 defines no motion verbs. Its actuation surface is the two state machines above, plus the standard Programs directory when a controller exposes one. The former non-normative AddOperations / AddOperation builder API has been removed; use the Robotics state machines for device control and the Robot Intent model for task-level verbs such as move, grasp, release, process, mission, retry and real-time-channel flows.

For application-specific verbs that do not fit the standardized Robot Intent types, expose an application-owned information model and call it through normal OPC UA methods, or encode the behaviour as a program selected through TaskControl. Do not create custom Robotics children in the OPC UA, DI, IA, or Robotics namespaces.

Sample

MinimalRobotServer exposes a MotionDeviceSystem with two independently mobile 6-axis robots, a cell emergency stop, a speed-override command, and a runtime-mounted gripper. It combines Robotics with OPC 10000-210 RSL frames and OPC 10000-211 GPOS locations and binds the whole cell to an OpenUSD stage, so a generic connector renders it live with no robot-specific code.

URML primitive mapping

The URML robot-intent language names OPC UA Robotics as its canonical non-ROS substrate, and its Layer-2 v0.1.0 vocabulary of 27 primitives is the motivating use case for issue #3827. Every primitive has a route through this SDK:

URML primitive Route
move_to, grasp, release convention verbs MoveTo, Grasp, Release
pick_from, place_at, swap_tool (industrial profile) convention verbs PickFrom, PlaceAt, SwapTool
set_output convention verb SetOutput
call_program normative: TaskControl(...).LoadByNameAsync + StartAsync, with programs listed through ProgramsAsync(controller). CallProgram is the fallback.
measure ReadAxisAsync and the other snapshot readers
wait_for ObserveAxisAsync / ObserveSafetyAsync / ObserveStateAsync
report plain session write
wait client-side delay; no server surface needed
bimanual address the specific MotionDevice; DiscoverMotionDevicesAsync enumerates them
dock, hover, detect, scan, capture, speak, listen, take_off, land, return_to_home, plan_path, follow_trajectory, drive, turn application-owned information model methods, or programs selected through TaskControl

URML's Layer-1 capability manifest is derived live from ReadSystemAsync, rather than hand-declared: motion devices give arm count, AxisSnapshot.Limits and AxisEngineeringOptions give joint limits and velocities, MotionDeviceCategory gives mobility, SafetyStateSnapshot gives the safety envelope, ProgramsAsync gives the declared programs, and RoboticsRelationshipSnapshot gives the kinematic structure. Robot Intent types and application-owned methods are resolved from the live address space, so no per-deployment NodeId mapping file is required.

Robot Intent

Hosting a controller

The hosted path is the normal path for an application server. AddRobotIntent registers the standalone node manager, the draft model provider and a rejecting executor. AddRobotIntentExecutor<T> replaces that rejecting executor with the application implementation, and ConfigureRobotIntent runs after the model and the Server/RobotIntent/Controllers root exist:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Opc.Ua;
using Opc.Ua.RobotIntent;
using Opc.Ua.Robotics.Server;
using Opc.Ua.Robotics.Server.Builders;

HostApplicationBuilder host = Host.CreateApplicationBuilder(args);

host.Services
    .AddOpcUa()
    .AddServer(options =>
    {
        options.ApplicationName = "IntentRobot";
        options.EndpointUrls.Add("opc.tcp://localhost:4840/IntentRobot");
    })
    .AddRobotIntent(options =>
    {
        options.InstanceNamespaceUri = "urn:example:intent-robot";
    })
    .AddRobotIntentExecutor<MyIntentExecutor>()
    .ConfigureRobotIntent(async (context, ct) =>
    {
        await context.AddIntentControllerAsync(
            "Arm1",
            ConfigureArmController,
            ct);
    });

await host.Build().RunAsync();

The direct-construction fallback is the same address space and the same builder, just without the Generic Host pipeline. A server that constructs node managers itself can register RobotIntentNodeManagerFactory directly, or construct RobotIntentNodeManager with explicit IRobotIntentModelProvider and RobotIntentServerOptions services. Once the manager has created its address space, call CreateRobotIntentBuildContext and use the same AddIntentControllerAsync extension:

using Opc.Ua;
using Opc.Ua.Robotics.Server;

var factory = new RobotIntentNodeManagerFactory(
    new IRobotIntentModelProvider[] { new RobotIntentModelProvider() }.ToArrayOf(),
    new RobotIntentServerOptions
    {
        InstanceNamespaceUri = "urn:example:intent-robot"
    });

// Register the factory with the server's node-manager registration mechanism.
// If you already have a RobotIntentNodeManager instance after address-space creation:
IRobotIntentBuildContext context = manager.CreateRobotIntentBuildContext(ct);
await context.AddIntentControllerAsync("Arm1", ConfigureArmController, ct);

That standalone path is intentionally useful: because the NodeSet requires only the base OPC UA namespace, a machine can expose Robot Intent without OPC 40010, DI or the Robotics topology model. A server that already owns an OPC 40010 node manager can instead use ConfigureRobotIntentFor<TNodeManager> and then link a MotionDeviceSystem to the intent controller with HasIntentController. That inverse reference is the structural evidence used to derive RI-Interop-40010.

RobotIntentNodeManager.DisposeAsync waits for every controller host and the base node-manager cleanup, including work deferred after ExecutorShutdownTimeoutMs expires. That host-level timeout does not permit shared server resources to be released while an executor is still running. Executors must therefore honor shutdown cancellation for asynchronous server teardown to complete. Synchronous Dispose remains nonblocking when host cleanup is deferred; subsequent or concurrent DisposeAsync calls join that cleanup and observe any failure.

Declaring a robot

The controller builder makes the model declaration and the host declaration one thing. What the server publishes under Capabilities is the contract IntentControllerHost enforces at submission time.

using System.Threading;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.RobotIntent;
using Opc.Ua.Robotics.Server.Builders;

static void ConfigureArmController(IIntentControllerBuilder controller)
{
    controller
        .WithOperationalMode(OperationalModeEnum.AutomaticExternal)
        .WithReady(true)
        .WithMaxQueueDepth(8)
        .Accepts<JointMoveIntentDataType>()
        .Accepts<LinearMoveIntentDataType>()
        .Accepts<CircularMoveIntentDataType>()
        .Accepts<GraspIntentDataType>(cancelSupported: false)
        .Accepts<ReleaseIntentDataType>()
        .Accepts<PickIntentDataType>(cancelSupported: false)
        .Accepts<PlaceIntentDataType>()
        .Accepts<ToolChangeIntentDataType>(cancelSupported: false)
        .Accepts<SetOutputIntentDataType>()
        .Accepts<CallProgramIntentDataType>()
        .Accepts<WaitIntentDataType>()
        .WithSafetyState(new MySafetySource());

    IIntentFrameBuilder world = controller.AddFrame(
        "World",
        "world",
        FrameRoleEnum.World,
        Pose("world", 0.0, 0.0, 0.0));
    IIntentFrameBuilder @base = controller.AddFrame(
        "Base",
        "robot-base",
        FrameRoleEnum.Base,
        Pose("world", 0.0, 0.0, 0.82),
        frame => frame.WithParent(world));
    IIntentFrameBuilder flange = controller.AddFrame(
        "Flange",
        "robot-flange",
        FrameRoleEnum.MechanicalInterface,
        Pose("robot-base", 0.0, 0.0, 0.18),
        frame => frame.WithParent(@base));
    IIntentFrameBuilder tcp = controller.AddFrame(
        "GripperTcp",
        "gripper-tcp",
        FrameRoleEnum.Tool,
        Pose("robot-flange", 0.0, 0.0, 0.12),
        frame => frame.WithParent(flange));

    controller.AddTool("ParallelGripper", tcp, fitted: true);

    for (uint index = 0; index < 6; index++)
    {
        controller.AddAxis($"J{index + 1}", index, AxisKindEnum.Revolute);
    }

    controller.AddLocation(
        "Bin",
        Pose("world", 0.45, -0.30, 0.82),
        location => location.WithOccupancy(false, capacity: 1));
    controller.AddLocation("Fixture", Pose("world", 0.50, 0.25, 0.82));

    controller.AddOutput("GripperOpen", DataTypeIds.Boolean, new Variant(true));
    controller.AddOutput("BenchLight", DataTypeIds.Boolean, new Variant(false));
    controller.AddProgram("Home", "home");
    controller.AddProgram("PickAndPlace", "pick-and-place");
    controller.AddRealTimeChannel(
        "JointTelemetry",
        "joint-telemetry",
        RealTimeTransportEnum.OpcUaFx,
        "opc.udp://239.0.0.40:4840");

    controller.WithDescription(description => description
        .WithKinematicChain(CreateKinematicChain())
        .WithLimits(
            reachRadius: 0.85,
            payloadLimit: 5.0,
            maxCartesianSpeed: 0.25,
            maxCartesianAcceleration: 0.7));
}

static Pose3DDataType Pose(string frameId, double x, double y, double z)
{
    return new Pose3DDataType
    {
        FrameId = frameId,
        Position = new[] { x, y, z }.ToArrayOf(),
        Orientation = new[] { 0.0, 0.0, 0.0, 1.0 }.ToArrayOf()
    };
}

static ArrayOf<KinematicJointDataType> CreateKinematicChain()
{
    var joints = new KinematicJointDataType[6];
    for (int ii = 0; ii < joints.Length; ii++)
    {
        joints[ii] = new KinematicJointDataType
        {
            AxisId = $"J{ii + 1}",
            Kind = AxisKindEnum.Revolute,
            OriginTransform = Pose(ii == 0 ? "robot-base" : $"J{ii}", 0.0, 0.0, 0.12),
            AxisVector = new[] { 0.0, 0.0, 1.0 }.ToArrayOf()
        };
    }
    return joints.ToArrayOf();
}

public sealed class MySafetySource : IRobotIntentSafetySource
{
    public ValueTask<RobotIntentSafetySnapshot> ReadAsync(CancellationToken cancellationToken)
    {
        return new ValueTask<RobotIntentSafetySnapshot>(new RobotIntentSafetySnapshot(
            SafeMotionFunctionEnum.None,
            EmergencyStopActive: false,
            ProtectiveStopActive: false,
            SafeSpeedLimitActive: false,
            SafeSpeedLimit: 0.0,
            SafetyControllerOk: true,
            LocalizedText.Null));
    }
}

The builder enforces the invariants that make the address space dependable. A tool can only point at a TCP frame whose role is Tool, and at most one tool below a controller can be Fitted=true, because a motion intent otherwise has no unambiguous active tool centre point. Axis indices must be unique and contiguous from zero, because JointMoveIntentDataType.JointTargets is an array and the index is the array coordinate. Capabilities.AxisCount is written from the number of axes, so a client can validate a joint target vector before submitting. Every capability must include BufferModeEnum.Aborting, which is the fail-safe "replace what is running" mode every controller must understand.

Declare only what the executor can really do. Accepts<TIntent>() is not documentation; it is the admission rule. If a robot cannot abandon a tool change safely, declare Accepts<ToolChangeIntentDataType>(cancelSupported: false) and implement CanCancel accordingly.

Writing an executor

IntentControllerHost owns admission, queueing, the Part 10 state machine, cancellation acceptance and the final result node. IIntentExecutor owns the doing: turning a typed intent into controller-specific motion, reporting progress and returning an outcome.

using System;
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.RobotIntent;

public sealed class MyIntentExecutor : IIntentExecutor
{
    public async ValueTask<IntentOutcome> ExecuteAsync(
        IntentExecution execution,
        CancellationToken cancellationToken)
    {
        switch (execution.Intent)
        {
            case LinearMoveIntentDataType linear:
                await MoveLinearAsync(linear.Target, execution.Progress, cancellationToken);
                return IntentOutcome.SucceededAt(linear.Target);

            case JointMoveIntentDataType joint when joint.HasJointTargets:
                await MoveJointsAsync(joint.JointTargets, execution.Progress, cancellationToken);
                return IntentOutcome.Success;

            case WaitIntentDataType wait:
                await Task.Delay(TimeSpan.FromMilliseconds(wait.Duration), cancellationToken);
                return IntentOutcome.Success;

            default:
                return IntentOutcome.Fail(
                    IntentFailureEnum.CapabilityNotSupported,
                    "The executor does not implement this intent.");
        }
    }

    public bool CanCancel(IntentExecution execution)
    {
        return execution.Intent is not ToolChangeIntentDataType;
    }

    private static async Task MoveLinearAsync(
        Pose3DDataType target,
        IIntentProgress progress,
        CancellationToken cancellationToken)
    {
        for (int step = 1; step <= 20; step++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            progress.ReportProgress(step / 20.0);
            progress.ReportPose(target);
            await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken);
        }
    }

    private static async Task MoveJointsAsync(
        ArrayOf<double> joints,
        IIntentProgress progress,
        CancellationToken cancellationToken)
    {
        await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
        progress.ReportProgress(1.0);
    }
}

The cancellation token is signalled after a cancel has been accepted and the operation has entered Cancelling. The executor then brings motion to a controlled end and returns; it does not need to manufacture a Cancelled result, because the host records that terminal state. CanCancel is the per-operation hook for refusing a cancel that would leave the cell in a worse state, such as a tool change mid-exchange.

Client: submit and await

The high-level client discovers controllers under Server/RobotIntent/Controllers, reads their capabilities, takes command authority, builds a typed intent and returns an awaitable operation handle.

using System;
using Opc.Ua;
using Opc.Ua.RobotIntent;
using Opc.Ua.Robotics.Client.Intent;

RobotIntentClient discovery = session.RobotIntent(telemetry);
ArrayOf<RobotIntentNodeLookupEntry> controllers =
    await discovery.DiscoverControllersAsync(ct);

RobotIntentControllerClient controller = discovery.Controller(controllers[0].NodeId);
RobotIntentControllerInfo info = await controller.ReadAsync(ct);

await using CommandAuthorityLease authority =
    await controller.RequestAuthorityAsync(ct);
if (!authority.Granted)
{
    throw new InvalidOperationException($"Command authority is held by {authority.CurrentOwner}.");
}

Pose3DDataType target = RobotIntentBuilder.Pose(
    x: 0.45,
    y: -0.30,
    z: 0.82,
    qx: 0.0,
    qy: 0.0,
    qz: 0.0,
    qw: 1.0,
    frameId: "world");

LinearMoveIntentDataType intent = RobotIntentBuilder
    .LinearMove(target, speed: 0.2)
    .WithIntentId("move-to-bin")
    .WithBufferMode(BufferModeEnum.Aborting)
    .Build();

await using IntentOperationHandle handle =
    await controller.SubmitIntentAsync(intent, ct);
IntentResultDataType result = await handle.Completion;
Console.WriteLine($"{handle.IntentId} ended with {handle.Current.ExecutionState}: {result.Failure}");

Use TrySubmitIntentAsync when refusal is part of the normal control flow and you want the fixed IntentFailureEnum rather than an exception:

IntentSubmissionResult submission = await controller.TrySubmitIntentAsync(intent, ct);
if (!submission.Accepted)
{
    Console.WriteLine($"Refused: {submission.Failure} {submission.Message.Text}");
    return;
}

await using IntentOperationHandle handle =
    await controller.TrackOperationAsync(submission.IntentId, submission.Operation, ct);

IntentOperationHandle.StartAsync subscribes to ExecutionState, Progress, CurrentPose and Result. The Changed event is the convenient way to update a UI:

handle.Changed += snapshot =>
{
    Console.WriteLine($"{snapshot.ExecutionState} {snapshot.Progress:P0}");
    Pose3DDataType current = snapshot.CurrentPose;
    _ = current.FrameId;
};

CurrentPose is a status report at the Subscription's sampling and publishing rate. It is deliberately not a servo channel; using it to close a motion-control loop is outside the Robot Intent model and outside OPC UA client/server timing guarantees.

Client: missions

A mission builder emits the same IntentDataType structures used for single submissions. Released steps form the base; unreleased steps form the horizon. A horizon update replaces the unreleased suffix while preserving the released prefix.

MissionDataType mission = RobotIntentBuilder.Mission("tray-42")
    .WithMissionUpdateId(1)
    .ReleasedStep("approach", RobotIntentBuilder
        .LinearMove(RobotIntentBuilder.Pose(0.40, -0.20, 0.90, 0, 0, 0, 1, "world"), 0.2)
        .Build())
    .ReleasedStep("pick", RobotIntentBuilder.Pick(
        info.Lookups.Locations[0].NodeId,
        info.Lookups.Tools[0].NodeId).Build())
    .HorizonStep("place", RobotIntentBuilder.Place(
        info.Lookups.Locations[1].NodeId,
        info.Lookups.Tools[0].NodeId).Build())
    .Build();

MissionSubmissionResult submitted = await controller.SubmitMissionAsync(mission, ct);
if (!submitted.Accepted)
{
    Console.WriteLine($"Mission refused: {submitted.Failure} {submitted.Message.Text}");
    return;
}

ArrayOf<MissionStepDataType> revisedHorizon = new[]
{
    new MissionStepDataType
    {
        StepId = "place",
        SequenceId = 3,
        Released = false,
        Intent = RobotIntentBuilder.Place(
            info.Lookups.Locations[1].NodeId,
            info.Lookups.Tools[0].NodeId).Build()
    }
}.ToArrayOf();

MissionUpdateOutcome update =
    await controller.UpdateMissionAsync("tray-42", 2, revisedHorizon, ct);
switch (update.Result)
{
    case MissionUpdateResultEnum.Accepted:
        break;
    case MissionUpdateResultEnum.Outdated:
        await controller.ReadAsync(ct);
        break;
    case MissionUpdateResultEnum.BaseConflict:
        Console.WriteLine("The update changed a released step; rebuild from the current base.");
        break;
    case MissionUpdateResultEnum.UnknownMission:
        Console.WriteLine("The mission has already ended or was never admitted.");
        break;
    case MissionUpdateResultEnum.Rejected:
        Console.WriteLine(update.Message.Text);
        break;
}

Transitions turn the flat list into a step graph. MissionCondition.Always() creates the empty ContentFilter that OPC UA defines as true; an empty Transitions array leaves the mission as a flat sequence.

MissionDataType branched = RobotIntentBuilder.Mission("inspect-or-rework")
    .WithMissionUpdateId(1)
    .ReleasedStep("inspect", RobotIntentBuilder.CallProgram(info.Lookups.Programs[0].NodeId).Build())
    .HorizonStep("accept", RobotIntentBuilder.Wait(100).Build())
    .HorizonStep("rework", RobotIntentBuilder.CallProgram(info.Lookups.Programs[1].NodeId).Build())
    .Transition("inspect", "accept", DivergenceKindEnum.Alternative, MissionCondition.Always())
    .Transition("inspect", "rework", DivergenceKindEnum.Alternative)
    .ErrorPolicy("rework", ErrorPolicyEnum.Retry)
    .Build();

Client: cancellation, pause and retry

Cancellation is refusal-aware. A server may legitimately refuse because the executor's CanCancel returned false or because the session does not hold command authority. Cancelling is not terminal; wait for the operation handle to reach Cancelled, Succeeded, Failed or Retriable.

IntentCommandOutcome cancel =
    await handle.CancelAsync(StopModeEnum.QuickStop, ct);
if (!cancel.Accepted)
{
    Console.WriteLine("The server refused this cancel request.");
}

IntentCommandOutcome pause = await handle.PauseAsync(ct);
if (pause.Accepted)
{
    await handle.ResumeAsync(ct);
}

IntentResultDataType final = await handle.Completion;
if (handle.Current.ExecutionState == ExecutionStateEnum.Retriable)
{
    IntentSubmissionResult retry = await handle.RetryAsync(ct);
    if (retry.Accepted)
    {
        await using IntentOperationHandle retryHandle =
            await controller.TrackOperationAsync(retry.IntentId, retry.Operation, ct);
        final = await retryHandle.Completion;
    }
}

Pause stops queue dispatch only. An intent that is already executing keeps reporting Executing; Resume lets queued work start again.

Pose maths

PoseMath implements Annex C conversion between Robot Intent's (x, y, z, w) quaternion and the core OPC UA ThreeDFrame A/B/C orientation:

Pose3DDataType pose = RobotIntentBuilder.Pose(
    0.4, 0.2, 0.8,
    0.0, 0.0, 0.3826834323650898, 0.9238795325112867,
    "world");

ThreeDFrame frame = PoseMath.ToThreeDFrame(pose);
Pose3DDataType roundTripped = PoseMath.FromThreeDFrame(frame, "world");

if (!PoseMath.TryValidate(roundTripped, 1e-6, out string? error))
{
    throw new InvalidOperationException(error);
}

FrameTree is the corresponding helper for re-expressing poses through a declared frame graph:

var frames = new FrameTree();
frames.TryAdd(
    "world",
    "",
    RobotIntentBuilder.Pose(0, 0, 0, 0, 0, 0, 1, "world"),
    FrameRoleEnum.World,
    out _);
frames.TryAdd(
    "robot-base",
    "world",
    RobotIntentBuilder.Pose(0.5, 0.0, 0.0, 0, 0, 0, 1, "world"),
    FrameRoleEnum.Base,
    out _);

Pose3DDataType inBase = RobotIntentBuilder.Pose(
    0.1, 0.0, 0.2,
    0.0, 0.0, 0.0, 1.0,
    "robot-base");

if (frames.TryExpress(inBase, "world", out Pose3DDataType inWorld, out string? frameError))
{
    Console.WriteLine(inWorld.Position[0]);
}

Handling refusal

A refusal is an ordinary method outcome: the Method call returns Good, Accepted is false and the failure is in the output arguments. A Bad StatusCode still means the transport, Session or Service layer failed. The point of the small failure set is that the client can choose a policy without parsing human text. NoTransition is IntentFailureEnum value 20:

IntentSubmissionResult submission = await controller.TrySubmitIntentAsync(intent, ct);
if (!submission.Accepted)
{
    switch (submission.Failure)
    {
        case IntentFailureEnum.QueueFull:
            await Task.Delay(TimeSpan.FromSeconds(1), ct);
            break;
        case IntentFailureEnum.ParameterInvalid:
        case IntentFailureEnum.NoTransition:
        case IntentFailureEnum.JointLimit:
        case IntentFailureEnum.WorkspaceLimit:
            Console.WriteLine("Re-plan with a reachable target.");
            break;
        case IntentFailureEnum.ControlNotOwned:
        case IntentFailureEnum.NotPermittedInMode:
        case IntentFailureEnum.SafetyLimitExceeded:
            Console.WriteLine($"Operator action required: {submission.Message.Text}");
            break;
        default:
            Console.WriteLine(submission.Message.Text);
            break;
    }
}

Monitoring and direct control for agents

An agent needs the same client API a conventional supervisory application needs, but it uses it in a tighter loop: discover, read the declaration, read live state, request authority, submit one bounded piece of work, and observe the operation before deciding what to do next. The client exposes those steps directly:

using Opc.Ua;
using Opc.Ua.RobotIntent;
using Opc.Ua.Robotics.Client.Intent;

RobotIntentClient robots = new(session, telemetry, streamingSubscription);
ArrayOf<RobotIntentNodeLookupEntry> controllers =
    await robots.DiscoverControllersAsync(ct);

RobotIntentControllerClient controller = robots.Controller(controllers[0].NodeId);
RobotIntentControllerInfo info = await controller.ReadAsync(ct);
RobotIntentControllerState state = await controller.ReadStateAsync(ct);

if (!info.SupportedFacets.Contains("RI-Motion-Linear") ||
    !(state.Ready.Available && state.Ready.Value))
{
    return;
}

await using CommandAuthorityLease authority = await controller.RequestAuthorityAsync(ct);
if (!authority.Granted)
{
    Console.WriteLine($"Command authority is held by {authority.CurrentOwner}.");
    return;
}

IntentSubmissionResult submission = await controller.TrySubmitIntentAsync(
    RobotIntentBuilder.LinearMove(RobotIntentBuilder.Pose(
        0.40,
        0.10,
        0.20,
        0.0,
        0.0,
        0.0,
        1.0,
        "world")).Build(),
    ct);

if (!submission.Accepted)
{
    Console.WriteLine($"{submission.Failure}: {submission.Message.Text}");
    return;
}

await using IntentOperationHandle operation = await controller.TrackOperationAsync(
    submission.IntentId,
    submission.Operation,
    ct);
IntentOperationWaitResult waited = await operation.WaitForCompletionAsync(
    TimeSpan.FromSeconds(2),
    ct);

if (!waited.Completed)
{
    ArrayOf<IntentOperationSnapshot> operations = await controller.ListOperationsAsync(ct);
    Console.WriteLine($"Still running; {operations.Count} operations are published.");
}

ReadStateAsync reads the values an agent must not infer from the declaration: OperationalMode, Ready, ControlOwner, ActiveIntent, ActiveMission when missions are supported, the safety state and the published operation and mission lists. ListOperationsAsync and ListMissionsAsync return the server's outstanding work, not a client-side cache. WaitForCompletionAsync is deliberately bounded: timeout returns Completed=false with a refreshed operation snapshot, so an agent can report progress or re-read state instead of blocking forever.

The same client carries the explicit control methods: CancelIntentAsync, CancelAllAsync, PauseAsync, ResumeAsync, RetryAsync and ReleaseControlAsync. Each returns the server's outcome. No helper retries, converts a refusal into a different command, or obtains command authority as a side effect.

MCP tools for Robot Intent

OPCFoundation.NetStandard.Opc.Ua.Mcp.Robotics packages the client surface above as Model Context Protocol tools for an LLM host. Register it beside the core OPC UA MCP package and select the Robotics profile when the host should expose robot tools:

using ModelContextProtocol.Server;
using Opc.Ua.Mcp;

builder.Services.AddOpcUaMcpCore();
builder.Services.AddOpcUaMcpRobotics();

builder.Services.AddMcpServer()
    .WithStdioServerTransport()
    .WithOpcUaMcpFilters()
    .WithOpcUaCoreTools(McpToolProfile.Robotics)
    .WithOpcUaRoboticsTools(McpToolProfile.Robotics)
    .WithTools<MyApplicationTools>();

McpToolProfile.Robotics gates registration. Passing the same profile to every OPC UA MCP package is safe: packages whose tools are not selected contribute nothing instead of failing. The Robotics package adds four tool groups:

  • discovery: list controllers and read a controller's declared SupportedIntents, SupportedFacets and lookup tables;
  • monitoring: read live state, page/filter concise operation and mission summaries, and wait for an operation or mission with a bounded timeout;
  • direct control: request and release authority, cancel, pause, resume, retry, and submit one tool per intent kind;
  • missions: submit, update the horizon of, and cancel missions.

Every controller argument is a selector: a NodeId remains valid, while an exact, unambiguous published name avoids copying NodeIds between calls. Tool, frame, location, output and program selectors inside an intent are resolved from the controller snapshot read for that call. Resolution is read-only and ambiguity is an error; it never requests command authority or submits exploratory work.

Intent payloads are structured MCP objects rather than JSON text embedded in a string:

{
  "controller": "BinPickingController",
  "input": {
    "intentId": "pick-red",
    "source": "Bin",
    "tool": "ParallelGripper",
    "objectClass": "RedCube"
  }
}

Mission steps and transitions are typed arrays. Each step carries a closed kind discriminator and exactly one matching payload. The server preserves a caller-supplied step IntentId, generates one only when omitted, and publishes the resulting StepId -> IntentId -> operation NodeId -> state correlation. robotics_wait_mission observes the mission operation NodeId returned by submission; it does not rediscover work through mutable active state.

The list tools return bounded pages. Summary detail omits poses and full output payloads; request Full detail only for the page that needs it. A timeout from either wait tool is not a failure: it returns completed=false and a refreshed snapshot.

When Vision and Robotics are composed, robotics_vision_pick runs one detection inference, applies exact class/detection filters and a confidence threshold, and deterministically chooses the highest-confidence candidate. It submits one Pick when no destination is supplied, or a two-step Pick/Place mission when a destination is supplied, then returns detection provenance and the authoritative submission handles. It never takes authority, retries, waits, cancels or converts a refusal into a success-shaped result.

The sample IntentViewerClient --mcp is one host for these tools. In headless mode it defaults to MCP stdio. With --view, it automatically uses Streamable HTTP because MCP stdio carries protocol frames on stdout and the in-process OpenUSD viewport shares that stream. An explicit --transport stdio --view is honoured for diagnostics but warned about.

The safe agent pattern is not a longer tool catalogue; it is a contract:

  1. Read SupportedIntents and SupportedFacets before commanding. The declaration is how the server tells the truth about itself. Asking for an undeclared intent, buffer mode, facet or mission feature is refused rather than probed into existence.
  2. Request command authority explicitly. Observation is open, but commanding is arbitrated between sessions. Authority is never acquired as a side effect of submitting an intent or mission.
  3. Treat a refusal as information, not a transient failure. NotPermittedInMode, SafetyLimitExceeded, ControlNotOwned and CapabilityNotSupported are decisions by the server: mode selection, safety state, command ownership and capability are not things an agent should brute-force. The MCP tools deliberately never retry on the agent's behalf.

Those rules are load-bearing because clauses 9 and 10 of the Robot Intent draft make the server's honesty about capabilities and safety awareness part of the safety case. A server that declares only what it can perform and refuses what it cannot lets a planner re-plan, escalate to an operator, or stop. An agent that hides refusals in a retry loop turns that honesty back into an unsafe black box.

The boundary remains the same whether the caller is a person, a program, or an LLM. Robot Intent commands a robot at the level of task intent. It does not change operational mode, clear stops, command safe motion functions, satisfy a safety-rated single-point-of-control requirement, stream cyclic servo samples, or bypass the robot controller's path planning and safety system.

Facets in code

A controller publishes the facets it claims in the read-only Capabilities.SupportedFacets, so a client reads the claim rather than reconstructing it. The server binds that variable to the facet calculator, so the list is recomputed on every read and tracks the address space instead of being a registration-time snapshot. On the server-side builder:

ArrayOf<string> facets = builder.ComputeFacets();
ArrayOf<string> published = RobotIntentFacetCalculator.Compute(builder.State);

On the client, ReadAsync returns what the server published:

RobotIntentControllerInfo info = await controller.ReadAsync(ct);
if (info.SupportedFacets.Contains("RI-Mission-Horizon"))
{
    Console.WriteLine("The controller accepts missions with horizon updates.");
}
if (!info.Facets.EveryCapabilitySupportsAborting)
{
    throw new InvalidOperationException("The server published an invalid capability set.");
}

info.Facets remains available as a convenience projection of the individual capability variables, and against a server that predates SupportedFacets it is all there is. Prefer SupportedFacets where the server publishes it: the projection can only see the flags, so it necessarily disagrees with the server about any facet whose requirements go beyond a single flag.

That disagreement is the reason SupportedFacets exists. A facet is not a restatement of the declaration a client has already read. Some of what the table below requires — that blending modes are honoured, that the refusal rules are followed, that a mission base is immutable — cannot be settled by reading the address space at all, so a client deriving facets locally is guessing at precisely the rows that matter most. A published claim that could drift from the model would reintroduce the same defect with the server's authority behind the wrong answer; the live read binding makes that drift impossible by construction.

Limitations

The current stack implements the draft information model, admission rules, Part 10 operation lifecycle, missions, command authority, cancellation, safety observation, capability/facet reporting, real-time channel leasing and the client handles shown above. It does not provide a safety-rated interface, a servo-level real-time channel, or a vendor robot driver. The facet calculator checks every structural requirement in clause 12.2, but behavioural requirements remain the server's attestation and require interop or acceptance testing to verify. The sample executor is a simulator, transition conditions are only as powerful as the server-supplied ConditionEvaluator, and real-time channels are brokered as leases rather than implemented as a cyclic transport in this package. The namespace http://opcfoundation.org/UA/RobotIntent/ and all NodeIds remain provisional until the companion specification is ratified.

Why a submission is not a method call

An OPC UA Call cannot stay open for the length of a real motion. Session timeouts, SecureChannel re-keying and transport timeouts all bound it, and OPC 10000-4 §5.12.2 is explicit that when the Session ends the method result is discarded "independent of the task actually performed at the Server". A synchronous method that commands a robot is therefore not merely inelegant: it loses the outcome of work that has already physically happened. The robot keeps moving after the answer has been thrown away.

OPC 10000-10 gives the OPC Foundation's own resolution — a Method performs a calculation, a Program runs a batch process or a machine-tool part program. So SubmitIntent returns as soon as the intent is admitted, and what it returns is a NodeId: an IntentOperationType instance, a Part 10 program instance created for that submission, which the client subscribes to for progress and reads for the result.

Building on ProgramStateMachineType buys four things this model then does not have to invent: transition events, a terminal result object that survives the operation, invocation diagnostics recording which Session commanded what, and a lifetime model for the instance itself. Two of those — FinalResultData and ProgramDiagnostic — are Optional in Part 10, and Robot Intent promotes both to Mandatory, because a shall that rests on a member a conformant server may omit is not a requirement.

The intent hierarchy

Intents are a DataType hierarchy, not one Method per verb.

IntentDataType (abstract)
├── MotionIntentDataType (abstract)
│   ├── JointMoveIntentDataType        movej / MoveJ / PTP / J / MOVJ
│   ├── LinearMoveIntentDataType       movel / MoveL / LIN / L / MOVL
│   ├── CircularMoveIntentDataType     movec / MoveC / CIRC / C / MOVC
│   ├── TrajectoryIntentDataType       a time-parameterised path, handed over whole
│   ├── CartesianPathIntentDataType    a taught path with per-waypoint blending
│   ├── ForceIntentDataType            move until contact
│   └── ProcessIntentDataType (abstract)
│       ├── ArcWeldIntentDataType          SpotWeldIntentDataType
│       ├── DispenseIntentDataType         FastenIntentDataType
│       └── PalletiseIntentDataType        SurfaceFinishIntentDataType
├── GraspIntentDataType / ReleaseIntentDataType
├── PickIntentDataType / PlaceIntentDataType
├── ToolChangeIntentDataType
├── SetOutputIntentDataType
├── CallProgramIntentDataType
└── WaitIntentDataType

Three consequences follow, and each is why the shape was chosen:

  • A single intent and a mission step are the same thing. MissionStepDataType.Intent is an IntentDataType, so nothing has to be expressed twice.
  • Extension is subtyping. A vendor adds an intent by deriving from IntentDataType. It is then carried, queued, cancelled and reported by the existing machinery without a new Method.
  • Discovery is a read, not a probe. IntentCapabilitiesType.SupportedIntents names each accepted DataType, so a client learns what a robot accepts by reading one Variable rather than by browsing for BrowseNames and inferring support from their presence.

Poses, frames and units

Pose3DDataType carries a FrameId, a Position of three doubles in metres, and an Orientation of four doubles forming a unit quaternion ordered (x, y, z, w).

Four rules make that unambiguous, and the server enforces all of them:

  1. Every frame is right-handed.
  2. Units are fixed by the specification and are not negotiable per instance: position in metres, joint targets in radians for a Revolute axis and metres for a Prismatic one, force in newtons, durations in milliseconds. Pose3DDataType appears as a Method argument, where no EUInformation property can reach it, so a per-instance unit would be undeliverable.
  3. Orientation must be normalised. A quaternion whose norm differs from 1 by more than 1e-6 is rejected with ParameterInvalid.
  4. FrameId names a CoordinateFrameType instance under the controller's Frames folder. An empty FrameId means the server's default work frame.

Quaternions are used because OPC UA defines no quaternion DataType anywhere, and because the A, B and C fields of the core ThreeDOrientation carry no convention of their own. PoseMath implements the specification's Annex C conversion in both directions, including the two properties that are normative and easy to get wrong:

  • the asin argument is clamped to [-1, +1], because floating-point error at a pole otherwise turns a legal orientation into a domain error;
  • q and -q denote the same orientation, and the conversion emits the representative whose w is non-negative, so two servers describing one orientation produce the same four numbers.

FrameTree composes the transforms along the path between two frames, so a pose given in one frame can be re-expressed in another.

The lifecycle

The Part 10 state machine carries the coarse state and generates the events. ExecutionState refines it, because Queued, Cancelling and the three distinct terminal outcomes cannot be told apart from CurrentState alone. The pairing is exhaustive — a combination not in this table is not legal:

ExecutionState Part 10 state Meaning
Accepted Ready Admitted and validated; not yet queued or executing.
Queued Ready Waiting behind another intent. QueuePosition is non-zero.
Executing Running Commanding the robot now.
Suspended Suspended Reserved for an executor-visible suspension; the stock Pause command does not publish it.
Cancelling Running A cancel was accepted; motion is being brought to a controlled end.
Succeeded Halted Terminal. Completed as requested.
Failed Halted Terminal. Result.Failure carries the reason.
Cancelled Halted Terminal. Ended early because a cancel was accepted.
Retriable Halted Terminal for now; Retry may re-attempt it.
stateDiagram-v2
    [*] --> Accepted: SubmitIntent admitted
    Accepted --> Queued: queued or buffered
    Queued --> Executing: dispatch starts
    Accepted --> Executing: no predecessor
    Executing --> Succeeded: executor returns success
    Executing --> Failed: executor fault or failure
    Executing --> Retriable: executor returns retriable
    Executing --> Cancelling: CancelIntent accepted
    Queued --> Cancelled: CancelIntent accepted
    Accepted --> Cancelled: CancelIntent on an admitted intent that is not current
    Cancelling --> Cancelled: controlled stop completes
    Cancelling --> Failed: executor fails while stopping
    Retriable --> Accepted: Retry creates a new operation
    Succeeded --> [*]
    Failed --> [*]
    Cancelled --> [*]

Cancelling is not terminal. A client that treats acceptance of a cancel as the end of motion acts too early.

ActiveIntent and, on servers with MissionsSupported true, ActiveMission summarize the currently executing work. ActiveMission is null when no executing intent belongs to a mission; servers without mission support omit it. Because ActiveMission is required whenever MissionsSupported is true, a client can treat a mission-capable server that omits it the same way it treats any other contradiction between declaration and address space: the capability claim is not usable as stated.

Refusal is an ordinary outcome

SubmitIntent refuses in a fixed order, and the order matters — a caller that lacks authority must be told that, not that its parameters are wrong:

  1. ControlNotOwned — the calling Session does not hold command authority.
  2. NotPermittedInMode — OperationalMode is not Automatic or AutomaticExternal.
  3. CapabilityNotSupported — the intent's DataType is not among SupportedIntents, or its BufferMode/BlockingMode is not among those the capability entry permits.
  4. ParameterInvalid — a parameter is missing, malformed or out of range.
  5. QueueFull — admitting it would exceed MaxQueueDepth.

A refusal creates no operation instance and moves nothing. It is reported in the output arguments — Accepted false with a Failure and a Message — and the call returns Good. A Bad StatusCode still means what it always meant: the transport, the Session or the Service layer failed. The distinction is normative, and it is what makes the failure set diagnosable: a client decides whether to retry, re-plan or escalate from the IntentFailureEnum value alone.

Queueing and blending

BufferMode decides how a new submission relates to what is already executing. The values are PLCopen's MC_BufferMode, adopted unchanged:

Value Meaning
Aborting Abort what is executing and start immediately. The default, and always accepted.
Buffered Queue; start when the predecessor succeeds.
BlendingLow / BlendingPrevious / BlendingNext / BlendingHigh Queue, and do not decelerate to a stop at the boundary.

Where blending occurs, the predecessor reaches Succeeded when blending begins, not when its target is exactly attained, and its Result.AchievedPose records where the tool centre point was at that moment. That is what PLCopen defines, and reporting it any other way would tell a client the robot stopped somewhere it never was. A server that accepts a blending mode but executes it as Buffered reports BlendingSupported false, so a client can tell a robot that blends from one that merely tolerates being asked to.

BlockingMode is orthogonal and constrains concurrency rather than ordering — it is the VDA 5050 blockingType matrix. A server does not begin an intent whose BlockingMode is Single or Hard while any other intent is executing.

Cancellation is not the Cancel Service

The OPC UA Cancel Service in OPC 10000-4 §5.7.5 cancels an outstanding service request. It does not stop the robot: it returns Bad_RequestCancelledByClient for that request and leaves the motion running. Stopping a robot is CancelIntent, CancelMission or CancelAll.

A server may refuse a cancel and says so in the Accepted output. Some motions cannot be abandoned part-way without leaving the cell worse than completing them would — a tool change mid-exchange, a placement mid-release.

Missions

A mission is an ordered sequence of intents submitted and tracked as a unit, so a supervisor can commit work in advance and still change what has not yet been committed.

Every step carries Released. The released steps form a prefix called the base; the rest form the horizon.

Step 0        Step 1        Step 2   │   Step 3        Step 4
released      released      released │   horizon       horizon
└────────── base: committed ─────────┘   └── revisable ──┘

The base is committed and immutable: the server assumes every released step is executing or already executed and refuses any update that would alter, remove or reorder one. UpdateMission replaces the horizon wholly and may release some of it, extending the base. MissionUpdateId must be strictly greater than the mission's current value, which is what makes two updates that crossed in flight safe — the later one wins and the earlier is rejected with Outdated rather than applied out of order. An update is applied atomically.

Where a mission carries Transitions, it becomes the step-and-transition form of an IEC 61131-3 sequential function chart. Conditions are OPC UA ContentFilters — the base specification's own filter grammar, reused rather than invented — and DivergenceKind says whether exactly one transition is taken (Alternative, evaluated in array order so two clients predict the same branch) or all of them are (Parallel). Per-step ErrorPolicy covers Abort, Retry, Skip, Fallback and Compensate. An empty Transitions array leaves the mission the flat sequence it was, which is what makes the graph an addition rather than a replacement.

Where a step has outgoing transitions and none of their conditions holds, the mission terminates Failed with NoTransition (IntentFailureEnum value 20). The same outcome is reported if the selected transition target no longer resolves to a step of the mission, rather than reporting success while leaving requested work unexecuted.

Command authority

At most one Session at a time holds command authority over a controller, and only that Session may submit. Authority is released automatically when the holding Session closes, so a crashed client does not lock a robot permanently. Reading, browsing and subscribing require no authority: observation is always permitted.

Command authority arbitrates between OPC UA clients. It is not the single point of control that ISO 10218-2 requires — that concerns mutual exclusion of remote command and local manual control and is enforced by safety-rated means outside this interface. It is also not authorisation: a Session that holds authority but lacks the necessary Role is still refused.

Safety, and the boundary that is never crossed

This is a non-safety-rated application interface. The Methods here are application-level requests. They do not constitute, and must not be used as, safety functions as defined in IEC 61508, nor safety communication as defined in IEC 61784-3 or IEC 62541-15.

This is a property of the technology, not a scoping preference. OPC 10000-15 carries cyclic safety data from a SafetyProvider to a SafetyConsumer, and the consumer's request carries an identifier, a monitoring number and one octet of explicitly non-safety flags — so a caller has no channel through which to supply safety-rated arguments. Every safety fieldbus expresses a safety command as a continuously asserted cyclic signal, because the integrity argument rests on the fail-safe state that follows when assertion stops. A Method call has no defined behaviour when it stops being called, and therefore cannot be a safety function however it is labelled.

What the model can do is observe and refuse. SafetyStateType reports what the safety system is enforcing, and the server refuses a submission:

  • with SafetyLimitExceeded, when SafeSpeedLimitActive is true and the intent's Constraints.CartesianSpeed exceeds SafeSpeedLimit;
  • with NotPermittedInMode, when EmergencyStopActive or ProtectiveStopActive is true, when SafetyControllerOk is false, or when OperationalMode is not Automatic or AutomaticExternal.

Each of those is observable against a running server: assert a protective stop and a conformant server refuses; lower SafeSpeedLimit below a submitted speed and it refuses.

What none of that makes true. These refusals are an application-layer courtesy performed by non-safety-rated software. They reduce the number of requests the safety system has to reject; they are not a protective measure.

  • A client must not treat acceptance of an intent as evidence that the motion is safe.
  • A client must not treat SafeSpeedLimit as a limit this interface enforces — the safety system enforces it, and would enforce it identically if this model did not exist.
  • StopMode expresses urgency and selects no IEC 60204-1 stop category. A client that requires a category-rated stop obtains it from the safety system; it cannot be obtained here.
  • The model may observe the safety system and refuse on what it sees. It may never instruct it: no Method commands a safe motion function, changes an operational mode, or clears a stop. OperationalMode is read-only, because mode selection is a safety function performed by a key switch or an interlock, and an interface that could change it from the network would defeat the arrangement it is reporting.

What this interface carries, and what it brokers

OPC UA method invocation is not deterministic and completes in tens of milliseconds. Vendor real-time channels run two to four orders of magnitude faster on dedicated transports. The model divides the work rather than pretending the gap is not there.

Carried here. A trajectory, a Cartesian path or a force-controlled move is handed over whole and run by the robot's own motion kernel. The round trip happens once, at submission, so transport latency bounds how quickly work can be started and never how accurately it is executed. This is the shape of FollowJointTrajectory in ROS and of the PLCopen buffered path function blocks, and it is why trajectory execution belongs here while trajectory streaming does not.

Brokered. Where a client genuinely needs a high-rate channel — visual servoing, force tracking, conveyor following — RealTimeChannelType describes one and OpenRealTimeChannel leases it. The samples travel on that channel and never through this interface. Of the transports named (Rtde, Egm, Fri, Rsi, MotoRos2, OpcUaFx, Other) only OpcUaFx is an OPC Foundation specification; the rest are vendor channels the model describes without defining.

A lease lapses at LeaseExpiry unless renewed, and is released when the holding Session closes — the same reasoning as command authority: a client that dies must not hold a resource for good. While a lease is held, the server refuses motion intents with CapabilityNotSupported unless it can genuinely arbitrate between the two sources, because two things commanding one robot with no arbitration is exactly the failure that rule exists to prevent.

IntentOperationType.CurrentPose exists so a client can watch a motion. It is a status report delivered at whatever rate the client's Subscription asks for, and using it to close a control loop is outside this model.

NodeIds in intents are untrusted input

Every NodeId-valued member of an intent is chosen by the client, so the server validates that each resolves to a node of the expected type under the controller being commanded, and refuses with ParameterInvalid otherwise. A NodeId that resolves to a node belonging to a different controller, or to no node at all, is never acted on.

Member Resolves to
PickIntentDataType.Source, PlaceIntentDataType.Destination, PalletiseIntentDataType.Pattern a LocationType under the controller
MotionIntentDataType.ToolFrame, ForceIntentDataType.FrameId a CoordinateFrameType under the controller; ToolFrame additionally of Role Tool
ToolChangeIntentDataType.Tool a ToolType under the controller, or null to release the fitted tool
SetOutputIntentDataType.Output an OutputSignalType under the controller; Value must match that signal's own DataType
CallProgramIntentDataType.Program, ProcessIntentDataType.ProcessProgram a ProgramType under the controller
WaitIntentDataType.Signal an OutputSignalType under the controller, or a Boolean Variable under it
FastenIntentDataType.Joint a joint in an OPC 40450/40451 model under the controller where one is implemented; otherwise the member is null and the intent's own parameters stand alone

CallProgramIntentDataType deserves particular care because it runs code the server holds: it is restricted to programs published as ProgramType instances, and a program identifier naming anything else is refused.

For FastenIntentDataType.Joint, absence of an OPC 40450/40451 joining or tightening model under the controller is the structural statement that non-null Joint values are not supported. The stock host refuses such values with CapabilityNotSupported. Where the controller does expose an OPC 40450/40451 model, a Joint that does not resolve to a joint in that model is malformed input and is refused with ParameterInvalid.

Commanding is a privileged operation. Every Method here moves a machine that can injure people and destroy property, so the server requires an authenticated Session and restricts the Methods of IntentControllerType by Role, distinctly from read access to the same address space. Observing a robot and commanding one are different privileges and are not conflated. UserExecutable is applied so a client discovers what it may invoke before invoking it.

Interoperating with OPC 40010

OPC 40010 describes the robot; Robot Intent commands it. The two are joined by one reference and are otherwise independent — this model takes no dependency on the Robotics NodeSet, and a server implementing only Robot Intent is fully conformant.

A server claiming the interop profile exposes a HasIntentController reference from the MotionDeviceSystemType instance describing the robot to the IntentControllerType instance that commands it, reports the same operational mode as the OPC 40010 model, publishes as ProgramType instances exactly those programs the OPC 40010 task control can load, and expresses its poses in frames consistent with the mounting and geometry OPC 40010 describes. The published RI-Interop-40010 facet is derived from the inverse of that reference on the intent controller, and because SupportedFacets is a live read binding, the claim tracks the address space whether the reference is attached before or after the controller is registered.

It does not duplicate OPC 40010's topology. AxisType exists here only to fix the order, kind and limits a joint target needs; where OPC 40010 is also implemented its axis description is the fuller one and OPC 40010 decides. RobotDescriptionType.KinematicChain is additive, because OPC 40010 defines no kinematic chain an inverse-kinematics solver could use — and no tool centre point at all, which is why ToolType.TcpFrame supplies the concept and has nothing in OPC 40010 to contradict.

Facets

Only RI-Base is mandatory. A server implements the facets it can honour and declares the rest false; a facet other than RI-Base is claimed only where every intent type it names appears in SupportedIntents. Each controller lists what it claims in Capabilities.SupportedFacets, which RI-Base requires — a conformance claim that cannot be read is not a claim.

Requirements below are of two kinds, and the difference decides what a tool can check. Structural requirements are settled by reading the address space and the capability declaration; the facet calculator checks every one of them, and a server shall not list a facet whose structural requirements are unmet. Attested requirements — accepting, honouring, maintaining or observing a rule — cannot be settled by reading, only by exercising the server, and are the server's own statement under the honesty rules. Listing RI-Blending while treating the blending modes as Buffered is a false statement in exactly the sense the honesty rules forbid, whatever BlendingSupported says.

Facet Requires
RI-Base (mandatory) RobotIntentRootType; at least one IntentControllerType with Capabilities, Frames, Tools, Locations, Axes and Intents; SupportedFacets; SubmitIntent, CancelIntent, CancelAll, RequestControl, ReleaseControl; IntentOperationType instances with the state model above; the refusal rules (attested)
RI-Motion-Joint / -Linear / -Circular the corresponding move intent; joint additionally needs AxisType instances covering 0..AxisCount − 1
RI-Trajectory TrajectoryIntentDataType, TrajectorySupported true, and the tolerance rules (attested)
RI-Path CartesianPathIntentDataType and TrajectorySupported true
RI-Force ForceIntentDataType and ForceControlSupported true — the robot genuinely regulates force (attested)
RI-RealTimeChannel RealTimeChannelsSupported true, the RealTimeChannels folder, and the lease rules (attested)
RI-Safety SafetyState present with a bound safety source; populated from the safety system, and the safety refusals (attested)
RI-Description Description with a KinematicChain covering every axis, ReachRadius, PayloadLimit, MaxCartesianSpeed
RI-Process-ArcWeld / -SpotWeld / -Dispense / -Fasten / -Palletise / -SurfaceFinish the corresponding process intent; palletise also needs a LocationType pattern, surface finish also needs RI-Force
RI-Grasp GraspIntentDataType, ReleaseIntentDataType, and a ToolType with a TcpFrame
RI-PickPlace PickIntentDataType, PlaceIntentDataType, and a LocationType
RI-ToolChange ToolChangeIntentDataType and more than one ToolType
RI-Output / RI-Program / RI-Wait SetOutputIntentDataType + Outputs; CallProgramIntentDataType + Programs; WaitIntentDataType
RI-Queue MaxQueueDepth greater than zero and Buffered accepted; QueuePosition maintained (attested)
RI-Blending BlendingSupported true and the four blending modes accepted; the modes honoured and Result.AchievedPose at the blend point (attested)
RI-Pause / RI-Retry Pause and Resume; Retry with Retriable reachable
RI-Mission MissionsSupported true, ActiveMission, SubmitMission, CancelMission, MissionType instances
RI-Mission-Horizon RI-Mission plus MissionHorizonSupported and UpdateMission; base immutability (attested)
RI-Mission-Branching RI-Mission plus MissionBranchingSupported; transitions evaluated and error policies honoured (attested)
RI-Interop-40010 inverse HasIntentController from the MotionDeviceSystemType instance to the IntentControllerType instance; operational-mode agreement with OPC 40010, ProgramType instances exactly matching the programs the OPC 40010 task control can load, pose/frame consistency and safety consistency (attested)

Profile and facet URI publication

SupportedFacets is per controller, but ServerProfileArray is per server. A Robot Intent server therefore publishes both levels: every controller lists its facet names in Capabilities.SupportedFacets, and the server publishes the URI of every claimed profile and every backing facet in Server/ServerCapabilities/ServerProfileArray. The two paths shall agree. A profile URI is backed by at least one controller whose SupportedFacets contains every facet in that profile, and a facet URI is backed by at least one controller that lists that facet.

Profiles use the base http://opcfoundation.org/UA-Profile/RobotIntent/Server/:

Profile URI
Robot Motion Server http://opcfoundation.org/UA-Profile/RobotIntent/Server/Motion
Robot Handling Server http://opcfoundation.org/UA-Profile/RobotIntent/Server/Handling
Robot Path Server http://opcfoundation.org/UA-Profile/RobotIntent/Server/Path
Robot Mission Server http://opcfoundation.org/UA-Profile/RobotIntent/Server/Mission

Facets use the base http://opcfoundation.org/UA-Profile/RobotIntent/Facet/, with the suffix being the facet name after the RI- prefix: RI-Base is http://opcfoundation.org/UA-Profile/RobotIntent/Facet/Base, RI-Motion-Joint is http://opcfoundation.org/UA-Profile/RobotIntent/Facet/Motion-Joint, and RI-Process-ArcWeld is http://opcfoundation.org/UA-Profile/RobotIntent/Facet/Process-ArcWeld. These URIs are provisional while Robot Intent is a draft; the controller's SupportedFacets remains the authority on what that controller satisfies.

See also