## Proposed changes Fixes #4857. The stack's log messages wrote network addresses (endpoint, discovery and reverse connect URLs, host names, IP addresses, ports), application and user identities, file paths and exception texts as plain parameters. Applications had no reliable way to keep them out of general-purpose logs. This PR replaces the stack's own redaction mechanism with the standard .NET [data classification and redaction](https://learn.microsoft.com/dotnet/core/extensions/data-redaction) packages. Each sensitive `[LoggerMessage]` parameter is classified. The application decides per classification what reaches its logs: erase it, replace it with an HMAC token so entries stay correlatable, or pass it through. ### Taxonomy `Opc.Ua.Redaction.OpcUaDataClassifications` defines the `OpcUa` taxonomy and one attribute per classification: | Classification | Used for | Parameters | |---|---|---| | `[NetworkAddress]` | URLs, host and domain names, IP/MAC addresses, end points, ports | 120 | | `[ApplicationIdentity]` | certificates, subject, issuer, thumbprint, ApplicationUri | 103 | | `[FilePath]` | file, directory and certificate store paths | 67 | | `[SensitiveText]` | exception or remote-supplied text passed as a parameter | 55 | | `[UserIdentity]` | user names, token subjects | 7 | Correlation ids (`ChannelId`, `SessionId`, ...), status codes and NodeIds stay unclassified. ### Pipeline and defaults - Opc.Ua.Core now references `Microsoft.Extensions.Telemetry(.Abstractions)` 10.11.0, so every project above Core uses the `Microsoft.Gen.Logging` generator. Opc.Ua.Client already did, through Http.Resilience. The packages ship net462 and netstandard2.0 builds, so net48 is supported. - **A plain `ILoggerFactory` logs `(null)` in place of classified values.** Classified values are only written by the redaction-aware logger. - The new `ILoggingBuilder.EnableOpcUaRedaction()` turns that logger on. It is `EnableRedaction()` with the discriminator off; otherwise every unredacted value gets `:ParamName` appended. `AddOpcUa().AddLogging(...)` and `DefaultTelemetry.Create(...)` call it, as do the samples, so they log the same information as before until redactors are registered. - `services.AddOpcUa()` alone does not change the host's logging. Generic-host apps call `builder.Logging.EnableOpcUaRedaction()`. This is documented as a behavior-breaking change in `docs/migrate/2.0.x/telemetry.md`. ### Obsolete API `Redact`, `RedactionWrapper<T>`, `IRedactionStrategy`, `RedactionStrategies` and `SimpleRedactionStrategy` are `[Obsolete]`. They still work, but the stack no longer uses them. ### Not classified - The `Exception` passed to a log call. Applications can drop exception messages with `LoggerEnrichmentOptions.IncludeExceptionMessage`. - Exception and `ServiceResult` message texts. - `ServiceResult`/`StatusCode` parameters and logged data values. - The few log calls whose level is decided at run time. - `Opc.Ua.Vision.OpenUsd`, which references only Opc.Ua.Types and cannot see the attributes. ### Generator behavior differences These are visible to log consumers and to tests: - `IFormattable` values such as `NodeId` are stored in the structured state as invariant strings, not as objects. - The generated log state is a thread-local object that is cleared after the call. - Classified values are pooled redactor objects, valid only during the `Log` call. Tests that inspected log state after the fact now capture it during the call. The shared `RecordingLoggerProvider` snapshots classified values. ## Related Issues - Fixes #4857 ## Types of changes - [ ] Bugfix (non-breaking change which fixes an issue) - [x] Enhancement (non-breaking change which adds functionality) - [ ] Test enhancement (non-breaking change to increase test coverage) - [x] Breaking change (fix or feature that would cause existing functionality to not work as expected; plain logger factories drop classified values) - [x] Documentation Update (if none of the other choices apply) ## Checklist - [x] I have read the CONTRIBUTING doc. - [x] I have signed the CLA. - [x] I ran tests locally with my changes, all passed. - [x] I fixed all failing tests in the CI pipelines. - [x] I fixed all introduced issues with CodeQL and LGTM. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added necessary documentation (if appropriate). - [ ] Any dependent changes have been merged and published in downstream modules. ## Further comments New tests: - `LogClassificationCoverageTests` (Tools.Tests) scans all 104 stack assemblies and fails if an address, identity or path parameter of a `[LoggerMessage]` method is unclassified. - `DataClassificationLoggingTests` (Core.Tests) pins erasing and HMAC redaction, pass-through, the plain-factory drop, and both stack defaults. Local results on net10.0: | Suite | Passed | Failed | |---|---|---| | Core.Tests | 6,489 | 0 | | Client.Tests | 3,231 | 0 | | Tools.Tests | 7,210 | 0 | | PubSub.Tests | 1,536 | 0 | | Core.Diagnostics.Tests | 571 | 0 | | Configuration.Tests | 241 | 0 | - **Server.Tests:** the full run predates the final test fixes and had 12 failures. All 12 were in the fixtures fixed afterwards, which now pass: RoleStateBinding (45/45) and the FileDirectory and compatibility-logging fixtures (74/74 in total). The full Core.Tests and Server.Tests runs were not repeated after those fixes. - **Builds:** `UA.slnx` builds with 0 errors for net10.0 and net48, and the whitespace check passes. - **NativeAOT:** not confirmed locally, because the machine has no C++ linker. A trimmed publish of `Opc.Ua.Aot.Tests` reported no trim or AOT warnings from the stack, the new packages or the generated logging code; its only errors come from the TUnit test framework (`FSharp.Core` not found). The `aot-test` CI job has to confirm the native build. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Robot Intent Viewer Client
This sample connects to IntentEnabledRobot, discovers its Robot Intent controller, prints the facets declared by its capabilities, obtains command authority, and maps OpenUSD target picks to Robot Intent linear moves. With --mcp on a supported target framework, it also hosts the Robot Intent MCP tools over the same OPC UA session. A submitted intent returns admission immediately; this client then waits on the returned operation and prints live ExecutionState, Progress, CurrentPose, and the terminal IntentResultDataType.
Connection and viewport trust boundaries
SampleSession checks the server certificate's hostname against the
operator-supplied --server URL, including on reconnect. --insecure opts into
trusting an otherwise untrusted certificate; it does not disable hostname
validation or replace the requested identity with a discovery-advertised URL.
With --view, the fetched initial stage is prepared before live streaming starts.
Live viewport updates forward attribute values, time samples and batches only.
Server-controlled component references, payloads, instances and activation changes
are not composed into the running native viewport, even when they name a local
file or command bindings are enabled. This prevents a live component update from
resolving an external asset with the viewer process's ambient Windows credentials.
Trusted host-side composition and file-sink export remain available; preparing an
initial stage still requires the operator to trust its content and asset resolver.
An asset digest establishes integrity, not trust in that content's dependencies.
MCP hosting
With --mcp, the viewer client exposes the Robotics MCP profile so an LLM agent can discover controllers, read state, request authority, submit intents and missions, pause/resume/cancel/retry operations, and wait for operation completion while a human watches the same robot in the OpenUSD viewport. The MCP tools use the one SampleSession opened by the sample; the viewer, OpenUSD live bindings, and MCP tools therefore observe and command the same connected controller.
MCP hosting is compiled for net8.0, net9.0, and net10.0. It is unavailable on net48; running that leg with --mcp reports the limitation on stderr. Without --mcp, the sample continues to run on every target framework it builds for.
HTTP hosting binds to loopback and accepts only localhost, 127.0.0.1, or
[::1] authorities on the selected port. A present Origin must also identify
an approved local HTTP authority; native MCP clients without an Origin remain
supported. These checks reject browser DNS rebinding before tool dispatch.
They do not authenticate other local processes. Do not expose this sample through
a remote proxy without adding caller authentication and authorization.
MCP options:
--mcpenables MCP hosting.--transport stdio|http|sseselects the MCP transport.sseis accepted as an alias forhttp.--port <number>selects the Streamable HTTP port. The default is5100.
Transport selection is visible on stderr:
- No
--transportand no--view: stdio is selected by default. - No
--transportwith--view: HTTP is selected automatically and the client explains that MCP stdio uses stdout for protocol frames, which cannot safely coexist with the in-process OpenUSD viewer. --transport httpwith or without--view: HTTP is used.--transport stdiowithout--view: stdio is used.--transport stdio --view: the explicit request is honored, but the client warns plainly that the viewer may share stdout and corrupt the MCP stdio protocol.
Example MCP client configuration for viewport mode:
{
"servers": {
"intent-viewer": {
"url": "http://localhost:5100/mcp"
}
}
}
Run the sample with HTTP MCP and the viewport:
dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj --framework net10.0 -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure --view --mcp --transport http --port 5100 --fetch-assets .\artifacts\intent-viewer-stage
For stdio MCP, point the MCP client at the command instead:
{
"servers": {
"intent-viewer": {
"command": "dotnet",
"args": [
"run",
"--project",
"samples\\Robotics\\IntentViewerClient\\IntentViewerClient.csproj",
"--",
"--server",
"opc.tcp://localhost:62840/IntentEnabledRobot",
"--insecure",
"--mcp"
]
}
}
}
Agents can submit only what the server advertises and accepts. Refusals are returned to the agent as tool results and are never retried on the agent's behalf; the agent must decide whether to re-plan, ask for operator action, or stop.
Headless mode
Start the server, then run the client:
dotnet run --project samples\Robotics\IntentEnabledRobot\IntentEnabledRobot.csproj -- --insecure
'1' | dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure
The client lists the target pucks published by the server's OpenUSD bindings. Enter the number for Bin, Fixture, Inspect, or Handoff; the same pick handler used by the viewport reads the mapped LocationType pose and submits a linear move.
Headless keyboard control is disabled when MCP stdio is active because both would read from standard input. Use --mcp --transport http if you want the headless menu and an MCP client at the same time.
--insecure is for localhost demos only. It accepts any server certificate for localhost demos; do not use it for production systems.
Viewport mode
On a machine with the optional viewer assembly and native OpenUSD renderer payload installed next to the sample output, run:
dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure --view --fetch-assets .\artifacts\intent-viewer-stage --pick-mode Auto
Click one of the four target pucks at /World/Targets/Bin, /World/Targets/Fixture, /World/Targets/Inspect, or /World/Targets/Handoff. With --pick-mode Auto, the viewer uses renderer-backed pointer picking first: the OpenUSD viewer owns input handling, DPI scaling, physical-pixel conversion, stale-revision retry, and reports hits through the host callback. Misses do not submit intents. If renderer picking is unavailable or unsupported, Auto falls back to the command prim watcher: the host watches /World/IntentCommand and turns a written prim path into an intent. Use --pick-mode Renderer to require renderer-backed picks, or --pick-mode CommandPrim to use only the command-prim path. The renderer payload supports win-x64, linux-x64 and osx-arm64; where it is unavailable the sample falls back to headless mode and says why.
Refusals and cancellation
Refusals are normal method outputs: Accepted=false, an IntentFailureEnum, and a Message. The sample switches on the failure to show retry, re-plan, or escalate decisions. Safety failures such as SafetyStop and SafetyLimitExceeded are reported as safety-system refusals observed by the client, not overridden by it.
Press C while an intent is moving to call CancelIntent(ProcessStop). The output deliberately continues through Cancelling and waits for terminal Cancelled, demonstrating that cancel admission is not the end of robot motion.
Mission demo
Add --mission to submit a tiny two-step mission with a released base and unreleased horizon before interactive picking:
dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure --mission
Agent workflow for pallet stacking
Use MCP when an LLM agent should drive the same robot a human watches:
dotnet run --project samples\Robotics\IntentEnabledRobot\IntentEnabledRobot.csproj -- --insecure
dotnet run --project samples\Robotics\IntentViewerClient\IntentViewerClient.csproj --framework net10.0 -- --server opc.tcp://localhost:62840/IntentEnabledRobot --insecure --view --mcp --transport http --port 5100
Configure the MCP client with http://localhost:5100/mcp, or omit --view --transport http and
register the dotnet run ... -- --mcp command as a stdio MCP server.
Before commanding anything, the agent should read:
robotics_list_controllersrobotics_read_controllerforSupportedIntents,SupportedFacets,Bin,Fixture,ParallelGripper,HeldPartPosition,HeldPartVisibleandPayloadSlotNNFilledrobotics_read_stateforReady, mode, safety and command ownerrobotics_request_control
The stacking scenario then exercises the full surface:
- provoke and handle a refusal, for example by submitting while another session owns control and
observing
IntentFailureEnum.ControlNotOwned; - take command authority explicitly;
- submit direct pick, move and place intents;
- call
robotics_wait_operationandrobotics_list_operationswhile motion is active; - pause, resume, cancel and retry as explicit tools, never as hidden client behaviour;
- compile a multi-step mission with
robotics_submit_mission; - inspect
robotics_list_missions, update a mission horizon, and cancel a mission; - verify payload outputs:
HeldPartVisible, changedHeldPartPosition, and filled stack slots.
Agents must not retry a refusal blindly, assume authority, ignore SupportedIntents, or keep issuing
commands after safety/mode/authority refusals without re-reading state and changing the plan.
Transcript-style sequence for one part:
agent -> robotics_list_controllers()
server -> [{ name: "UR5eIntentController", nodeId: "ns=...;s=..." }]
agent -> robotics_read_controller(controllerId)
server -> SupportedIntents includes Pick, Place, LinearMove, JointMove and Wait;
SupportedFacets includes RI-Mission and RI-Mission-Horizon;
locations include Bin and Fixture; outputs include PayloadSlot01Filled.
agent -> robotics_read_state(controllerId)
server -> Ready=true, OperationalMode=AutomaticExternal, ControlOwner=<other session>
agent -> robotics_submit_linear_move(controllerId, { intentId: "probe", target: ... })
server -> { accepted: false, failure: ControlNotOwned, message: "..." }
agent decision: this is an authority refusal, not a motion problem; do not retry the same call.
agent -> robotics_request_control(controllerId)
server -> { granted: true }
agent -> robotics_submit_pick(controllerId,
{ intentId: "pick-slot-01", source: "<Bin NodeId>", tool: "<ParallelGripper NodeId>" })
server -> { accepted: true, operation: "<IntentOperation NodeId>" }
agent -> robotics_wait_operation(controllerId, "pick-slot-01", operation, 2000)
server -> completed=true
agent -> robotics_submit_joint_move(controllerId,
{ intentId: "carry-slot-01", jointTargets: [0.1, -1.0, 1.5, -0.9, 0.75, 0.0] },
axisCount: 6)
server -> { accepted: true, operation: "<IntentOperation NodeId>" }
agent -> robotics_wait_operation(controllerId, "carry-slot-01", operation, 2000)
server -> completed=true; HeldPartPosition has changed and HeldPartVisible is true.
agent -> robotics_submit_place(controllerId,
{ intentId: "place-slot-01", destination: "<Fixture NodeId>", tool: "<ParallelGripper NodeId>" })
server -> { accepted: true, operation: "<IntentOperation NodeId>" }
agent -> robotics_wait_operation(controllerId, "place-slot-01", operation, 2000)
server -> completed=true; PayloadSlot01Filled is true.