Commit Graph

43 Commits

Author SHA1 Message Date
Joseph Doherty 73132b7d78 fix(opcuaclient): #910 name the per-endpoint connect budget; widen it in the #816 host-name tests
Cause: the client-side PerEndpointConnectTimeout (3 s), not opc-plc. The
validate:null case is the first test the assembly runs, so it pays the
cold-process cost of the first Basic256Sha256 connect: loading the fresh
throwaway certificate, validating the server's, and the asymmetric handshake.
In the G2 full-solution run that local work used up the budget. The opc-plc
log at 13:13:50-53Z shows the discovery channel closing at 50.709, then nothing
from the client for 3 s, then OpenSecureChannel answered at 53.775 and the
client closing the channel 48 ms later. The SDK reports a wait cancelled by
that budget as BadRequestInterrupted. opc-plc auto-accepted the certificate
(--aa, "Trusting certificate ... command line option") with no reject and no
store round-trip.

- Driver: a per-endpoint budget expiry now surfaces as TimeoutException naming
  PerEndpointConnectTimeout instead of BadRequestInterrupted or a bare
  TaskCanceledException. There is no behaviour change. Unit test added
  against a silent TCP listener.
- Test: the ServerHostNameValidationTests secure options use a 15 s budget,
  the same as the browser integration tests' 10 s. Connect latency is not
  what these tests check.
- docs/drivers/OpcUaClient.md: documents what the budget covers.
2026-11-13 12:42:45 -05:00
Joseph Doherty e6ec5a0948 docs(opcuaclient): keep server host-name check opt-in; surface it in the Security panel (#837)
Owner decision 2026-11-11: certificateValidation.validateServerHostName stays
opt-in (unset = off). The AdminUI toggle moves from the "Subscriptions, limits &
discovery" panel to the Security panel beside mode/policy, with help text on the
NAT/IP caveat, IPv6 zone ids and the loose first-label match. Binding unchanged
(tri-state, "Default (off)" leaves the key absent). OpcUaClient.md states the
decision; drivers rule updated; source-scan test pins placement + caveats.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 11:57:59 -05:00
Joseph Doherty 0810e808c9 docs(security): per-instance credential references (#835) and the user-certificate directory (#836)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 03:52:48 -05:00
Joseph Doherty e9064dbdf4 Merge fix/815-credential-ref-scoping
# Conflicts:
#	.claude/rules/drivers.md
#	docs/drivers/Heidenhain.md
2026-11-11 02:08:24 -05:00
Joseph Doherty 4563940792 fix(security): warn-only compatibility for deployed drivers + upgrade preflight (#815)
User decision: save, Test Connect, live browse and the deploy gate keep
refusing out-of-namespace credential references, but an ALREADY-DEPLOYED
driver must not fault at the upgrade restart or (Galaxy) at the next
gateway reconnect, since ReopenAsync re-resolves Gateway.ApiKeySecretRef.

- LegacyCredentialReferenceTracker (Core.Abstractions): the one, deprecated,
  call-site-chosen compatibility path. Running Galaxy (InitializeAsync,
  ReopenAsync, ack fallback) and OpcUaClient (session open) pass it and keep
  the pre-#815 semantics (case-insensitive prefixes, Galaxy file:, OpcUaClient
  literal pass-through) with a rate-limited Warning (driver/field/allowed
  form, never the value) and a credential-compat:<field> diagnostics entry
  (both drivers now implement IDriverDiagnostics). Browsers and probes pass
  none and stay hard-fail; Heidenhain has none.
- Deploy-gate message names the migration.
- Upgrade preflight: CredentialReferencePreflightService (admin nodes, once
  after ApplicationStarted, ConfigDb only, resolves nothing) logs a Warning
  per Draft row / Deployed-artifact finding; the /raw banner shows them plus
  Running drivers reporting warn-only use (driver-health diagnostics).
- docs/security.md: exact replacement table (incl. the old form default
  env:MX_API_KEY, file:, un-namespaced secret:, casing changes), the
  ReopenAsync reconnect behaviour, the warn-only window and its removal.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 02:07:33 -05:00
Joseph Doherty 9aff90f884 fix(opcuaclient): never pass checkDomain for a None endpoint; document both SDK check sites (#816 review)
SDK 2.0.1 checks the server host name in TWO places: Session.OpenAsync (after
the chain is trusted) and DefaultSessionFactory.CreateChannelAsync, against the
certificate in the GetEndpoints reply and before any security-mode test. opc-plc
sends a certificate on its None endpoint, so `connect -u
opc.tcp://10.100.0.35:50000 --validate-host-name` was refused on an
unauthenticated channel, where a host check proves nothing.

All three call sites now pass checkDomain only when the selected endpoint's
SecurityMode != None: OpcUaEndpointSelection.CheckDomainFor for the driver
(OpenSessionOnEndpointAsync, so the failover sweep too) and the AdminUI browser;
the same expression in Client.Shared ConnectToEndpointAsync (shared by
failover). New live tests: driver None + flag on vs :50000 reads; browser None +
flag on vs the NAT fixture browses; the secure + flag refusals still hold.

Docs: name both check sites; a zone-qualified IPv6 host never matches (FindDomain
compares the zone too), so link-local needs validateServerHostName=false; the
match is loose (first label alone, subject DC= parts count, no wildcards), which
caveats the CA recommendation.
2026-11-11 01:53:01 -05:00
Joseph Doherty 9742a66a9e docs(security): credential-reference namespaces + migration note (#815)
docs/security.md gains the canonical table of per-driver namespaces, the five
enforcement seams, the out-of-scope cases (appsettings ${secret:}, Sql
connectionStringRef) and the migration steps for existing configs. Galaxy,
OpcUaClient, Heidenhain, Configuration and driver-specs docs, the Galaxy
options XML docs, the drivers rule file and the docker-dev compose comment
point at it. docker-dev's env:GALAXY_MXGW_API_KEY stays valid (allowed legacy
prefix), so no rig change is needed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 01:37:25 -05:00
Joseph Doherty ae91e6b7f7 feat(opcuaclient): opt-in server-certificate host-name check; NAT fixture keeps its PKI (#816, #818)
#816 finding: the SDK checks that the server certificate names the host only
when a session is created with checkDomain:true. The driver, the AdminUI
browser and Client.Shared all called the 8-argument
DefaultSessionFactory.CreateAsync, which forwards checkDomain:false — so no
host-name check ever ran (why a trusted NAT cert for *.invalid browsed fine
over an IP in #813). When enabled the SDK (Session.OpenAsync ->
ValidateDomains -> FindDomain) compares ConfiguredEndpoint.EndpointUrl.IdnHost
— the CONFIGURED host, because OpcUaEndpointSelection/EndpointSelector
rewrote the advertised one — against the cert's DNS/IP SANs; secured
sessions only, and the auto-accept handler accepts a mismatch.

Policy: certificateValidation.validateServerHostName, tri-state. Unset =
OpcUaCertificateValidationOptions.DefaultValidateServerHostName = false, the
pre-#816 behaviour, so nothing changes on upgrade; true = validate the
configured host; false = explicit NAT/port-publish opt-out. Threaded into the
driver, the AdminUI browser (same JSON) and the AdminUI form (a select that
keeps "unset" unset), Client.Shared ConnectionSettings.ValidateServerHostName
and the CLI --validate-host-name. A driver refusal names the opt-out.

#818: the NAT fixture's /app/pki is on the opc-plc-nat-pki volume (verified:
same thumbprint across --force-recreate), its PUBLIC cert is committed as
Docker/nat/opc-plc-nat-server.der, and NatPublishedEndpointBrowseTests run
every secure browse on a throwaway PKI (internal browser seam): refusal with
nothing trusted, positive with the cert trusted (unset/false host check), and
a BadCertificateHostNameInvalid refusal with the check on. The trusted cert is
OPCUA_NAT_SERVER_CERT or the committed one (skip on thumbprint mismatch);
OPCUA_NAT_SERVER_CERT_TRUSTED is retired. CI exports its throwaway NAT
container's public cert. ServerHostNameValidationTests pin the driver against
the plain opc-plc. MIN_EXECUTED not raised (needs a hosted run): +6 in
v2-integration (440 -> 446), documented in scripts/ci/README.md.
2026-11-11 01:33:00 -05:00
Joseph Doherty 88c05cc01a docs(opcuaclient): document the shared endpoint-URL rewrite and how to run the NAT tests (#814)
docs/drivers/OpcUaClient.md, docs/plans/2026-05-28-driver-browsers-design.md and
docs/Client.CLI.md: one OpcUaEndpointUrlRewriter for driver, AdminUI browser and
Client CLI/UI; configured host+port win, advertised path kept for opc.tcp; policy/mode
selection and certificate validation unchanged; NAT test recipe against the #813 fixture.
2026-11-10 22:53:01 -05:00
Joseph Doherty 780f363f16 docs: document OPC UA 2.0 compatibility and rollout 2026-11-10 11:48:19 -05:00
Joseph Doherty 90db63cfaf fix(opcuaclient): don't count idle-session BadNoSubscription as a publish error (lmxopcua#764)
v2-ci / unit-tests (push) Has been cancelled
The SDK keeps sending publish requests on a session that holds no subscription,
and the server answers each with an anonymous (id 0) BadNoSubscription. With
nothing tracked there is nothing to lose, but every answer was counted on
publish_error, so the counter climbed on any idle driver. HandlePublishError now
returns before counting for an anonymous BadNoSubscription while neither the data
nor the alarm subscription set tracks anything. Any other PublishError, and an
anonymous one while something is tracked, is counted and handled as before.

Not addressed from #764: the Republish/state probe for a delete slower than the
5 s grace, and the #728 wire test's fixed delays.

Tests: OpcUaClientIdlePublishErrorTests (idle session: not counted, with a
BadTooManyPublishRequests positive control; tracked subscription: still counted).
The idle test fails without the fix. Driver.OpcUaClient.Tests 295/0.
Docs: drivers/OpcUaClient.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:03:59 -05:00
Joseph Doherty 0217d36cf4 fix(opcuaclient): stale BadNoSubscription after a resubscribe is not a subscription loss (lmxopcua#763, lmxopcua#728)
v2-ci / unit-tests (push) Has been cancelled
HandlePublishError treated any anonymous BadNoSubscription (subscription id 0)
as loss of a tracked subscription whenever anything was tracked. A publish the
SDK queued while the session briefly held no subscription (the driver had just
deleted its last one) is answered BadNoSubscription and can be handled after the
replacement is registered, so a routine unsubscribe->resubscribe declared the new
subscription lost: Degraded health, a BadNoCommunication quality-only annotation
on every alarm condition and ConnectionLost (host re-initialise). That annotation
is the extra Unspecified event the #728 ConditionRefresh wire test saw.

- A BadNoSubscription that names a subscription id now counts only if the id is
  tracked (same rule as BadSubscriptionIdInvalid).
- An anonymous BadNoSubscription within StaleNoSubscriptionGrace (5 s) of a
  delete that left the driver tracking nothing is ignored (Debug log). The window
  is stamped on removal and restarted when the delete completes; failed-subscribe
  removes stamp it too; it resets with the session. A delete that leaves another
  subscription tracked opens no window. A genuine loss inside the window is still
  caught by the PublishingStopped heuristic; only the immediate ConnectionLost is
  forgone.
- OpcUaClientStaleNoSubscriptionTests: stale error after alarm and data
  resubscribe (fail without the fix), id-attributed stale error, plus genuine-loss
  controls (after the grace, no recent delete, non-last delete, session release),
  each mutation-checked.
- The ConditionRefresh wire test now records Kind, AlarmType, message, severity,
  quality, restatement and timestamp of every post-resubscribe event on failure.

Wire test locally: 1/10 passing before (all 9 failures the 0x80310000
ConditionType quality-only annotation), 12/12 after.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 12:47:58 -05:00
Joseph Doherty c39bcd6e24 fix(opcuaclient): unresolvable acks warn instead of tripping the ack breaker; shutdown holds subscription management (review r1 N-1, N-2, N-3)
- N-1 (Important): m3's BadNodeIdUnknown throw for an ack with no resolvable
  condition counted toward the host's AlarmAcknowledge breaker (threshold 5), so
  re-acks of one unresolvable condition blocked every genuine ack. It now logs a
  Warning and returns; only a no-reply transport failure throws. Test through the
  real CapabilityInvoker: six unresolvable acks, then a genuine ack that is still
  delivered (red with the throw restored).
- N-2: ShutdownAsync holds _subscriptionGate for the whole teardown (in-flight
  subscribes finish first, bounded by the budget), so a subscribe arriving during
  the delete phase finds the session released instead of creating a subscription
  on the closing session. Red-tested.
- N-3: the shutdown-side cert-validation detach is generation-checked under
  _probeLock with the map / endpoint clear.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:58:37 -04:00
Joseph Doherty f0d807e7b7 fix(opcuaclient): review r0 minors m1-m10
- m1: the form hint now says ProbeTimeoutSeconds can only shorten Test Connect's
  10 s budget (passing the configured value as the button's budget is shared code -
  follow-up).
- m2: a superseded init no longer overwrites _options (generation-checked under
  _probeLock) or the cert-validation handler (handed back and published with the
  session; detached when not installed; a previous one detached on replace).
- m3: an ack none of whose condition ids resolves throws BadNodeIdUnknown + Warning.
- m4: an ack with no reply (transport failure / non-caller timeout) degrades.
- m5: no session before the gate is the same ServiceResultException(BadNotConnected).
- m6: the caller-cancelled-write deviation is documented.
- m7: a reply during a published outage advances the stamp but does not write
  Healthy; the keep-alive recovery ends the outage.
- m8: test for a non-caller OperationCanceledException write -> Degraded.
- m9: the browse session owns and disposes the user certificate it loaded
  (OpcUaUserIdentityFactory.LoadCertificate; a key-less cert is disposed before the
  throw); AdminUI-host certificate path documented.
- m10: a subscribe overtaken by a shutdown / re-init removes its half-built
  subscription instead of registering it (data and alarm).
- Sync Dispose drains each gate for 500 ms (was 2 s).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:42:29 -04:00
Joseph Doherty fcdc44f9f7 feat(opcuaclient-browser): real AttributesAsync + certificate-auth browse (R06:O-5)
- AttributesAsync was a stub returning nothing; it now reads NodeClass /
  DisplayName / DataType / ValueRank / UserAccessLevel in one request and maps them
  through UpstreamAttributeMapping, the same mapping discovery registers with (moved
  into the shared .Session leaf; the driver forwards to it). Objects have no value
  attributes.
- Certificate auth was refused "in v1". The browse now builds its identity through
  OpcUaUserIdentityFactory (shared .Session leaf, the driver forwards to it),
  including the secret: resolution of Password / UserCertificatePassword through the
  host's ISecretResolver (optional ctor param, DI-injected). Fail-closed: a secret:
  ref with no store throws instead of being sent as the literal (the browse used to
  send a secret: Password verbatim). The identity is built before any network I/O.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:33:08 -04:00
Joseph Doherty d98f4a5bf1 fix(opcuaclient): wire ProbeTimeoutSeconds into the Test Connect probe (R06:O-4)
The knob was authored by the AdminUI and parsed, but consumed by nothing. It now
caps the probe deadline (shorter of the caller's budget and the config; never
extends it; 1 s floor), the same rule as the Galaxy probe.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:28:20 -04:00
Joseph Doherty cba3b2e960 fix(opcuaclient): writes feed health; a good keep-alive clears a stale Degraded
Health contract: a write response (per-node refusals included) is the server
answering, so it restores Healthy and advances LastSuccessfulRead; a write with no
reply (transport failure / timeout) writes Degraded with the stamp preserved; a
caller-cancelled write touches nothing. A good keep-alive with no outage or dead
subscription latched now clears a Degraded left by a failed read/write instead of
only moving the stamp (a subscription-only driver otherwise stayed Degraded until
its next re-initialise). All writes are generation-stamped.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:26:43 -04:00
Joseph Doherty 66d100a9e9 perf(opcuaclient): split the read gate from subscription management; no gate on writes or the discovery browse (R06:O-6)
One SemaphoreSlim serialised reads, writes, subscribes, acks and the whole recursive
discovery browse, so a stalled write (up to the operation timeout) blocked every
read and subscription create, and a discovery browse would block the data plane for
its full duration. Reads/acks keep the read gate; writes and DiscoverAsync are
ungated (the SDK session is safe for concurrent service calls, the host bounds write
concurrency); subscription management takes its own gate. Dispose drains both
(DisposeAsync's drain no longer over-releases on a timed-out wait).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:24:40 -04:00
Joseph Doherty afafd483dd fix(opcuaclient): an acknowledge with no reply fails with its transport status (R06:O-8)
AcknowledgeAsync swallowed every non-cancellation transport failure (and returned
normally when the session was lost while the ack queued), so the host logged the
Part 9 ack as applied while upstream still showed it unacknowledged. Both now throw
(ServiceResultException with the transport status / BadNotConnected). A reply,
including per-ack refusals, still returns normally and counts as the server answering.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:21:19 -04:00
Joseph Doherty beb1c583b1 fix(opcuaclient): #676 live gate A1 — a sustained closed connection is a definitive loss
Live gate A1 (docker-dev, opc-plc restart / SIGINT / kill): the fast path never fired —
a real process stop delivers no session status; the keep-alives report
BadSecureChannelClosed (0x80860000) once, then BadConnectionClosed (0x80AE0000)
interleaved with BadNoCommunication for the whole outage.

Reproduced in-process with TcpStallProxy.HardClose (reset every connection, refuse new
ones; server and session alive behind it): the identical sequence, and the SDK 1.5.378
session never opens a new connection on its own (48 s with the proxy accepting again,
zero attempts) — a closed channel cannot recover in place.

The driver now treats BadSecureChannelClosed/BadConnectionClosed as definitive on the
SECOND closed-channel keep-alive failure in one outage (a good keep-alive resets the
count), raising ConnectionLost ~1 keep-alive after the drop. BadNoCommunication /
BadRequestTimeout / BadNotConnected (stall/pause) stay with the watchdog; the stall test
still sees no ConnectionLost. New wire test (hard close + reopen ⇒ raised once, SDK never
reconnects, reinit brings the identical value back Good) and unit tests (sustained raises
once; a single blip that recovers does not) — all red on the previous code. Docs updated.
2026-10-11 12:34:52 -04:00
Joseph Doherty 27bf4826d6 fix(opcuaclient): #676 re-review — trailing read skips refs delivered to while in flight
1. A per-ref delivery counter (bumped under the delivery lock by every publish path) is
   captured before the trailing read is issued; a ref delivered to since is skipped.
   Without source timestamps the read's sample could not be ordered against a notification
   that landed mid-read, and publishing it regressed the node. Test injects the
   notification inside the mocked read, with and without a read source time (red before).
2. Delivery-lock comment corrected (it serialises across subscriptions, which the SDK
   dispatches on separate workers); PublishCommsLoss now publishes under the lock too.
3. CancelSessionWork cancels without disposing, so a keep-alive thread reading the old
   token cannot hit ObjectDisposedException.
2026-10-11 11:44:12 -04:00
Joseph Doherty 30a9d5f094 fix(opcuaclient): #676 review fix-ups (port-less rewrite, full reconcile, delivery lock, timers)
1. RewriteEndpointUrl keeps the advertised port when the configured URL names none
   (Uri.Port -1 would have sent the session to 4840); port-less + IPv6 tests.
2. The trailing re-read repairs any (value, status) difference that is not older than
   the last delivered value — Good->Bad/Uncertain included; a read without a source
   timestamp counts as newer; an equal source time with different content is published
   (repairs the replay guard's by-design equal-timestamp drop).
3. One driver-wide delivery lock makes decide+publish atomic across live notifications,
   the recovery read and the trailing read, so an older value can never land after a
   newer one (deterministic race test via a seam).
4. Known gap documented: a ConnectionLost raised while the host is not Connected is
   swallowed and that session falls back to the watchdog.
5. Only a SERVER-reported subscription loss raises ConnectionLost; the publishing-stall
   heuristic stays with the #527 watchdog.
6. Post-recovery timers run on Task.Delay(…, Clock, ct) with a session-work CTS
   cancelled on session release and dispose.
7. Replay summary at Information only when something was dropped (Debug otherwise);
   a re-arm reports the superseded window's drops and counts afresh.
8. DriverInstanceActor: AttachHostStatusSource doc comment restored; Reconnecting
   swallows a stray DisconnectObserved (test).
9. Stall wire test: 120 s session timeout, 3000-cycle subscription lifetime, one further
   bad keep-alive tick, 60 s headroom on the post-recovery waits (5/5 green).
2026-10-11 11:35:53 -04:00
Joseph Doherty fccffd0a0f feat(opcuaclient): #676 follow-ups — endpoint rewrite, stall wire test, fast definitive-loss recovery
- Endpoint rewrite: the driver now connects to the configured host AND port (scheme
  match, advertised path kept) instead of the server-advertised URL — fixes NAT/port-map
  upstreams and lets an in-test proxy carry the session (SDK SelectEndpoint rule).
- TcpStallProxy + OpcUaClientDriverStallWireTests: the in-place blip (#670) is now
  wire-tested on loopback — one Bad, frozen read stamp, same session, identical value
  republished Good, a post-recovery change arrives, no source-time regression after
  the SDK's late backlog flush.
- Defect found by that test, fixed: a change published right after a stall rode a
  Publish response the SDK timed out and never republished, leaving the node Good on
  the pre-change value indefinitely. New trailing reconcile read (Timeout +
  KeepAliveInterval after recovery) publishes any Good value newer than the last
  accepted one, through the replay guard's mark.
- IDriverConnectionLossSource (Core.Abstractions): opt-in definitive-loss signal.
  DriverInstanceActor attaches in PreStart / detaches in PostStop and turns a raise
  into a disconnect only while Connected. OpcUaClient raises once per session on
  keep-alive BadSessionIdInvalid/BadSessionClosed/BadSessionNotActivated/
  BadServerHalted (measured: the SDK never re-opens a halted channel) or a dropped
  subscription; ambiguous codes stay with the #527 watchdog.
- Replay guard: one Information summary per recovery when the window closes
  ("dropped N late notifications across M refs"), with a timer so quiet
  subscriptions log it too.
- ReconnectPeriod deleted (options, AdminUI form, docs, tests); stored configs that
  carry reconnectPeriod still parse in the factory, probe and browser.
- Docs: OpcUaClient.md, DriverLifecycle.md, driver-specs.md, CLAUDE.md.
2026-10-11 11:35:52 -04:00
Joseph Doherty 099bc1b6e0 fix(opcuaclient): #670 F2 re-gate — hold the replay guard for the whole window
The F2 live gate failed (10 value regressions over 4 blips): IsLateReplay
disarmed a ref on its first in-order notification, which arrives ~1 s after
recovery, while the SDK's late replay lands 4-6 s later. Equal source times
also passed, so a replay equal to the recovery read got through.

While the window is open each ref now carries a high-water mark (seeded at
arming, raised by the recovery read, advanced by every accepted notification);
a notification at or below it is dropped (equal included — OPC UA moves the
source timestamp on every value/status change). Null source time passes
without moving the mark. Window expiry still disarms entirely, so an upstream
clock step-back is never frozen outside the window. Arm/expiry serialised by a
lock so an expiry racing a re-arm cannot wipe fresh seeds.
2026-10-11 09:42:39 -04:00
Joseph Doherty 87e8aff786 fix(opcuaclient): scope the #670 F2 late-replay drop to the post-recovery window
The global 'never older than the last Good source time' rule froze a node
whenever an upstream clock stepped backwards (NTP correction, PLC clock
reset) until the clock caught up. The check is now a replay guard armed
only by a same-session recovery (keep-alive bad->good): per ref it holds
the last Good upstream source time (raised by the recovery read), a ref
disarms on its first in-order notification, and the whole guard expires
after max(30 s, 2x the longest subscription keep-alive window). Outside
it every notification is accepted. The steady-state tracker records the
LAST accepted time, not a high-water mark.
2026-10-11 09:15:51 -04:00
Joseph Doherty 99c13e4459 fix(opcuaclient): #670 live-gate follow-ups — historize the outage Bad, drop late replays
F1: the driver's outage BadNoCommunication carried the last Good sample's
source timestamp. The recorder historizes SourceTimestampUtc ??
ServerTimestampUtc, so the Bad landed on the Good row's (TagId,
SourceTimeUtc) key and the insert-if-absent historian dropped it - no
outage marker until the host's watchdog marker ~95 s in, none at all for
a short blip. Now stamped with the detection time (source + server), as
the host's comms-loss fan does.

F2: after a same-session recovery the SDK delivered a missed notification
seconds late with an older source time and the node regressed. A
notification older than the newest Good upstream source time for its ref
(notification or recovery read) is dropped and counted
(stale_notification_dropped); equal timestamps pass; the outage Bad never
moves the comparison; a re-initialise resets it.
2026-10-11 09:11:34 -04:00
Joseph Doherty b028322568 fix(opcuaclient): #670 review — subscription loss with a live session, stale recovery latch
M1: a subscription that dies while the session lives (outage longer than
the subscription lifetime but shorter than the session timeout) used to
end Healthy with fresh keep-alive stamps and frozen values. PublishError
BadNoSubscription / BadSubscriptionIdInvalid naming a tracked
subscription, or a tracked subscription whose SDK PublishingStopped
persists one more keep-alive window + 5 s across good keep-alives, now
declares it lost: Degraded, outage published once, and nothing advances
LastSuccessfulRead until the session is released, so the #527 watchdog
re-initialises and re-subscribes. Both detectors wire-tested.

M2: in-place recovery is bound to the session whose keep-alive recovered;
one overtaken by a re-initialise no longer re-arms the new session's
outage latch or degrades its health (ReadCoreAsync expectedSession).

L1 CAS health updates; L2 re-check before each recovery publish; L3 skip
CloseSession on a lost session; L4 SDK log forwarding rate-limited
(20/min); L5 per-driver SDK LoggerFactory disposed with the driver.
Wire test asserts the read stamp stays frozen while the server is down.
2026-10-11 08:41:27 -04:00
Joseph Doherty 2a8f9af5e4 fix(opcuaclient): upstream session loss goes Bad and recovers (#670)
A bad keep-alive now degrades health (LastSuccessfulRead preserved) and,
once per outage, publishes BadNoCommunication (value retained) for every
subscribed ref plus a quality-only annotation on subscribed alarm
conditions. Good keep-alives and data/event notifications advance
LastSuccessfulRead, so an idle-but-alive subscription never looks dead
to the #527 watchdog. A keep-alive that recovers on the same session
re-reads the Bad refs and republishes them Good; a session that does not
come back is recovered by the watchdog's ShutdownAsync + InitializeAsync
+ re-subscribe.

Deletes the SessionReconnectHandler wiring (it retried forever in SDK
1.5.378, cloned subscriptions the driver never re-tracked and kept health
Healthy), OnReconnectComplete, TryRearmReconnect and the reconnect_*
counters (new keepalive_recovered). Teardown of a lost session skips
wire deletes and bounds CloseSession. SDK warnings reach the driver log.
Wire test reworked: restart => Bad, then watchdog-style reinit => Good.
2026-10-11 08:27:04 -04:00
Joseph Doherty 48a80b5962 fix(alarms): atomic InactiveUnacknowledged + flagged Clear/Acknowledge first sight for OpcUaClient (#657)
After an OpcUaClient respawn an upstream inactive+unacked condition went out as
Retrigger+Clear (duplicate Activated+Cleared rows, Active flicker), and a
first-sight inactive+acked one as Unspecified (an Activated row for an inactive
alarm; a retained-Active host condition never cleared).

- New AlarmTransitionKind.InactiveUnacknowledged: one projection, rows only for
  what changed (Activated+Cleared from acked, Cleared from active+unacked, none
  when already inactive+unacked).
- OpcUaClient uses it for moves into inactive+unacked; first sight of an
  inactive condition is InactiveUnacknowledged or Clear+Acknowledge, flagged.
- Projector: a flagged Acknowledge on an already-acked condition writes no row.
- E2E tests through DriverHostActor.ForwardNativeAlarm (red-proven).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 06:08:39 -04:00
Joseph Doherty 82750cccb9 Merge hw3-alarms (#656 atomic ActiveAcknowledged + re-statement flag for native alarms)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 00:14:47 -04:00
Joseph Doherty e138e39778 docs(alarms): #656 re-statement flag + ActiveAcknowledged kind (AlarmTracking, OpcUaClient, Galaxy, ScriptedAlarms, CLAUDE.md)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 23:43:09 -04:00
Joseph Doherty 67059e479f docs(#655): history is server-side only; rewrite OPC-009 and stale driver-history references
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 23:36:25 -04:00
Joseph Doherty 83ab123466 Merge b647-opcua-histstatus: OpcUaClient HistoryRead honours per-node StatusCode (#649)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:30:06 -04:00
Joseph Doherty b8c2136732 fix(opcuaclient): #649 review minors - CodeBits match, Bad-only exception ctor, BadNoData + Raw-resume tests, latent-impact docs
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:29:47 -04:00
Joseph Doherty 22e79112b6 fix(opcuaclient): HistoryRead honours the per-node StatusCode (BadAggregateNotSupported + other Bad) instead of returning empty data (#649)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:16:55 -04:00
Joseph Doherty dd05baedd6 refactor(opcuaclient): remove vestigial TargetNamespaceKind + UnsMappingTable (#648)
Nothing read the mapping table beyond the startup validator; v3 UNS projection is
explicit via UnsTagReference. Legacy keys in stored DriverConfig are ignored (pinned).
Reverts the docker-dev opc-plc seed workaround from fdee129f.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:14:37 -04:00
Joseph Doherty 8f5b265db8 fix(opcuaclient): native-alarm ConditionRefresh + transition table, branch, dedup and thread-safety fixes (#619 re-review)
Re-review of 3de2cc3b/454775c6:
- AlarmTransitions could not reach (inactive, unacked): (active,acked)->(inactive,unacked) emitted Clear
  (host stayed acked) and a first-seen cleared-unacked condition emitted nothing. The table is now total;
  an exhaustive test replays every (prior, target) pair through the projector's semantics.
- The per-RawPath read-compute-emit was a lock-free TryGetValue/set across SDK publish threads; it now runs
  under one lock with the projection and emission.
- Condition BRANCH events (non-null BranchId) were projected (flipping the trunk) and remembered as the ack
  target (acking the branch); they are dropped. BranchId is select clause 9.
- EventId is copied, not aliased from the decoded notification.
- An unchanged state re-emitted Unspecified; it now emits nothing unless message/severity changed (an
  Unspecified projects from the host projector's default after a redeploy reset it).
- Exact upstream severity rides OpcUaSeverity (#626) instead of being re-bucketed.
- ShutdownAsync no longer forgets the last-told state: a reinit re-subscribes the same set and the refresh
  would otherwise re-raise every acked alarm. A CHANGED set (host rebuilt) forgets it.

ConditionRefresh: called after the alarm event item is created, after an in-place reconnect (subscriptions
found on the new session by display name, since a recreate clones them) and on RefreshRequired. Refresh
markers are never projected. BadNotSupported/BadMethodInvalid/... logs once. Wire test: a condition already
active before subscribe is reported Raise; a release+resubscribe refresh re-raises nothing (falsified by
disabling the refresh call).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:13:54 -04:00
Joseph Doherty 454775c67c fix(opcuaclient): derive AlarmTransitionKind from upstream ActiveState/AckedState (#619 review)
NativeAlarmProjector derives Active/Acked ONLY from AlarmEventArgs.Kind, and this driver
never set it - every routed event projected Unspecified, leaving the condition inactive+acked
(only Message/Severity moved). The live-gate expectation 'toggling Active as opc-plc cycles'
could not have passed. Select ActiveState/Id + AckedState/Id (two-element browse paths) and
emit the Kind sequence that moves the projector from the state last told to the host to the
event's (Raise+Acknowledge when a notification carries both).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:04:48 -04:00
Joseph Doherty 3de2cc3ba8 fix(opcuaclient): alarm index survives namespace reorder; match condition node first; ack with real EventId (#619 review)
The alarm index was keyed on ns=N strings resolved once at subscribe time, while an
in-place SDK reconnect transfers the subscription without re-running it - a remote
namespace reorder silently orphaned every alarm tag. Keys are now nsu= on both sides.
Events match the authored nodeId against the event's ConditionId first, then its
SourceNode, so several conditions on one source can be authored 1:1. Acknowledge
passes the EventId of the event that named the remembered condition (paired, so node
and EventId can never come from different conditions) instead of an empty one.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:02:21 -04:00
Joseph Doherty d93491499d chore(docker-dev): seed OpcUaClient driver for opc-plc + doc native-alarm source semantics (#619)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:55:13 -04:00
Joseph Doherty 1219b7b13e fix(opcuaclient): review fixes - drop driver-side OperationLimits chunking (SDK batches), diagnostics as OTel counter, safe discovery selection, options validation at load (#273 #275 #276 #277 #278)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:55:53 -04:00
Joseph Doherty ba62022e88 feat(opcuaclient): subscription tuning, OperationLimits chunking, diagnostics counters, revocation options, DiscoveryUrl (#273 #275 #276 #277 #278)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:40:20 -04:00
Joseph Doherty f21883d607 docs(audit): G2 completeness — S7/TwinCAT/OpcUaClient/Historian overview pages 2026-06-03 16:14:40 -04:00