Array nodes were forced read-only for every driver. A new single authority,
Core.Abstractions DriverArrayWriteSupport, says which (driver, element type)
pairs encode a whole-array write: Modbus (Coils / numeric HoldingRegisters),
S7 (Byte/Int16/UInt16/Int32/UInt32/Float32), OpcUaClient (pass-through) and
Galaxy (Boolean/Int32/Int64/Float/Double/String/DateTime). Everything else
stays read-only, so the server never advertises a write the driver refuses.
- Composer derives RawTagPlan.ArrayWritable + UnsReferenceVariable
.BackingArrayWritable at compose time (both seams, byte-parity); the
applier's one writability rule is Writable && (!IsArray || ArrayWritable)
for materialise and surgical paths, raw and UNS alike.
- ArrayWritable is on the classifier's surgical raw-tag whitelist and
mirrored on the UNS record (UnsReferenceBackingParityTests updated).
- Node manager refuses a wrong-length / non-array value on an array node
(BadTypeMismatch) and any IndexRange write (BadWriteNotSupported) inline,
before dispatch; WriteOperate/ACL gates and the revert are unchanged.
- Per-driver parity tests pin the table against the real Modbus write path,
S7 EncodeArrayBlock and Galaxy MxValueEncoder (UInt32/UInt64 excluded:
array covariance reinterprets their bits).
- Over-the-wire proof in OpcUaServer.IntegrationTests (ArrayWriteWireTests).
- ValueRank > 1 documented as out of scope by decision (docs/Uns.md).
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
Adversarial review of 9f68f272 (classifier: non-alarm raw-tag deltas became
surgical) found stale UNS inheritance after a surgical raw edit. Editing a raw
tag's IsHistorized / HistorianTagname / IsArray / ArrayLength left every
referencing UNS node STALE: none of those fields were on UnsReferenceVariable,
so no coincident UnsReferenceDelta fired; the applier updated the raw node in
place; and EnsureVariable early-returns on an existing node. Consequences:
HistoryRead through the UNS NodeId served a different series than through the
raw NodeId (breaking the documented dual-registration invariant), a
de-historized tag kept serving history via UNS, and a scalar->array flip left
the UNS mirror scalar AND writable (bypassing the array read-only write gate).
UnsReferenceVariable now carries Backing{IsHistorized,HistorianTagname,IsArray,
ArrayLength}, copied from the backing tag's TagConfigIntent at compose time, so
record equality makes the reference Changed in the SAME plan. Both deltas are
non-rebuild kinds, so the existing machinery repairs the mirror surgically:
remove-previous in Apply, recreate in MaterialiseUnsReferences. That pass now
reads the four values from the RECORD (the single truth the planner diffed)
instead of a rawByPath back-lookup, preserving historian resolution exactly
(blank override => BackingRawPath, which is the backing tag's NodeId).
The artifact-decode seam needs no change: DeploymentArtifact.BuildRawAndUnsSubtrees
drives AddressSpaceComposer.Compose verbatim, which is what guarantees byte-parity.
The classifier needs no change either (a UnsReferenceDelta never forces Rebuild);
its comment now records why.
Also from the same review: a three-place-contract comment on RawTagPlan's
hand-written Equals naming its two downstream consumers, plus classifier pins for
the .Any(!eligible) quantifier, an ArrayLength-only delta, and an
alarm-content-only delta.
Un-darken the address-space composition for the v3 dual namespace.
Composer (AddressSpaceComposer):
- Raw subtree: RawContainerNode (Folder/Driver/Device/TagGroup as
Object/Folder nodes, keyed s=<RawPath>, parent-before-child) + RawTagPlan
(raw tag Variables keyed (realm=Raw, s=<RawPath>) carrying DataType /
writable / historize+historian tagname / array shape). Native-alarm intent
attaches at the RAW tag (ConditionId = RawPath) with the list of referencing
equipment UNS paths (one alarm at the raw tag, not one per equipment).
- UNS subtree: UnsReferenceVariable per UnsTagReference, keyed
(realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>), carrying its
backing RawPath (Organizes UNS->Raw target + fan-out) and inheriting
DataType/AccessLevel from the backing raw tag. Effective name =
DisplayNameOverride else backing raw Tag.Name.
- All RawPaths flow through one shared RawTopology (RawPathResolver +
memoised container paths), byte-parity with EquipmentReferenceMap.
- Every new node carries an AddressSpaceRealm (so WP3's applier picks the
namespace at the call site). Additive/defaulted model changes only — the
un-migrated AddressSpaceApplier still compiles.
Planner (AddressSpacePlanner.Compute) + AddressSpacePlan:
- Per-realm diff sets: RawContainers + RawTags keyed by RawPath (NodeId) so a
rename = remove+add; UnsReferenceVariables keyed by the stable
UnsTagReferenceId so a backing-tag rename re-points (BackingRawPath moves,
NodeId stable) and a display-name-override edit is UNS-only.
- PINNED: raw-tag rename = remove+add in Raw AND re-point in UNS.
Classifier folds the new sets in (adds->PureAdd, removes->PureRemove,
changed->Rebuild safe default).
Tests: AddressSpaceComposerDualNamespaceTests (7),
AddressSpacePlannerDualNamespaceTests (8, incl. the rename pin),
AddressSpaceChangeClassifierDualNamespaceTests (8); reactivated
EquipmentNamespaceMaterializationTests' Batch-4-pending case, re-authored to
drive the composer over the seeded config (the artifact-decode seam is WP3).
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox