An MTConnect driver instance could not be authored at all: RawDriverTypeDialog
(the only DriverInstance creation surface) offered a hardcoded 9-entry list
without it, and DriverConfigModal's default: branch renders a warning with no
raw-JSON fallback — so even a hand-inserted row could not be configured and
DriverConfig stayed "{}", on which ParseOptions throws "missing required
AgentUri".
Adds the typed MTConnectDriverForm (the consistent sibling pattern) covering
agentUri / deviceName / the four polling knobs / probe / reconnect, wires the
dispatch case, and offers the type in the dialog.
- All decisions live in the static MTConnectDriverForm.BuildConfigJson +
MTConnectFormModel (plain C#), because this project has no bUnit.
- The wire shape mirrors the driver's private MTConnectDriverConfigDto, NOT
MTConnectDriverOptions: the latter's TimeSpan probe knobs would serialise as
"00:00:05" and never bind to the driver's integer intervalMs.
- arch-review 01/S-6: a non-positive timing knob is REMOVED from the document
rather than written, so ParseOptions substitutes the driver's own positive
default instead of the driver faulting at Initialize on RequirePositive.
Removal (not just skipping) also stops a stale positive value surviving and
disagreeing with what the form shows.
- Unknown top-level keys survive a load/save, so the driver's hand-authored
tags[] surface is not silently deleted by opening the form.
- _jsonOpts carries JsonStringEnumConverter, satisfying the
DriverPageJsonConverterTests fleet guard properly rather than by exemption.
New RawDriverTypeDialogCoverageTests resolves the offered set through
DriverTypeNames.All, so the next registered driver fails here until the dialog
offers it.
Registers the MTConnect typed tag editor in TagConfigEditorMap /
TagConfigValidator (keyed off DriverTypeNames.MTConnect, never a literal) and
adds the razor shell over Task 17's MTConnectTagConfigModel.
The shell stays trivial: the /probe-sourced mtCategory/mtType/mtSubType/units
row is rendered disabled + readonly with NO binding of any kind, and no
inferred-type hint is shown — MTConnectDataTypeInference.Infer() also keys on
the DataItem `representation`, which the tag-config model does not carry, so
calling it here would agree with the browse picker for ordinary items and
silently disagree for exactly the DATA_SET/TABLE/TIME_SERIES ones.
Also fixes the picker -> editor round trip, which was broken in both
directions:
- RawBrowseCommitMapper.BuildTagConfig had no MTConnect branch, so a committed
tag landed as the generic {"address": "<dataItemId>"} while the model read
only `fullName`. The data plane kept working (the driver accepts all three id
spellings), so the only symptom was an empty DataItem-id field in the editor
— and saving from that state stamped fullName:"" over the tag.
- MTConnectTagConfigModel.FromJson now accepts dataItemId/address as fallbacks
(fixing tags committed before this change) and ToJson normalises onto
`fullName`, dropping the aliases so the two spellings cannot drift.
Wave 1 of the driver-expansion program. The branch was complete and live-gated but
had never been merged; it sat 15 commits ahead and 50 behind master.
Purely additive behind the existing IModbusTransport seam — the application protocol
(function codes, codecs, planner, coalescing, write path, probe, materialisation,
HistoryRead) is identical across TCP and RTU; only the wire framing differs
([addr][PDU][CRC-16], no MBAP, no TxId). Zero changes to codecs/planner/health.
Selected by a new Transport config field (ModbusTransportMode: Tcp | RtuOverTcp),
routed through ModbusTransportFactory (throws on unknown mode), with the string-enum
guard on options/DTO/factory and a Transport selector on the AdminUI Modbus form.
ModbusSocketLifecycle was extracted from ModbusTcpTransport (behaviour-preserving)
and is composed by both transports.
Descoped by design: direct-serial (System.IO.Ports) and Modbus ASCII — RTU buses are
reached exclusively via a serial->Ethernet gateway. No per-tag config changes; the
existing per-tag UnitId already makes a multi-drop bus work.
Re-validated against current master at merge time (the branch's own gate predates 50
commits of master): no overlapping files, clean merge, solution builds 0 errors, and
Modbus 344/344, Modbus.Addressing 161/161, AdminUI 740/740, Runtime 507/507,
Configuration 131/131 all pass. Confirmed the Transport selector is still reachable
through master's current authoring path (DriverConfigModal -> ModbusDriverForm), so
the branch's live /run gate result still applies.
Both defects are PRE-EXISTING (present on master, unrelated to #499) and were found
live-verifying the #499 guard, which lives on this page and was unreachable without
them fixed.
1. **HTTP 500 on load.** `FormModel.ServiceLevelBase` was `byte` to match the
entity, and `InputNumber<T>`'s static constructor throws outright for
`System.Byte` — "The type 'System.Byte' is not a supported numeric type". That
escapes `BuildRenderTree` unhandled, which in Blazor takes the WHOLE page down,
not one field. `/clusters/{id}/nodes/{nodeId}` and `.../nodes/new` have never
rendered. Now `int` with the same `[Range(0, 255)]` and a cast on the way to the
entity, so the stored type is unchanged. (Same failure mode as #504: an
unhandled throw inside the render tree is a page-kill.)
2. **Save did nothing, silently.** `NodeId` was validated against
`^[A-Za-z0-9_-]+$`, which forbids the colon — but every NodeId in this system is
`host:port` (`ClusterRoleInfo` and `ConfigPublishCoordinator` derive it as
`member.Address.Host:Port`, the seed writes `central-1:4053`, and
`NodeDeploymentState.NodeId` is FK-bound to it). So every existing node failed
validation, `OnValidSubmit` never ran, and the button was inert. Pattern now
allows `:` and `.` (hostnames may be dotted).
3. **Added the missing `<ValidationSummary/>`.** The form had a
`DataAnnotationsValidator` but nothing rendering its output, so a validation
failure produced no feedback at all — which is precisely how (2) stayed
invisible. This is the change that stops the next such defect being silent.
Live-verified on docker-dev (both central nodes rebuilt, since :9200 round-robins
the pair): page 500 → 200 on both routes, and a real edit now persists.
`ClusterNode.DriverConfigOverridesJson` is the third surface a Sql driver's config
is persisted on, and the only one nothing gated. It is a map keyed by
`DriverInstanceId` merged onto the cluster-level `DriverConfig`, so a literal
`connectionString` pasted there leaks exactly as one pasted into the driver — the
leak #498 closed for `DriverInstance.DriverConfig` and `Device.DeviceConfig`.
Gated at the SAVE, not at the deploy — a deliberate departure from both options the
issue offered:
- `DraftSnapshot` carries no `ClusterNode` rows, and adding them would widen the
snapshot and every builder of one for a single rule about a value that never
enters the artifact.
- More to the point, a deploy gate is the wrong instrument here. Node overrides are
not in the artifact, so blocking a deploy would not stop the credential being
stored — it is already in the database and replicated by then. Refusing the save
is the only point where "refuse to store it" is literally true, which is #498's
own framing: discarding a secret on read is not the same as refusing to store it.
The check moves into a shared `SqlCredentialGuard` so the two enforcement points
cannot drift; `DraftValidator` now calls it instead of its own private helper.
Kept deliberately narrow — the `Sql` driver's `connectionString` only, and only for
instance ids that ARE Sql drivers. The issue asked whether to generalise to
credential-shaped keys across every driver type; not doing that, for the same
reason #498 did not: a broader sweep would start refusing configs that are
legitimate today for drivers which never made an indirect-credential guarantee,
which is a regression rather than defence in depth. Widen per driver, as each gains
its own contract.
The error names the offending driver instance(s) and NEVER the value — it reaches
the AdminUI and the audit trail.
14 new cases cover both halves, including the case variants (System.Text.Json binds
`ConnectionString` to `connectionString`, so a case variant is the same key, not a
bypass), non-Sql ids being ignored, and every blank/malformed/non-object shape.
`Scripts.razor` rendered a hash prefix as `@s.SourceHash[..12]…`. `SourceHash`
is a plain nvarchar(64) with no length floor, so any value shorter than 12
characters threw ArgumentOutOfRangeException out of BuildRenderTree — unhandled
in Blazor, which takes the WHOLE page to an HTTP 500, not just the offending
row. Live-reproduced on docker-dev, where two Script rows carry `h1` and
`h-abs-hr200`: /scripts was a hard 500.
New `Components/Shared/DisplayText.Abbreviate(string?, int)`:
- null/blank renders the Admin UI's em-dash placeholder rather than throwing;
- a value already within budget renders verbatim with NO ellipsis, so the
display never implies text that isn't there (the old markup appended "…"
unconditionally, outside the slice — `h1` would have shown as `h1…`);
- a maxLength < 1 is clamped, so a display bug cannot become a page crash.
Applied to every unguarded range-operator slice over a DB-sourced string found
by sweeping the AdminUI for `[..N]`:
Scripts.razor SourceHash (the reported crash)
Certificates.razor Thumbprint (read off the on-disk store)
Deployments.razor x2 RevisionHash
Clusters/ClusterOverview.razor RevisionHash
The other `[..N]` hits are safe and untouched: Guid.ToString("N") slices
(always 32 chars) and the `Length > 60 ? [..60]` ternaries that self-guard.
NOTE — the issue text blamed the docker-dev seed; that was wrong and is
corrected on #504. `seed-clusters.sql` inserts only ServerCluster, ClusterNode
and LdapGroupRoleMapping. The short-hash rows came from earlier live-gate
authoring. A freshly seeded rig is fine; the trigger is any Script row that did
not go through ScriptEdit / UnsTreeService.HashSource — REST-API authored,
hand-inserted, or migrated in.
Tests: 14 new in DisplayTextTests, covering the short/null/blank/clamped cases
and a never-throws sweep over every value x budget combination. AdminUI suite
739/739.
Live-verified on docker-dev (AdminUI has no bUnit): /scripts 500 -> 200 across
both central nodes, rendering `hash=h1` and `hash=h-abs-hr200` in full;
/deployments still truncates 64-char hashes at 12 with the ellipsis;
/certificates and /clusters/MAIN unchanged.
Task 26 — the P2 milestone gate. Ran the live /run verification on an isolated
docker-dev rig (project otopcua-mqtt, ports 9210/4850-4851/14350, image built
from this branch) against the real Mosquitto TLS+auth broker and the C#
Sparkplug edge-node simulator on 10.100.0.35, and recorded the result.
The gate found three defects. Two are the same defect class the P1 gate found
twice — a hand-maintained AdminUI surface left behind by a driver-side feature —
and the third is a pre-existing cross-driver bug that only a Sparkplug flow
could surface.
1. CRITICAL, FIXED — MqttDriverForm still shipped its P1 Sparkplug PLACEHOLDER.
Switching Mode to SparkplugB rendered a "not available yet" notice and NO
Group ID field, so with Sparkplug ingest fully shipped there was still no
way to author a Sparkplug driver from the AdminUI at all. Sparkplug.GroupId
is the driver's entire subscription filter (spBv1.0/{GroupId}/#): blank ⇒
connected, Healthy, ingesting nothing. Now authors all five Sparkplug keys,
MERGES over the existing sub-object rather than replacing it (so a key a
newer driver adds inside it survives an older AdminUI), leaves it untouched
in Plain mode, and validates the group id as the topic segment it is.
Pinned by 8 MqttDriverFormModelTests cases, verified falsifiable — 8 RED
with the fix stubbed out.
2. PRE-EXISTING + CROSS-DRIVER, FIXED — every node label in the shared
DriverBrowseTree was an <a href="#">. In a Blazor Web App, blazor.web.js's
enhanced-navigation click interceptor resolves a bare "#" against
<base href="/">, so clicking ANY browse-tree node label navigated the whole
AdminUI to "/", tore down the circuit, and destroyed the hosting modal —
losing the browse session AND the tag selection. @onclick:preventDefault
does not help: it suppresses the browser's default action, not Blazor's own
interceptor. Labels are now <button type="button">. Dates to the component's
introduction (2026-05-28) and affects EVERY driver's picker; it surfaced
only now because choosing a rebirth scope is the first flow that requires
clicking a label rather than the ▶ toggle (always a button, always fine).
3. FIXED (mitigation) — the browse tree rendered exactly once, at open, and
never again, while OpenAsync returns as soon as the SUBSCRIBE is granted.
On Sparkplug that means it is almost always empty forever, because births
are never retained. Added a Refresh button that re-reads the root against
the SAME session (nothing reconnects, nothing observed is lost, the tag
selection is kept, only the armed rebirth scope is cleared).
Live gate result (all against the real broker + simulator, group OtOpcUaSim):
- driver authored end-to-end through the fixed MqttDriverForm; blob is
Mode:SparkplugB + Sparkplug.GroupId:OtOpcUaSim with enums as names
- browse: BROWSER OPEN chip, Sparkplug-only rebirth panel, tree renders
Group → EdgeNode → [Device] → Metric with the device folder Filler1 and
node-level metric leaves SIDE BY SIDE, and "Node Control/Rebirth" as ONE
leaf (the metric-name-is-not-a-topic-segment rule holding)
- rebirth: group scope names the group + the 32-node cap, Cancel published
NOTHING (simulator log unchanged); edge-node scope → "1 command published"
and the simulator logged "rebirth NCMD #1 received; republishing metadata";
group scope → "2 commands published", both edge nodes re-birthed; a device
and a metric both resolve UP to "edge node EdgeA"; the panel does NOT render
for a Plain MQTT device
- browse-commit wrote bindable tuples with no topic/address key —
{"groupId","edgeNodeId","metricName","deviceId"} for the device metric and
deviceId ABSENT (not blank) for the node-level one, datatypes inherited from
the birth (Int64 / Float)
- deployed (Sealed), and both nodes served live changing values through OPC UA
at the 2 s cadence — Good 0x00000000, no BadNodeIdUnknown
- death→STALE: stopping the simulator drove both nodes to 0x80000000 with an
empty value ~2 s later, and restarting it recovered them to Good on the new
birth (FillCount back to its birth-declared 1000, then counting)
- rediscovery fired exactly TWICE (once per authored scope: OtOpcUaSim/EdgeA
7 metrics, OtOpcUaSim/EdgeA/Filler1 3 metrics) and was then gated across
many subsequent births/rebirths — the anti-storm change gate working, and
EdgeB correctly filtered out as an unauthored scope
- tag editor: opens in SparkplugB (mode inference on reopen) with all four
descriptor fields populated; blank group → "A Sparkplug group ID is
required."; "Plant/1" rejected; "Node Control/Rebirth" ACCEPTED and saved
with the slash whole; dataType override persists and its key is ABSENT again
after selecting "(from birth certificate)"
Two things are NOT verified, and are recorded rather than claimed:
- Rediscovery is INERT in v3 and this is a platform gap, not an MQTT one:
nothing subscribes to IRediscoverable.OnRediscoveryNeeded and
DriverHostActor.HandleDiscoveredNodes hard-returns. A DBIRTH introducing a
metric does NOT change the OPC UA tree; redeploy. Verified from the driver's
log line only, exactly as far as it can be verified.
- A metric name containing "/" cannot be BROWSE-COMMITTED: the derived raw tag
Name is a RawPath segment and may not contain "/", so the spec-mandatory
"Node Control/Rebirth" is refused ("Row 3: Name must not contain '/'"). The
refusal is loud and all-or-nothing, never a silently mis-bound tag, and
Manual entry is the working path. Fixing it needs a name-sanitisation policy
with a collision answer, so it was recorded rather than guessed at.
Also left open + documented: an empty browse tree cannot arm a rebirth (the
scope comes from a tree click, and the session side already accepts a bare
{group}/{edgeNode} — only a UI path is missing); _canRebirth is captured at
browse-open; the tag editor injects the Plain-only payloadFormat key into a
Sparkplug blob (cosmetic — the factory routes on the tuple).
Suites: offline 1528 passed / 0 failed (581 Driver.Mqtt + 809 AdminUI + 138
Core.Abstractions); live 15/15 against the broker + simulator.
Docs: docs/drivers/Mqtt.md brought fully up to date for P2 (Sparkplug config
sub-object, both tag shapes, the ingest state machine, rediscovery, browse +
rebirth + Refresh, a Known-gaps table); Mqtt-Test-Fixture.md gains the simulator
and drops its "Sparkplug has no fixture" claims; infra/README.md §3 gains the
simulator row; CLAUDE.md gains the simulator endpoint facts; the tracking doc
marks MQTT/Sparkplug COMPLETE. docker-dev/docker-compose.mqtt.yml is the
isolated-rig overlay the gate ran on.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Two gaps the Task 23/24 review found, both blocking Task 26's live gate.
Gap 1 — browse-commit produced a silently dead MQTT tag. RawBrowseCommitMapper
had no `Mqtt` case, so a committed leaf fell through to the generic
`{"address": …}` key. Neither MqttTagDefinitionFactory entry point reads
`address`: the tag deployed clean and reported BadNodeIdUnknown forever, with
no signal at commit time. Predates Task 23 (Plain was affected too), but Task 23
built the Sparkplug metric tree precisely so an operator could browse and commit
a binding.
The address is a DESCRIPTOR, not a reference string, and it cannot be recovered
from the browse node id: `{group}/{node}[/{device}]::{metric}` where a metric
name legitimately contains `/` (`Node Control/Rebirth`) — the ambiguity
MetricSeparator's remarks already name. So the session STATES it, via a new
`AttributeInfo.AddressFields` seam, and the mapper reads it. Which keys are
emitted is also how the mapper learns Plain vs Sparkplug — the driver type
reaching it is just `Mqtt`, and the mode lives on the driver config. Key names
are single-sourced in the new `MqttTagConfigKeys` (producer, mapper, factory),
with the literals pinned by a test so a symmetric rename cannot silently unbind
already-persisted blobs. A leaf with no stated address is refused at commit in
words rather than committed dead.
Gap 2 — RequestRebirthAsync had no UI. Task 23 shipped it backend-only; Task 26
step 1 assumes the button, and Task 25's runbook notes the picker tree stays
empty until a birth lands, so it is the only way to fill it on demand. Added to
the /raw browse modal: scope = the clicked tree node (device/metric resolve up to
their edge node, group fans out and is refused whole past 32), an explicit
two-click confirm naming the resolved scope and its consequence, the outcome or
the refusal shown rather than swallowed. Offered only for a Sparkplug session
(new `IRebirthCapableBrowseSession.RebirthAvailable` — one session class serves
both modes, so a type test alone would draw a button that can only throw) and
only to a DriverOperator; the server-side gate remains the boundary.
Tests: MQTT 545 → 581, AdminUI 781 → 800. The round-trip tests feed the emitted
blob to the REAL factory and were falsified by mutating the emitted key name.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Task 24 (P2): replaces the P1 Sparkplug Validate() stub (always null) and the
"not available yet" editor placeholder with real groupId/edgeNodeId/deviceId/
metricName authoring, mirroring MqttTagDefinitionFactory.FromSparkplugTagConfig
in both directions -- required ids + optional deviceId, dataType/qos read
strictly but only when present (dataType is optional: the birth certificate
declares it). One rule is deliberately stricter than the factory: group/edge-
node/device ids reject '/', '+', '#' (topic-segment characters a decoded
incoming id can never carry, so an authored one that does can never bind);
metricName is exempt since Sparkplug's own canonical names use '/' (e.g.
"Node Control/Rebirth"). No FullName key is written -- TagConfig carries no
identity under v3, and the plan's snippet asking for one was corrected per the
brief. A SparkplugB tag now survives save/reopen because its descriptor keys
re-infer Mode on load; there was never a 'mode' key to persist.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
The P1 live gate had to insert MQTT driver config directly via SQL: DriverConfigModal and
DeviceModal both fell through to "No typed config form for driver type Mqtt" with no raw-JSON
fallback, so broker host/port/TLS/credentials were unauthorable from the AdminUI. Task 12 built
the *tag* editor; these are the driver + device surfaces.
MqttDriverForm authors the whole MqttDriverOptions connection surface (host/port/clientId,
TLS + CA pin, credentials, protocol version/clean-session/keep-alive, connect timeout, reconnect
backoffs, mode, maxPayloadBytes, and the Plain sub-object). The Sparkplug sub-object stays P2 —
a marked placeholder mirroring MqttTagConfigEditor's stub, with any existing sparkplug keys
preserved untouched.
The connection is authored on the DRIVER, not the device — unlike Modbus/S7/OpcUaClient and
contrary to what docs/drivers/Mqtt.md said. DriverDeviceConfigMerger merges a device's keys up
only when the driver has exactly ONE device, so a device-authored broker connection vanishes
silently the moment a second device is added. MqttDeviceForm is therefore informational (the
GalaxyDeviceForm shape) and round-trips DeviceConfig verbatim; it flags legacy connection keys
left on a device by a pre-form deployment, because those still win via merge-up.
Serialization goes through the ONE shared MqttJson.Options instance (Task 9's decision), never a
fifth per-form copy — pinned by an assertion that an ordinal-only reader CANNOT bind the emitted
blob, and by extending the fleet-wide DriverPageJsonConverterTests guard to resolve a form's
serializer from an explicit external-instance registry when it has no _jsonOpts field. Dropping
the converter from MqttJson.Options reddens 3 tests and leaves the symmetric round-trip green —
the Task 9 lesson, reproduced.
RawTags is stripped from the emitted blob (the deploy artifact owns it) and unknown top-level
keys survive a load->save. Validation mirrors the driver's own [Range] bounds inline, and
ToOptions() additionally clamps, so an ignored error still cannot persist a
connectTimeoutSeconds:0 driver-brick. A non-blank clientId raises the P1 live-gate warning
inline (fixed ids make redundant pair nodes evict each other while both report Healthy).
AdminUI 761/761 green (was 730), TWAE-clean; Driver.Mqtt 266/266 green.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
The read-only Sql driver's runtime factory/probe and tag-side editors were
wired, but the AdminUI could not author the DRIVER: the /raw "New driver" type
list, the DriverConfigModal switch, and the legacy identity dropdown all omitted
Sql, and no SqlDriverForm existed. Live-driving the AdminUI surfaced this.
- Add SqlDriverForm.razor over the SqlDriverConfigDto shape: Provider
(SqlServer-only in v1), required connectionStringRef (an env-var NAME, not a
connection string — label is explicit), optional poll/operation/command
timeouts, maxConcurrentGroups, nullIsBad. Enums serialize by NAME via
JsonStringEnumConverter; a connection-string literal can never be authored
(no such field is collected); rawTags (composer-owned) + allowWrites (inert,
read-only v1) are never emitted.
- Wire Sql into DriverConfigModal switch, RawDriverTypeDialog type list, and
DriverIdentitySection legacy dropdown.
- Add SqlDriverFormContractTests: the exact JSON the form emits constructs a
driver through the real factory, missing connectionStringRef is rejected, and
provider round-trips as the "SqlServer" name (never an ordinal).
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Task 14 — the P1 milestone gate. Ran the live /run verification on a docker-dev
rig against the real Mosquitto TLS+auth fixture, and recorded the result.
The gate did its job: it found MQTT was unauthorable in production.
RawDriverTypeDialog's driver-type list is a hand-maintained array that nobody
added MQTT to, so the /raw "New driver" picker never offered it — with every
other layer (contracts, driver, browser, factory, host registration, typed tag
editor) complete and 266 green unit tests. Nothing tied that array to
DriverTypeNames and AdminUI has no bUnit, so no offline test could see it.
Fixed, plus RawDriverTypeDialogParityTests: a reflection parity guard that
fails for ANY DriverTypeNames entry missing from the picker. Verified
falsifiable — RED with "…cannot be authored from the /raw New-driver picker,
so they are unreachable in production: Mqtt" before the one-line fix.
A second gap is left OPEN and documented, not fixed (no task ever covered it):
MqttDriverForm / MqttDeviceForm do not exist, and DriverConfigModal/DeviceModal
have no raw-JSON fallback — so an operator can create an MQTT driver but cannot
author its broker endpoint or credentials. P1 is not operator-complete until
those two razor forms land. The gate authored config via SQL to get past it.
Live gate result (isolated 2-node MAIN stack, image built from this branch,
fixture at 10.100.0.35:8883, AllowUntrustedServerCertificate rather than
pinning the CA into the rig):
- typed MQTT tag editor renders and round-trips; Json shows the JSON-path
field, Raw/Scalar hide it; wildcard topic a/+/c blocked at save; data-type
list offers Float64 and no Double; qos/retainSeed absent on "(driver
default)" and qos:2 written as a number; isHistorized/historianTagname
survive a topic-only edit; SparkplugB renders the P2 placeholder and
switching back keeps Plain values; jsonPath is guidance not a gate in all
three shapes ($ seeded only for a brand-new blank tag, blank stays absent,
clearing saves fine)
- deployed, and both editor-written blobs resolved and served changing live
values through OPC UA (22.8 / 1629, StatusCode Good) — no BadNodeIdUnknown
- the bespoke #-observation browser drove a real broker session and rendered
the fixture's actual topic tree (line1/{counter,state,temperature},
noise/chatter, retained/seed); a Modbus device's picker still correctly
reports "Browsing unavailable"
Also found live and documented (not a code change): both nodes of a redundant
pair run the driver, so a FIXED clientId makes them evict each other forever —
the broker logs "already connected, closing old connection" and both nodes
reconnect every ~2s while still reporting Healthy. Unset (the default) is
correct; confirmed 0 reconnects/60s on both nodes after removing it.
Suites: offline 1134 passed / 0 failed (266 Driver.Mqtt + 730 AdminUI incl. the
3 new guards + 138 Core.Abstractions); live 7/7 against the fixture.
Docs: new docs/drivers/Mqtt.md + Mqtt-Test-Fixture.md (the per-driver + fixture
convention every other driver follows), MQTT rows in docs/drivers/README.md,
TestConnectProbes.md, root README.md, and infra/README.md §3.
CLAUDE.md drift corrected while adding the MQTT endpoints:
- the "every fixture carries project: lmxopcua" claim was false — only the
MQTT fixture does; the filter returns one stack, not the fleet
- lmxopcua-fix.ps1 is Windows-VM-only; on macOS drive the host over ssh/rsync
(and the docker-dev rig itself runs locally under OrbStack)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Review follow-up. Dropped the hard Validate rejection of a blank jsonPath under
a Json payload: the runtime defaults it to the document root, which is the real
"publisher puts a bare JSON scalar on the topic" case, so rejecting it made the
editor refuse what the driver accepts — inverting "authoring surface accepts <=>
publish accepts" — and blocked whole CSV-import batches, since this validator
also gates RawManualTagEntryModal's review grid.
Replaced with guidance: an UNAUTHORED tag (no topic AND no jsonPath) seeds the
field with "$", so a fresh tag emits an explicit "jsonPath":"$" while an
existing path-less tag keeps the key ABSENT and the driver defaults it. The
wildcard-topic rule stays strict — a wildcard has no legitimate single-Tag
interpretation.
Also: omit a blank "topic" rather than writing "topic":"", and record why the
_lastConfigJson guard diverges from the Modbus template — the landmine is a
DERIVED, NON-PERSISTED UI FIELD INFERRED FROM BLOB CONTENT (Mode), which Task
24's Sparkplug field group will be tempted to add more of. Named the host-side
dependency (RawTagModal only mutates through this callback) that keeps the
staleness trade benign today.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Thin typed model over a preserved JsonObject key bag, mirroring the sibling
<Driver>TagConfigModel template. Key names and strictness rules mirror
MqttTagDefinitionFactory rather than inventing an editor-side schema; enums
round-trip as NAMES (the factory reads them strictly by name).
No FullName key: under v3 a tag is identified by its RawPath, which the factory
keys the definition's Name off — a composed identity key here would be dead
weight nothing reads.
Mode is a UI-only sub-shape selector inferred from the blob (any Sparkplug
descriptor key present) and never serialised — the driver takes Plain vs
SparkplugB from its DRIVER config. Only Plain Validate() is implemented; the
Sparkplug branch is a stub Task 24 fills, and its keys are preserved untouched.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
SqlTagConfigModel + SqlRowSelectorModel mirror SqlEquipmentTagParser's
accept/reject boundary byte-for-byte on the fields they own: model/type
serialize as NAME strings (never numbers), KeyValue requires
table+keyColumn+keyValue+valueColumn, WideRow requires table+columnName+a
selector (where-pair OR topByTimestamp), Query is rejected, and unknown keys
(top-level and nested rowSelector) survive load->save. Registered in
TagConfigEditorMap + TagConfigValidator keyed off SqlDriver.DriverTypeName
(DriverTypeNames.Sql is deferred to Task 11). A minimal placeholder
SqlTagConfigEditor.razor lands the map entry now; Task 20 fleshes out the UI.
The load-bearing test rounds editor output back through
SqlEquipmentTagParser.TryParse (editor-output <=> parser-input agreement).
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Register SqlDriverBrowser as an IDriverBrowser beside the OpcUaClient/Galaxy
lines, project-reference Driver.Sql.Browser, and add SqlAddressPickerBody.razor:
a DriverBrowseTree schema/table/column picker (DriverOperator-gated) with a
column attribute side-panel, manual-entry model/selector fields retained, that
composes the per-tag TagConfig blob (KeyValue / WideRow) matched to
SqlEquipmentTagParser. Pasted ad-hoc connection strings are session-only and
never persisted into the composed blob or driver config.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Redundancy.md: KNOWN-LIMITATION (per-Akka-cluster election) marked RESOLVED;
new 'Per-cluster meshes (Phase 6)' section + pair-local secrets note; manual-
failover mesh-scope caveat removed; auto-down 1-vs-1 note updated (every mesh is
now two nodes, drill deferred to Phase 7). IManualFailoverService + ClusterRedundancy
razor caveats updated to pair-local reality. Configuration.md documents the
cluster-{ClusterId} role, per-pair self-first seeds, SplitTopologyTransportValidator,
and the intentional Dps default. Program + design status tables + CLAUDE.md marked
Phase 6 DONE.
(Task 8 subagent completed the edits but died on an API error before committing;
reviewed and committed by the controller.)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Build: 0 errors solution-wide, and 0 warnings from every project this branch
touches. The ~816 solution-wide warnings are pre-existing xUnit1051 /
OTOPCUA0001 / CS86xx in untouched driver + client test projects.
Tests: full solution run compared against a full run on a detached worktree at
the pre-branch baseline 2e46d054. The two failure SETS are identical -- all 13
tests, same names, zero new failures. Net +26 tests: +3 Core.AlarmHistorian
(drain gate), +6 Runtime (role view), +17 Host.IntegrationTests (migrator +
convergence). Set comparison rather than counts, because the suite carries
standing environment- and load-dependent failures a count would hide.
The greps found real drift Task 6 missed -- eight live sites still naming the
deleted SqliteStoreAndForwardSink, including the AdminUI /alarms/historian
panel text, which is user-visible, and a <see cref> in HistorianAdapterActor
that resolved to nothing without warning. All repointed at
LocalDbStoreAndForwardSink; CLAUDE.md's alarm-history paragraph now also
records the LocalDb buffer and the primary-gated drain, and drops DatabasePath
from the knob list. docs/AlarmTracking.md still promised an
AlarmHistorianOptions.Validate() startup warning for a relative DatabasePath
and an empty SharedSecret; both branches are gone, so it now says so.
Code references to AlarmHistorian:DatabasePath reduce to exactly two intentional
ones: AlarmSfLegacyMigrator.LegacyPathKey and its test. No `new SqliteConnection`
remains anywhere in Core.AlarmHistorian.
Recon doc gains the durable verification record: guard-deletion evidence for
both vacuous passes, the two exact-set replicated-table pins (both assert set
equality, so an added or a dropped registration fails), and the baseline test
comparison with a per-failure account of why each of the 13 is not this
branch's.
Stops here per the plan. Task 8's live gate needs explicit go-ahead; nothing on
this branch is to be merged.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
The Secrets.Ui RCL's routable page was routed directly, but this host's
router is deliberately static (cookie SignInAsync needs SSR) with per-page
@rendermode opt-in - a directive the RCL page cannot carry, because the
other three family hosts render it under a globally interactive router
where a nested render mode throws. Routing straight to the RCL page
therefore rendered /admin/secrets with no circuit: it displayed, but every
@onclick was silently dead.
Fix: a host-side wrapper page (Pages/SecretsAdmin.razor) now owns
/admin/secrets, carries the standard per-page InteractiveServer render
mode, re-states the RCL page's own authorization policy (the router only
enforces [Authorize] on the routed component, which is now the wrapper),
and renders the RCL page as an ordinary child. The RCL assembly is
de-registered from both AdditionalAssemblies sites (router + endpoint) so
the route is unambiguous.
Guards: SecretsPageWiringTests pins the render mode (the #483 regression),
route parity with the RCL page, and policy parity; the page census in
PageAuthorizationGuardTests classifies the new page and admits the
secrets:manage policy. AdminUI.Tests 665/665.
Live-verified on the rebuilt docker-dev rig with Playwright: /_blazor
circuit negotiated on /admin/secrets and Add secret opens the editor
(before the fix: zero interactive markers, no circuit, dead click).
Closes#483.
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
Mount the shared ZB.MOM.WW.Secrets.Ui RCL page at /admin/secrets on admin nodes:
- Register secrets:manage/secrets:reveal policies additively via
Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization()) in AddAdminUI
(the admin-only composition layer that already references the RCL — avoids forcing
an RCL dependency into the core Security lib; mirrors HistorianGateway)
- Register the RCL routable assembly in BOTH the SSR endpoint (AddAdditionalAssemblies)
and the interactive Router (App.razor AdditionalAssemblies) or the route 404s
- Add a Secrets nav item; the page's own [Authorize(Policy=...)] gates access
Claim-type MATCH: AdminRole=Administrator reads the same ClaimTypes.Role as FleetAdmin,
so existing Administrators are authorized with no new role mapping. Full clean boot
verified; interactive reveal deferred to the live gate (shared UI already proven).
Materialize each native alarm ONCE at the raw tag (ConditionId = RawPath, Raw
realm); wire the single condition as an SDK event notifier of each referencing
equipment's UNS folder so one ReportEvent fans to every root without
re-reporting per root (which would break Server-object dedup + Part 9 ack
correlation).
- New sink method WireAlarmNotifiers(alarmNodeId, alarmRealm,
notifierFolderNodeIds, notifierFolderRealm) on IOpcUaAddressSpaceSink,
forwarded through DeferredAddressSpaceSink + SdkAddressSpaceSink +
NullOpcUaAddressSpaceSink (the forwarding-trap guard); auto-covered by the
DeferredSinkForwardingReflectionTests realm + forwarding guards + a
hand-written forward test.
- OtOpcUaNodeManager: the normative AddNotifier(isInverse) pair + idempotent
EnsureFolderIsEventNotifier per equipment folder; tracked per condition in
_alarmNotifierWiring. Teardown symmetry: RemoveNotifier(bidirectional:true) on
RebuildAddressSpace, RemoveAlarmConditionNode, and RemoveEquipmentSubtree so no
inverse-notifier entry leaks across redeploys.
- AddressSpaceApplier.MaterialiseRawSubtree wires notifiers for each native alarm
tag, resolving its ReferencingEquipmentPaths (Area/Line/Equipment) to the
EquipmentId folder NodeIds via BuildEquipmentIdByFolderPath.
- AlarmTransitionEvent gains ReferencingEquipmentPaths (empty default); /alerts
renders the referencing-equipment list as display metadata.
- Un-skipped + rewrote the native-alarm dark tests
(DriverHostActorNativeAlarmTests x6, DriverHostActorNativeAlarmAckRoutingTests
x1) for the v3 raw-condition model; new NodeManagerMultiNotifierAlarmTests
proves multi-notifier wiring + teardown symmetry (no leaked duplicates after a
re-trip) + applier wiring test.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the
equipment page's Tags tab (v3 reference-only equipment): columns are effective name,
computed raw path, inherited datatype/access (read-only), display-name override
(editable), and remove. The retired equipment-authored-tag editor (TagModal.razor)
is deleted; the VirtualTag and ScriptedAlarm flows are untouched.
- "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in
PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped
structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single
cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw
usage is byte-unchanged (PickerMode defaults false).
- UnsTreeService (+ IUnsTreeService) gain the reference mutations:
LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver),
AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync,
SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync /
LoadDescendantTagIdsAsync.
- Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered):
AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update
mutations now call CheckAsync before persisting and surface a collision as the failure.
WP2 supplies the implementation + DI registration.
- Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput,
the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the
dead decision-#122 driver-cluster guard.
Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate
rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers);
Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI
suite green (639 passed, 3 skipped); full solution builds 0 errors.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Part 1 — rewire the three DriverType dispatch maps from hand-authored string
literals to the canonical DriverTypeNames constants (single source of truth):
- TagConfigEditorMap, TagConfigValidator (AdminUI)
- EquipmentTagConfigInspector (ControlPlane)
This FIXES the live drift ("TwinCat"->TwinCAT, "Focas"->FOCAS). The maps are
OrdinalIgnoreCase so behavior is identical; the value is no future drift.
Calculation is not added to the inspector (no CalculationTagDefinitionFactory.Inspect).
Added TagConfigDriverTypeNameGuardTests pinning the maps to the constants.
Part 2 — retire the routed /clusters/{id}/drivers driver-authoring flow (/raw
Waves A-C now cover driver/device/tag authoring end-to-end):
- Deleted DriverTypePicker, DriverEditRouter, the 8 *DriverPage shells, and the
ClusterDrivers list page.
- Removed the "Drivers" tab from ClusterNav. Routes /clusters/{id}/drivers* now
simply do not exist -> clean 404.
- The Forms/DeviceForms bodies, shared driver sections, and address pickers used
by the /raw modals are untouched.
Part 3 — updated PageAuthorizationGuardTests census: removed the 10 deleted
ConfigEditor pages + ClusterDrivers, updated group-count comments (ConfigEditor
21->11, AuthenticatedRead 17->16). Removed the now-dead Clusters.Drivers usings
from the 4 *DriverPageFormSerializationTests + the guard test.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Browse device… on a /raw Device/TagGroup opens the discovery-browser modal
against the merged Driver+Device config and commits selected browse leaves as
raw Tag rows under the target, via IRawTreeService.ImportTagsAsync.
- RawBrowseModal.razor: two-tier browse gate (bespoke IDriverBrowser →
universal DiscoveryDriverBrowser via SupportsOnlineDiscovery → disabled),
merged config from LoadMergedProbeConfigAsync, multi-select over
DriverBrowseTree, opt-in 'create matching tag-groups' folder mirror,
commit via ImportTagsAsync.
- RawBrowseCommitMapper: pure leaf→RawTagImportRow mapper — DriverDataType→
OPC UA type, per-driver address field (nodeId/tagPath/symbolPath/address/
attributeRef), target-group + mirror path combine. Unit-tested (39 tests).
- DriverBrowseTree: additive multi-select mode (checkboxes + ancestor-path
callback), default off preserves single-select pickers.
- RawTree: OnBrowseDevice wired to the modal (Device + TagGroup menus).
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Reviewer H1 (v3 regression): the endpoint→DeviceConfig split ADDED the endpoint to DeviceConfig but
the driver forms still serialized it into DriverConfig at defaults. Single-device worked only by an
STJ case-insensitive last-key-wins accident; a 2nd device on a single-top-level-Host driver (Modbus/S7)
silently reverted to the driver-form default (host=127.0.0.1). Fix: Modbus/S7/OpcUaClient driver-form
GetConfigJson strips the endpoint keys (host/port/unitId; host/port/rack/slot; scalar endpointUrl) from
the serialized channel config, leaving DeviceConfig as the sole source (OpcUaClient keeps its endpointUrls
failover list, which is driver-level policy).
Enum-serialization verified CLEAN fleet-wide; ImportTagsAsync all-or-nothing verified; RawTree wiring sound.
Deferred (documented in PR): H2 pre-existing Modbus/S7 form TimeSpan vs factory *Ms-int DTO mismatch (authored
durations dropped — predates v3); M1 CSV export drops non-columned alarm sub-keys; M2 blank flag column
clobbers a base-JSON isHistorized/isArray; M3 OpcUaClient device/driver endpoint precedence; L1 unused
LoadMergedProbeConfigAsync; L5 refresh collapses sibling expansion.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Replace RawTree's 22 stub handlers with the real Wave-B modals and 3 new
small dialogs, plus post-mutation subtree refresh:
- New dialogs: RawNameDialog (create/rename, RawPaths.ValidateSegment inline),
RawConfirmDialog (deletes), RawDriverTypeDialog (New driver type+name picker,
incl. Calculation).
- Configure driver/device -> DriverConfigModal/DeviceModal (edit); New device ->
DeviceModal (create); Edit tag -> RawTagModal; Manual entry / CSV import/export
-> the Raw*Modal surfaces; New folder/tag-group/group + New driver + Rename +
Delete + Toggle -> IRawTreeService directly via the dialogs.
- Refresh: reload the container after create-under, the parent after item-level
ops (parent found by searching the loaded tree; robust, non-throwing).
- Rename warnings + blocked-delete errors surfaced via the repurposed RawStubModal
message surface. Browse device stays a placeholder (Wave C). Driver-level
'Test connect' menu item removed (test-connect now lives on the device modal).
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Extract the 8 typed driver pages into embeddable <Driver>DriverForm bodies
(channel/protocol config) and add per-driver <Driver>DeviceForm bodies
(connection endpoint -> Device.DeviceConfig, v3 endpoint split). New
DriverConfigModal + DeviceModal dispatch by DriverType (DriverTypeNames) for
the /raw tree; Test-connect inside DeviceModal builds the merged Driver+Device
config transiently via DriverDeviceConfigMerger. Routed pages now host the
form bodies and keep working. Round-trip + enum-serialization guard tests for
the Modbus driver form + Modbus device model; retargeted the existing
page-form serialization tests + the _jsonOpts converter guard to the forms.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Wave-A reviewer findings (no Critical/High):
- M-2: GlobalRaw auto-expands Enterprise roots so the Cluster level is visible on load (mirrors GlobalUns.ExpandStructural); clusters stay lazily collapsed.
- L-1: create mutations route SaveChanges through SaveCreateAsync — a lost sibling-name race (filtered-unique-index clash) becomes a friendly failure instead of an uncaught DbUpdateException.
- L-2: SaveDeleteAsync no longer leaks raw EF/SQL text; operator-friendly guidance.
- L-5: RawNode.ChildCount doc clarified (direct children only).
- Regression fix: PageAuthorizationGuardTests classifies the two new routable pages (/raw → ConfigEditor, /dev/context-menu-demo → AuthenticatedRead).
M-1 (ordinal vs SQL-collation sibling uniqueness) assessed benign: name indexes use the DB default collation and the server-side pre-check runs under that same collation, so pre-check and index agree — no case-variant rows can coexist, so runtime ordinal RawPath keying never collides.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Wave-0 reviewer findings (no Critical/High):
- T0-1 M: ContextMenuItem.OnClick Action→Func<Task> (kills async-void at menu call sites Wave A/B/C need); keyboard-activated ⋯ now anchors below the button instead of viewport (0,0); focus returns to trigger on close; backdrop closes on native right-click too.
- T0-3 M: guard test now discovers *DriverFactoryExtensions.Register by convention from the deployed driver assemblies instead of a hand-copied list, so a future driver lacking a constant is actually caught.
- T0-2 L: corrected the round-trip 'sole non-round-trippable shape' comment (any table whose final row is a lone empty field).
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Adds a reusable right-click / context menu Blazor component for the AdminUI:
- ContextMenu.razor: right-click surface (browser menu suppressed) + optional
ellipsis fallback trigger, both anchoring the menu at the pointer; keyboard
nav (arrows/Enter/Esc), transparent backdrop outside-click close (no JS).
- ContextMenu.razor.css: scoped styles on the shared theme tokens.
- ContextMenuItem.cs: Label/OnClick/Icon/Disabled/IsSeparator model + Separator().
- Dev demo page at /dev/context-menu-demo.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
Namespace entity + NamespaceKind retired; DriverInstance dropped NamespaceId;
Tag is raw-only (no EquipmentId/DriverInstanceId/FullName/FolderPath); driver
options replaced pre-declared Tags with RawTags. Stub the retired surfaces so
the AdminUI + Host build green; real Raw/UNS authoring arrives in v3 Batch 2/3.
- ClusterNamespaces/NamespaceEdit: bodies replaced with 'Namespaces retired' banner
- DriverIdentitySection: dropped Namespace dropdown + NamespaceId model field
- 8 driver pages: dropped Namespace binding; retired the pre-declared Tags editor
(replaced with a '/raw Batch 2' note); multi-device pages keep their Devices editor
- UnsTreeService: equipment-tag counts/list -> empty; Create/UpdateTag -> failure
result ('Tag authoring moved to the Raw tree (/raw) in v3 Batch 2'); Namespace/
driver-binding queries removed
- IScriptTagCatalog: project surviving Tag columns only (path from TagConfig FullName, else Name)
- OpcUaClientTagConfigEditor: rebind stale FullName -> model's NodeId
- ClusterDrivers: drop retired NamespaceId column
AdminUI + Host build 0/0. AdminUI.Tests / Host.IntegrationTests remain red (WP6).
AbCip/TwinCAT/FOCAS driver-config pages host the same *AddressPickerBody as the /uns TagModal but
did not pass GetConfigJson, so their universal Browse button would capture against an empty {} (no
device). Thread SerializeCurrentConfig through. DriverType is left to the picker-body default
(canonical registered type) rather than the page's DriverTypeKey const, whose TwinCAT/FOCAS values
("TwinCat"/"Focas") are non-canonical casing that could miss the factory TryCreate lookup.
Task 14/15/16: each picker body keeps its manual builder and appends a DriverOperator-gated
browse affordance (mirrors OpcUaClientAddressPickerBody) — Browse/Refresh/Close, DriverBrowseTree
over the universal DiscoveryDriverBrowser session, leaf-only commit (OnNodeSelected fires for
folders too), snapshot-at-open label, fire-and-forget close on dispose. _canBrowse evaluated once
in OnInitializedAsync (CanBrowse constructs a throwaway driver). FOCAS commits the leaf FullName
directly (the group/id builder can't reconstruct it). Editors pass DriverType + GetConfigJson
through. Razor-live-verified at Task 18.
Task 1: ITagDiscovery.SupportsOnlineDiscovery default member (=> false)
Task 2: Commons -> Core.Abstractions ProjectReference
Task 13: TagModal BuildEditorParameters passes DriverType + GetDriverConfigJson;
all 7 typed tag editors declare the two new [Parameter]s (DynamicComponent
throws on unmatched params, so every editor must accept them).
Models gain an optional bool? Writable (absent stays omitted; explicit false round-trips,
unknown keys preserved); the six editors gain a Writable checkbox (FOCAS disabled+read-only
hint). 9 model round-trip tests green. docker-dev /run verify of one editor deferred-live.