Commit Graph

80 Commits

Author SHA1 Message Date
Joseph Doherty db751d12a5 feat(alarms): condition Quality tracks source connectivity (#477)
v2-ci / build (pull_request) Successful in 4m49s
v2-ci / unit-tests (pull_request) Failing after 3h11m25s
Part 9 ConditionType.Quality was never assigned; default(StatusCode)==Good
so every native + scripted condition reported Good unconditionally — a
comms-lost device still showed a healthy, inactive, Good condition (a
wrong-VALUE bug, distinct from the null-value #473/#475). Clients (and HMIs
bucketing on IsGood) could not tell "genuinely inactive" from "lost contact".

Layer 1 — make Quality a real, plumbed field:
- AlarmConditionSnapshot gains OpcUaQuality Quality (default Good).
- MaterialiseAlarmCondition sets it (native BadWaitingForInitialData, scripted Good).
- WriteAlarmCondition projects snapshot.Quality; the delta-gate gains a Quality
  member so a quality-bucket change fires a Part 9 event.

Layer 2 — drive native quality from driver connectivity (a comms-lost driver
emits no alarm transitions, and an alarm-bearing raw tag has no value variable,
so quality can't come from either existing channel):
- DriverInstanceActor Tells parent ConnectivityChanged on Connected/Reconnecting.
- DriverHostActor fans it to every native condition the driver owns as
  OpcUaPublishActor.AlarmQualityUpdate (Good on connect, Bad on disconnect).
- New dedicated IOpcUaAddressSpaceSink.WriteAlarmQuality sets ONLY Quality and
  fires only on a bucket change — never touches Active/Acked/Retain (an active
  alarm that loses comms stays active). Not a full-snapshot re-projection, so it
  can't clobber severity/message and works for a never-fired condition.
  Forwarded through DeferredAddressSpaceSink (F10b trap; auto-verified by the
  reflection forwarding guard). Ungated by redundancy role; no /alerts row.

Scripted conditions stay Good; worst-of-input quality deferred to #478 (Layer 3).

Tests: node-level (materialise/project/no-clobber/unknown-node no-op),
NativeAlarmProjector, DriverInstanceActor connectivity emission, DriverHostActor
fan-out, OpcUaPublishActor routing, and the wire-level guard
(Condition_event_Quality_tracks_source_connectivity_on_the_wire) — RED-verified
against a simulated pre-fix always-Good server. Existing DriverInstanceActor
parent probes ignore the new ConnectivityChanged.

Docs: docs/AlarmTracking.md §"Condition source-data Quality (#477)";
design doc docs/plans/2026-07-17-alarm-condition-quality-477-design.md.
2026-07-17 15:10:04 -04:00
Joseph Doherty 8ebc712eff feat(v3-batch4-wp4): multi-notifier native alarms (single ReportEvent → raw + equipment notifiers) + teardown symmetry
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
2026-07-16 12:07:11 -04:00
Joseph Doherty 2e0743ad25 fix(v3-batch4-wp3): realm-qualified write routing + dormant discovery guard + self-correction/byte-parity tests (Wave B review H1/M1/M2/L1/L3)
H1 (HIGH): write-routing key now (AddressSpaceRealm, bareId), not bare-only.
A raw s=<RawPath> and a UNS s=<Area/Line/Equip/Eff> can collide as bare
strings; the bare-only key let a colliding raw+UNS pair route to the WRONG
driver ref (last-writer-wins). The realm the node manager resolves (RealmOf)
is now threaded through IOpcUaNodeWriteGateway.WriteAsync -> RouteNodeWrite ->
_driverRefByNodeId keyed by (realm, bareId). New regression test:
Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm.

M1 (MEDIUM): discovered-node injection made coherently DORMANT. HandleDiscoveredNodes
hard-short-circuits (single enforcement point; _discoveredByDriver never
populates so the re-inject tail is inert too), with a clear log pointing at the
/raw browse-commit flow. New pin: Discovered_nodes_are_ignored_dormant_in_v3;
the 16+2 v2 injection scenarios re-pointed to an accurate skip reason
(DiscoveryInjectionDormantV3).

M2 (MEDIUM): realm-qualified dual-node self-correction tests —
Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched +
Raw_realm_revert_reverts_raw_node_only (the second fails if the realm is dropped).

L1: removed the = AddressSpaceRealm.Uns defaults from the consequential
node-manager mutation methods (WriteValue/WriteAlarmCondition/MaterialiseAlarmCondition/
EnsureFolder/EnsureVariable/UpdateFolderDisplayName/UpdateTagAttributes/
RaiseNodesAddedModelChange/Remove*/RevertOptimisticWriteIfNeeded) + the
AttributeValueUpdate/AlarmStateUpdate records, so the compiler forces explicit
realm; read-only accessors + internal builders retain their defaults.

L3: fixed the stale VirtualTagHostActor class comment (V3NodeIds.Uns, not the
retired EquipmentNodeIds.Variable).

Also: DeploymentArtifactRawUnsParityTests — Raw/UNS node-set byte-parity
round-trip between AddressSpaceComposer.Compose and DeploymentArtifact.ParseComposition.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 11:30:13 -04:00
Joseph Doherty 3efcf8014b feat(v3-batch4-wp3): raw-only binding + UNS fan-out + write routing
Wave B of Batch 4 — the runtime binding seam for the dual namespace.

Applier (both realms, explicit realm at every sink call site):
- MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables
  keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath),
  all in AddressSpaceRealm.Raw; historian tagname = override else RawPath.
- MaterialiseUnsReferences: each UNS reference Variable under its equipment
  folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/
  historian tagname from the backing raw tag (both NodeIds -> one tagname).
- FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref
  stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS
  refs in place (raw-container removal falls back to rebuild).

DriverHostActor (dual-NodeId, single-source fan-out):
- _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from
  RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans
  to the raw NodeId AND every referencing UNS NodeId with identical
  value/quality/timestamp. Write inverse map keyed by the bare id; the
  ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a
  write to either NodeId resolves the same driver ref (-> RawPath write).
- Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate
  + AlarmStateUpdate carry the realm through to the sink.

Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and
RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now).

DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity
with the composer (reconstruct entities -> AddressSpaceComposer.Compose).

Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from
IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink /
DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now
explicit (realm reordered before the trailing optionals on EnsureVariable +
MaterialiseAlarmCondition). Node-manager convenience methods keep their
defaults (they are not the interface impl; Sdk delegates explicitly).

Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) +
DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3
raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags
provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 10:56:31 -04:00
Joseph Doherty e95615cef3 fix(v3-batch4-wp2): AddReference sink seam for Organizes UNS→Raw (Wave A review M1)
Close the Wave-A M1 gap: the sink surface had no way to wire the mandated
cross-tree Organizes reference from each UNS reference Variable to its backing
Raw node, forcing WP3 to reopen the frozen surface. Add a dedicated
realm-qualified AddReference method instead.

- IOpcUaAddressSpaceSink.AddReference(sourceNodeId, sourceRealm, targetNodeId,
  targetRealm, referenceType="Organizes"): realm-qualified both ends; idempotent;
  a missing endpoint is a logged no-op (never throws) so a mid-rebuild race can't
  fault a deploy. Base-interface capability (no surgical sniff, no transitional
  default — WP3 wires it explicitly per UNS reference variable).
- SdkAddressSpaceSink forwards to the node manager; DeferredAddressSpaceSink
  forwards unconditionally (like EnsureFolder); NullOpcUaAddressSpaceSink no-ops.
- OtOpcUaNodeManager.AddReference: resolve both nodes by full ns-qualified key
  (variable/folder/condition via ResolveNodeState), guard both-exist, then wire
  the edge bidirectionally (forward Organizes on source, inverse on target) with a
  ReferenceExists idempotency guard + ClearChangeMasks on mutated sides. Reference
  type resolved by ResolveReferenceType (Organizes default). Added internal
  GetNodeReferences test/diagnostic accessor.
- Reflection guard: the exhaustive-forwarding test + the realm-discriminator guard
  auto-cover AddReference (it has two AddressSpaceRealm params) — no edit needed.
- New SdkAddressSpaceSinkTests: Organizes UNS→Raw edge created bidirectionally +
  idempotent; missing endpoint is a safe no-op.
- All 15 IOpcUaAddressSpaceSink test doubles gain the AddReference no-op so the
  solution still builds.

Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors.
Tests: Commons.Tests 310/310; OpcUaServer.Tests 337 passed / 4 pre-existing skips.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 09:52:30 -04:00
Joseph Doherty 5534698d70 feat(v3-batch4-wp2): node manager dual-namespace + realm-aware sink surface
Register both v3 namespaces (Raw first, UNS second) on OtOpcUaNodeManager and
thread an AddressSpaceRealm discriminator through every node-naming sink method
so a bare node id is resolved to the correct namespace by realm — never parsed
out of the id string.

- IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink: every node-naming method
  gains `AddressSpaceRealm realm = AddressSpaceRealm.Uns` (transitional default so
  un-migrated WP3 call sites still compile; WP3/Wave B makes them explicit and
  removes the default). Null + SdkAddressSpaceSink impls updated; DeferredAddress-
  SpaceSink forwards realm through every method (the forwarding trap).
- DeferredSinkForwardingReflectionTests: existing exhaustive-forwarding guard
  auto-covers the new signatures; added an explicit guard that every node-naming
  sink method carries an AddressSpaceRealm parameter (RebuildAddressSpace exempt).
- OtOpcUaNodeManager: register RawNamespaceUri + UnsNamespaceUri; realm->namespace
  index via NamespaceIndexForRealm; all node maps (_variables/_folders/
  _alarmConditions/_nativeAlarmNodeIds/_historizedTagnames/_eventNotifierSources)
  re-keyed by the full ns-qualified NodeId string so Raw and UNS nodes sharing a
  bare id stay distinct; _notifierFolders already NodeId-keyed. A historized UNS
  reference node registers the SAME historian tagname as its backing raw node
  (both NodeIds -> one tagname). Inbound-write hook routes the full ns-qualified
  NodeId to the write gateway (realm-aware) and reverts by bare id + realm.
  HistoryRead seams resolve via NodeId directly. DefaultNamespaceUri kept as a
  transitional alias to UnsNamespaceUri for the SubscriptionSurvivalTests.
- Test doubles across Commons.Tests / OpcUaServer.Tests / Runtime.Tests updated to
  the new interface signatures; SdkAddressSpaceSinkTests asserts the UNS namespace
  index for default-realm nodes.

Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors.
Tests: Commons.Tests 310/310; OpcUaServer.Tests 335 passed / 4 pre-existing skips.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 09:33:45 -04:00
Joseph Doherty a1a56e22bb feat(v3-batch4): contracts — dual-namespace URIs, V3NodeIds, AddressSpaceRealm
Batch 4 contracts commit (coordinator lands before Wave A fan-out). Purely
additive so wave agents branch from a green base:

- AddressSpaceRealm enum (Raw | Uns) — the explicit realm discriminator that
  travels alongside a node's s= identifier at every sink seam (WP2 adds it to
  IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink; WP3 threads it).
- V3NodeIds — the two namespace URI constants (https://zb.com/otopcua/raw,
  https://zb.com/otopcua/uns, replacing the single .../ns) + Raw() pass-through
  (s= id == RawPath) + Uns()/UnsChild() slash-joined Area/Line/Equipment[/Eff]
  builders, both sharing RawPaths.Separator + ordinal segment validation.

Placement note: URIs live in V3NodeIds (Commons) rather than beside the retired
OtOpcUaNodeManager.DefaultNamespaceUri so runtime + tests reference them without
depending on OpcUaServer (single-source-of-truth, §5). WP2 rewires the node
manager to register both via V3NodeIds.RawNamespaceUri/UnsNamespaceUri.

EquipmentNodeIds retirement is NOT in this commit: its callers are the applier +
runtime (DriverHostActor/VirtualTagHostActor/DiscoveredNodeMapper), all WP3-owned
(the composer does not use it) — deleting it here would red the base. WP3 sweeps
it in Wave B.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 09:03:29 -04:00
Joseph Doherty dc0d7653b9 feat(v3-batch3): B3-WP4 — {{equip}}/<RefName> reference-relative resolution
Replace the deleted equipment-base-prefix {{equip}} mechanism (impossible now
that equipment is reference-only) with per-reference resolution:
{{equip}}/<RefName> resolves through the owning equipment's UnsTagReference rows
(by effective name) to the backing raw tag's RawPath. Every unresolved <RefName>
is a clear deploy error + authoring rejection + Monaco diagnostic — preserving
"editor accepts <=> publish accepts".

Commons:
- EquipmentScriptPaths: delete DeriveEquipmentBase; SubstituteEquipmentToken now
  takes the equipment's reference map (effectiveName -> RawPath) and substitutes
  {{equip}}/<RefName> inside path literals (unresolved left intact, never throws);
  add ExtractEquipReferenceNames (path-literal scoped) +
  ExtractEquipReferenceNamesFromText (message templates). Slash syntax replaces
  the v2 dot joint.
- New EquipmentReferenceMap: shared, input-shape-agnostic builder (entity + JSON
  sides) over RawPathResolver — the single authority both compose seams + the
  validator use, so resolved RawPaths agree byte-for-byte.

Compose seams (byte-parity kept):
- AddressSpaceComposer.Compose + DeploymentArtifact.ParseComposition both build
  the per-equipment reference map from UnsTagReferences + Tags + raw topology and
  substitute VirtualTag AND ScriptedAlarm-predicate sources before dependency
  extraction (runtime dep refs = resolved RawPaths).

Deploy gate + authoring + editor:
- DraftValidator.ValidateEquipReferenceResolution: EquipReferenceUnresolved error
  for unresolved {{equip}}/<RefName> in VirtualTag/ScriptedAlarm scripts + alarm
  message-template tokens (naming script, equipment, missing ref).
- UnsTreeService.ValidateEquipTokenAsync (Wave-A M1): reference-relative authoring
  rejection, replacing the stale DeriveEquipmentBase(empty)->reject-all logic.
- ScriptAnalysisService: {{equip}}/ completion offers the equipment's reference
  effective names; DiagnoseAsync flags unresolved refs (OTSCRIPT_EQUIPREF) same as
  the deploy gate. Requests carry optional EquipmentId (threaded MonacoEditor ->
  monaco-init.js -> fetch bodies). IScriptTagCatalog.GetEquipmentRelativeLeavesAsync
  repurposed to GetEquipmentReferenceNamesAsync(equipmentId, filter).

Tests: EquipmentScriptPaths substitution/extraction (slash pinned); composer<->
artifact reference-resolution byte-parity; DraftValidator unresolved-ref;
ScriptAnalysis completion+diagnostic; M1 authoring gate. Build clean (0/0); all
five affected suites green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:58:17 -04:00
Joseph Doherty 17c7e97efb feat(commons): T0-2 RFC-4180 CSV parser + writer
Add a pure, no-I/O RFC 4180 CSV reader and writer to the Commons project
(greenfield — no CSV code existed).

CsvParser: string / TextReader -> rows of string[]; streaming IEnumerable
overload + eager Parse(string) + thin ParseWithHeader. Handles quoted
fields (embedded delimiter/CR/LF/CRLF, "" -> " escape), space
preservation, CRLF/LF/CR terminators, no phantom trailing row. Strict
malformed-input policy: FormatException with 1-based line/column on a
quote inside an unquoted field, a char after a closing quote, or an
unterminated quote. Faithful empty-line policy: a blank line is one empty
field (callers filter).

CsvWriter: rows -> string / TextWriter; quote-on-demand (delimiter, quote,
CR, LF only; internal quotes doubled), configurable delimiter/newline
(default CRLF), optional quote-all, no trailing terminator.

Tests (xUnit + Shouldly, 109): full RFC edge corpus for the parser, the
writer quote-on-demand rules, and the required Parse(Write(rows)) == rows
round-trip property over a table of every tricky character (x CRLF/LF/
quote-all/semicolon).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:06:43 -04:00
Joseph Doherty e81ac352ed v3 B1-WP4/WP5: pipeline rewire to greenfield schema + dark address space
- ConfigComposer: snapshot v3 tables (RawFolders/TagGroups/UnsTagReferences),
  drop the retired Namespaces snapshot; RevisionHash follows the new blob.
- DeploymentArtifact: compute each raw tag's RawPath (RawFolder->Driver->Device->
  TagGroup ancestry via shared RawPathResolver); per-driver RawTags + merged
  DriverConfig+DeviceConfig config injected into DriverInstanceSpec via
  DriverDeviceConfigMerger; ParseComposition emits an empty EquipmentTags set
  (DARK until Batch 4); EquipmentNode driver/device hooks always null; cluster
  scoping attributes equipment by UNS line only.
- AddressSpaceComposer: rewritten to the v3 shape (no Namespace/NamespaceKind,
  no dropped Tag/Equipment fields); emits empty EquipmentTags (parity mirror).
- DriverHostActor: fan-out/write map tuple member FullName -> RawPath.
- Commons: new RawPathResolver (shared identity authority) + DriverDeviceConfigMerger
  (single/multi-device endpoint+RawTags merge).
- DraftValidator: v3 rules — raw-name charset (RawPaths.ValidateSegment),
  historized effective-tagname <=255, UNS effective-leaf uniqueness across
  UnsTagReference/VirtualTag/ScriptedAlarm; DraftSnapshot(+Factory) carry the new tables.
- AdminOperationsActor: tag-config inspection resolves driver via Device (no
  EquipmentId/DriverInstanceId on Tag).
- Tests: RawPathResolver/merger unit tests (Commons.Tests, green) +
  DeploymentArtifact RawPath/dark test (Runtime.Tests, awaits WP6 corpus fixes).
2026-07-15 20:45:24 -04:00
Joseph Doherty d4dab75323 v3 B1-WP2: TagConfigIntent sheds FullName identity; RawPaths tests; nodeId key normalization 2026-07-15 18:58:57 -04:00
Joseph Doherty cb720bb8c3 v3 batch1 contracts: RawPaths identity helper + reshaped greenfield entities
Shared-type foundation for Batch 1 fan-out (coordinator contracts commit):
- New Commons/Types/RawPaths.cs — the v3 identity authority (Build/Combine/
  Split/Leaf/TryParent/ValidateSegment, ordinal comparer).
- New entities RawFolder, TagGroup, UnsTagReference.
- Reshape DriverInstance (drop NamespaceId, add nullable RawFolderId),
  Tag (require DeviceId, add nullable TagGroupId, drop DriverInstanceId/
  EquipmentId/FolderPath, keep WriteIdempotent), Equipment (drop
  DriverInstanceId/DeviceId), Device (doc: now universal), ServerCluster
  (nav Namespaces -> RawFolders).
- Delete Namespace, EquipmentImportBatch(+Row), NamespaceKind enum.
- DriverTypeMetadata: drop AllowedNamespaceKinds + NamespaceKindCompatibility.

Solution is intentionally red below the foundation projects until Wave C
integrates. Commons + Core.Abstractions compile green.
2026-07-15 18:52:19 -04:00
Joseph Doherty bbbda99719 feat(browse): Wave-0 Batch F — capture coalescing/cap + AddAdminUI DI registration
Task 10: in-flight coalescing (Lazy<Task<CapturedTree>> keyed on driverType+SHA256(config))
         so repeat Browse clicks share one device walk; global SemaphoreSlim cap
         (MaxConcurrentCaptures=4) queues excess; per-caller ct via WaitAsync leaves the shared
         capture (bounded by open-timeout, CancellationToken.None) unaffected; in-flight-only map
         (Refresh = fresh capture). 7 concurrency tests green (158 total in Commons.Tests).
Task 12: AddAdminUI registers IUniversalDriverBrowser->DiscoveryDriverBrowser with
         NullDriverFactory fallback (standalone AdminUI => CanBrowse false => manual entry).
         AdminUI.Tests 542 green.
2026-07-15 17:37:02 -04:00
Joseph Doherty 698703744f feat(browse): Wave-0 Batch E — UntilStable settle loop + BrowserSessionService fallback
Task 9: DiscoverTreeAsync UntilStable settle loop (FOCAS) — re-capture on a 1s interval until
        the node set is non-empty and stable across two passes, bounded by open-timeout; on
        timeout with a prior non-empty capture, return it (best-effort) instead of failing.
Task 11: BrowserSessionService resolves bespoke-first, falls back to IUniversalDriverBrowser
         when no bespoke browser matches and CanBrowse is true; new CanBrowse on the service.
16 unit tests green (4 settle + 12 service).
2026-07-15 17:31:50 -04:00
Joseph Doherty fa339a5565 feat(browse): Wave-0 Batch D — IUniversalDriverBrowser + DiscoveryDriverBrowser core
Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser open path — construct(+PatchForBrowse)
        -> connect -> one-shot capture -> bounded (10s) shutdown; CanBrowse gate (catch-throwing
        TryCreate, defensive teardown of the throwaway instance); deep-merge browse patch.
        Commons gains Microsoft.Extensions.Logging.Abstractions (ILogger doesn't flow transitively)
        + InternalsVisibleTo(Commons.Tests). OTOPCUA0001 suppressed on the deliberate one-shot
        browse-capture DiscoverAsync (not a runtime dispatch path). 12 unit tests green.
2026-07-15 17:27:04 -04:00
Joseph Doherty 15da8d4f0d feat(browse): Wave-0 Batch C — CapturingAddressSpaceBuilder + CapturedTreeBrowseSession
Task 6: CapturedTree/CapturedNode model + CapturingAddressSpaceBuilder — an in-memory
        IAddressSpaceBuilder that records a driver's DiscoverAsync stream (folder nesting,
        leaf FullName as node id, alarm mark, node-cap truncation, no-op alarm sink).
Task 7: CapturedTreeBrowseSession — serves the captured snapshot through IBrowseSession
        (Root/Expand/Attributes from memory, truncation marker, shared-immutable-tree dispose).
13 unit tests green.
2026-07-15 17:22:00 -04:00
Joseph Doherty 85776cf650 feat(browse): Wave-0 Batch A — SupportsOnlineDiscovery gate, Commons ref, TagModal editor plumbing
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).
2026-07-15 17:15:32 -04:00
Joseph Doherty 438004e371 feat(commons): add surgical remove members to ISurgicalAddressSpaceSink + deferred forwarding (guard-verified) (R2-07 T7) 2026-07-13 12:07:01 -04:00
Joseph Doherty 1a698cbb97 Merge R2-11 TagConfig consolidation (arch-review round 2) [PR #434]
v2-ci / build (push) Successful in 3m55s
v2-ci / unit-tests (push) Failing after 8m14s
Findings 01/C-1, 01/P-1 (single TagConfigIntent.Parse in Commons, 4 copies
delegate, parse-once) + 05 CONV-2/UNDER-1/UNDER-6 (shared strict TagConfigJson
readers, per-driver Inspect() + writable key, FOCAS capability pre-flight,
Modbus probe timeoutMs, deploy-gate Warn|Error). Phase C runtime-strict flip
deferred. T22/T24 deferred. Auto-merged AddressSpaceApplier.cs + DriverHostActor.cs
with R2-04/R2-10; verified OpcUaServer.Tests 286/286 + Runtime.Tests 396/396.
Build clean.
2026-07-13 11:29:32 -04:00
Joseph Doherty d62b140ccd Merge R2-04 Failure-visibility trio (arch-review round 2) [PR #433]
v2-ci / build (push) Successful in 3m48s
v2-ci / unit-tests (push) Failing after 9m14s
Findings 01/S-1 (AddressSpaceApplyOutcome failure field + apply.failed logging,
no optimistic success), 06/S-1 (Galaxy write fails closed -> BadCommunicationError
+ #5 revert, no knowingly-lost raw Write), 03/S4 (PrimaryGatePolicy default-deny
unknown-role-multi-driver on all gates + scripted-alarm emit gate). T13/T15 (2-node
live gates) deferred -- T15 is the behavior-affecting S4 live gate, must run in heavy
pass. Clean merge, build clean.
2026-07-13 11:19:24 -04:00
Joseph Doherty f2e899aaad feat(r2-04): PrimaryGatePolicy - membership-aware default-deny + denial meter (03/S4) 2026-07-13 10:55:46 -04:00
Joseph Doherty 759125372a fix(r2-04): surface degraded address-space applies (Error log + otopcua.opcua.apply.failed) 2026-07-13 10:44:18 -04:00
Joseph Doherty b6633f7562 feat(commons): DeviceConfigIntent — device-host extraction + normalization home (R2-11) 2026-07-13 10:39:54 -04:00
Joseph Doherty 2cb3f962b9 fix(r2-10): DriverResilienceStatusChanged DPS contract 2026-07-13 10:39:18 -04:00
Joseph Doherty cd3f1270ee feat(commons): TagConfigIntent.Parse — single home for the byte-parity TagConfig intents (R2-11, 01/C-1) 2026-07-13 10:39:07 -04:00
Joseph Doherty 7fd44f0fa7 fix(vtags): real script timeout + ALC-safe compile cache (arch-review 02/U2,U3)
U2 (Critical): RoslynVirtualTagEvaluator ran scripts with a CancellationToken
that a synchronous Roslyn script can never observe, so one CPU-bound/infinite-
loop virtual-tag script hung the owning VirtualTagActor forever (no timeout, no
recovery). Route evaluation through the existing TimedScriptEvaluator (Task.Run
+ WaitAsync) so the wall-clock budget is real; a runaway script now returns Bad
within the timeout instead of wedging the actor.

U3 (High): the live path cached evaluators in a raw ConcurrentDictionary never
cleared on republish, leaking a collectible AssemblyLoadContext per edited
script forever. Swap in the purpose-built CompiledScriptCache (Lazy single-
compile, dispose-on-Clear) and add an apply-boundary clear: a new narrow
IScriptCacheOwner seam the VirtualTagHostActor calls on each ApplyVirtualTags
generation (mirrors ScriptedAlarmEngine's per-generation clear).

Tests: infinite-loop-fails-within-timeout (bounded by WaitAsync so a regression
fails instead of hanging), happy-path-after-wrapping, ClearCompiledScripts-
empties-cache, and a host-actor wiring guard asserting each apply generation
calls ClearCompiledScripts (theme #1 production-caller assertion). Host.Integration
VT suite 18/18, Runtime VT suite 12/12.
2026-07-08 16:21:47 -04:00
Joseph Doherty 9cad9ed0fc docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
2026-07-07 12:38:39 -04:00
Joseph Doherty 34fc304712 fix(otopcua): guard discovered-injection equipment id + cover deferred forwarding 2026-06-26 07:27:09 -04:00
Joseph Doherty 598cdfad5a feat(otopcua): applier pass to materialise discovered nodes idempotently 2026-06-26 07:16:36 -04:00
Joseph Doherty 23b42b424d fix(code-review): resolve OpcUaServer-001 — UNS Area/Line rename refreshes folder DisplayName
A rename-only deploy produced an IsEmpty plan that short-circuited before MaterialiseHierarchy,
leaving the OPC UA folder DisplayName stale. AddressSpacePlanner now diffs UnsAreas/UnsLines by
stable id into a RenamedFolders set (counted in IsEmpty); the applier refreshes the folder in
place via a new UpdateFolderDisplayName on ISurgicalAddressSpaceSink (forwarded through
DeferredAddressSpaceSink so it is NOT inert on driver hosts; falls back to rebuild when the sink
is non-surgical). DeploymentArtifact byte-parity untouched (rename rides the existing Name
round-trip). No EF migration, no serialized wire/proto contract change. +13 OpcUaServer tests, Runtime rebuild test.
2026-06-20 23:10:24 -04:00
Joseph Doherty 6dc74289ce review(Commons): record findings + add deferred-sink/equip-nodeid tests, fix stale Phase7 doc
Code review at HEAD 7286d320. Commons-001 (stale Phase7 telemetry doc) fixed;
Commons-003/004 close test-coverage gaps (DeferredAddressSpaceSink/ServiceLevelPublisher
forwarding seam + EquipmentNodeIds whitespace branch). Commons-002 (CorrelationId
typing) deferred as cross-cutting.
2026-06-19 10:22:59 -04:00
Joseph Doherty fb094fa566 feat(opcua): FB-7 surgical DataType/array-shape in-place tag writes
Widen the F10b surgical address-space path so a changed equipment tag whose
only differences are DataType / IsArray / ArrayLength (on top of the existing
Writable / Historizing) is applied IN PLACE on the live node instead of forcing
a full RebuildAddressSpace that drops every client's subscriptions server-wide.

ISurgicalAddressSpaceSink.UpdateTagAttributes gains (dataType, isArray,
arrayLength); the DeferredAddressSpaceSink wrapper forwards all six args (the
prod-inertness seam). OtOpcUaNodeManager swaps DataType + ValueRank +
ArrayDimensions in place, and on a real shape change (a) resets the node to
BadWaitingForInitialData so no stale wrong-typed value is exposed (closes the
prior brief-value-type-mismatch objection) and (b) raises a Part 3
GeneralModelChangeEvent (verb=DataTypeChanged) so model-aware clients re-read
the definition. A Writable/Historizing-only change leaves the shape untouched
(no reset, no model event) — original behaviour preserved byte-for-byte.

AddressSpaceApplier.TagDeltaIsSurgicalEligible adds the three shape fields to
its whitelist; FullName/Name/DriverInstanceId/alarm differences still rebuild.

Tests: new NodeManagerSurgicalShapeUpdateTests boots a real server to prove the
in-place swap + value reset + the no-reset backward-compat path + the model-event
builder; AddressSpaceApplierTests invert the two former DataType/IsArray-rebuild
cases to surgical and assert the shape args land; DeferredAddressSpaceSinkTests
assert the shape args forward. 273/273 OpcUaServer.Tests green; full solution builds.
2026-06-19 03:21:03 -04:00
Joseph Doherty 40e8a23e7c refactor(opcuaserver): rename Phase7* address-space pipeline to AddressSpace*
v2-ci / build (push) Failing after 37s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
The OPC UA address-space build pipeline was named after a v2-roadmap
milestone number rather than its domain. Rename the family to describe
what it does (build/diff/apply the OPC UA address space):

  Phase7Composer          -> AddressSpaceComposer
  Phase7CompositionResult -> AddressSpaceComposition
  Phase7Planner           -> AddressSpacePlanner
  Phase7Plan              -> AddressSpacePlan
  Phase7Applier           -> AddressSpaceApplier
  Phase7ApplyOutcome      -> AddressSpaceApplyOutcome

The 9 Phase7*Tests suites follow suit; Phase7ScriptingEntitiesTests ->
ScriptingEntitiesTests (it tests the scripting migration, not the
pipeline). Log-message prefixes move to the new class names.

Pure mechanical rename, no behavioral change. EF migration classes/IDs
(AddPhase7ScriptingTables, ExtendComputeGenerationDiffWithPhase7) are
immutable and left untouched, as are historical design docs.

Build clean; OpcUaServer 261/261, Runtime 272/272, ScriptingEntities
12/12 green.
2026-06-18 19:16:28 -04:00
Joseph Doherty 22b0611fb9 fix(opcua): forward ISurgicalAddressSpaceSink through DeferredAddressSpaceSink (F10b surgical path was inert in prod) 2026-06-18 13:57:53 -04:00
Joseph Doherty 3fc258bd42 feat(opcua): add ISurgicalAddressSpaceSink + node-manager in-place tag-attribute update (F10b) 2026-06-18 13:32:53 -04:00
Joseph Doherty a792820283 feat(opcua): EnsureVariable array params (ValueRank=OneDimension + ArrayDimensions) 2026-06-16 21:16:07 -04:00
Joseph Doherty 069a5f3165 feat(adminui): Galaxy picker pre-fills native-alarm fields from IsAlarm 2026-06-16 16:55:05 -04:00
Joseph Doherty be6858baa1 fix(alarms): OnEnableDisable native-check via lock-guarded IsNativeAlarmNode + unstale AlarmCommand doc (code-review) 2026-06-15 14:30:17 -04:00
Joseph Doherty 418663b359 feat(alarms): thread isNative through MaterialiseAlarmCondition; node manager tracks native conditions [H6a] 2026-06-15 14:13:30 -04:00
Joseph Doherty 6041dc202b feat(historian): materialise historized vars with Historizing + HistoryRead bit + NodeId->tagname map 2026-06-14 19:09:32 -04:00
Joseph Doherty 0f7c47a559 feat(commons): IOpcUaNodeWriteGateway + NodeWriteOutcome for write-outcome routing 2026-06-14 01:20:14 -04:00
Joseph Doherty a23fb2b82e feat(server): equipment-tag node writability from Tag.AccessLevel (parity-safe, no migration) 2026-06-13 11:46:00 -04:00
Joseph Doherty 26816fd17e feat(commons): EquipmentNodeIds — single source of truth for folder-scoped equipment NodeIds 2026-06-13 06:26:59 -04:00
Joseph Doherty 5edea52bd7 docs(galaxy): fix stale SystemPlatform/alias/Galaxy doc comments (review follow-up)
Resolves the code-review notes on 95be607a + the AdminUI bundle: the
EnsureVariable docs (IOpcUaAddressSpaceSink, OtOpcUaNodeManager) and the Tag
entity doc no longer say 'Galaxy / SystemPlatform / alias'; the DriverHostActor
ForwardToMux comment now states the real equipment-tag value-routing gap (the
FullName→NodeId 'live values' milestone) instead of claiming Galaxy values map
straight through.
2026-06-12 22:00:52 -04:00
Joseph Doherty 2ba2f8a899 feat(commons): TryParseRelayBody — detect pure ctx.GetTag relay scripts 2026-06-11 20:59:10 -04:00
Joseph Doherty 61b230d79a harden(historian): nullable HistorizeToAveva (missing→historize) for rolling-restart-safe deserialize + middle-link test 2026-06-11 13:00:57 -04:00
Joseph Doherty 8012509584 feat(historian): honor per-alarm HistorizeToAveva opt-out at the durable write 2026-06-11 12:48:13 -04:00
Joseph Doherty 8ac3ac5be9 feat(alarms): carry AlarmTypeName + operator Comment on AlarmTransitionEvent (historian feed prep) 2026-06-11 11:03:00 -04:00
Joseph Doherty f9932f2d8e refactor(admin): use CorrelationId wrapper for alarm ack/shelve commands 2026-06-11 09:27:24 -04:00
Joseph Doherty 370a2b7b48 feat(alerts): AdminUI alarm ack/shelve via AdminOperationsActor singleton
T21: add an AdminUI path for acknowledging/shelving alarms that routes
through the admin-pinned AdminOperationsActor cluster singleton, which
republishes onto the same 'alarm-commands' DPS topic the OPC UA method
path (T18) and the engine subscriber (T19) use. The broadcast + the
ScriptedAlarmHostActor ownership filter handle cross-node routing, so
the singleton needs no knowledge of which node owns the alarm.

- Commons: AcknowledgeAlarmCommand/ShelveAlarmCommand (+ result records)
  and a shared AlarmCommandsTopic const; ScriptedAlarmHostActor now
  re-exports that const (mirrors the DriverControlTopic pattern).
- AdminOperationsActor: two handlers map the control-plane messages to
  AlarmCommand (Acknowledge / OneShotShelve / TimedShelve / Unshelve,
  threading User/Comment/UnshelveAtUtc) and publish via the DPS mediator.
- IAdminOperationsClient + AdminOperationsClient: typed Acknowledge/Shelve
  ask wrappers mirroring StartDeploymentAsync.
- Alerts.razor: per-row DriverOperator-gated Ack/Shelve/Unshelve controls;
  operator name from AuthenticationState. Timed-shelve datetime UI deferred.
- 5 TestKit tests (mediator-probe subscribed to alarm-commands) verifying
  each kind's mapping + reply; 56/56 ControlPlane tests green.
2026-06-11 06:44:27 -04:00