Commit Graph

261 Commits

Author SHA1 Message Date
Joseph Doherty 7482a88888 fix(opcua): deferred condition events never outlive their condition; per-condition bounded deferral; canonical lockout identity (#838, #844 review)
#838 review of 91e28e87:
1. Ghost events. The deferred report ran the SDK walk off Lock with no
   liveness check, so an event waiting behind a full channel when a deploy
   removed its condition still reached i=2253 subscribers after the NodeDeleted
   (a ghost Retain alarm for ScadaBridge) and folder items. Delivery now walks
   the SDK path itself (OnReportEvent, OnReportEventAsync, inverse notifiers,
   then the Parent as BaseInstanceState does) re-checking before EVERY hop that
   the condition is the live instance; OnConditionReportEvent checks the same,
   which also stops a non-live scripted condition being admitted through its
   old equipment folder (#808). A guard test pins that only NodeState and
   BaseInstanceState define the walk. New wire test removes the condition in
   the same Lock hold as a flood; the flag-off variant fails without the checks.
2. Back-pressure. Deferral is per condition (one condition's events stay
   ordered; others are not held up). Overflow policy: coalesce, newest wins --
   past 1024 waiting per condition or 65,536 total, the oldest not-yet-started
   event is discarded (a condition event carries full state). Warning on
   entering deferral (<=1/min), Information on draining, meters
   otopcua.opcua.condition_events.deferred / .discarded{reason}.
3. Verified by decompiling SDK 2.0.1: the notifier list is snapshotted under the
   node's own lock, MonitoredNode2.Dispose completes its writer (pending writes
   fail with ChannelClosedException, swallowed), and the consumer catches
   per-event exceptions -- no fix needed; documented.
4. ConditionRefresh does not flush deferrals; documented as a residual.

#844 review: DescribeCredential canonicalises the key id (trim, lowercase;
non-hex ids fold into one identity) and the username, since ConfigDbApiKeyStore
matches with SQL Server's case-insensitive, trailing-space-blind `=`.
2026-11-11 04:35:30 -05:00
Joseph Doherty 2fc9e02b9a Merge #844 #845 #838 credential lockout per identity, non-blocking condition events 2026-11-11 04:08:13 -05:00
Joseph Doherty ffba75bb93 fix(opcua): credential lockout counts per credential identity, ignores transient denials (#844, #845)
#844: a success cleared the whole per-client count, so a client holding a valid
key could make four guesses at another key id, sign in with its own, and repeat
forever. Failures are now charged to the (client, credential identity) pair --
IOpcUaUserAuthenticator.DescribeCredential, key:<keyId> for API keys -- and to
the client as a whole. A success clears only its own identity's count; the
per-identity threshold locks that identity; a new per-client cap
(OpcUa:CredentialLockout:MaxFailedAttemptsPerClient, default 2 x
MaxFailedAttempts) that a success never resets bounds spraying across key ids
and locks every credential. Slots are bounded on both counts; a waiter whose
identity has room is not held behind one whose identity has none.

#845: (1) store timeouts, miss-budget refusals, store/authenticator exceptions,
an unset pepper and cancellation are reported Transient
(OpcUaUserAuthResult.Unavailable) and released uncounted, so a ConfigDb stall
can no longer lock every keyed client out; the client still sees the same
opaque denial. (2) a full 4096-entry table defers its next prune until the
earliest entry could age out instead of rescanning under the lock per failure.
(3) ApplicationUri deliberately NOT added to the key: on a None channel it is a
free string, so it would let an attacker reset the count per guess --
documented. (4) wire tests: distinct client certificates on a Sign endpoint are
locked out separately; the ApplicationConfigPath branch keeps the SDK lockout
off (falsified by removing the loaded-branch disable).
2026-11-11 03:47:10 -05:00
Joseph Doherty ddc1f29e3a feat(heidenhain): Part 9 condition class from alarm class/group, ALARM: selectors in the browse picker, unclaimed alarms counted + logged (#839)
- Core: optional AlarmEventArgs.ConditionClass (AlarmConditionClass Base/Process/Maintenance/System/Safety)
  carried by NativeAlarmProjector on AlarmConditionSnapshot and written to ConditionClassId/Name by
  OtOpcUaNodeManager (kept across class-less events, carried across a #641 rebuild).
- Heidenhain: HeidenhainAlarmClasses — HEIDENHAIN DNC-interface class/group names (corroborated by the
  captured iTNC 530 record); class 5 => Safety, group 1/2 => Process, 4/5 => System, else Base.
- DiscoverAsync offers AlarmConditions/ selector leaves (IsAlarm Boolean); browse-commit seeds the alarm object.
- Alarms no selector claims: otopcua.driver.alarms.unmatched counter, /hosts diagnostics, rate-limited Warning.

Refs #839
2026-11-11 03:41:14 -05:00
Joseph Doherty 2314a53842 fix(opcua): parallel UserName activations wait for a slot instead of being refused (#833)
A legitimate consumer opening several keyed sessions at once from one key
(e.g. a ScadaBridge site node behind one NAT IP on a None endpoint) must
never see BadUserAccessDenied for parallelism. BeginAsync now hands out at
most MaxFailedAttempts - recordedFailures verification slots per key;
further activations queue FIFO and are woken as slots free. A waiter is
refused only when the key locks out (never reaching the authenticator) or,
uncounted, after OpcUa:CredentialLockout:SlotWaitSeconds (default 10,
BadTimeout). Cancelled waiters are dequeued; pruning accounts for waiters.
The security property is unchanged: no more than MaxFailedAttempts
unverified guesses in progress-or-failed per window.
2026-11-11 02:43:38 -05:00
Joseph Doherty 988927c8ac fix(opcua): credential lockout review fixes (#833)
- Reserve an in-flight slot per client before authenticating (TryBegin /
  Complete / Abandon under one lock); refuse once recorded failures +
  in-flight attempts reach MaxFailedAttempts, so a parallel burst cannot
  pass the check before its first failure is recorded.
- An expired lockout resets the count (LockoutSeconds < FailureWindowSeconds
  relocked on every single failure after the first lockout).
- Validate OpcUa:CredentialLockout at host start: disabled, or both
  durations > 0.
- Log the first refusal of a lockout episode at Information, the rest at
  Debug.
- Document that on None endpoints the key is effectively IP-only (NAT
  neighbours share a UserName count) and a null peer shares one key.
2026-11-11 02:32:40 -05:00
Joseph Doherty 40969e5c4b fix(opcua): failed sign-ins no longer lock a client out of anonymous sessions (#833)
OPC Foundation SDK 2.0.1's SessionManager lockout refused EVERY
activation (Anonymous included, before the token is read) from a client
with 5 failed identity validations in 60 s, for 5 minutes, keyed on cert
thumbprint else peer IP. Disable it (MaxFailedAuthenticationAttempts=0
on built and loaded configs) and enforce FailedCredentialLockout in the
identity-registry adapter instead: UserName activations only, keyed on
thumbprint+peer together, same 5/60s/300s defaults, configurable via
OpcUa:CredentialLockout.
2026-11-11 01:23:33 -05:00
Joseph Doherty e39a125069 fix(acl): close the #808 review findings — reference variables, owner folder chain, NUL keys, unknown ids (#808)
Review of af5c9744:
1. UNS reference variables (s=Area/Line/Equip/Eff) of a moved or renamed
   equipment stayed readable/writable under the OLD names' grants: they now
   resolve as ReferenceVariable and are authorized at their split path only
   while it equals their live equipment folder's placement (live chain ==
   generation chain) + one name; otherwise denied (Read and Write).
2. TryPlaceScriptedCondition now also requires the owner folder's live chain
   to match the generation (AuthorizeScriptedCondition gains ownerFolderChain),
   so a moved equipment's alarm/children/commands follow its folder.
3. Identifiers containing NUL are denied before the decision cache, keeping
   client ids disjoint from the NUL-prefixed resolved-scope keys (a client
   could otherwise poison a real folder's cached verdict for its key shard).
4. An id no live node holds is Unknown: placed at [\0unknown] so only
   Cluster / Namespace-uns grants decide it (a removed opaque id can no longer
   split onto a same-named narrow grant; narrow grantees get no NodeDeleted for
   removed UNS folders/vtags/conditions — documented); 4-segment
   reference-shaped ids keep their split.
5. A wire test drives placements from DeploymentArtifact.ParseDataPlaneAuthority.

Baseline updates: #797 owner tests (unit + wire) and Sdk2RepeatedAlarmAuthority
now install folder scopes; Sdk2HistoryAuthority's UNS alias used the RAW path
as its UNS id (never minted in production) and now carries EqA/HistoryAlias.
Each new guard was falsified (reverting it fails its test).
2026-11-10 23:44:11 -05:00
Joseph Doherty af5c9744b7 fix(acl): place opaque-id UNS folders, virtual tags and scripted conditions for every operation (#808)
UNS Area/Line/Equipment folders are minted under their opaque entity ids
(s=UA-/UL-/EQ-...), virtual tags under s=EQ-.../Name and scripted conditions
(+ #629 children) under s=<ScriptedAlarmId>[/...], while every UNS grant is
planted at a NAME path. DataPlaneAuthorizer split those ids, so with
Security:Acl:Enabled a narrow (Area/Line/Equipment) grant could not Browse,
Read, subscribe or HistoryRead them, and Server-object delivery never reached
it — so a narrow key never held the EventId Ack/Confirm/AddComment need.

- The generation carries UnsFolderScopes (folderId -> path + id chain), built
  by DeploymentArtifact from the same AclScopePathResolver as the grants; ids
  two entity kinds share are dropped; plumbed through IDataPlaneAuthoritySink.
- OtOpcUaNodeManager.ResolveUnsAclSubject reports live facts (folder id
  ancestry / scripted owner binding); attached by the Authorizer setter.
- Evaluate places the subject against the CURRENT generation before the
  decision cache, which now caches the walk keyed on the resolved scope.
  Folders require live chain == generation chain (a moved equipment is denied
  under old and new line until the rebuild). Owned nodes (vtags, conditions,
  children) get owner path + a reserved segment, so Tag grants and their
  Browse transit never expose them. Conditions share TryPlaceScriptedCondition
  with the #797 command gate.
- Server-object visibility of a scripted condition is decided on the
  condition alone, so a moved alarm cannot reach its old owner there.

Tests: UnsFolderScopeWireTests (opaque ids, end-to-end browse -> EventId ->
Acknowledge on an Equipment grant; sibling denied; Tag-only grant; moved alarm
and moved equipment stale windows). Rig tests now install folder scopes
explicitly (ServerEventRig.FolderScopes); the #797 recreate test deliberately
asserts the moved alarm is invisible to the Cluster observer in the window.
2026-11-10 23:12:46 -05:00
Joseph Doherty 4620e542e8 fix(acl): require the live scripted-alarm owner to match the generation's deployed owner (#797)
Review of 3d1b86b4: OpcUaPublishActor installs a new authority generation
before applying its address space, and an apply that throws keeps the old one.
A scripted alarm moved from equipment A to B therefore stayed commandable with
A's grant under B's generation (briefly before the rebuild, or indefinitely
after a failed apply).

The generation now carries ScriptedAlarmOwners (ScriptedAlarmId -> EquipmentId,
decoded by ParseDataPlaneAuthority from the artifact's ScriptedAlarms rows, the
same rows the composition materialises from). AuthorizeScriptedCondition denies
unless the node manager's live binding names the same owner as the generation:
a moved alarm is denied under the old owner's, the new owner's and a Cluster
grant until the address space re-binds it; an alarm the generation no longer
deploys is denied. Install/apply order unchanged.

Tests: window (a) generation moved / binding stale => both owners and Cluster
denied, then rebuild under the new owner => new owner allowed, old denied;
window (b) apply failed after install => denied across clock and further
generations, then a successful rebuild restores the new owner only; undeployed
alarm denied; artifact decode of the owner map.
2026-11-10 21:42:03 -05:00
Joseph Doherty 3d1b86b451 fix(acl): authorize scripted alarm commands at their owning equipment's scope (#797)
Scripted condition NodeIds are opaque ScriptedAlarmIds, so splitting them as a
topology path could never match an Equipment grant: a narrow equipment grant was
denied while a Cluster grant passed.

- OtOpcUaNodeManager binds each LIVE scripted condition instance to the
  EquipmentId it was materialised under (only when it hangs under that folder);
  dropped on remove / drop-recreate / equipment-subtree removal / rebuild; a
  same-id re-materialise under another equipment re-parents and re-binds.
- DataPlaneAuthoritySnapshot carries EquipmentScopePaths (EquipmentId ->
  [uns, Area, Line, Equipment]) decoded from the artifact by the same
  AclScopePathResolver call that plants Equipment grants.
- DataPlaneAuthorizer.AuthorizeScriptedCondition re-reads key, grant and
  generation per call (uncached) and evaluates at the owner path; unbound,
  replaced or unplaceable owners deny under every grant. No id-split or
  realm-root fallback.
- Native conditions (RawPath) and the ACL-off role gate are unchanged.
  NodeIds and AlarmCommand.AlarmId are unchanged.

Sdk2OpaqueScriptedScopeBaselineTests deliberately flipped: it pinned the denial
and now pins owner-grant allow / sibling deny / cluster allow. Follow-up #808
(folder ids + Server-object event visibility for narrow UNS grants).
2026-11-10 21:23:57 -05:00
Joseph Doherty 2cf14f403b test: verify SDK2 host disposal ownership and shared completion 2026-11-10 17:16:47 -05:00
Joseph Doherty 41e65f6a22 fix(opcua): restore alarm component reference after SDK creation 2026-11-10 12:14:35 -05:00
Joseph Doherty a21104d27c fix(opcua): scope application audit receive permissions to validation 2026-11-10 11:41:31 -05:00
Joseph Doherty 344bc9df8a test(opcua): assert alarm UA type across supported adapters 2026-11-10 11:04:53 -05:00
Joseph Doherty 09ac0e58cb fix(opcua): preserve repeated alarm methods and owned write reverts 2026-11-10 11:04:53 -05:00
Joseph Doherty 9cddc9306b test: preserve server SDK 2.0 value security and history contracts 2026-11-10 10:39:39 -05:00
Joseph Doherty 11a5c1d4e2 test: deflake load-sensitive tests (lmxopcua#769)
v2-ci / unit-tests (push) Has been cancelled
- NodeManagerLockTimeBenchmark: wall-clock budget enforced only with OTOPCUA_BENCH=1; default run keeps the state-correctness asserts and logs timings.
- OpcUaClientDriverWire host-status: signal (semaphore) wait that only accepts a non-Running state observed after the server is disposed.
- AbCip PollLatencyComparison: retry once on a fixture connection reset; p95 criterion enforced only with AB_LAT_ENFORCE=1.
- ScriptRunGuardTests: orphan scripts held by a ctx.Released signal instead of a 250 ms wall-clock spin that could finish inside the 60 ms budget under load; stuck-orphan wall-clock rule disabled in the tests that pin the strike counting.
- DependencyMuxReplayTests: split the inside-grace positive control (1 h grace) from the release test (control asserted before unregister), removing the 200 ms grace race.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 05:39:22 -05:00
Joseph Doherty 2da58d0360 build(deps): OPC Foundation SDK 1.5.378.106 -> 1.5.378.182 (lmxopcua#782 interim hop)
v2-ci / unit-tests (push) Has been cancelled
Behaviour-neutral hop: pin the SDK AggregateManager default to our
TreatUncertainAsBad=false (SDK .182 flipped it to true), parity guard now an
explicit deliberate-divergence assertion plus a booted-server check; adapt to
obsolete FindPredefinedNode(Type)/TraceConfiguration; Microsoft.Extensions.
Logging.Abstractions 10.0.7 -> 10.0.8 (SDK floor); stall wire test's late-flush
positive control is best-effort (.176 preserves publish queue ordering).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 22:47:58 -05:00
Joseph Doherty e790938f94 fix(historian): QuestDB modified reads keep a landed change whose marker was lost; H2 review NITs (lmxopcua#778)
QuestDB modified-history reads dropped every still-pending trail row that was
not the latest at its source time, so V0->V1 (applied marker never written)
followed by V1->V2 hid the V0->V1 change for good. A pending row below the
latest is now served when the NEXT trail row at that time recorded a different
prior value (kind, value, status or the prior's server_ts) - the next change saw
what this one wrote; an orphan of a change that never landed leaves the same
prior for the next row and stays hidden. QuestDB window functions reject
VARCHAR, so the next row's prior comes from a LEFT JOIN on (ts, lead(id)).
The updater's comment and marker-failure log no longer claim modified reads
"still serve it" unconditionally.

Regression tests (QuestDB 10.0.1 throwaway container): marker lost then a
replace / delete at the same time; an orphan followed by a marker-lost retry
then a later change (orphan still dropped); the random marker-mix fuzz now
varies the recorded priors. All four fail on the old filter.

NITs:
- SqlServerHistorianValueWriter: a SqlException raised while the token is
  cancelled (SqlClient's "Operation cancelled by user") is rethrown as
  OperationCanceledException instead of booked as a failed write. Integration
  test holds a TABLOCKX on Sample and cancels mid-INSERT; it returned false before.
- SqlServerHistorianDataUpdater.DeleteRawBatches is an Interlocked counter.
- HistoryReadEvents: an event filter whose SDK validation throws (unknown
  operator) failed with the generic BadUnexpectedError; the node manager now
  re-throws any failed event-filter validation as BadEventFilterInvalid.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 16:17:24 -05:00
Joseph Doherty 29665c1872 feat(opcua): HistoryReadEvents projects EventType, ConditionName, SourceNode; honours the where-clause (lmxopcua#746)
HistoryReadEvents projected only EventId / SourceName / Time / ReceiveTime /
Message / Severity: a select clause asking for EventType, ConditionName or
SourceNode (what most generic alarm viewers ask for) got nulls, and the
event filter's where-clause (an OfType) was ignored.

- HistoricalEvent gains optional EventTypeName, ConditionName and AlarmId.
  SqlServer, QuestDB and Gateway event reads fill them from what their
  alarm writers already store (AlarmTypeName / AlarmName / AlarmId; the
  gateway event Type and its AlarmName / AlarmId properties).
- The node manager maps the type name to its Part 9 event type (unknown or
  non-event => AlarmConditionType), projects ConditionName, and resolves
  SourceNode to the materialised condition the alarm id names (the live
  events' SourceNode), else null.
- The where-clause is evaluated per event with the SDK content filter
  through an IFilterTarget (OfType via the type tree; other operators
  against the projected fields), after the page's cursor is taken, so
  paging is unchanged.

Tested in the node manager, over a real client session (the Client.CLI has
no `historyread --events` mode yet; filed as a follow-up), and by alarm
writer -> event read round trips on SqlServer and QuestDB.

Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22
in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:40:47 -05:00
Joseph Doherty acc3057643 test: de-flake NodeManagerLockTimeBenchmark budget and OpcUaClientDriverWire host-status wait (lmxopcua#769)
v2-ci / unit-tests (push) Waiting to run
Benchmark wall-clock budget 1 s -> 5 s (measured 5-60 ms; still catches O(N^2)). Host-status wait is already a TCS signal; its 30 s ceiling is raised to 120 s so a starved box cannot time out before the keep-alive reports the loss.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:18:16 -05:00
Joseph Doherty 919009c5ef fix(ablegacy): stamp health from the injectable Clock; drop the redundant per-project TreatWarningsAsErrors (lmxopcua#768)
AbLegacyDriver stamped LastSuccessfulRead (init, read, write), the read's sample
timestamps and host-state change times from raw DateTime.UtcNow; only the backoff
path read the injectable Clock. All of them now read Clock(), so
NativePcccBridgeStatusHealthTests drives a manual clock (init at T0, read at T1)
and asserts LastSuccessfulRead == T1 exactly instead of spin-waiting for the wall
clock to pass the init stamp (#767). Without the driver change the 6 tests fail
(init stamp is wall-clock, not T0).

Directory.Build.props sets TreatWarningsAsErrors for every project since #766, so
the 75 per-csproj copies (64 src + 11 tests) are removed and the props comment
says it is now the only place. tools/mem-probe keeps its explicit opt-out.
Release build: 0 warnings, 0 errors; a planted CS0168 still fails the build in a
src project and in a test project that used to carry its own copy.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:40:07 -05:00
Joseph Doherty 8e8d4cdf13 fix(alarms): surface a refused native alarm ack synchronously, revert one that fails later (lmxopcua#760)
The native-ack path is fire-and-forget after the Part 9 Acknowledge returns, so
a Galaxy ack refused for lack of a credential (#749), or rejected by MXAccess,
left the client with Good and the condition showing Acknowledged while Galaxy
was never acked. Both options from the issue, layered:

1. Synchronous refusal for what the driver can decide without I/O. New optional
   IAlarmAcknowledgePreflight (Core.Abstractions); GalaxyDriver refuses an
   anonymous session when the driver has no AlarmAck fallback account.
   DriverHostActor publishes a condition NodeId -> owning-driver snapshot into
   NativeAlarmAckPreflightView (lock-free, volatile); the node manager's new
   NativeAlarmAckPreflight seam, consulted after the authz and Primary gates,
   returns BadUserAccessDenied with the reason - nothing routed, applied or
   reported. A keyed session is not pre-checked (its identity lives in the
   Secrets store, which must not be read under the SDK lock).

2. Revert for everything else. RouteAlarmAck carries the condition NodeId;
   DriverInstanceActor replies AlarmAckFailed on a refusal, a backend failure,
   a driver without an alarm source, or an ack dropped while connecting /
   reconnecting. DriverHostActor re-projects the condition unacknowledged via
   NativeAlarmProjector.TryRevertAcknowledge, but only while the local ack is
   still pending - any driver transition projected since then wins. A disabled
   or unspawned driver is not reverted (unchanged).

GatewayGalaxyAlarmAcknowledger now throws DriverOperationRefusedException on a
non-zero native hresult (wrong password, no ack permission) instead of only
warning, so that case reverts too; as a refusal it neither retries nor trips
the breaker.

Tests (each fails with its fix mutated out): projector revert/supersede,
DriverHostActor refused + failed ack -> unacked AlarmStateUpdate (successful
ack control), preflight view snapshot, node-manager preflight unit test,
over-the-wire ConditionMethodEventTests (BadUserAccessDenied, no route, no
event; positive control), Galaxy preflight + hresult refusal.
Docs: Galaxy.md, AlarmTracking.md, ScriptedAlarms.md, .claude/rules/alarms.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:12:17 -05:00
Joseph Doherty dc0a24f224 feat(galaxy): operator-authenticated alarm ack — send Galaxy credential to mxaccessgw (#749)
v2-ci / unit-tests (push) Failing after 1h8m43s
mxaccessgw 488649c+ acknowledges by AuthenticateUser as the operator and
writing <alarm>.AckMsg, so AcknowledgeAlarmRequest now needs a real Galaxy
operator_user + operator_domain + operator_password. The OPC UA data plane is
API-key/anonymous and carries no Galaxy password, so the driver resolves the
account per ack:

1. the session's API key's Galaxy ack identity, set on AdminUI /apikeys and
   held ONLY in the encrypted Secrets store
   (otopcua/apikeys/<keyId>/galaxy-ack-{user,password}); a key with a user but
   no password secret is refused, never silently downgraded;
2. else the driver instance's AlarmAck fallback account (DriverConfig
   FallbackUser/FallbackDomain/FallbackPasswordSecretRef; secret:/${secret:}/
   env:/file:/dev: only — a literal is refused);
3. else DriverOperationRefusedException: nothing is sent, never an empty
   operator_user. Refusals are excluded from the resilience retry/breaker.

- Pins ZB.MOM.WW.MxGateway.Client/Contracts 0.2.1 -> 0.3.0 (vendored into
  nuget-packages/ from the Gitea feed, per the local-mxgw source mapping).
- The session ApiKeyId (not a secret) rides NativeAlarmAck ->
  RouteNativeAlarmAck -> RouteAlarmAck -> AlarmAcknowledgeRequest; the password
  is resolved in the driver and never travels through actor messages.
- The OPC UA principal + key id are appended to the AckMsg comment
  ("[OPC UA: <principal>, key <id>]") and logged, so a shared fallback
  account does not lose who acknowledged.
- Password never logged: GalaxyAckCredential.ToString omits it, the
  acknowledger scrubs it from every gateway message it echoes and withholds
  any exception that echoes it; audit rows carry only the key id.
- AdminUI: /apikeys "Ack identity" editor (set/remove; delete-key clears it),
  Galaxy driver form gains the fallback section.
- Synchronous OPC UA status for a refusal is follow-up #760 (the native ack
  path is fire-and-forget after the Part 9 method returns).

Tests: request mapping (password populated), password absent from logs and
exception text (mutation-checked), key-mapped / fallback / refused paths at
resolver and driver level, breaker non-trip, key-id propagation through node
manager and DriverHostActor, AdminUI service + form round-trip.

Closes #749
2026-11-06 16:23:00 -05:00
Joseph Doherty 5533de6281 feat(arrays): Modbus String[]/BitInRegister[] array writes reachable; table follows the resolver
The Modbus lane (#741 U3) gave the driver String[] and BitInRegister[] holding-register array encoders, and
moved address resolution to ModbusTagAddressResolution (keys first, a bit region coerced to Bool, strict key
reads), but DriverArrayWriteSupport still listed both array shapes read-only (follow-up F1), so a client
write was BadNotWritable before it reached the driver.

Modbus row now mirrors the driver end to end:
- Keys shape only (region + address both present). A string-only addressString tag is resolved by the
  driver with its own family through a parser Core.Abstractions cannot reference, so it stays read-only
  (pinned: the conservative direction).
- Strict reads as the resolver: address an integer 0-65535; a present region / dataType / byteOrder /
  stringByteOrder string must name a member.
- Coils: any dataType (coerced to Bool), <= 1968 coils (FC15).
- HoldingRegisters (FC16, never Bool): numeric, String ((stringLength+1)/2 registers, stringLength >= 1),
  BitInRegister (1 register, bitIndex 0-15, span ending <= 65535), all within maxRegistersPerWrite.
  FC23 is opt-in and never changes the answer.
- Elements convert (ScadaBridge int[] -> Int16 stays writable); only a String / DateTime node over a
  numeric or bit element is left out, since such an element never converts.

Tests: the parity test's PendingF1 exemption and its guard are gone; the parity matrix is now region x
element type x node type against the real WriteAsync with a spec-conformant device (FC15 > 1968 refused),
plus shape edges at every cap boundary (numeric, String, BitInRegister, coil), strict key reads, FC23
on/off, string-only, the ScadaBridge int[] shape and U3 wire bytes. Core.Abstractions row tests and a
composer test (String[] and BitInRegister[] writable; over-cap, no-stringLength and string-only read-only).
Docs: table XML doc, Uns.md, modbus-addressing.md, drivers/Modbus.md, data-plane rules.

Refs #741

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:10:00 -04:00
Joseph Doherty d520f77ba0 Merge Wave-2 integration: array writes reachable for AbCip/AbLegacy/TwinCAT (#737 #738 #739)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:25:16 -04:00
Joseph Doherty 44ea6595e5 feat(arrays): advertise AbCip/AbLegacy/TwinCAT array writes in DriverArrayWriteSupport
The Wave-2 lanes gave AbCip, AbLegacy and TwinCAT real whole-array write
encoders (review 07 U10/U9), but the capability table still listed all three
as scalar-only, so every array node materialised read-only and a client write
was BadNotWritable before it reached the driver.

Rows added, each listing only what WriteAsync encodes end to end:
- AbCip: SInt/USInt/Int/UInt/DInt/UDInt/Real/Dt/LInt/ULInt/LReal, declared
  length, not a safetyTag. BOOL/STRING/Structure arrays stay read-only.
- AbLegacy: Int/AnalogInt on N/B/A, Long on L, Float on F (file-width match),
  plain <file><n>:<word> address, <= 256 elements; not /bit, .sub, indirect,
  S:/I:/O:, nor native PLC-5.
- TwinCAT: every codec element type, declared length; not a BOOL bit-in-word
  path.
Elements convert in all three encoders, so the rows never key on an exact
node DataType (ScadaBridge int[] -> Int16 stays writable); they leave out
only node types whose elements never convert (DateTime/String over numeric,
Boolean on AbLegacy, STRING under DateTime on TwinCAT: culture-dependent).

Tests: driver-level parity for each driver (every element type x node type
plus shape cases, against the real write path), the AbLegacy parity tests
un-skipped, Core.Abstractions row tests, a composer test (three drivers
writable, refused shapes read-only), and the existing AbCip negative cases
moved to a BOOL array. Docs: Uns.md table, per-driver docs, data-plane rules.

Refs #737 #738 #739

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:17:48 -04:00
Joseph Doherty ff4e210a27 fix(opcua): replay kept scripted conditions after a rebuild; fit window values onto re-shaped nodes (Lane B fix round 3)
N3 (critical): the node manager carries only NATIVE conditions' state over a
full rebuild; scripted conditions come back fresh and rely on the engine's
re-assert, which since the off-thread plan is handled in the window and lands
on the discarded instance. On a rebuild the condition replay scope is now
AddedConditions plus every enabled scripted condition in the new composition
(native kept conditions stay carried-over, not replayed). The test sink again
models the real rebuild: it clears non-native condition state only.

N4: a surgical in-place shape edit (DataType / IsArray / ArrayLength) resets the
raw node and its UNS mirror to BadWaitingForInitialData without ensuring them,
so the window's value was not replayed and the two NodeIds disagreed. The
applier records re-shaped nodes (MaterialisationRecording.Reshaped); their
deferred value replays only once fitted to the new shape (VariableShape.TryFit:
as is, or converted invariantly; never an old-typed value - WriteValues assigns
the value verbatim with no type check), otherwise it is dropped at Debug and the
node keeps waiting. Status-only entries are not replayed onto a re-shaped node.

Minors: m1/m2 documented on _replayScope (degraded-path only); m3 corrected the
false carry-over premise in the scope doc (the ScriptedAlarmHostActor ordering
remark is outside this lane - F8); m4 the replay Debug log reports alarm-state
and alarm-quality counts again.

Refs #732
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:46:29 -04:00
Joseph Doherty a8284f499e fix(opcua): scope the deferred-write replay to nodes the apply created (Lane B fix round 2)
N1: a full alarm-state snapshot also sets the condition's Quality, so it now
supersedes an earlier quality-only write for the same id in the deferred
buffer; a quality arriving after the state is kept and replays after it.

N2 / M1: the replay is not inert on a node the apply KEPT - an SDK-applied
Acknowledge/Confirm/Shelve or an optimistic client Write in the window would
be rolled back (with spurious Part 9 events). The replay is now scoped to
nodes the apply created: variables the applier ensured during this apply
(AddressSpaceApplier.BeginRecordingEnsuredVariables) or every variable after a
full rebuild; conditions in the applied composition but not the previous one
(also on a rebuild, which carries a kept condition's state over). Option (b),
a sink that reports whether a live write landed, needs the Commons
DeferredAddressSpaceSink seam outside this lane (follow-up F7).

M2: the benchmark remark now matches its Category=Benchmark trait.
M3: TestHooks and the seam-carrying constructor are internal; PropsForTests
produces the actor through an IIndirectActorProducer.
M4: pinned replay-nothing-and-drop-the-buffer after a failed plan.
M5: PreRestart answers every queued rebuild requester (degraded).

Refs #732
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:08:44 -04:00
Joseph Doherty f966efcfad fix(opcua): replay writes deferred during an off-thread plan; cache-ref and guard fixes (Lane B fix round 1)
C1 (critical): B6 computes the plan off the pinned thread while B1 made writes
non-creating, so a value/status/alarm write for a node the in-flight plan adds
was skipped, and change-driven drivers never republish it (BadWaitingForInitialData
indefinitely; on a full rebuild every re-subscribe value landed on discarded
nodes). OpcUaPublishActor now applies writes immediately AND remembers the
latest per (realm, id) while a plan is in flight or queued (value+status
compose; alarm state/quality coalesce to the latest, which the node's delta
gate makes inert on an existing condition), replays it after each apply before
answering, and drops it once the pipeline is idle. Bounded (250k keys by
default; beyond it new keys are not held, logged once).

I2: the data-plane-authority parse runs in its own try inside the plan task; a
throw is logged and never degrades the address-space apply.

I3: the SDK's DeleteMonitoredItems releases an item's component-cache
reference on a Good delete; a tombstoned item's reference was already
released, so the delete took one from the re-created node's entry. Released
items are remembered and the orphan delete takes one back first. Event items
never held a reference (EventManager + SubscribeToEvents), so the three
event-item releases are gone.

I4: direct tests for the HistoryUpdate override's per-request updater check
and the HistoryReadEvents override's source guard (both reached by restoring
the bit after a reset - the B7 race window); fixed the false remark.

Minors: the MaterialiseAlarmCondition kind-swap tombstones the dropped
instance; a bytes-less rebuild arriving mid-plan queues behind it (replies in
order); a PlanReady/PlanFailed from a previous actor incarnation is ignored and
its requester answered degraded; the tie-cluster end bound of an uncapped read
fits against the backend ceiling (defence in depth - guarded by a sweep test);
the lock-time benchmark stays in the default run, headroom noted.

Refs #732
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:30:11 -04:00
Joseph Doherty 0117e1501b fix(opcua): trace every rebuild path; align history tests with the B7 contract
B6 moved the address-space-rebuild span into ApplyPlan, so the two rebuilds
that complete synchronously on the actor thread (no applier -> raw-sink
rebuild; no artifact bytes -> held) stopped emitting it
(OtOpcUaTelemetryHookTests.RebuildAddressSpace_starts_an_address_space_rebuild_span).
Both now open their own span.

B7 (late HistorianDataSource / HistorianDataUpdater re-evaluate the
capability bits) reversed three tests that pinned the old "fixed at
materialisation" contract. Re-expressed against the new one, end to end:
- events: a folder promoted without a source is unsupported, then served
  once a source is wired (source reached);
- in-process HistoryUpdate: clearing the updater retracts HistoryWrite and
  the old updater is never reached;
- over the wire: a late updater lights HistoryWrite and an insert reaches
  it; a reset retracts the bit and the insert is BadHistoryOperationUnsupported.

Refs #732
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:29:07 -04:00
Joseph Doherty be22b48b1d feat(opcua): advertise the historian raw cap as MaxReturnDataValues on source sync
Lane H1 follow-through: SyncAdvertisedAggregates now also calls
HistoryPaging.AdvertiseMaxReturnDataValues(Server, source
.MaxRawValuesPerRead) so HistoryServerCapabilities/MaxReturnDataValues
(i=11273) follows the historian source on every assignment (the Null
default advertises 0 = no limit); a false return logs a Warning (the cap
is enforced by HistoryRead regardless).

Test: NodeManagerMaxReturnDataValuesSyncTests reads i=11273 back through
the SDK master node manager after setting a capped source (7000, 250)
and after resetting to the Null source (0).

Refs #732 #736

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:47:32 -04:00
Joseph Doherty 8a1116e658 fix(opcua): fit the raw end bound against the page's effective cap
H1 fuzz finding (routed to Lane B because AppendEndBoundAsync lives in
OtOpcUaNodeManager.cs): a bounded HistoryRead-Raw page sized BELOW
NumValuesPerNode by the backend's row ceiling (RawPageLimit.Plan) took
the end bound whenever its data fitted, because the fit was judged
against NumValuesPerNode instead of the page's effective cap — a full
ceiling-sized page came back with cap + 1 values. The bound is now
fitted against the page allowance (the ceiling-adjusted data cap plus
the start bound a first page already carries); when the page is full
the bound goes to the next page through the continuation point.

Test: NodeManagerHistoryReadBoundsTests
.A_full_ceiling_sized_page_carries_the_end_bound_on_the_next_page
(ceiling 4 ⇒ 3 data rows; first page = start bound + 3 with a CP, second
page = the end bound alone; control: no ceiling ⇒ one page of 5).

Refs #732 #736

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:47:32 -04:00
Joseph Doherty d90f35b6be fix(opcua): refresh historian capability bits on late source/updater assignment (B7)
Review 01 U3. EnsureFolderIsEventNotifier granted the EventNotifiers
.HistoryRead bit (and registered the event-history source) only when a
real HistorianDataSource was wired at promotion time, and EnsureVariable
composed the AccessLevels.HistoryWrite bit from whether a
HistorianDataUpdater existed at materialise time — so an assignment
after the first apply left existing folders without event history and
existing nodes refused by the SDK's HistoryUpdate admission until a full
rebuild, and a source reset to Null never took the bit back.

The two setters now re-evaluate: HistorianDataSource walks every promoted
folder (bit + source registration, both directions), HistorianDataUpdater
recomposes the AccessLevel of every historized variable (Writable read
back off the node's own CurrentWrite bit). The Host's boot ordering is no
longer load-bearing.

Tests: NodeManagerCapabilityRefreshTests (late source lights the bit on
an already-promoted folder and a reset takes it back, live-event
subscription untouched; late updater grants HistoryWrite to historized
nodes only and a reset takes it back, authored Writable preserved).

Refs #732

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:47:32 -04:00
Joseph Doherty 118933d5d5 refactor(opcua): delete the dead v3 equipment-tag materialisation (B5)
Review 01 U2. The composer never emits EquipmentTags in v3 (device values
live in the Raw subtree and reach equipment as UnsReferenceVariables), so
the equipment-tag plan diff sets (Added/Removed/ChangedEquipmentTags,
EquipmentTagDelta), the planner's diff over them, the classifier's
TagDeltaIsSurgicalEligible and its Rebuild clause, the applier's
MaterialiseEquipmentTags pass, surgical ChangedEquipmentTags loop,
removed-tag teardown loop and add-announcement term, and the publish
actor's equipment-tag pass were unreachable, maintained dead weight.
IsEmpty keeps its semantics (an otherwise-empty plan is still Empty).

Kept, deliberately: AddressSpaceComposition.EquipmentTags and the
EquipmentTagPlan record — Runtime's DeploymentArtifact (outside this
lane) still assigns the empty set and the artifact parity tests assert
it; follow-up F2 removes both together.

Tests: the equipment-tag vehicle tests for PureRemove / AddRemoveMix /
the rebuild ratchet are re-expressed over raw tags (same applier paths);
the equipment-tag surgical and classifier tests are deleted (their raw /
UNS-reference equivalents already exist); SubscriptionSurvivalTests now
materialises its tags through MaterialiseRawSubtree.

Refs #732

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:47:32 -04:00
Joseph Doherty 1578efd4a9 fix(opcua): write-path robustness, injected clock, configurable quotas (B4)
Review 01 L3/H5/H4/P6/P4.

- L3/H5 OnEquipmentTagWrite's dispatch is factored into DispatchDeviceWrite:
  a gateway that throws synchronously is refused inline with
  BadDeviceFailure (it used to escape the handler under the SDK Lock), and
  a faulted/cancelled dispatch task has its exception observed and logged
  and carries the real reason into the revert/audit event instead of
  surfacing as UnobservedTaskException at finalization.
- H4 every timestamp the manager stamps (condition quality, timed-shelve
  expiry, method-transition events, carry-over, revert, revalidation)
  reads the injected Clock; RemainingShelveMs takes the clock reading.
- P6 OpcUa:Quotas:* (OpcUaQuotaOptions on OpcUaApplicationHostOptions):
  Min/MaxRequestThreadCount, MaxQueuedRequestCount with the former
  hard-coded 5/100/200 as defaults, and the TransportQuotas members as
  nullable overrides over the SDK defaults.
- P4 the SDK line exposes IHistoryReadAsyncNodeManager but CustomNodeManager2
  offers no async virtual to override (adopting it means re-implementing
  the base dispatcher), so the lever is the admission cap:
  MaxConcurrentHistoryReadBatches defaults to clamp(2*ProcessorCount, 4, 16).
  The Host still overrides it from ServerHistorianOptions (default 16) —
  follow-up F3.

Tests: NodeManagerWriteDispatchTests (sync throw refused; faulted task
reverts; success leaves the value; clock-stamped revert),
OpcUaApplicationHostQuotaTests.

Refs #732

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:47:32 -04:00
Joseph Doherty dca822dc59 perf(opcua): de-quadratic the lock-time teardown paths (B3)
Review 01 P1/P2/P3/P5 — work done under the manager-wide SDK Lock on the
pinned publish thread, where every Read/Browse/Publish waits.

- P1 RebuildAddressSpace detaches the shared root's top-level children
  once instead of every leaf from its parent (NodeState.RemoveChild is a
  linear scan + RemoveAt, ~k²/2 compares per k-child folder); root
  notifiers are cleared as a list instead of one O(F) RemoveRootNotifier
  each; ForgetPredefinedSubtree / TombstoneMonitoredSubtree are iterative
  with one child buffer.
- P2 RemoveEquipmentSubtree walks a parent→children index maintained by
  EnsureFolder/EnsureVariable/MaterialiseAlarmCondition (keyed by the
  parent id the caller named) instead of prefix-scanning six maps per
  removed equipment; a surviving condition's wiring into a removed folder
  is found through the folder's own notifier list; descendants are not
  detached from a folder that is itself being discarded.
- P3 LowerAllEventsMonitoring(condition) replaces the full O(C) reconcile
  on RemoveAlarmConditionNode / RemoveEquipmentSubtree / the kind-swap.
- P5 AlarmNotifierWiring.Folders is a reference-identity HashSet and an
  already-wired pair skips the SDK's linear AddNotifier dedup.

Benchmark (NodeManagerLockTimeBenchmarkTests, M-series Mac under load,
three runs each): rebuild of a 10k-tag folder 75–150 ms → 5–6 ms;
removing 200 equipment (250 signals each) from a 50k-node space
471–681 ms → 58–64 ms; all-events bookkeeping visits for 10 condition
removals over 50 conditions 900 → 10. The two timing legs assert < 1 s;
the P3 leg asserts the visit count (machine-independent).

Refs #732

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:47:31 -04:00
Joseph Doherty eaac65286d fix(opcua): tombstone MonitoredNode2 on every node removal (B2a)
Review 01 L2. The SDK keys its fan-out lists by NodeId and its component
cache hands back the CACHED NodeState for a NodeId it already holds, so a
rebuild or a surgical remove+re-add left a NEW item on the same NodeId —
from any session — bound to the DEAD node: created Good, fed its stale
initial value, never again, until every session had deleted its items.

Every removal path (RebuildAddressSpace, RemoveVariableNode,
RemoveAlarmConditionNode incl. the condition's instance-scoped children,
RemoveEquipmentSubtree) now queues BadNodeIdUnknown to the node's data
items, drops the MonitoredNodes entry, releases each item's component
cache reference and forgets the _liveItems/_eventItems entries. Items
the ACL pass stood down (on no fan-out list) are swept by NodeId. A later
client delete of an orphaned item is completed by the DeleteMonitoredItem
override / the SubscribeToEvents unsubscribe arm instead of answering
BadMonitoredItemIdInvalid or NRE-ing in MonitoredNode2.Remove. The
revalidation restore re-resolves the node by NodeId and no longer
re-attaches to Handle.Node blindly. Timestamps now come from the injected
Clock (UtcNow).

Tests: StaleMonitoredNodeWireTests (real SDK, three legs: rebuild,
surgical remove+re-add, second session; each keeps the old item alive,
proves the new item receives a value written after its creation, the old
one gets BadNodeIdUnknown, and its delete succeeds — all three fail with
the tombstone neutered), NodeManagerStaleMonitoredNodeTests (stood-down
item swept on surgical remove and rebuild; sibling removal leaves it
restorable).

Refs #732

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:47:31 -04:00
Joseph Doherty 00300d0f60 fix(opcua): no write ever creates a node; cross-kind id guard (B1)
Review 01 L1/L4/H2. WriteValue/WriteValues are non-creating (an unknown
id is skipped, counted and Debug-logged, as WriteStatuses always was), so
a driver publish racing a surgical remove can no longer resurrect the id
as a flat BaseDataType phantom under the shared root — nor hand a later
re-add of the same tag the phantom's parent/DataType/writability through
EnsureVariable's early return. WriteAlarmCondition for an unmaterialised
id is a logged no-op instead of minting the legacy bool[2] placeholder
(AddPredefinedNode is AddOrUpdate, so it replaced the SDK lookup entry of
any folder sharing the id). The applier no longer writes a condition
state against every removed equipment id (a folder, never a condition).
EnsureFolder/EnsureVariable/MaterialiseAlarmCondition refuse an id already
held by another node kind in the same realm. CreateVariable is deleted.

Tests: NodeManagerPhantomNodeTests (publish racing a remove leaves no
node; the re-add gets the authored shape and is live; unknown alarm id
is a no-op; the guard in all three directions, realm-qualified); the
sink / batch-write / write-statuses tests now declare their variables.

Refs #732

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:47:31 -04:00
Joseph Doherty 3e62683517 chore(historian): review cleanups for the raw cap
- GatewayHistorianDataSource.ReadRawWindowAsync keeps the cap + 1 probe budget
  (budgetCap) separate from the client's cap: a strict-ceiling or crowding
  refusal now reports Limit = the server cap that applied and advises setting
  NumValuesPerNode at or below it (it said "lower NumValuesPerNode" with
  Limit = cap + 1 to a client that sent 0).
- Fuzz: the oracle-source over-cap assertion was vacuous (cap 0 never counts);
  it now checks no reverse window reached the forward-only shape. Class docs
  name the post-floor cap set (3 ... 1000) and distinguish NumValuesPerNode
  1 / 2 from the smallest server cap.
- HistoryPaging.IsFullPage doc says it judges the REQUEST cap (page + ties +
  lookahead), which is finite under any limit; AdvertiseMaxReturnDataValues
  swallows any exception (returns false for the caller to log), as its doc
  promised.

Refs #736

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:41:37 -04:00
Joseph Doherty 7e8941fbdc test(historian): paged-vs-unpaged raw-cap fuzz, MaxReturnDataValues advertisement, cap floor of 3
- NodeManagerHistoryReadRawCapFuzzTests: 10k seeds (env OTOPCUA_PAGING_FUZZ_ITERATIONS)
  through a real SDK node manager against wire-faithful fakes of SqlServer / QuestDB /
  Gateway that enforce the backend half of the cap; random series with ties, random
  windows (empty, on/past a tie, between samples, forward/reverse/end-only, bounds),
  N in {0,1,2,small,huge}, cap in {off,3..1000}, tie budget {3,8,65536}. Pages must
  concatenate to the cap-off one-shot answer (checked against the series), terminate
  within values + 2 (+ one empty step per oversized boundary cluster), never exceed
  the cap, never ask the backend for 0 / over-cap; refusals only past the budget.
- HistoryPaging.AdvertiseMaxReturnDataValues writes HistoryServerCapabilities/
  MaxReturnDataValues (i=11273), read back through the SDK master node manager.
- MinMaxRawValuesPerRead raised to 3: at 2 a resumed page has no room (value + its
  boundary tie + lookahead) and every value cost an empty page + CP (seed 15).
- RawPageLimit rows pin the default-cap arithmetic (9 999 first page, 9 998 resumed).

Fuzz findings recorded as follow-ups: a bounded read whose data fits one page
returns cap + 1 (end bound fitted against N, not the cap; node manager, F2);
the Gateway walk-back's cost on a sparse series under a tiny tie budget is
pre-existing (seed 320: 82 reads uncapped, 93 capped).

Refs #736

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:15:21 -04:00
Joseph Doherty c8e3e279b4 Retain and replay ServiceLevel across publisher binding and failures
v2-ci / unit-tests (pull_request) Waiting to run
2026-10-13 13:26:27 -04:00
Joseph Doherty 97668d26d3 fix(historian): respect gateway ceilings and history value contracts
v2-ci / build (pull_request) Successful in 5m10s
v2-ci / unit-tests (pull_request) Failing after 28m34s
2026-10-13 03:51:04 -04:00
Joseph Doherty 3c3513ea3e fix(historian): #694 page raw reads at a strict gateway's learned ceiling; opt-in EventReadHorizon
Item 1: IHistorianDataSource.MaxRawValuesPerRead (Gateway: learned ceiling - 16 = L; 0 elsewhere, guard-tested).
RawPageLimit plans every raw page on top of #692's lookahead: with L > 0 the read (page P + boundary ties k
+ lookahead) is <= L, P = min(N, L - k - 1) (uncapped: L - k - 1); no room => straight to the tie-cluster
over-fetch (never an uncapped or underflowing request). One re-plan + re-read when the read taught a new
limit or was refused for crowding pre-window rows (HistoryReadLimitExceededException.PreWindowRows): the
page shrinks by them. Over-fetch bound min(MaxTieClusterOverfetch, L - 1); slice size never 0.
Review fixes: F1 a window starting at/before the epoch counts AVEVA's synthetic row as pre-window (a
ceiling-truncated read was reported complete); F2 the page path disables ReverseRawReader's own re-run
(cold end-only ceiling 20/N=3000 cost 3,023 reads, now <= 40); F3 crowded look-back re-read around, not
refused; F4 the #687 crowding re-read room at least doubles (up to 8 re-reads), so a cluster of hundreds
just before a resume point no longer fails on a default gateway; a crowding refusal that leaves no room
is BadTooManyOperations, never a tick-by-tick cursor.

Item 2: ServerHistorian:EventReadHorizon (default 00:00:00 = exact, unchanged). When set, every fresh
HistoryReadEvents window's earlier bound is raised to now - horizon (both directions, all backends).
Lossy by design; documented.

Item 3 (native TimeAverage/Total re-check) stays blocked on historiangw#27.
2026-10-13 01:39:32 -04:00
Joseph Doherty a5a0496176 Merge fix/693-gateway-attime: Gateway AtTime from raw reads + shared evaluator, node type hint (#693) 2026-10-13 01:18:09 -04:00
Joseph Doherty e04e1bf127 fix(historian): #692 review — a drained tie cluster never resumes onto the exclusive EndTime
F1 (pre-existing): SliceTieCluster continued a forward read while next <= endUtc, but the
forward window is end-exclusive; an oversized cluster one step below EndTime resumed AT
EndTime, the resume became a start == end point read and returned the EndTime samples as
data (microsecond: EndTime 8.1 us truncated to 8.0). Continue only while next is strictly
before EndTime truncated to the backend resolution.

Tests: A_tie_cluster_just_below_the_end_time_never_resumes_onto_the_end_time (native +
microsecond; both fail on the old rule) and
A_cursor_inside_a_tie_cluster_over_fetches_without_a_normal_read_first (pins the
WithinTieCluster shortcut by per-page read count; fails with the shortcut disabled).
Docs: the oversized-cluster exception to 'every page full / no lookahead => last' (F2),
and the N + skip + 1 strict-ceiling edge note referencing #694 (F4).
2026-10-13 01:02:12 -04:00
Joseph Doherty 0d249f3ee9 fix(historian): #693 AtTime type hint — Boolean nodes stepped, integer nodes rounded to their type
Review follow-up: on the Gateway every value is a Double (AVEVA stores a Boolean
as a 0/1 Int2), so the raw-evaluated AtTime interpolated a Boolean node to 0.5.

- IHistorianDataSource gains a default-interface overload
  ReadAtTimeAsync(fullReference, timestamps, TypeCode? valueType, ct) that
  forwards to the existing one (SqlServer/QuestDB/Null/fakes unchanged).
- OtOpcUaNodeManager.HistoryReadAtTime passes the node's scalar DataType
  (AtTimeValueType: built-in type -> TypeCode; arrays/other types => null);
  Raw and UNS NodeIds alike.
- GatewayAtTimeReader converts each raw row to the hinted kind BEFORE the shared
  AtTimeEvaluator runs (Boolean = value != 0 => stepped; integers => Int64 =>
  interpolated, rounded half away from zero, returned as the node's integer
  type; floats Double), so the evaluator's own type branch decides.
- AtTimeEvaluator boxed every interpolated Int64 as a Double (`cond ? (long)x :
  v` widens to double); its "keeping its type" test passed because Shouldly
  ShouldBe compares numbers across types. Fixed (affects SqlServer/QuestDB too)
  and pinned with ShouldBeOfType.
2026-10-13 00:52:03 -04:00
Joseph Doherty 8dfa165110 fix(historian): #692 every HistoryRead-Raw page is full — no empty page between values
A resumed raw page asked the backend for only N rows from the boundary timestamp
(inclusive), then trimmed the boundary ties already emitted: it netted N - skip values,
and at N = 1 nothing — the empty "full" page was taken for a stalled tie cluster and a
MaxTieClusterOverfetch + 1 (65,537-row) over-fetch stepped one tick, returning an EMPTY
page with a continuation point between every value (8 values = 17 pages / 25 reads),
forward and reverse, with or without ReturnBounds, on every backend.

Each page's read now asks for N + boundarySkip + 1 rows: the ties it trims, the page and
one lookahead row. The lookahead row is the 'more data' signal, so an exact multiple ends
on its last full page (no trailing empty page). The over-fetch fires only when every row
past the trimmed ties is still on the boundary (a genuine cluster larger than the page);
a cursor left inside a cluster is flagged WithinTieCluster and over-fetches directly.
A page that never leaves the boundary adds the prior skip to its cursor.

Low items: BoundedReadHasAnythingAsync carries its walk hint and, when there is no data,
the end bound it found (HistoryEndBound.Resolved + DataExhausted), so the next page reads
nothing; HistoryBounds.IsPlaceholder is an explicit marker (ConditionalWeakTable over the
NotFound instances) instead of status+null shape, so a stored Bad_BoundNotFound row is data.

Tests: NodeManagerHistoryReadPageFillTests (N=1 every shape/direction/bounds, tie clusters
straddling boundaries at N=1/2/3, exact-multiple, carried end bound + walk hint, stored
placeholder-shaped row). Existing paging tests updated where they encoded the short pages.
2026-10-13 00:42:13 -04:00
Joseph Doherty 934909b430 fix(historian): #691 review — clamp Gateway at-or-after read, GoodNoData when nothing exists, HonorReturnBounds kill switch
1. The at-or-after bound read on a forward-only backend (Gateway) ends at
   now + ReverseRawReader.LookAhead instead of DateTime.MaxValue (the gateway
   only checks start <= end, so a year-9999 end reached AVEVA); a bound beyond
   the horizon is not found without a read. SqlServer/QuestDB keep MaxValue:
   they bind it safely and may hold samples further ahead. The Gateway fake now
   refuses far-future ends, so the missing-end-bound test is no longer vacuous.
3. No data and no bound found (whole read empty) => GoodNoData, no values —
   the pre-bounds answer (6.5.3.2: success only if one or both bounds exist).
   A one-value page whose start bound is missing first checks there is data or
   a found end bound.
4. ServerHistorian:HonorReturnBounds (default true); false ignores ReturnBounds
   as before #691, including the modified-read Bad_InvalidArgument.
2026-10-12 17:52:34 -04:00