docs(plan): MTConnect write-back design + implementation plan; MQTT P3 recorded as already shipped

This commit is contained in:
Joseph Doherty
2026-08-05 06:08:44 -04:00
parent de036c62aa
commit e784545844
3 changed files with 347 additions and 0 deletions
@@ -0,0 +1,151 @@
# MTConnect write-back + MQTT write-through record reconciliation — design
**Status:** DRAFT 2026-08-05.
## 1. Scope correction: the MQTT half of this request is already shipped
The request ("MQTT P3 write-through plus its two documented browse-UI gaps, MTConnect write-back")
came from `docs/plans/2026-07-24-driver-expansion-tracking.md` §Next actions (lines 288–289) — which
is **stale**. On master today:
| Item | Status | Evidence |
|---|---|---|
| MQTT P3 write-through (`IWritable`: plain `writeTopic` publish + Sparkplug NCMD/DCMD) | **Shipped, live-gated** — #508, `72be1922`, 2026-07-31 | `MqttDriver.cs:65-67` implements `IWritable`; `Mqtt.md:16-20` status banner |
| Browse-UI gap 1 (empty tree cannot arm a rebirth) | **Fixed** — #514, `7f53bd96` | `RawBrowseModal.razor:127-157` manual `{group}/{edgeNode}` scope input |
| Browse-UI gap 2 (slashed metric name refused at browse-commit) | **Fixed** — #509, `7f53bd96` | `RawBrowseCommitMapper.ToRawSegment` (`:107`), collision refusal (`:117`) |
So the only MQTT work left is **documentation reconciliation** — three stale records that now
contradict the code and `Mqtt.md`'s own closure line (`:597-602`):
1. `docs/drivers/Mqtt.md:550-556` — still prints the #514 rebirth gap as an open "Known gap".
2. `docs/drivers/Mqtt.md:586-587` — "Read-only. No writes reach the broker; every node is ViewOnly"
contradicts §Write-through (`:407-480`) in the same file.
3. `docs/plans/2026-07-24-driver-expansion-tracking.md:288-289` §Next actions still lists MQTT P3 +
the two gaps as remaining work; the P2 section (`:239-263`) still describes the gaps as
"documented not fixed".
4. `deferment.md:132-133` — "`IWritable` is absent from MTConnect, MQTT, Sql and Calculation" was
audited pre-#508 and is wrong about MQTT (and, after this branch, about MTConnect).
## 2. MTConnect write-back — the real work
### 2.1 What was deferred, and what mechanism to build on
The driver shipped deliberately read-only (`MTConnectDriver.cs:63` — no `IWritable`;
`docs/plans/2026-07-15-mtconnect-driver-design.md` §3.6). The deferral named **MTConnect
Interfaces** (a request/response state-machine handshake between two agents) as the only write-back
shape and judged it misleading to model as `IWritable` — "revisit only on a concrete deployment
need". This request is that need, but Interfaces is still the wrong mechanism: it is rarely
deployed, requires adapter-side cooperation, and is a state machine rather than a value write.
The mechanism this design uses instead is the **Agent's HTTP PUT/POST surface** — a standard
cppagent feature, off by default, enabled by `AllowPut = true` (optionally scoped by
`AllowPutFrom = <host list>`) in `agent.cfg`. With it enabled, `PUT`/`POST
{agent}/{deviceName}?{dataItemId}={value}` sets the named observation in the agent's buffer exactly
as if an adapter had reported it. The repo's own integration fixture already carries the key —
currently `AllowPut = false` with a "must not accept writes" comment
(`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg:25-26`).
### 2.2 Honest semantics — this is observation injection, not machine command
A PUT write updates **what the agent reports**, not the machine: the value lands in the agent's
observation buffer, is visible to every agent consumer, and the next adapter/SHDR update for that
data item overwrites it. That is genuinely useful (operator-set EVENT values, external systems
feeding data items the adapter does not own, test rigs) but it must never be documented as
commanding the CNC. The docs section this branch writes states this prominently, and the feature is
**opt-in at two independent layers**: the agent (`AllowPut`) and the driver config
(`write.enabled`, default `false`). Either alone leaves today's read-only behaviour untouched.
### 2.3 Driver design
**Config.** `MTConnectDriverConfigDto` (+ typed `MTConnectDriverOptions`) gains a `Write` block:
```json
"write": { "enabled": true }
```
Default absent/`false` — nothing changes for existing deployments. No per-tag key: writability is
derived (below), matching how the driver derives everything else from the probe model.
**Capability.** `MTConnectDriver` adds `IWritable`. The factory already returns the concrete type
(`MTConnectDriverFactoryExtensions.cs:33-39` documents why), so capability dispatch picks it up
with no registry change.
**`WriteAsync` shape** (mirrors `MqttDriver.cs:670-745` — never throws, batch shape preserved,
per-slot codes):
- `write.enabled == false` → every slot `BadNotWritable` (0x803B0000).
- No live client (pre-Initialize / post-Shutdown) → `BadNotConnected`, matching `ReadAsync`'s
convention (`MTConnectDriver.cs:628-695`).
- RawPath does not resolve via `TagBinding.ToDataItemId` → `BadNodeIdUnknown`.
- Data item is CONDITION-category, or the value is null / not representable as an invariant string
→ `BadNotWritable` / `BadTypeMismatch` respectively. (Condition PUTs use a different level|text
wire format and carry alarm semantics — excluded from v1.)
- HTTP call: `POST {agentBase}/{deviceName}?{dataItemId}={value}` via the agent-client seam. The
owning `deviceName` comes from a `dataItemId → deviceName` map built at bind time from the cached
probe model (falling back to `options.DeviceName`); a multi-device agent is addressed correctly
without config.
- Agent refusal (non-2xx, or an `MTConnectError` document in a 200 body) → `BadCommunicationError`
with the agent's own error text logged (never in `LastError` — type-only discipline).
- `OperationCanceledException` fills remaining slots and returns; any other exception → log warning
+ `BadCommunicationError`.
- Values format with `CultureInfo.InvariantCulture`; booleans as the raw invariant string.
**Seam extension.** `IMTConnectAgentClient` gains
`Task<MTConnectWriteOutcome> WriteObservationAsync(string deviceName, string dataItemId, string value, CancellationToken ct)`
(outcome = accepted / agent-error-with-text / transport-error). `MTConnectAgentClient` implements
it over `_http` (the unary client with the bounded timeout) using `HttpMethod.Post`; the stub
`HttpMessageHandler` ctor seam (`MTConnectAgentClient.cs:107`) covers response-shape unit tests
with no socket. The unit-test `CannedAgentClient` records write calls and returns scripted
outcomes.
**Discovery stamping.** `DiscoverAsync` currently stamps every leaf
`SecurityClassification.ViewOnly` (`MTConnectDriver.cs:1040-1042`) with a comment saying that is
the only honest tier for a driver with no `IWritable`. That derivation becomes conditional:
`CanWrite(dataItem)` (= `write.enabled` && category is not CONDITION) → `Operate`, else `ViewOnly`
— the same "a node cannot advertise writability the driver would refuse" principle MQTT pinned
(`MqttDriver.cs:504`, `CanWrite` at `:625-627`). The implementer must verify how materialization
derives the `CurrentWrite` access bit (capability presence + SecurityClass) so a stamped-Operate
node is actually writable over OPC UA, and a ViewOnly node is not.
**Doc comments to update in the same change:** the class remarks
(`MTConnectDriver.cs:46-53` "deliberately no IWritable"), the factory remark
(`MTConnectDriverFactoryExtensions.cs:39`), and `MTConnectTagDefinition.cs:10-14` ("no
corresponding write path").
### 2.4 Out of scope
- **MTConnect Interfaces** — still deferred, same rationale as §3.6; this design does not touch it.
- Asset writes (`POST /asset`), per-tag write opt-outs, write retry (`isIdempotent` stays `false`
at `DriverInstanceActor.cs:776`), and PUT `time=` timestamps — YAGNI.
- Any AdminUI surface — the driver form gains nothing; `write.enabled` is authored in the existing
driver-config JSON. (No chrome gate needed for this branch.)
### 2.5 Testing
1. **Unit (offline, falsifiable).** New `MTConnectWriteTests`: batch contract + every refusal leg
via `CannedAgentClient` (disabled-by-default, unknown RawPath, condition category, null value,
no client, cancellation fill, agent-error outcome). Encoder-free — the write is a URL, so the
URL/verb/device-resolution assertions live in `MTConnectAgentClient` stub-handler tests
(MTConnectError-in-200 detection included). Discovery stamping test: same probe fixture browsed
with write on/off flips leaf SecurityClass, CONDITION stays ViewOnly.
2. **Integration (env-gated, docker fixture).** Flip the fixture to `AllowPut = true` (update the
comment to say writes are now in scope) and add a write leg: write via the driver → read
`/current` back → the observation carries the written value; a second leg proves an
`AllowPut = false`-style refusal maps to `BadCommunicationError` is *not* needed (we cannot run
two agent configs in one fixture) — instead a bad-dataItemId write asserts the agent-error
mapping. Remove the "(b) Writes" not-covered remark
(`MTConnectAgentIntegrationTests.cs:64-65`).
3. **Live gate (no chrome).** Against the docker-dev rig + the shared fixture agent: author an
MTConnect driver with `write.enabled`, deploy, `Client.CLI write` to a writable node, confirm
via `/current` and via OPC UA read-back. Runnable headless by the executing session.
## 3. Bookkeeping (both halves)
- `docs/drivers/MTConnect.md`: capability surface (`:71-77`), Deferred (`:295-301`), new
§Write-back section with the §2.2 semantics warning.
- `docs/plans/2026-07-15-mtconnect-driver-design.md` §3.6: superseded note pointing here.
- `docs/plans/2026-07-24-driver-expansion-tracking.md`: MQTT P2 gap bullets + §Next actions
rewritten to record #508/#509/#514 closure and this branch's MTConnect write-back.
- `docs/drivers/Mqtt.md`: delete stale blocks `:550-556` and `:586-587`.
- `deferment.md`: correct `:132-133` (post-branch: `IWritable` absent from Sql and Calculation
only) and the MTConnect row (`:126`).
@@ -0,0 +1,183 @@
# 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:
```csharp
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.
@@ -0,0 +1,13 @@
{
"planPath": "docs/plans/2026-08-05-mtconnect-writeback.md",
"tasks": [
{ "id": 0, "subject": "Task 0: Write options block (config + parse)", "status": "pending" },
{ "id": 1, "subject": "Task 1: Agent-client write seam", "status": "pending" },
{ "id": 2, "subject": "Task 2: MTConnectDriver IWritable + discovery stamping", "status": "pending", "blockedBy": [0, 1] },
{ "id": 3, "subject": "Task 3: Integration fixture + write legs", "status": "pending", "blockedBy": [2] },
{ "id": 4, "subject": "Task 4: Docs reconciliation (MTConnect + stale MQTT records)", "status": "pending", "blockedBy": [2] },
{ "id": 5, "subject": "Task 5: Full-suite verification", "status": "pending", "blockedBy": [3, 4] },
{ "id": 6, "subject": "Task 6: Live gate (main session, headless)", "status": "pending", "blockedBy": [5] }
],
"lastUpdated": "2026-08-05T00:00:00Z"
}