Commit Graph

534 Commits

Author SHA1 Message Date
Joseph Doherty 2bae1b4c9f refactor(localdb): delete the dormant LiteDB LocalCache (superseded by ZB.MOM.WW.LocalDb)
Never registered in DI and dead since Phase 6.1. Keeping two unwired cache designs
invites the next reader to wire the wrong one.

Deletes all three test files - LiteDbConfigCacheTests.cs was missing from the plan's
list and would have failed to compile (it calls new LiteDB.LiteDatabase directly).
StaleConfigFlagTests lives inside ResilientConfigReaderTests.cs, so it goes too.

The XML-doc reference in ILdapGroupRoleMappingService is rewritten rather than removed:
it now records that no sign-in fallback exists and that reviving one means an admin-side
cache on LocalDb.
2026-07-20 10:46:07 -04:00
Joseph Doherty 42f8550716 docs(localdb): phase-1 recon findings 2026-07-20 10:18:01 -04:00
Joseph Doherty 043e237dba docs(alarms): state #478 coverage boundary + file Layer-4 comms-loss follow-up (#481)
v2-ci / build (pull_request) Successful in 5m42s
v2-ci / unit-tests (pull_request) Failing after 13m11s
Post-implementation review (HIGH finding) noted #478's mux-delivered
input-quality path does not cover a driver comms-loss: a poll driver
(Modbus/S7) whose device goes unreachable emits only ConnectivityChanged and
goes silent on the value feed, so a scripted alarm keeps the last Good value.
The code as shipped faithfully implements #478's written scope (worst of input
tags' qualities via the dependency mux). The comms-loss bridge for scripted
alarms (symmetric of native #477-L2, plus the null-value/cold-start asymmetry
and its VT-quality ripple) is tracked as #481. Docs updated in
AlarmTracking.md + the design doc.
2026-07-17 16:07:55 -04:00
Joseph Doherty 8c5e2be92e feat(alarms): scripted condition Quality from worst-of-input tag quality (#478)
v2-ci / build (pull_request) Successful in 3m48s
v2-ci / unit-tests (pull_request) Failing after 11m0s
Layer 3 of #477: a scripted alarm's condition Quality now reflects the WORST
quality across its input tags, mirroring the native OT semantic (#477 L2).

Plumbing (quality was silently discarded twice on the live path):
- VirtualTagActor.DependencyValueChanged gains Quality (defaulted Good); the
  DependencyMuxActor forwards the published AttributeValuePublished.Quality it
  already carried; ScriptedAlarmHostActor.OnDependencyChanged pushes the real
  quality into the engine (was hardcoded 0u/Good).

Engine (Core.ScriptedAlarms):
- ScriptedAlarmEngine computes worst-of-input quality each eval (skipping
  not-yet-published inputs, which are a readiness concern, not a quality signal)
  and carries it on ScriptedAlarmEvent.WorstInputStatusCode.
- A real transition carries the current worst quality so ToSnapshot's full
  snapshot doesn't clobber quality back to Good (e.g. transition while Uncertain).
- A Bad input freezes the condition (no transition), like a comms-lost native
  driver; a quality-bucket change with no transition emits the new
  EmissionKind.QualityChanged, routed to the existing #477-L2
  AlarmQualityUpdate -> WriteAlarmQuality node path (quality only, no /alerts
  row, no historian write). ScriptedAlarmSource skips QualityChanged so it never
  fabricates a phantom IAlarmSource event.

Host: ToSnapshot maps WorstInputStatusCode -> OpcUaQuality; OnEngineEmission
routes QualityChanged out of band.

Tests (TDD, RED-first): engine worst-carry + Bad/restore QualityChanged +
unchanged-bucket-no-emit; source swallows QualityChanged; mux forwards quality;
host Bad-dep -> AlarmQualityUpdate(no alerts) + transition snapshot carries worst.
Docs: AlarmTracking.md Layer-3 section + design doc.

Closes #478
2026-07-17 15:56:06 -04:00
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 e08b6b0e69 fix(alarms): populate ConditionClassId/ConditionClassName on conditions (#475)
MaterialiseAlarmCondition never assigned the mandatory Part 9 ConditionType
classification fields, so every condition event — native and scripted — shipped
ConditionClassId = NodeId.Null (i=0) and ConditionClassName = empty text. Same
mechanism as #473: Create() builds the mandatory children from the type's
embedded definition but leaves them unset, and nothing downstream synthesises
them (ReportEvent / InstanceStateSnapshot copy children verbatim). An HMI
bucketing alarms by condition class dropped every OtOpcUa alarm as unclassified.

Report BaseConditionClassType — Part 9's "no condition class modelled" value.
This is the honest report: we hold no classification at the materialise seam.
Deliberately NOT ProcessConditionClassType (the SDK sample's pick), which would
assert a classification we cannot back and would be actively wrong for a Galaxy
alarm whose upstream category is Safety/Diagnostics — trading a detectable null
for an undetectable lie. Real per-alarm classification needs the driver's
AlarmCategory carried to this deploy-time seam (it lives only on the runtime
AlarmEventArgs transition today) and is a separate feature.

Guards, both observed RED against the pre-fix server:
- NativeAlarmEventIdentityFieldDeliveryTests: wire-level, its own select clause
  (the #473 test's clause mirrors ScadaBridge's exactly and its indices are
  load-bearing, so it is left untouched). The class fields are declared on
  ConditionType, not BaseEventType, so they are selected against that type.
- NodeManagerAlarmSourceFieldsTests: node-level, native (Raw) + scripted (Uns).

Stacked on #473 (PR #474) — merge after it.
2026-07-17 00:49:43 -04:00
Joseph Doherty 7339a4af07 fix(alarms): populate EventType/SourceNode/SourceName on conditions (#473)
v2-ci / build (pull_request) Successful in 3m38s
v2-ci / unit-tests (pull_request) Failing after 9m38s
MaterialiseAlarmCondition never assigned the three mandatory BaseEventType
identity fields, so all three arrived null on every condition event — native
and scripted. The SDK does not synthesise them on this path: Create() builds
the children from the type definition but leaves them unset, the auto-filling
BaseEventState.Initialize overload is only used for transient events, and
ReportEvent / InstanceStateSnapshot copy children verbatim. A conforming
client could not attribute an alarm to its source.

  EventType  = the concrete materialised type (TypeDefinitionId)
  SourceNode = the condition's own NodeId (== ConditionId) — the condition IS
               the source; an alarm-bearing raw tag materialises only the
               condition, with no sibling value variable
  SourceName = the same identifying id string: RawPath (native) /
               ScriptedAlarmId (scripted)

SourceName carries the unique id rather than the leaf name: the leaf collides
across devices (HR200 on two PLCs) and is already carried by ConditionName, so
nothing is lost. Documented in docs/AlarmTracking.md, including that clients
must key on ConditionId and must not compose SourceName with ConditionName,
and that SourceName is NOT a live<->history join key (the alarm-history writer
stamps it with the EquipmentPath — a pre-existing divergence, now called out).

Tests: NativeAlarmEventIdentityFieldDeliveryTests is the wire-level guard —
a real client subscription using the standard [EventType, SourceNode,
SourceName, Time, Message, Severity] select clause, verified to fail against
the pre-fix server. NodeManagerAlarmSourceFieldsTests guards the node across
both realms, the base-type fallback, and the kind-swap re-materialise.

The HistoryRead events projection is a separate path (it projects historian
rows, not node fields) and is unaffected — its EventType => Variant.Null
assertions still hold.
2026-07-17 00:36:44 -04:00
Joseph Doherty ce383df39a docs(secrets): G-5 clustered master-key posture runbook (Task 6)
OtOpcUa is Akka-clustered and AddZbSecrets is registered unconditionally, so every
node (admin, driver, fused) resolves secrets and needs the SAME KEK + same store rows.
Ship G-5 as a production-posture runbook rather than hardcoding Source=File + shared
paths into the committed role-overlay appsettings (which dev + TwoNodeClusterHarness
also consume — that would break every dev/CI boot). Base appsettings stays on
Source=Environment. Documents the interim File-KEK + shared-SQLite posture and the
G-7 hand-off (ConfigDb-backed ISecretStore mirroring the existing DP
PersistKeysToDbContext<OtOpcUaConfigDbContext> key-ring sharing). Cross-linked from
docs/security.md. Mirrors the ScadaBridge G-5 resolution.
2026-07-16 17:30:32 -04:00
Joseph Doherty 73d8439412 docs(secrets): G-4 ${secret:} config delivery convention + fail-closed proof (Task 4)
OtOpcUa commits no plaintext secrets — the only secret-shaped committed value is
ServerHistorian:ApiKey='' (empty, disabled by default). Because the pre-host expander
is fail-closed AND section-agnostic, hard-committing a ${secret:} token would break
every dev/CI/integration boot for zero at-rest benefit. Instead document the token
delivery convention for all five config secrets (jwt/ldap/deploy/configdb/historian)
in docs/security.md and note the token option in the ServerHistorian comments.

Mechanism proven live: unseeded token boot fails with SecretNotFoundException at
pre-host expansion; seeded token resolves and boot proceeds. Behavior-neutral
(comment + doc only).
2026-07-16 17:14:32 -04:00
Joseph Doherty b4b378c5cd docs(v3): Batch 4 PR description — dual-namespace address space (v3.0)
v2-ci / build (pull_request) Successful in 4m0s
v2-ci / unit-tests (pull_request) Failing after 11m55s
7-leg live-gate evidence + per-wave reviewer verdicts + the live-gate-caught VT
reassert fix + documented follow-ups (confirmed-but-deferred scripted-alarm
redeploy recovery, raw-rename hot-rebind, cross-repo ScadaBridge cutover).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 14:36:30 -04:00
Joseph Doherty b8208b3312 test+docs(v3-batch4-wp5): 2-node dual-namespace harness tests + address-space docs
Tests:
- OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs (NEW, over-the-wire,
  offline-safe): both namespace URIs registered + distinct; Raw + UNS subtrees browse
  and read; UNS variable Organizes-references its raw node; single-source fan-out parity
  (identical value/quality/timestamp on both NodeIds); HistoryRead via either NodeId ->
  GoodNoData under the shared tagname; WriteOperate gate symmetric across both NodeIds.
- Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs (extended): full deploy
  -> persisted-artifact -> ParseComposition round-trip carrying both realms, sealing across
  the redundant 2-node cluster (redundancy non-interference). In-memory harness, offline.

Docs (dual-namespace reality):
- CLAUDE.md: new "v3 OPC UA Address Space (Batch 4)" section + Batch-4 testing paragraph.
- docs/Uns.md: address-space projection (two namespaces, Organizes edge, effective-name leaf).
- docs/Historian.md: dual-registration (both NodeIds -> one tagname); updated CLI examples.
- docs/ScriptedAlarms.md + docs/AlarmTracking.md: multi-notifier fan-out, ConditionId=RawPath.
- docs/ScriptEditor.md: dual-namespace clarification (script tag-path semantics unchanged).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 13:11:34 -04:00
Joseph Doherty 0c65e4412e docs(v3): Batch 3 PR description
v2-ci / build (pull_request) Successful in 3m36s
v2-ci / unit-tests (pull_request) Failing after 9m8s
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 07:38:20 -04:00
Joseph Doherty bdcb84bd7d docs(v3-batch3): UNS reference-only Equipment + {{equip}}/RefName resolution
Uns.md Tags section rewritten to the reference-only model (UnsTagReference list,
cluster-scoped picker, effective-name uniqueness, {{equip}}/RefName); CLAUDE.md
gains a v3 Batch 3 paragraph. ScriptEditor.md was updated by WP4.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 07:37:36 -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 33915c9e4d docs(v3): Batch 2 — /raw authoring tree guide + CLAUDE.md + PR description
v2-ci / build (pull_request) Successful in 3m25s
v2-ci / unit-tests (pull_request) Failing after 8m37s
docs/Raw.md (the /raw project tree: lazy tree, endpoint→DeviceConfig split, manual/CSV/browse
authoring, Calculation driver, routed-flow retirement); CLAUDE.md AdminUI section notes /raw +
DriverTypeNames rewire + Calculation driver; PR body with the 7-item live-gate evidence.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 05:27:31 -04:00
Joseph Doherty 080e958d94 docs(v3): Batch 1 schema/identity note in Configuration.md + PR description
v2-ci / build (pull_request) Successful in 3m31s
v2-ci / unit-tests (pull_request) Failing after 11m16s
2026-07-15 22:05:19 -04:00
Joseph Doherty 26ef8131b0 docs(browse): Wave-0 live-gate result + follow-ups (Task 18)
Live-verified on docker-dev: AbCip picker renders Browse button (full CanBrowse chain), clicking
it runs the universal-browser capture in production (central-2 logs) with PatchForBrowse
demonstrably applied (@tags walk ran without the authored config setting EnableControllerBrowse);
error surfaces gracefully. Modbus picker shows NO Browse button (negative). Populated tree-render
is fixture-blocked — ab_server returns ErrorUnsupported for the controller @tags walk (test sim
limitation, not a code defect). Two follow-ups recorded: (1) full tree-render needs a symbol-browse
capable AB backend; (2) driver-page pickers don't pass live config to the picker body.
2026-07-15 18:14:03 -04:00
Joseph Doherty 014c76b986 docs(browse): Wave-0 status flip — universal browser P1 implemented (live gate pending)
Task 17: design doc Status draft -> P1 implemented; program doc §9 Code not started -> Wave 0
landed. Full build 0 errors; touched suites green (Core.Abstractions 135, Commons 158,
AdminUI 542, AbCip 342, TwinCAT 191, FOCAS 271).
2026-07-15 17:42:42 -04:00
Joseph Doherty 80cda8f227 feat(browse): Wave-0 Batch G — AbCip/TwinCAT/FOCAS picker bodies gain universal browse
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.
2026-07-15 17:41:21 -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 679484ae78 feat(browse): Wave-0 Batch B — AbCip/TwinCAT/FOCAS opt into online discovery
Task 3/4/5: SupportsOnlineDiscovery => true on AbCipDriver, TwinCATDriver, FocasDriver
(their device enumeration is real, config-gated on EnableControllerBrowse / FixedTree.Enabled
which PatchForBrowse guarantees at open). One gate fact per driver test project.
2026-07-15 17:18:02 -04:00
Joseph Doherty c006363ec2 docs(driver-expansion): Wave-0 universal-browser implementation plan (18 tasks, parallel-batched)
v2-ci / build (push) Successful in 3m31s
v2-ci / unit-tests (push) Failing after 8m18s
2026-07-15 17:09:31 -04:00
Joseph Doherty fd0bec4ffe docs(driver-expansion): resolve all open design concerns from the review pass
v2-ci / build (push) Successful in 3m30s
v2-ci / unit-tests (push) Failing after 8m53s
Every concern the 7-agent review parked is now decided and integrated:

- universal browser: in-flight capture coalescing keyed (driverType,
  config-hash) + global cap of 4 concurrent captures; CanBrowse catches a
  throwing TryCreate and defensively shuts down the throwaway instance;
  cleanup ShutdownAsync bounded at 10s (R2-01); picker Refresh = close +
  re-capture. Coalesced sessions share an immutable tree, so DisposeAsync
  drops the session's reference, not the tree.
- mtconnect: UNAVAILABLE pinned to BadNoCommunication (fleet's
  BadCommunicationError stays reserved for the driver's own transport
  failures); TIME_SERIES sampleCount flows into ArrayDim; Subscribe timeout
  bounds only the stream-start handshake; library version/TFM folded into
  the license checklist.
- mqtt: browse rebirth is an explicit DriverOperator-gated "Request rebirth"
  button (RequestRebirthAsync(scope)) - never fired by open/root/expand;
  hand-rolled reconnect loop committed for P1 (MQTTnet v5 dropped
  ManagedMqttClient); Wave-2-start library re-verification checkbox;
  implement from the Sparkplug v3.0 spec text.
- bacnet: P1 opens with a first-spike checklist (package TFM, BBMD/
  segmented-RPM/COV API smoke, unicast-I-Am fixture behavior); AdminUI-node
  browse registers a second foreign device - BBMD FD-table sizing note.
- sql-poll: split-node browse needs env parity for Sql__ConnectionStrings__
  refs on admin nodes (actionable error + session-only pasted literal);
  one-row-per-key query contract (last-wins + rate-limited warning);
  absent key -> BadNoData; operationTimeout > commandTimeout authoring rule;
  Browser->Driver.Sql SqlClient transitive on AdminUI accepted on record.
- omron: FINS framer gated on W227 + live golden vectors; CIP string layout
  is the first P1 live-gate item; AbCip harness static-init anti-pattern
  note; writable-defaults-true operator warning.
- modbus-rtu: first P1 step confirms pymodbus exposes framer=rtu on a TCP
  server (custom-script fallback otherwise).
- program doc: new cross-cutting rule - driver ctors must be connection-free
  (the universal browser's CanBrowse throwaway instances depend on it).
2026-07-15 16:54:09 -04:00
Joseph Doherty cf03ca279d docs(v3): implementation plans — master orchestration + 4 batch plans
- v3 design finalized: all 7 open items resolved (live historian probe:
  255-char tagname limit; SDK multi-notifier spike: native AddNotifier,
  never duplicate ReportEvent; NamespaceKind.Simulated retired; CSV column
  dictionaries; ScadaBridge re-bind sized; rename-warning scan decided),
  reconciled with the pre-v3 universal discovery browser (its new §11
  v3 forward note re-targets the browse commit contract).
- Calculation pseudo-driver mini-design (IDependencyConsumer capability,
  mux-fed change/timer triggers, Tarjan cycle deploy gate, VT-parity
  error semantics).
- Implementation plans for Opus-agent execution, grounded against exact
  files/symbols: master plan (batch DAG, worktree-isolated parallel
  waves, contract-first fan-out, global gotcha list) + one plan per
  batch (1 schema+RawPath identity, 2 /raw UI+Calculation, 3 UNS
  reference-only+{{equip}}, 4 dual-namespace address space = v3.0),
  each with per-wave file ownership and a mandatory docker-dev live gate.
2026-07-15 16:50:30 -04:00
Joseph Doherty fbe393471e docs(driver-expansion): descope Modbus direct-serial — RTU-over-TCP only
v2-ci / build (push) Successful in 4m10s
v2-ci / unit-tests (push) Failing after 9m7s
Per user decision 2026-07-15: the direct-serial transport (ModbusRtuTransport,
System.IO.Ports, serial config fields, socat live rig) is not being built.
Serial RS-485 buses are reached exclusively via serial->Ethernet gateways
(Moxa NPort etc.) using the new ModbusRtuOverTcpTransport — zero new package
deps, no container device mapping, reuses the hardened socket lifecycle.

ModbusTransportMode shrinks to Tcp|RtuOverTcp (Rtu member reserved). The
direct-serial design is kept in the RTU doc's §2b as a marked record,
including the R2-01 SerialPort-BaseStream-ignores-ReadTimeout trap.
2026-07-15 16:45:40 -04:00
Joseph Doherty 8fc147d8d4 docs: driver-expansion program — 8 research reports + 7 design docs, parallel-reviewed
v2-ci / build (push) Successful in 3m14s
v2-ci / unit-tests (push) Failing after 9m13s
Adds the driver-expansion program design (umbrella: universal Discover-backed
browser + MTConnect, MQTT/Sparkplug B, BACnet/IP, SQL poll, Omron, Modbus RTU;
MELSEC deferred) plus the per-driver research reports.

All docs went through a 7-agent parallel review against the codebase before
this commit. Highlights fixed in review:

- universal browser: FOCAS FixedTree fills post-connect -> UntilStable settle
  + FixedTree.Enabled patch; MQTT reconciled to bespoke (was contradicting the
  program doc's SupportsOnlineDiscovery=false verdict)
- modbus-rtu: SerialPort.ReadTimeout doesn't bound async BaseStream reads ->
  linked-CTS per-op deadline (R2-01 class); BCL enum reuse would leak
  System.IO.Ports into Contracts
- bacnet: DiscoveryRediscoverPolicy enum name; UDP 47808 contention; live
  suite rewritten around unicast Who-Is + BBMD (broadcast doesn't cross VMs)
- sql-poll: real tier registration via DriverFactoryRegistry.Register;
  blackhole gate must not docker-pause the shared central SQL Server
- mqtt: Sparkplug v3.0 STATE topic form; first-in-repo proto codegen noted
- omron: host hardcodes isIdempotent:false today (retry seam unshipped);
  v1 scopes UDTs to dotted-leaf access
- mtconnect: SecurityClassification.ViewOnly; factory ParseEnum<T> pattern
- program doc: both valid enum-serialization patterns; IRediscoverable is
  change-signal-gated; RTU P2 adds System.IO.Ports; label is host-side
2026-07-15 16:40:36 -04:00
Joseph Doherty 37cdbef7a5 docs(v3): design — Kepware-style Raw tree + UNS projection (two-subtree address space) 2026-07-15 15:54:49 -04:00
Joseph Doherty 2791ab5810 docs(#420): final reconciliation — alarms-over-gateway feature complete; C.1 superseded
v2-ci / build (pull_request) Successful in 4m15s
v2-ci / unit-tests (pull_request) Failing after 10m50s
#420 (filed 2026-05-01 as '4 inert scaffolds + D.1 smoke') is stale. Current
master + the mxaccessgw sibling repo show the feature landed on both sides:

- mxaccessgw A.2/A.4: native alarms served via the wnwrap consumer
  (WnWrapAlarmConsumer over WNWRAPCONSUMERLib.wwAlarmConsumerClass) +
  FailoverAlarmConsumer + AlarmDispatcher; StreamAlarms active-alarm snapshot
  (20 alarms verified live). The WM_APP-pump premise this issue was written
  against was ruled out by the 2026-05-01 probes; the design pivoted to wnwrap.
- mxaccessgw A.3: NotWiredAlarmRpcDispatcher gone (only in generated proto);
  ack routes MxGatewayClient.AcknowledgeAlarmAsync -> AlarmClient.AlarmAckByGUID.
- lmxopcua: GalaxyDriver : IAlarmSource wires GatewayGalaxyAlarmFeed
  (-> OnAlarmEvent, native metadata incl. operator-comment) + inbound ack via
  GatewayGalaxyAlarmAcknowledger; native feed live-proven by
  GatewayGalaxyAlarmFeedLiveTests.
- C.1 (SdkAlarmHistorianWriteBackend / aahClientManaged): SUPERSEDED. The whole
  Wonderware sidecar was retired; alarm-history writes cut over to the
  HistorianGateway (GatewayAlarmHistorianWriter, SendEvent path), live-verified
  2026-07-15 (R2-06/R2-08, 6/6). The target no longer exists in the tree.

Updated the alarms-over-gateway.md banner (final reconciliation) + the
alarms-d1-smoke-artifact.md status note. Sole residual = a Windows-parity-rig
running-server OPC UA A&C round-trip (needs live Galaxy + human IDE).
2026-07-15 15:23:32 -04:00
Joseph Doherty b0eb653bad fix(redundancy): 2-node SBR exit-and-rejoin recovery — watchdog + restart supervision + both-node seeds (#459)
v2-ci / unit-tests (pull_request) Failing after 12m9s
v2-ci / build (pull_request) Successful in 5m12s
Corrects the #459 finding. 2-node keep-oldest recovery works fine (the ScadaBridge
sister project proves it); OtOpcUa was missing the supervision pieces that make it
automatic, and docs/Redundancy.md wrongly claimed in-place oldest-crash failover.

Mechanism (confirmed on a 2-container rig + by decompiling Akka KeepOldest.OldestDecision):
on an OLDEST-node crash keep-oldest downs the LONE survivor (DownReachable including
myself) — down-if-alone can't rescue a lone survivor (its branch needs >=2 survivors).
Recovery is exit-and-rejoin: run-coordinated-shutdown-when-down terminates the node and
the service supervisor restarts it. My earlier 'total outage' was a docker-dev artifact
(no restart policy); production Install-Services.ps1 already has sc.exe failure restart.

Changes (ScadaBridge parity):
- ActorSystemTerminationWatchdog (Host, registered after AddAkka): watches
  ActorSystem.WhenTerminated and on an unexpected self-down calls StopApplication so the
  process exits (supervisor restarts it) instead of idling with a dead actor system.
  Distinguishes graceful shutdown via _stopRequested + ApplicationStopping. 3 unit tests.
- docker-dev: restart: unless-stopped on the host anchor (models production supervision) +
  both redundancy peers in SeedNodes so a restarted node re-forms via either peer.
- docs/Redundancy.md: rewrote the split-brain recovery section — younger-loss = in-place
  fast failover; oldest-loss = exit-and-rejoin under supervision (not in-place); the three
  requirements (supervisor + watchdog + both-node seeds); flagged HardKillFailoverTests as
  non-representative (Transport.Shutdown, not a real crash). Instant in-place takeover on
  ANY single loss needs 3+ members.

Cluster.Tests 29/29 (SBR guards), watchdog tests 3/3, full solution builds.
Live re-verify of the watchdog image pending (host docker disk full).
2026-07-15 10:52:36 -04:00
Joseph Doherty 9f62310b49 fix(historian): map Boolean historization to Int2, not degenerate Int1 (#441)
v2-ci / build (pull_request) Successful in 3m24s
v2-ci / unit-tests (pull_request) Failing after 8m19s
The historian's Int1 analog-tag creation path is server-degenerate:
EnsureTags(Int1) stores an unusable stub (live-probed on the deployed
2023 R2 historian; root-caused + documented gateway-side in
HistorianGateway #11 / PR #16). HistorianTypeMapper mapped
DriverDataType.Boolean -> HistorianDataType.Int1, so Boolean historized
tags failed auto-provisioning.

Map Boolean -> Int2 instead (store 0/1). Int2 is a proven-creatable
analog type, and the value-write path already sends every value as a
double on WriteLiveValues regardless of tag type, so Boolean 0.0/1.0
round-trips cleanly. Both Float and Int2 are supported, so a
pre-existing Float Boolean tag retypes cleanly on its next deploy.

- HistorianTypeMapper: Boolean => Int2 (+ remarks)
- Unit pins: HistorianTypeMapperTests, GatewayTagProvisionerTests
  (Boolean_definition_carries_explicit_Int2_presence)
- Live gate: EnsureTags_boolean_sandbox_provisions_as_Int2 (was _Int1);
  retype probe reframed Float->Int2 (drops the dead Int1 leg)
- Docs: docs/Historian.md 'Boolean historization maps to Int2', CLAUDE.md,
  archreview STATUS.md (R2-06 live gate -> 6/6)

Resolves #441.
2026-07-14 11:57:27 -04:00
Joseph Doherty 9ba162d0f8 docs(archreview): cross-reference historiangw#11 for the Int1 EnsureTags gap
v2-ci / build (push) Successful in 3m49s
v2-ci / unit-tests (push) Failing after 8m10s
2026-07-14 05:35:15 -04:00
Joseph Doherty 416789c6ab docs(archreview): R2-06 live gate 5/6 + U-7 retype policy answered
v2-ci / build (push) Successful in 4m21s
v2-ci / unit-tests (push) Failing after 8m44s
- StorageRateMs bug found+fixed (PR #439); live suite 3/6 -> 5/6
- U-7: EnsureTags is a real upsert; supported-type retype ACCEPTED (Float->Double);
  Int1 not creatable on this histsdk (ProtocolEvidenceMissingException, Int1-only)
- record the observed retype policy in docs/Historian.md (fills the placeholder)
2026-07-13 19:31:47 -04:00
Joseph Doherty 354d74d370 docs: ServerHistorian HistoryRead knobs + Security:Ldap resilience keys; R2-08 status (03/S2, 03/S3)
v2-ci / build (pull_request) Successful in 3m33s
v2-ci / unit-tests (pull_request) Failing after 9m24s
2026-07-13 12:21:33 -04:00
Joseph Doherty 62a49779ac test(host): unreachable-directory activation fails bounded; document the SDK impersonation threading contract (03/S2) 2026-07-13 12:00:35 -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 7fc3b19961 docs: R2-11 TagConfig consolidation + strictness rollout decisions 2026-07-13 11:24:39 -04:00
Joseph Doherty 8da54652c3 docs(r2-04): failure-visibility trio semantics (gate table, meters, Galaxy fail-closed) 2026-07-13 11:14:49 -04:00
Joseph Doherty 46fedda3e4 Merge R2-02 ResilienceConfig hardening (arch-review round 2) [PR #431]
v2-ci / build (push) Successful in 3m53s
v2-ci / unit-tests (push) Failing after 8m15s
Findings 01/S-6 (High — clamp hostile ResilienceConfig, no ValidationException
driver-brick), 01/U-6 (delete dead bulkhead knob), 01/S-8=03/S12 (Write/Ack
non-idempotent + retry->0), 04/C-7 (JsonObject round-trip preserves unknown
keys), 01/S-7=03/S13 (per-Create options-generation kills respawn cache race).
T15/T18 deferred. STATUS.md conflict resolved additively. Build clean.
2026-07-13 10:29:43 -04:00
Joseph Doherty 07eec11d31 docs+test(r2-02): bulkhead doc sweep + parsed-knob-must-be-wired guard (01/U-6, OVERALL theme 1) 2026-07-13 10:15:32 -04:00
Joseph Doherty 12b3206555 Merge R2-05 AdminUI authorization (arch-review round 2) [PR #428]
v2-ci / build (push) Successful in 3m27s
v2-ci / unit-tests (push) Failing after 8m29s
Finding 04/C-1 (High): explicit named-policy gating on all 38 AdminUI pages via
new AdminUiPolicies (ConfigEditor + AuthenticatedRead) + anti-drift reflection
guard. T15 live pass deferred (docker-dev auto-admin can't observe deny).
STATUS.md conflict resolved additively. Build clean.
2026-07-13 10:12:57 -04:00
Joseph Doherty 479057097a Merge R2-06 ServerHistorian fail-fast + 0.2.0 DataType (arch-review round 2) [PR #427]
v2-ci / build (push) Successful in 3m15s
v2-ci / unit-tests (push) Failing after 8m17s
Findings 06/S-11 (fail-fast ServerHistorianOptionsValidator, no UriFormatException
crash-loop), 06/U-7 (0.2.0 Boolean->Int1 explicit-presence + retype probe),
06/C-7 (keep-populating decision doc). T12 live VPN gate deferred. STATUS.md conflict
(R2-12 bookkeeping) resolved additively. Build clean.
2026-07-13 10:10:48 -04:00
Joseph Doherty 4640ecb19b docs(r2-06): note WriteLiveValues Quality is a documented-dead input (0.2.0 C-002); keep 192 populated (06/C-7) 2026-07-13 10:04:29 -04:00
Joseph Doherty e5cf986a10 docs(r2-06): 0.2.0 DataType provisioning delta (upsert/retype) + HISTGW_BOOL_SANDBOX_TAG recipe (06/U-7) 2026-07-13 10:02:37 -04:00
Joseph Doherty dad783514a docs(security): document AdminUI policy tiers (ConfigEditor/AuthenticatedRead) + R2-05 status 2026-07-13 10:01:52 -04:00
Joseph Doherty 6e1903b231 docs(r2-12): document 5 alarm operator CLI commands; fix StyleGuide product name; add minimal .editorconfig (07/U-3, 07/C-5)
- docs/Client.CLI.md: added ### ack / confirm / shelve / enable / disable sections
  (synopsis + options + example + expected output), each noting the AlarmAck LDAP-role
  gate and that --event-id comes from a prior `alarms` notification. Doc now covers all
  13 CLI commands (was 8). Overview operations list updated.
- StyleGuide.md: 'ScadaBridge' -> 'OtOpcUa' (header line 3) + the ScadaBridge:Timeout
  config-key example -> OpcUa:Timeout.
- .editorconfig (new, root): minimal code-style anchor; all C# analyzer rules at
  suggestion severity so nothing fails a TreatWarningsAsErrors build (verified: 808
  warnings unchanged from baseline, 0 errors).
2026-07-13 09:57:36 -04:00
Joseph Doherty 73d4061b77 chore(r2-12): untrack/archive root planning files; honor pending.md's never-stage rule (07/U-4)
- pending.md, current.md: git rm --cached (untrack, keep on disk) + root-anchored
  .gitignore entries — making the files' own declared 'never stage' rule true.
- looseends.md: deleted (2026-05-18 snapshot; sole open item + all follow-ups
  target the retired Wonderware historian sidecar rig, moot since the gateway
  cutover; recoverable from history).
- stillpending.md -> docs/plans/2026-06-18-stillpending-backlog-audit.md (git mv;
  referenced by docs/Historian.md + memory index, preserved under dated-plan convention).
- HISTORIAN-GATEWAY-INTEGRATION-ISSUES.md -> docs/plans/2026-06-27-historian-gateway-integration-issues.md (git mv).
- docs/Historian.md §'Closed backlog §2 items' repointed at the archived path.
Historical docs/plans/* references to the bare stillpending.md filename are frozen
point-in-time records, left untouched per the documentation-audit convention.
2026-07-13 09:53:01 -04:00
Joseph Doherty eaf78aad90 docs(archreview #11): correct Critical 1 premise — SBR was already active on master, not NoDowning
Akka.Cluster.Hosting's WithClustering enables an SBR downing provider by default
(applies SplitBrainResolverOption.Default when ClusterOptions.SplitBrainResolver is
null), which reads the pre-existing akka.conf keep-oldest block. So the cluster was
NOT running NoDowning before Critical 1 and hard-crash failover already worked — the
typed KeepOldestOption is reinforcing/explicit-in-code, not the sole activator.

Corrects the inaccurate 'HOCON inert / NoDowning / never fails over' framing in:
- ServiceCollectionExtensions.BuildClusterOptions XML comment
- akka.conf split-brain-resolver comment
- docs/Redundancy.md Split-brain section
- SplitBrainResolverActivationTests summary + assertion message (+ method rename)

No code revert (the typed option is correct belt-and-suspenders). Cluster.Tests 29/29.
Surfaced by the #9 hard-kill failover negative control.
2026-07-08 22:34:20 -04:00