Files
lmxopcua/docs/plans/2026-08-05-mtconnect-writeback.md
T

9.8 KiB

MTConnect write-back Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (opus implementers per user instruction).

Goal: Implement IWritable on MTConnectDriver via the Agent's HTTP PUT/POST surface (opt-in at agent AllowPut AND driver write.enabled), and reconcile the stale MQTT write-through/browse-gap records.

Architecture: See docs/plans/2026-08-05-mtconnect-writeback-design.md (in this branch). Write = observation injection into the agent buffer, never machine command. Batch-safe WriteAsync mirroring MqttDriver.cs:670-745; new seam method on IMTConnectAgentClient; discovery stamps Operate only for leaves the driver would accept.

Tech Stack: .NET 10, xUnit v3 + Shouldly, existing CannedAgentClient / stub HttpMessageHandler seams. No new packages.

Worktree: /Users/dohertj2/Desktop/OtOpcUa/.claude/worktrees/mqtt-mtconnect-write (branch feature/mqtt-mtconnect-write). All paths below relative to it.


Task 0: Write options block (config + parse)

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 1

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs (config DTO region ~:3257-3345 + ParseOptions ~:2222)
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverOptionsTests.cs, tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs

TDD: failing test first — {"write":{"enabled":true}} parses to Options.Write.Enabled == true; absent block defaults to false; malformed value behaves like the sibling Probe/Reconnect blocks (match their existing tolerance/failure semantics exactly — read them first). Add MTConnectWriteOptions (sealed record, Enabled, default false) on MTConnectDriverOptions following the Probe/Reconnect pattern, a nullable WriteDto on the config DTO, wire through ParseOptions. Commit feat(mtconnect): parse the write.enabled config block.

Task 1: Agent-client write seam

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 0

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs (new)

Add to the seam:

Task<MTConnectWriteOutcome> WriteObservationAsync(
    string deviceName, string dataItemId, string value, CancellationToken cancellationToken);

with MTConnectWriteOutcome a small readonly record: Accepted bool + AgentError string? (agent-produced MTConnectError text) — transport failures THROW (HttpRequestException etc.), the driver maps them; agent refusals return Accepted=false with the text. Implementation in MTConnectAgentClient over _http (the bounded-timeout unary client): HttpMethod.Post to {base}/{deviceName}?{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)} built with the same left-part logic as BuildRequestUri (:382-403). A 2xx whose body parses as an MTConnectError document is a refusal (reuse the LocalName-only XML helpers in MTConnectXml.cs); non-2xx is a refusal carrying the status; empty 2xx is accepted.

TDD via the internal HttpMessageHandler ctor seam (MTConnectAgentClient.cs:107), the same pattern MTConnectDriverProbeTests.cs:22-28 uses: assert verb, exact URI (device path + escaped query), accepted on empty 200, refusal on MTConnectError-in-200 (agent text surfaced), refusal on 404, throw on handler-thrown transport error. Commit feat(mtconnect): agent-client observation write (HTTP POST, AllowPut surface).

Task 2: MTConnectDriver implements IWritable + discovery stamping

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs (class decl :63, remarks :46-53, WriteAsync new, TagBinding device-name map ~:3170-3213, DiscoverAsync stamping :1030-1046)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs (remark :33-39)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinition.cs (remark :10-14)
  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs (record writes, scripted outcomes)
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectWriteTests.cs (new), MTConnectDiscoverTests.cs (stamping cases)

Per design §2.3. Status-code constants: follow MQTT's named-const pattern (MqttDriver.cs:76-89) — StatusBadNodeIdUnknown 0x80340000, StatusBadNotWritable 0x803B0000, StatusBadTypeMismatch 0x80740000, StatusBadCommunicationError 0x80050000, plus BadNotConnected matching ReadAsync's existing constant. Refusal order per slot: disabled → BadNotWritable; no client → BadNotConnected; unresolved RawPath → BadNodeIdUnknown; CONDITION category → BadNotWritable; null/unrepresentable value → BadTypeMismatch; then the seam call — Accepted=false or thrown transport error → BadCommunicationError (log agent text at Warning, never into LastError). OperationCanceledException fills remaining slots with BadCommunicationError and returns. Never throw. Success = WriteResult(0u).

Device-name resolution: extend TagBinding (or a parallel FrozenDictionary built in BuildBinding ~:2887-2980) with dataItemId → owning deviceName from the probe model; fall back to options.DeviceName; unresolvable device → BadNodeIdUnknown. Values format via Convert.ToString(value, CultureInfo.InvariantCulture).

Discovery: CanWrite(dataItem) = options.Write.Enabled && !IsCondition(category) → SecurityClassification.Operate else ViewOnly; update the :1040-1042 comment. Verify how materialization derives CurrentWrite (search for how MQTT's Operate-stamped nodes get the write bit — SecurityClass + capability) and state the finding in the commit message; if a server-side gate keys on the driver capability alone, note that existing ViewOnly-committed raw tags keep their authored SecurityClass.

TDD: every refusal leg observed red first where feasible; the batch-shape test (one bad slot doesn't fail the batch) mirrors MqttDriverWriteTests.cs:74. The publish itself is proven by Task 3 live legs, not by asserting a fake was called beyond the seam-call recording (same philosophy as MqttDriverWriteTests.cs:11-15). Commit feat(mtconnect): IWritable via agent observation writes (#468-era deferral closed).

Task 3: Integration fixture + write legs

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 4

Files:

  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg (AllowPut = true + comment rewrite)
  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs (remove the "(b) Writes" not-covered remark :64-65; add write legs)

Legs (env-gated by the existing MTCONNECT_AGENT_ENDPOINT fixture, Assert.Skip clean):

  1. Driver with write.enabled writes a distinct value to a writable EVENT data item (pick one from Devices.xml the SHDR adapter does not continuously overwrite — read adapter.py to choose; if every item is pumped, add a static EVENT item to Devices.xml) → poll /current until the observation carries the value (bounded wait, StreamWait pattern).
  2. A write naming a nonexistent dataItemId → BadCommunicationError (agent refusal mapping).
  3. write.enabled absent → same write returns BadNotWritable without touching the agent. Commit test(mtconnect): live write legs against the AllowPut fixture.

Task 4: Docs reconciliation (MTConnect + stale MQTT records)

Classification: small Estimated implement time: ~5 min Parallelizable with: Task 3

Files:

  • Modify: docs/drivers/MTConnect.md (capability :71-77, Deferred :295-301, new §Write-back)
  • Modify: docs/plans/2026-07-15-mtconnect-driver-design.md (§3.6 superseded note)
  • Modify: docs/drivers/Mqtt.md (delete stale :550-556 and :586-587)
  • Modify: docs/plans/2026-07-24-driver-expansion-tracking.md (P2 gap bullets :239-263 → closed #509/#510/#513/#514; §Next actions :288-289 rewritten)
  • Modify: deferment.md (:126 MTConnect row, :132-133 four-driver IWritable line → Sql + Calculation only)

Per design §1 + §3. The Write-back section MUST carry the §2.2 semantics warning (observation injection, adapter overwrites, two-layer opt-in, AllowPutFrom pointer). Commit docs: MTConnect write-back recorded; stale MQTT write-through/browse-gap records reconciled.

Task 5: Full-suite verification

Classification: small Estimated implement time: ~4 min Parallelizable with: none

dotnet build ZB.MOM.WW.OtOpcUa.slnx (0 errors) + full MTConnect unit suite + AdminUI suite + dotnet test --filter "FullyQualifiedName~Mqtt" (docs-only but cheap insurance). Integration suite compiles; live legs skip offline. Fix fallout, commit if changes.

Task 6: Live gate (main session, headless — NO chrome)

Classification: standard Estimated implement time: ~10 min (main session, after merge)

Main session, not a subagent: bring up the MTConnect fixture (ssh dohertj2@10.100.0.35 compose in /opt/otopcua-mtconnect/ — lmxopcua-fix sync equivalent via rsync first, since agent.cfg changed), run the Task 3 legs live (dotnet test --filter the new write tests with MTCONNECT_AGENT_ENDPOINT set), then the docker-dev rig leg: author + deploy an MTConnect driver with write.enabled, Client.CLI write a value, read it back via Client.CLI and /current. Record results in the design doc §Outcome.