Commit Graph

63 Commits

Author SHA1 Message Date
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 d64c77b62d fix(opcuaclient): shutdown cancels session work before the gate wait; dispose skips a redundant nested shutdown (review r2 R2-1, R2-2)
- R2-1: the ConditionRefresh sweeps (RefreshRequired, post-outage recovery) run
  under the session's work token, and ShutdownAsync cancels session work first
  thing - so a sweep holding the subscription gate against a stalling upstream no
  longer eats the whole teardown budget the deletes and close need.
- R2-2: DisposeAsync skips its nested ShutdownAsync when a shutdown already started
  for the current lifecycle generation (nothing initialised since), so PostStop's
  dispose does not re-wait the gate past its budget. Test: with the gate held, the
  shutdown waits (positive control) and the dispose does not wait again; red with
  the skip removed.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:08:44 -04: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 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 6c3a754be9 fix(opcuaclient): lifecycle generation, close the local session on init throw, one teardown budget (R06:O-2, O-9)
- Every InitializeAsync/ShutdownAsync bumps a lifecycle generation. The session,
  namespace map and endpoint are installed under _probeLock only while the init's
  generation is current, so an init the host abandoned that finishes after the next
  shutdown discards its session instead of resurrecting it. Every health write
  (lifecycle, keep-alive, publish-error, read path) is generation-stamped, so a late
  writer can no longer overwrite newer state.
- O-2: the init catch closes and disposes the LOCAL session it opened (it closed the
  still-null Session property, orphaning an activated session + keep-alive timer).
- O-9: ShutdownAsync spends ONE budget (4 s, inside the host's 5 s PostStop budget) on
  wire calls: every subscription delete concurrently, then the session close with
  what remains; waits are abandoned when the budget fires even if the SDK ignores the
  token. It used to be 5 s per subscription, serially.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:19:48 -04:00
Joseph Doherty 4d0d5d229d fix(opcuaclient): remove a half-built data subscription when its create throws (R06:O-1)
SubscribeAsync left the SDK Subscription on the session (server-side when
CreateAsync succeeded, item handlers attached, tracked by nothing) when
CreateAsync/CreateItemsAsync threw, so the host's per-tick reconcile retry
accumulated one live subscription per retry. Mirror the alarm path: detach
the item handlers, bounded RemoveSubscriptionAsync, rethrow.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:12:03 -04:00
Joseph Doherty 826312befe docs: fill missing XML docs and strip tracking IDs from src comments
Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false
positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns,
normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping
the rationale they annotated. Comment-only: comment-stripped sources are identical
to the parent commit.
2026-10-15 06:14:58 -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 1ea1da8236 refactor(runtime): delete the dead in-place ApplyDelta seam; config change = respawn only (#665)
Since #516 a DriverConfig change is stop + respawn, so DriverSpawnPlan.ToApplyDelta
was always empty and the in-place path had no producer. #663 and #664 were both
latent bugs in it. This deletes it:
- host: ApplyChildDelta, _pendingDelta, HandleApplyResult and HandleDeltaApplied
- plan: DriverSpawnPlan.ToApplyDelta
- actor: the ApplyDelta, ApplyResult and DeltaApplied messages, the Connected
  reinit handler and the mid-connect AdoptConfigDuringInit
- tests: the actor tests that drove the seam

IDriver.ReinitializeAsync and each driver's implementation and unit tests stay.
Comments and docs that cited AdoptConfigDuringInit as the reason a driver must
release prior state at init now cite the IDriver contract and overlapping
retries, which still apply.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 11:23:16 -04:00
Joseph Doherty 28ca04e466 fix(#662 review): back off alarm retries, contain failed OpcUaClient alarm subscribes
- Reconcile retries of a failing alarm subscribe back off (skip 2^(n-1)-1
  ticks, cap 7 => >= every 4 min at the 30 s tick) so a hanging subscribe
  cannot hold the mailbox every tick; the stack is logged once per streak.
- The unreachable handler re-attach logs at Warning (it only fires on a
  regression).
- OpcUaClient: a failed SubscribeAlarmsAsync detaches its notification
  handler and bounds the session cleanup to 5 s under _gate;
  OnEventNotification drops events from subscriptions not registered in
  _alarmSubscriptions, so a stray that survives cleanup cannot deliver.
- The retry test also covers 3 failures (crosses the backoff); its tick
  positive control documents its dependence on the stub's changing health.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 07:35:50 -04:00
Joseph Doherty 5067509dd4 fix(runtime): keep native-alarm feed across data re-subscribes; reconcile retries alarms (#662)
A data re-subscribe (every deploy's bulk SetDesiredSubscriptions push to a
kept driver) dropped the prior data subscription through the full
DetachSubscription, which also unhooked OnAlarmEvent and forgot the alarm
handle. On any deploy that did not respawn the driver, the alarm
subscription kept logging "established" while every event reached nobody
until a reconnect or respawn. The data path now uses DetachDataSubscription;
only a lost connection and PostStop detach both.

The #488 health-poll reconcile only looked at data, so an alarm subscribe
that failed on (re)connect gated the feed for the life of the connection.
ReconcileAlarmSubscription now re-attaches a missing handler and
re-subscribes a stale/missing alarm handle every tick.

OpcUaClient removes a half-built alarm subscription from its session when
SubscribeAlarmsAsync throws, so the new retries cannot accumulate.

Tests: actor-level data-resubscribe + reconcile-retry tests and an
end-to-end NativeAlarmFeedSurvivesDeployTests through DriverHostActor; all
three fail against the unfixed actor.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 07:30:25 -04:00
Joseph Doherty 24eed8addd docs(opcuaclient): #657 review — correct stale rebuild comment, note cold-host residual
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 07:15:26 -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 b39bb6794d fix(alarms): native-alarm re-statements no longer duplicate history or hide acked alarms (#656)
- AlarmTransitionKind.ActiveAcknowledged (appended): active AND acked applied as ONE projection.
  Galaxy ActiveAcked snapshot entries and OpcUaClient inactive->(active,acked) use it; the
  projector writes 'Activated' only if not already active, 'Acknowledged' only if not acked.
- AlarmEventArgs.IsRestatement (optional, default false): set only by Galaxy snapshot entries
  and OpcUaClient first sight. A flagged Retrigger on an Active+unacked condition with the same
  message and severity, or a flagged Clear on an inactive condition, writes no /alerts row.
- NativeAlarmProjector reports the history rows per transition; ForwardNativeAlarm writes one
  row per entry. Unflagged sources (FOCAS/MTConnect/AbCip, live Galaxy) are unchanged.
- Tests: projector rules each with a must-write control; OpcUaClient transition table;
  Galaxy feed/driver mapping; Galaxy + OpcUaClient end-to-end through DriverHostActor.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 23:35:50 -04:00
Joseph Doherty f7d497e532 refactor(#655): delete the dead driver-side history capability
HistoryRead is served server-side by IHistorianDataSource; nothing ever dispatched
to a driver's IHistoryProvider. Removes IHistoryProvider, the OpcUaClient history
passthrough, DriverCapability.HistoryRead (+ resilience tier rows and AdminUI row),
HistoryReadStatusException and its node-manager catches, and the tests that only
covered them. Shared result types and HistoryAggregateNotSupportedException stay
(moved to Historian/HistoryReadContracts.cs). A stored ResilienceConfig still
carrying a HistoryRead key parses (diagnostic only; new test).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 23:34:02 -04:00
Joseph Doherty e7dd742541 Merge b647-focas-alarms: FOCAS native alarms reach Part 9 conditions (ALARM: address) + respawn keeps acks host-side (#647)
v2-ci / build (push) Successful in 4m57s
v2-ci / unit-tests (push) Failing after 21m6s
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 20:46:13 -04:00
Joseph Doherty 89961af6e6 fix(alarms): #647 review — Raise on an active condition is a re-statement; FOCAS outage quality + health
MAJOR-1: a deploy that changes a driver's config respawns it, and the fresh instance's empty told
record re-sent Raise for every still-active alarm, un-acking acknowledged conditions and writing a
duplicate "Activated" alerts row. NativeAlarmProjector now treats Raise on an already-active
condition as a re-statement (keeps Acked; ForwardNativeAlarm skips the alerts row); only Retrigger
(or inactive->active) un-acks. Sources whose upstream ack state is authoritative send Retrigger for
an unacked re-statement: OpcUaClient on first sight (no prior told), Galaxy for an unacked active
snapshot entry. Runtime E2E drives a FRESH FOCAS driver after a redeploy and asserts the ack survives.

MAJOR-2: a failed FOCAS alarm read annotates each subscribed condition BadNoCommunication once per
outage (#626 quality-only path) with one Warning (MINOR-6), lifted to Good on recovery; failed alarm
reads degrade driver health with LastSuccessfulRead frozen (successful ones advance it), so an
alarm-only driver trips the #527 comms-loss watchdog.

MINOR-3 CLAUDE.md #630 bullet; MINOR-4 split line; MINOR-5 a non-empty cnc_rdalmmsg2 payload that
fits neither stride throws; MINOR-7 value-tag + parsed-address tables are built and swapped, never
cleared in place under the retained poll engine. Docs corrected (FOCAS.md, AlarmTracking.md, CLAUDE.md).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 20:18:03 -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 e56926dc47 fix(opcuaclient): native alarms translate RawPath <-> upstream source/condition ids (#619)
The host subscribes alarms with RawPaths and routes transitions on
(driver, RawPath), but the driver filtered upstream SourceNode ids against
the RawPaths and reported the upstream condition NodeId as ConditionId, so
no opc-plc (or any upstream) alarm could ever reach a condition node.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:54:44 -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 935ba8d1d4 Merge branch 'master' into b626-opcuaclient 2026-10-08 14:47:49 -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 461133235a fix(drivers): InitializeAsync releases previous device/session state before overwrite (#630)
FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient orphaned the previous
probe/recycle/fixed-tree loops and clients when re-initialised on a live driver.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:28:55 -04:00
Joseph Doherty 55eac5f68b feat(history): full Part 13 aggregate set; unsupported-by-backend aggregates answer BadAggregateNotSupported (#285)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:24:25 -04:00
Joseph Doherty b007d450d8 chore(build): OpcUaClient driver/browser on ITelemetryContext ctors, TreatWarningsAsErrors on remaining src projects (#621 #606)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:59:25 -04:00
Joseph Doherty dd900f970a fix(hosts): a driver that never built a tag table reports NO ANSWER, not zero skips
Found on the docker-dev live gate, not in tests. Two drivers that faulted
during initialise (rtu-gate, opcua1) each rendered "0 tags" on /hosts against
two authored tags apiece, because a ledger that had never committed returned
SkippedTagReport.Empty.

A zero report is a positive claim -- "this driver skipped nothing" -- and
making it on behalf of a driver that never got as far as looking is exactly
the quiet inaccuracy #541 exists to remove. GetSkippedTags() and
SkippedTagLedger.Snapshot() now return null until the first committed build,
so the column renders the same em-dash it already used for a driver that
cannot report at all. Both are 'no answer'; neither is 'nothing skipped'.

Knock-on: a FOCAS driver whose only init threw now reports null rather than a
zero report, which is strictly more honest than the assertion the test
originally made.
2026-08-05 20:16:31 -04:00
Joseph Doherty 39db75083d feat(drivers): every driver reports the authored tags it skipped (Gitea #541)
A driver builds its RawPath -> definition table at init and tolerates a
per-tag miss: an unmappable TagConfig, an unparseable address, a
capability-matrix rejection. That tolerance is right -- one bad tag must not
take down the other few thousand. What was missing is any statement of the
fact afterwards. A skip is not an error and happens before any read, so the
driver stays Healthy, errors/5 min stays 0, Last error stays empty, and Last
read advances live off the tags that did map. On the wonder Makino in #540
that reading held while focas-z-34184 ignored 20 of its 22 tags.

New optional capability ISkippedTagReporter, backed by a shared
SkippedTagLedger so the reset/commit/cap rules are one implementation rather
than one per driver. The report -- authored count, exact skipped count, and a
capped sample of RawPath + reason -- rides DriverHealthChanged to a new Tags
column on /hosts, over the Phase 5 telemetry stream. No table: a driver-only
node has no ConfigDb to write to, the lesson from #521.

The issue named eight drivers. It is twelve -- every driver in
DriverTypeNames.All has an authored-tag skip path. Calculation (no scriptId),
MTConnect (no readable DataItem id), Galaxy (no attributeRef) and OpcUaClient
(no nodeId) were unlisted, and OpcUaClient's was the quietest of the twelve:
NamespaceMap drops such an entry with a bare `continue` and not even a log
line. SkippedTagReporterFleetTests pins the invariant reflectively over every
IDriver in src/Drivers/, so a thirteenth cannot re-introduce the silence.

Sql drops a tag at TWO points and its report is the union of both: a blob
that does not map, AND an identifier the 8.1 catalog gate rejects against the
live catalog. The second is what schema drift produces -- a renamed column, a
dropped view -- months after a deployment sealed green, and reporting only
the first would have that driver claim "0 skipped" while polling half its
tags, which is worse than reporting nothing because the column would then be
actively reassuring.

Deliberately additive to driver state, never a change of it. Latching
non-Healthy for a config problem would satisfy both halves of the #527
comms-loss conjunction -- 90 s without a successful read AND non-Healthy --
and a driver whose tags were ALL skipped has no successful reads by
construction, so it would reconnect-loop forever against a healthy device.

Three details that are load-bearing rather than incidental:

- The skip digest is part of the publish fingerprint. A skipped tag leaves
  every other fingerprint component identical to a clean driver's, so without
  it PublishHealthSnapshot's dedup swallows exactly the publish carrying the
  signal -- the trap that already bit the rediscovery and host-status
  channels. The digest covers the sample, not just the counts: a redeploy
  that fixes one tag and breaks another leaves both counts unchanged.
- The count is exact; only the sample is capped, and the tooltip says so when
  it is showing fewer than it counted. A truncated list that looked complete
  would have an operator fix twenty tags and believe the job done.
- The ledger stages and commits, so an init that throws part-way leaves the
  previous report standing. A partial snapshot would read as "we now skip
  fewer tags", the opposite of the truth.

The proto field needs no companion presence flag, unlike host_statuses: a
singular message field carries real presence in proto3, and a has_ flag would
collide with protoc's generated HasSkippedTags.

Verified red-first three ways: dropping SkippedTagDigest from the fingerprint
fails the two dedup tests, removing the seam from one driver fails the fleet
guard, and dropping the catalog-gate leg fails the Sql union test. Full
solution builds with zero new warnings against 4e22f024.
2026-08-05 19:28:46 -04:00
Joseph Doherty b815446940 refactor(drivers): delete GetMemoryFootprint / FlushOptionalCachesAsync (#525)
Both `IDriver` members had no production call site. Their only consumer was
`MemoryTracking`, deleted with the driver-tier recycle machinery in #522, and
`GetMemoryFootprint`'s doc-comment claimed "polled every 30s by Core" — which
had not been true since v3, even before #522 removed the poller.

Owner decision on #525 was (a) delete rather than (b) build the process-wide
watchdog that would consume them. The driver-side implementations are in git
history and were mostly reusable; what was always missing is the trigger, and a
member nobody calls is how the misleading doc-comment survived this long.

Removed from `IDriver`, all 12 driver implementations, and every test stub —
plus the support code that existed only to feed them, which is the part a
member-only deletion would have left behind as dead weight:

- MTConnect's three per-entry byte-weight constants, its cached
  `ProbeModelDataItemCount`, and `CountDataItems` — a full device-tree walk run
  on every cache fill to populate a number with no remaining reader.
- OpcUaClient's `_discoveredNodeCount` field and the discovery-pass write to it.
- MQTT's `ApproxBytesPerAuthoredTag`.

Two behavioural notes, neither a change in what the software does:

- `AbCipTemplateCache` now has no production reset path. Its only caller was
  AB CIP's flush, which itself had no caller — so nothing changes except how
  visible the fact is. Recorded on the type, along with the correction that its
  comment's claim about a device `ReinitializeAsync` also flushing it was never
  true. A UDT redefined on a live controller keeps its stale shape until the
  driver instance is rebuilt.
- MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an
  Agent restart. That is now `internal` rather than `private` so the tests which
  used the flush purely to empty the cache drive the real path instead — they
  now cover a case that actually occurs in production. Several unrelated doc
  comments cited `FlushOptionalCachesAsync` as the canonical CAS example and are
  repointed to it.

Tests: the ones that existed only to assert on the removed members are deleted
(3 OpcUaClient, 3 Galaxy, 2 MQTT, 1 MTConnect, 1 AbCip). Worth noting one was
vacuous — `FlushOptionalCachesAsync_resets_footprint_counter` asserted a
footprint of 0 before the flush and 0 after with no discovery in between, so it
would have passed against a flush that did nothing at all.

Full suite green except 7 failures verified identical on master before and
after (4 AbLegacy integration with the compose profile down, 3
Host.IntegrationTests), checked by name rather than assumed.

Docs corrected where they described this as live: `DriverLifecycle.md` (which
carried the #525 placeholder), `drivers/AbCip.md`, `drivers/AbLegacy.md`, and
`v2/driver-stability.md` — whose "polls this every 30 s" + per-driver
`DriverConfig` memory budget were never built as written and are now banner-ed
as design intent rather than description.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 09:07:11 -04:00
Joseph Doherty d32d89c340 fix(drivers): stop silently discarding driver config edits (§8.3, #516)
Five drivers ignored the config handed to them on reinitialize, serving the
options their constructor captured — and the deployment sealed green anyway.
Fixed on both halves, as chosen.

Seam (load-bearing): DriverSpawnPlanner routes a changed DriverConfig to
ToStop + ToSpawn instead of an in-place delta, making the factory the single
parse authority. This REVERSES the deliberate decision documented at
DriverSpawnPlan.cs:49-50 ("a pure DriverConfig change stays an in-place delta
— no reconnect"). That reasoning was correct about the resilience pipeline and
wrong about the driver. The accepted price is a reconnect per config edit.

Per-driver, and this split was not anticipated:
- Re-parse in place — Modbus, AbLegacy, OpcUaClient. ParseOptions extracted
  from each factory, called from InitializeAsync behind a HasConfigBody guard
  so "{}" still keeps the constructor options.
- Respawn-only, deliberately NOT re-parsed — Sql and FOCAS. Each builds more
  than options from config (Sql's dialect + resolved connection string,
  FOCAS's client-factory backend, both injected at construction), so adopting
  new options alone would run a NEW tag set against an OLD connection. Half a
  re-parse is worse than none. SqlDriver's doc-comment asserted the opposite
  premise — "config parsing belongs to the factory, which builds a fresh
  instance" — which was false when written and is true only now.

Two green seals removed from ApplyChildDelta: it overwrote the cached Spec
synchronously BEFORE the child dequeued the message, so the host believed the
new config was live and the next reconcile computed no delta, sealing the
drift permanently; and it Tell'd with no Receive<ApplyResult> registered, so a
failed reinit — including Galaxy's deliberate NotSupportedException —
dead-lettered.

Exposed (not created) by the change: a factory throw is a CONFIG error, and
SpawnChild catches it and silently substitutes a stub. Only a brand-new driver
could reach that before; an ordinary config edit can now. Raised Warning ->
Error with an actionable message. It still does not fail the deployment —
doing so would let one malformed driver block a fleet deploy, so that is a
follow-up rather than a drive-by.

Tests: every pre-existing reinit test in these suites passes "{}", the exact
input a guarded re-parser treats as "keep constructor options" — blind to this
defect by construction. New tests pass CHANGED json; the Modbus one was
verified falsifiable by deleting the re-parse (goes red). Two tests asserted
the old behaviour and were rewritten, including the positive control in
DriverHostActorUnreadableArtifactTests, whose observable moved from
UnsubscribeAsync to ShutdownAsync now that teardown happens by stopping the
child rather than emptying its desired set.

Remaining full-suite failures are pre-existing and environmental, verified
identical on the pre-change tree: Host.IntegrationTests (3) and
Driver.AbLegacy.IntegrationTests (4, docker fixture-gated).
2026-07-27 19:32:46 -04:00
Joseph Doherty 9bb237b794 feat(secrets): G-2b resolve OpcUaClient secret: Password/UserCertificatePassword (Task 8)
Resolve secret:-prefixed Password + UserCertificatePassword through the shared
ISecretResolver, fail-closed on absent, retiring the cleartext-in-DB path. The
driver-registry factory is synchronous (Func<string,string,IDriver>), so resolution
is done lazily in the async session-open (InitializeAsync, before BuildUserIdentity)
rather than at deserialize — mirroring Task 7's Galaxy pattern and matching its
re-resolve-on-reconnect behavior. Both consumers (username Password and the
certificate-password LoadPkcs12 path via BuildCertificateIdentity) see the resolved
connect-scoped options; _options stays raw (secret: refs intact), no long-lived
plaintext field.

Scope corrections vs the plan (verified against v3): the probe is unauthenticated
GetEndpoints-only and never reads either credential, so it is NOT a resolution site
(comment added, no dead code); OpcUaClientDriverOptions was a sealed class, converted
to sealed record for the with-expression (no positional params → identical JSON; no
reference-equality/dict-key/ToString-log usages → no behavior/leak change).

ISecretResolver threaded via factory Register/CreateInstance + DriverFactoryBootstrap
(real resolver from DI); NullSecretResolver null-object backs test/parse paths only,
fail-closed on secret: refs. TDD: 4 helper tests RED→GREEN; 142 OpcUaClient tests pass.
2026-07-16 18:14:12 -04:00