Commit Graph

1120 Commits

Author SHA1 Message Date
Joseph Doherty 7095e128e3 test(historian): shared ProcessedConformance asserts DurationGood/DurationBad on every SQL backend (#840)
#820 served DurationGood/DurationBad (ms) on SqlServer/QuestDB but only
QualityMixConformance asserted them. The shared Processed suite now covers
them like the percentages: hand values over tag A (20000/10000/20000/BadNoData
and 0/10000/0) with statuses + timestamps and Duration = Percent x interval;
the SDK-calculator parity theory (forward/reverse/truncated); outage markers;
the only-Bad interval; string tags; and the time-weighted row cap.
Live: SqlServer.IntegrationTests 211/211 (central SQL), QuestDb.IntegrationTests
249 passed / 1 skipped (shared /opt/otopcua-questdb fixture).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 03:50:12 -05:00
Joseph Doherty 61b9adeb9c Merge branch 'master' into fix/832-docker-dev-batch 2026-11-11 02:32:42 -05:00
Joseph Doherty e9064dbdf4 Merge fix/815-credential-ref-scoping
# Conflicts:
#	.claude/rules/drivers.md
#	docs/drivers/Heidenhain.md
2026-11-11 02:08:24 -05:00
Joseph Doherty 4563940792 fix(security): warn-only compatibility for deployed drivers + upgrade preflight (#815)
User decision: save, Test Connect, live browse and the deploy gate keep
refusing out-of-namespace credential references, but an ALREADY-DEPLOYED
driver must not fault at the upgrade restart or (Galaxy) at the next
gateway reconnect, since ReopenAsync re-resolves Gateway.ApiKeySecretRef.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 02:07:33 -05:00
Joseph Doherty 0856261ef8 docs(mtconnect): record the #823 end-to-end gate (adapter stop, Agent restart/stop/pause, genuine overrun) 2026-11-11 01:57:21 -05:00
Joseph Doherty d3285af6b3 Merge feat/830-heidenhain-followups 2026-11-11 01:55:10 -05:00
Joseph Doherty 97991e0476 test(heidenhain): wait for the whole outage-invalidation pass before asserting on it (pre-existing race)
Comms_loss_invalidates_every_remote_value_and_recovery_republishes_identical_values failed
1/40 on base 5bbe1e3c (and 1/29 on this branch): "Capabilities/Identity should be
CommunicationLost but was Supported". RetireAll -> Invalidate publishes every tag in authored
order under one lock, capability LAST, and the recorder sees each publish as it happens; the
test asserted the capability the instant the five value tags read BadNoCommunication, i.e.
before the pass's final publish. Now the wait includes the capability's CommunicationLost
(a presence the pass must produce) and additionally asserts no value went Good again during
the outage.

Second latent race fixed in the same test: Degraded holds through Backoff AND Connecting, but
the host status is Stopped only in Backoff (Unknown while a reconnect attempt is in flight),
so the test now waits for Degraded && Stopped instead of sampling whichever phase it hit.

After the fix: 79/80 filtered runs passed and 10/10 full-suite runs; the one non-pass was a vstest
"failed to connect to testhost after 90 s" host-startup timeout (the test never ran), not a test result.
2026-11-11 01:54:53 -05:00
Joseph Doherty 598c668108 Merge fix/816-opcuaclient-cert-host 2026-11-11 01:53:35 -05:00
Joseph Doherty 9aff90f884 fix(opcuaclient): never pass checkDomain for a None endpoint; document both SDK check sites (#816 review)
SDK 2.0.1 checks the server host name in TWO places: Session.OpenAsync (after
the chain is trusted) and DefaultSessionFactory.CreateChannelAsync, against the
certificate in the GetEndpoints reply and before any security-mode test. opc-plc
sends a certificate on its None endpoint, so `connect -u
opc.tcp://10.100.0.35:50000 --validate-host-name` was refused on an
unauthenticated channel, where a host check proves nothing.

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

Docs: name both check sites; a zone-qualified IPv6 host never matches (FindDomain
compares the zone too), so link-local needs validateServerHostName=false; the
match is loose (first label alone, subject DC= parts count, no wildcards), which
caveats the CA recommendation.
2026-11-11 01:53:01 -05:00
Joseph Doherty 8a6f0e3c84 fix(mtconnect): the Agent refuses count ABOVE bufferSize, not at it (#825)
Live gate against the cppagent 2.7 fixture: count=16384 with bufferSize
16384 streams; 16385 is OUT_OF_RANGE ('count' must be less than 16385).
Warn only when SampleCount > bufferSize, pin both sides of the edge
against the live Agent, and correct the #804 wording that said 'at or
above'.
2026-11-11 01:38:48 -05:00
Joseph Doherty 2b0eed1a8f feat(heidenhain): controller alarms as native Part 9 conditions; rediscovery on identity change (#831)
- ALARM: selectors (HeidenhainAlarmSelector, Contracts): ALARM:*, ALARM:OTHER,
  ALARM:CLASS=<r>[/GROUP=<r>][/NUMBER=<r>] (<r> = n | lo..hi; negative numbers allowed).
  An ALARM: address and the cross-driver alarm object only work together — the factory
  refuses either alone, so editor, save path, CSV and deploy inspector agree. Data type
  fixed Boolean; never a value (ReadAsync BadNotSupported), never in a data subscription.
- HeidenhainDriver implements IAlarmSource: one condition per ALARM: tag at its RawPath,
  driven by the SAME allowlisted R_RI 27/28 walk on the control's single loop, on the new
  Polling.AlarmMs cadence (default 2000 ms, only for controls carrying an ALARM: tag).
  FOCAS #647 transition rules (Raise / Retrigger / Clear), identity = class+group+number+text,
  deterministic display order, told-record on the driver instance, recorded only while a
  handler listens. A failed walk (refusal, overflow, comms loss) is quality-only, never a clear.
- Acknowledge is LOCAL-ONLY: AcknowledgeAsync is a no-op success (FOCAS/MTConnect posture);
  Lsv2ReadOnlyPolicy is unchanged — no ack or write request exists.
- Opt-in per tag, no driver flag (FOCAS #647 / MTConnect #626 precedent); Alarms/* arrays unchanged.
- IRediscoverable: a reconnect whose R_VR identity (model / NC / PLC / options) differs from
  the previous session raises OnRediscoveryNeeded once (advisory re-browse chip). No extra traffic.
- AdminUI: Heidenhain joins NativeAlarmModel's alarm drivers; the typed editor fixes ALARM:
  tags at Boolean and seeds/drops the alarm object with the selector; driver form gains
  "Alarm conditions (ms)". (HeidenhainDriverFormModel also carries the #830 credential-notice change.)

Tests: HeidenhainAlarmTests (grammar, pairing, rules, simulator e2e with the captured iTNC 530
alarm record, local-only ack = zero bytes sent, quality-only outage/refusal, rediscovery),
Runtime.Tests HeidenhainNativeAlarmEndToEndTests (real DriverHostActor condition), a
DraftValidator case, and the NativeAlarmDriverSetGuard now scans the Heidenhain driver.
2026-11-11 01:37:41 -05:00
Joseph Doherty ae91e6b7f7 feat(opcuaclient): opt-in server-certificate host-name check; NAT fixture keeps its PKI (#816, #818)
#816 finding: the SDK checks that the server certificate names the host only
when a session is created with checkDomain:true. The driver, the AdminUI
browser and Client.Shared all called the 8-argument
DefaultSessionFactory.CreateAsync, which forwards checkDomain:false — so no
host-name check ever ran (why a trusted NAT cert for *.invalid browsed fine
over an IP in #813). When enabled the SDK (Session.OpenAsync ->
ValidateDomains -> FindDomain) compares ConfiguredEndpoint.EndpointUrl.IdnHost
— the CONFIGURED host, because OpcUaEndpointSelection/EndpointSelector
rewrote the advertised one — against the cert's DNS/IP SANs; secured
sessions only, and the auto-accept handler accepts a mismatch.

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

#818: the NAT fixture's /app/pki is on the opc-plc-nat-pki volume (verified:
same thumbprint across --force-recreate), its PUBLIC cert is committed as
Docker/nat/opc-plc-nat-server.der, and NatPublishedEndpointBrowseTests run
every secure browse on a throwaway PKI (internal browser seam): refusal with
nothing trusted, positive with the cert trusted (unset/false host check), and
a BadCertificateHostNameInvalid refusal with the check on. The trusted cert is
OPCUA_NAT_SERVER_CERT or the committed one (skip on thumbprint mismatch);
OPCUA_NAT_SERVER_CERT_TRUSTED is retired. CI exports its throwaway NAT
container's public cert. ServerHostNameValidationTests pin the driver against
the plain opc-plc. MIN_EXECUTED not raised (needs a hosted run): +6 in
v2-integration (440 -> 446), documented in scripts/ci/README.md.
2026-11-11 01:33:00 -05:00
Joseph Doherty bde6cce2fc test(infra): plain opc-plc fixture healthcheck uses bash /dev/tcp (#817)
The opc-plc image ships no netstat, so the old check left otopcua-opc-plc
permanently unhealthy. Same probe as the NAT fixture (27302314). Redeployed
to /opt/otopcua-opcuaclient on the shared docker host; reports healthy.
2026-11-11 01:32:38 -05:00
Joseph Doherty 8e8408a9ca fix(opcuaclient): keep the configured IPv6 zone index through the endpoint rewrite (#819)
.NET's Uri parses a zone ([fe80::1%eth0]) but drops it from Host, Authority,
AbsoluteUri and ToString(); only DnsSafeHost/IdnHost carry it. The shared
OpcUaEndpointUrlRewriter rebuilt the URL through UriBuilder, so the zone was
lost and link-local endpoints were unroutable. The SDK dials
new Uri(EndpointUrl).IdnHost (TcpByteTransport -> DnsEndPoint), which keeps
it, so splicing the zone back into the string is enough. Kept verbatim
(%eth0, %4, or RFC 6874 %25eth0) so the session dials exactly what
GetEndpoints reached. Applies to the driver, the AdminUI browser and the
Client CLI/UI (all share the rewriter).
2026-11-11 01:32:38 -05:00
Joseph Doherty 1a4d46a702 docs(historian)+infra: shared QuestDB fixture, correct SQL test credential, DurationGood/Bad + #821 docs (#822, #820, #821)
#822: HISTORIAN_SQL_TEST_CONNECTION example now targets the shared host
10.100.0.35,14330 with its committed dev sa password (OtOpcUaDev_2026!); the
docker-dev rig's own localhost,14330 password (OtOpcUa!Dev123) is called out as
a different instance. New labelled (project: lmxopcua) QuestDB fixture stack
tests/.../Historian.QuestDb.IntegrationTests/Docker/docker-compose.yml
(questdb/questdb:10.0.1, PG wire :8812, console :19000), deployed to
/opt/otopcua-questdb on the Docker host; documented in infra/README.md §2a,
docs/Historian.md, CLAUDE.md endpoints and the historian rules.
Also corrects infra/README's stale "only MQTT/NAT carry the label" note.

Docs for #820 (nineteen served aggregates, DurationGood/DurationBad in ms,
Gateway still refuses) and the #821 facts (Part 13 vs frozen live vs stock
SDK 2.0.1, both directions; decision open with #809).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 01:29:01 -05:00
Joseph Doherty a224fd6391 fix(security): scope operator-authored credential references per driver (#815)
A Designer could point a Galaxy / OpcUaClient endpoint at a host they control,
set a credential field to any reference the node can resolve
(env:ConnectionStrings__ConfigDb, env:ServerHistorian__ApiKey, secret:<any>,
Galaxy file:<any path>) and click Test Connect / live browse; the node resolved
the value and sent it to that host.

One shared rule in Core.Abstractions (CredentialReferences +
DriverCredentialPolicy) generalises the #803 Heidenhain namespace:
- Galaxy: secret:galaxy/... | secret:otopcua/galaxy/... | env:OTOPCUA_GALAXY_... |
  legacy env:GALAXY_MXGW_...; file: refused; dev:/literal API key unchanged.
- OpcUaClient: secret:opcuaclient/... | env:OTOPCUA_OPCUACLIENT_...; literals unchanged.
- Heidenhain adopts the shared parser/resolver (namespace unchanged).
Enforced at the AdminUI save path (DriverConfigSaveGate), the deploy gate
(CredentialReferenceDeployChecks, code CredentialReferenceOutOfScope), Test
Connect (AdminOperationsActor, before any probe), live browse
(BrowserSessionService, before any browser) and again at resolve time in every
driver/browser (fail closed, before any store/env read).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 01:28:58 -05:00
Joseph Doherty e7b485c0a2 feat(historian): serve DurationGood/DurationBad on the SQL backends; pin the #821 late-start facts (#820, #821)
#820: SqlServer + QuestDB now serve Part 13 DurationGood / DurationBad from the
same Good / Bad region totals TimeWeightedAggregator already computes for
PercentGood / PercentBad — reported in milliseconds (OPC UA Duration, the SDK's
unit), Uncertain time in neither (Mantis 0006166), same BadNoData / status /
Partial rules. AggregateFamilies.Served grows 17 -> 19, so i=11201 and the
up-front refusal follow automatically; Gateway still refuses both (explicit
served set, unchanged). Client.Shared AggregateType / mapper / CLI gain both.
The #796 oracle answers DurationGood through the Good-only subclass as well.
QualityMix conformance asserts both directly (Q and QP, hand values, ms, and
= PercentGood x interval), replaces the "refused up front" test, and adds
Duration* to the forward/reverse/truncated oracle comparisons.

#821 (facts only, no decision): SdkAggregateParityTests pins an Uncertain-first
late-start interval (QP over [0,20), both directions) against ours (Part 13
Total 163.96, TimeAverage 10.93), the frozen 1.5.378.182 calculators (188.96,
9.448), SDK 2.0.1's stock calculators (188.96, 9.448) and a Good-first control
(all 163.96); LiveAggregateLateStartFactsTests pins the same through the booted
server's runtime AggregateManager (live AggregateFilter) incl. DurationGood
15000 / PercentGood 75 there vs 8000 / 40 in HistoryRead (#809). QualityMix
adds the reverse late-start variant (reverse TW pin + reverse oracle windows).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 01:28:54 -05:00
Joseph Doherty f8f6781a24 feat(mtconnect): warn at Test Connect and deploy when SampleCount >= the Agent bufferSize (#825)
Parse the /probe Header bufferSize; MTConnectDriverProbe reads sampleCount
(default 1000) and answers Ok with a WARNING message (amber chip in the
Test Connect button); InitializeAsync logs the same Warning and exposes
agentBufferSize on the diagnostics bag. Live-checked against the shared
cppagent fixture (bufferSize 16384).
2026-11-11 01:23:33 -05:00
Joseph Doherty 7af9c40e41 Merge feat/803-heidenhain-lsv2 2026-11-11 00:03:43 -05:00
Joseph Doherty f7a3588b24 fix(heidenhain): namespaced credentials, optional-role credential isolation, loop robustness (#803 review)
- Credential exfiltration (review 2): password references are scoped to
  secret:heidenhain/<name> and env:OTOPCUA_HEIDENHAIN_<NAME> only. Enforced by
  HeidenhainOptionsParser.TryParseCredentialReference (parser, probe, deploy gate) AND again at
  resolve time in HeidenhainCredentials (fail closed without consulting the store or the
  environment), so Test Connect can no longer send env:ConnectionStrings__ConfigDb or another
  subsystem's secret to an arbitrary Host. The probe also refuses an unsendable password.
- Optional-role credentials (review 6): an unresolvable/NUL/non-Latin-1/>247-byte DNC or DATA
  credential never sends a login and never fails the session; the reads needing that role are
  BadConfigurationError (capability ConfigurationError) and INSPECT telemetry continues. A
  policy refusal inside AcquireAsync is per-tag, not a session teardown.
- Start after DisposeAsync is a no-op (review 3); initial subscription push and Publish are
  ordered under one lock (review 4); JSON-null sections are normalised and the acquisition loop
  catches every fault (review 5); backoff resets only after a session published a poll batch,
  with clamped delays (review 7).

Tests: HeidenhainReviewFixTests + namespace/normalisation tests in HeidenhainConfigTests;
each was confirmed red against the reverted fix.
2026-11-10 23:49:48 -05:00
Joseph Doherty 8a0f0df05f Merge feat/804-mtconnect-parity 2026-11-10 23:24:27 -05:00
Joseph Doherty 418d0bbb87 Merge fix/810-uncertain-conformance 2026-11-10 23:24:27 -05:00
Joseph Doherty d6392dae17 fix(mtconnect): close Java-driver parity gaps found by the #804 review
- Lift an Agent's MTConnectError body under a non-2xx status (cppagent 2.x serves
  OUT_OF_RANGE as HTTP 400, NO_DEVICE as 404) into the refusal-marked
  InvalidDataException, reading at most 64 KiB. The 400 used to be a transport
  fault, so the OUT_OF_RANGE re-baseline never ran against a real 2.x Agent.
- Run the instanceId restart test on every re-baseline snapshot: on 2.x a restart
  is first seen there (the old cursor gets a 400), and was adopted silently.
- Report the stream Degraded after 3 consecutive re-baselines with no /sample
  chunk (it read Healthy for ever); LastSuccessfulRead keeps advancing.
- Prohibit DTDs on the /probe and write-answer parses; bound the streams walk to
  64 levels (unbounded recursion = uncatchable StackOverflowException).
- Skip structural elements under <Components>/<Devices> instead of failing the probe.
- Never serve a non-finite float as Good; serve integer-valued decimals on
  integral tags losslessly (a real fraction stays BadOutOfRange).
- Merge sibling browse containers that resolve to one folder name.

Unit 678 -> 714, integration 18 -> 20 (live fixture); every gap test failed first.
2026-11-10 23:08:42 -05:00
Joseph Doherty d50ee809e3 feat(heidenhain): native read-only LSV2 driver + in-process simulator (#803)
Native LSV2 TCP transport (default port 19000; no Java/Python/vendor SDK), ported from
the ignitionoee v0.20.3 reference driver:

- Lsv2ReadOnlyPolicy: payload-aware outbound allowlist (A_LG INSPECT/DNC/DATA, R_VR, R_PR,
  R_RI 22-28/30-34/51, bounded R_MB, R_DP), applied in Lsv2Codec.Frame before any byte is
  written; no public raw-command API.
- Lsv2Client: serialized transactions, one monotonic deadline per transaction, EOF/oversize/
  unexpected/trickle close the client; Close never waits on the gate.
- Lsv2Data / HeidenhainBindingCodec: reference decoders, R_PR byte-capacity + element-index
  rules, next-area cap, DATA Boolean 00/01/FF, strict non-truncating coercion.
- HeidenhainSession: per-batch wire cache, refusal suppression, provisional DNC denial retry,
  alarm cursor (57 = end, overflow = BadOutOfRange), capability strings.
- HeidenhainDeviceRuntime: one loop per control, generation-guarded publication, all-node
  BadNoCommunication on comms loss, bounded exponential reconnect.
- HeidenhainDriver: cached ReadAsync, push subscriptions deduped on (value,status), static
  catalog discovery (no connect), host status, diagnostics, skipped-tag report. No IWritable.
- Probe (INSPECT login + version) and factory registered in DriverFactoryBootstrap (probe on
  admin nodes too); DriverTypeNames.Heidenhain.

Tests: 159 unit tests against an in-process LSV2 simulator incl. the Java fixtures' captured
iTNC 530 replies as golden vectors; env-gated LiveIntegration category for real hardware.
2026-11-10 23:08:30 -05:00
Joseph Doherty 87b0c18fb3 test(historian): Uncertain/partial-data conformance datasets for PercentGood/PercentBad (#810)
QualityMixConformance (SqlServer + QuestDB): tag Q mixes Good/Uncertain/Bad
regions across eight 10 s intervals (two all-Uncertain - Mantis 0006166 0%/0% -
one through an Uncertain bound, plus Uncertain bounds from a sloped non-Good end
and from a hold into an outage); tag QP has data starting and ending inside
intervals. PercentGood/PercentBad are asserted against hand-derived Part 13
DurationGood/DurationBad, the oracle is checked against the same hand values,
and Count/Average/TimeAverage(2)/Total(2)/Start/End/Delta/Interpolative are
compared with the #796 oracle forward, reverse and truncated.

Teeth: with SdkAggregateOracle.Run answering PercentGood from the baseline
(Uncertain as Good), 8 tests fail per backend; all pass with the fixed oracle.

Records a new SDK divergence: with an Uncertain first sample the baseline
TimeAverage/Total integrate the pre-data span as a ramp from 0; ours covers
only the data (Part 13) - pinned by hand.
2026-11-10 22:58:09 -05:00
Joseph Doherty 273023144b test(infra): NAT fixture healthcheck uses bash /dev/tcp — the opc-plc image has no netstat (#813) 2026-11-10 22:54:00 -05:00
Joseph Doherty ac1b33555a test(infra): port-NAT opc-plc fixture for the NAT browse tests; wire OPCUA_NAT_ENDPOINT into CI (#813)
Docker/nat/docker-compose.yml (stack /opt/otopcua-opcuaclient-nat on 10.100.0.35,
project: lmxopcua label): opc-plc 2.14.10 advertising --ph=otopcua-opc-plc-nat-private.invalid
on internal 50000, published on 50010, so every advertised URL is unreachable on host AND
port. Deployed; NatPublishedEndpointBrowseTests 3/3 against opc.tcp://10.100.0.35:50010
(and the secure case positive once, with the fixture cert temporarily trusted).

CI: integration-fixtures.sh starts a throwaway otopcua-ci-opcplc-nat (50110->50000) and
exports OPCUA_NAT_ENDPOINT; v2-e2e.yml points it at the shared stack. infra/README.md,
scripts/ci/README.md and the fixture README document it.
2026-11-10 22:53:01 -05:00
Joseph Doherty d52a3b1c64 fix(client): Client.Shared EndpointSelector uses the shared OpcUaEndpointUrlRewriter (#812)
Client.Shared now project-references Driver.OpcUaClient.Contracts (Core.Abstractions
only, no NuGet/SDK; pinned by ContractsIsDependencyLightTests) and delegates the
host/port rewrite to OpcUaEndpointUrlRewriter, deleting its own copy.

The two copies differed; reconciled toward the client so its pinned behaviour is
unchanged (EndpointSelectorRewriteParityTests, written green against the old copy):
- no-op rewrite (same host case-insensitively + same port) now returns the advertised
  string verbatim in the shared rewriter too (it re-serialised: added a trailing slash
  and case-folded a case-only host difference);
- a real rewrite serialises via AbsoluteUri, not Uri.ToString(), which un-escaped the
  advertised path (/x%20y became /x y) in the driver + browser;
- unusable URLs: the rewriter still falls back to the configured URL (driver/browser);
  the client keeps its loud failure as call-site validation (null -> InvalidOperation,
  unparseable -> UriFormatException).
2026-11-10 22:46:51 -05:00
Joseph Doherty 8013300f64 Merge fix/806-browser-nat-endpoint 2026-11-10 21:24:39 -05:00
Joseph Doherty bd6d3fc2c3 test(historian): Part 13 PercentGood oracle + pin the 1.5.378.182 divergence (#796)
The 1.5.378.182 StatusAggregateCalculator (vendored as the application's
compatibility baseline) counts Uncertain regions as Good time under
TreatUncertainAsBad = false; 1.5.378.106 counted Good regions only, as Part 13
§5.4.3.31/§5.4.3.33 and Mantis 0006166 require. Our evaluator follows Part 13,
so the oracle moved, not the server: 10 SdkAggregateParityTests failed.

SdkAggregateOracle.Run now answers PercentGood through a subclass of the
baseline StatusAggregateCalculator that counts Good regions only (same bounds,
regions, status/Partial bits and configuration as PercentBad). RunSdkAsIs keeps
the untouched baseline. Rejected the proposed 100 - PercentBad under
TreatUncertainAsBad = true: that flag also changes the region machinery (the
end-of-data 1 ms rule), so it computes over a different total; pinned by a test.

New tests pin the divergence on hand-computed G/U/B/G (50 vs baseline 75) and
all-Uncertain (0/0 vs baseline 100) intervals, forward and reverse, plus a
data-starts-inside interval where oracle == baseline.

Sql.Common.Tests 265/275 -> 280/280.
2026-11-10 21:04:27 -05:00
Joseph Doherty 378962eeb1 fix(opcuaclient-browser): keep configured host/port behind NAT (#806)
The AdminUI OPC UA browse picker connected to the EndpointUrl the server
advertised in GetEndpoints verbatim, so behind NAT / a container port
publish it dialled the server's private hostname + internal port and
failed DNS where the runtime driver (which rewrites per #676) connected.

- Move the rewrite to the shared OpcUaEndpointUrlRewriter (.Contracts,
  pure Uri logic, no SDK dependency) and the policy+mode selection to
  OpcUaEndpointSelection (.Session leaf). Driver and browser both select
  through it, so the browse connects exactly where the driver does.
- Policy/mode filter, server certificate and strict browse PKI
  (no auto-accept) are unchanged.
- Unit tests: rewrite (host/port differ, same, IPv6 both sides, path
  kept, port-less, scheme mismatch untouched), browser selection seam.
- Live NAT tests (Fixture=opc-plc-nat, OPCUA_NAT_ENDPOINT): browse via a
  published port with a premise check that the advertised host:port
  differs and does not resolve, nsu= round-trip, unreachable endpoint
  fails cleanly, SignAndEncrypt reaches the server and is refused
  BadCertificateUntrusted. Verified red without the rewrite.

Refs #806
2026-11-10 21:00:04 -05:00
Joseph Doherty 67bc61236a Merge remote-tracking branch 'origin/master' into codex/opcua-sdk-2 2026-11-10 14:40:51 -05:00
Joseph Doherty 230cef4ef0 build: adopt HistorianGateway 1.0 TLS options
v2-ci / unit-tests (push) Has been cancelled
2026-11-10 14:40:30 -05:00
Joseph Doherty a76fca09f6 test: converge historian and runtime fixtures on SDK 2.0 2026-11-10 11:52:09 -05:00
Joseph Doherty ab25bd5318 test: preserve historian baseline oracles under SDK 2.0 2026-11-10 11:43:59 -05:00
Joseph Doherty 96fd67695f test: verify SDK 2 driver boundaries and lifecycle contracts 2026-11-10 10:23:28 -05:00
Joseph Doherty 83ed880e44 build: bump xunit.v3 to 3.2.2 repo-wide, drop the four VersionOverrides (#795)
v2-ci / unit-tests (push) Has been cancelled
Central pin 1.1.0 -> 3.2.2; AdminUI/ControlPlane/Runtime/Host.Tests no longer carry VersionOverride.
The three driver IntegrationTests fixtures' Fact/Theory attributes gain the source-info ctor (xUnit3003).
No xUnit1051 fallout remained (already cleaned). Test counts unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 09:31:31 -05:00
Joseph Doherty 0796928504 feat(scripting): batch submit, exact per-item deadlines, scripted-alarm seam on the out-of-process runner (lmxopcua#780)
v2-ci / unit-tests (push) Waiting to run
- IScriptRunner.RunBatch: one EvalBatch frame per worker for every evaluation ready to run; per-item, streamed results.
  The default (InProcessScriptRunner) is RunAsync per item. Used by the Calculation pump (dequeue all, split at a
  repeated tag, publish in dequeue order), the VirtualTag seam (ScriptBatchCoalescer, no timer) and the scripted-alarm
  engine (whole dirty set). In process the pump and the engine stay one at a time, unchanged.
- Exact per-item deadlines: the worker hands work to an idle evaluation thread or defers it (EvalResults
  deferred_eval_ids); the host stops that run's clock and restarts T then G when it starts (started_eval_ids). A run
  queued behind a runaway is neither timed out nor struck and is a victim, not the culprit, at the kill.
- Scripted-alarm seam behind Scripting:Runner:Seams; unset Seams now means VirtualTag + Calculation, so alarms run out of
  process only when listed. Load still compiles each predicate (to PE bytes only) so a broken one fails the load; passes
  batch and apply in the serial order under the gate. Script faults freeze the condition BadInternalError; runner faults
  freeze it BadResourceUnavailable and re-evaluate on recovery; a dead worker never clears an alarm. IScriptRunnerProvider
  carries the runner to DriverHostActor.
- Perf harness: batch throughput (RunBatch vs RunAsync x n) and alarm-pass latency (in process / batched / serial);
  design §12.3 records macOS + windev. On windev the serial out-of-process alarm pass misses 250 ms from ~500 alarms;
  batched p99 <= 53 ms at 1,000.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 06:37:36 -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 70050c9d7e test(opcuaclient): slow server-side subscription delete keeps the stale-BadNoSubscription grace running from delete completion (lmxopcua#764)
v2-ci / unit-tests (push) Has been cancelled
Unsubscribe paths now delete through the existing DeleteSubscriptionAsync seam so a test can hold the delete open >grace. Mutation: dropping the post-delete restamp fails both slow-delete tests.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 05:05:18 -05:00
Joseph Doherty f50b29abae feat(scripting): PE cache, quarantine-race fix, recovery, readiness for the out-of-process runner (lmxopcua#780 P1)
v2-ci / unit-tests (push) Failing after 1h12m10s
Second P1 slice of docs/plans/2026-11-09-out-of-process-script-runner-design.md; still opt-in behind
Scripting:Runner=OutOfProcess (InProcess unchanged).

- Compiled-PE cache (design §7, §14 #8): Scripting:Worker:PeCacheDir holds <dir>/pe/<id>.pe entries authenticated
  with HMAC-SHA256 under a per-node random key (<dir>/pe-cache.key, created by the host, 0600/0700 on Unix), handed to
  the worker in Hello. Entry id = SHA-256(compile fingerprint + kind + source); the fingerprint covers the Roslyn
  version, the Core.Scripting build (analyzer, wrapper, stack guard), the sandbox reference set and the runtime. Edited,
  truncated, other-key or renamed entries are rejected, deleted and recompiled; the worker never loads unverified bytes.
  Pruned by recency at startup (30 d / 50 000). ScriptEvaluator splits into CompileToImage + FromImage. Measured on
  40 scripts, restart to every first result: macOS 820 ms no cache / 840 ms empty / 85 ms warm;
  windev (Windows) 9.5 s / 9.4 s / 1.0 s.
- Quarantine race: WorkerConnection runs exit attribution synchronously BEFORE completing the evaluations/loads it held
  (and before Exited), and the shard passes it straight to the runner — a kill strike is in place before the killed
  key's slot is released.
- Supervision: a dead worker restarts by itself after its backoff; Unavailable outcomes carry ScriptRunOutcome.Recovery
  (-> VirtualTagEvalResult.RetryWhen) and the VT actor / Calculation driver re-evaluate once against current inputs when
  it completes. Isolation worker stops after Scripting:Worker:IsolationIdleTimeout (10 min). Keys compiling when a
  worker dies are suspects too (isolation finds a poison compile and quarantines it). Startup orphan sweep by launch
  marker: kills only a process whose marker's host is gone and whose pid + start time + image match.
- Crash-loop circuit (Scripting:Worker:CircuitOpenAfter, 2 min): runner Unavailable, one Error, evaluations answered
  at once while shards keep retrying at the cap.
- Observability (§10): otopcua.script.runner.state, worker.up/inflight/queue_depth/heap_bytes (shard),
  ipc.roundtrip + eval.duration (kind), compile{cache}, isolation.suspects, exits reason=idle. `script-runner` health
  check on the ready tier, at worst Degraded (§14 #12: degraded but still ready).

Left on #780: self-contained worker publish, installer (account, pipe ACL, firewall), docker-dev live drill,
scripted-alarm seam, the §4.5 per-seam batch submit, the §8 perf harness.

Tests: SupervisionTests, PeCacheStoreTests/PeCacheWorkerTests (real workers), ScriptRunnerHealthCheckTests, VT + Calc
recovery seam tests; 14 mutations each turned their test red.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 00:20:24 -05:00
Joseph Doherty 69a6e0b4c8 feat(scripting): out-of-process pre-emptible script runner behind Scripting:Runner (lmxopcua#780 P1)
v2-ci / unit-tests (push) Waiting to run
Opt-in (Scripting:Runner=OutOfProcess; InProcess stays the default and its code path is unchanged). Implements
the P1 slice of docs/plans/2026-11-09-out-of-process-script-runner-design.md with the owner's §14 decisions:

- Options: Scripting:Runner (InProcess | Shadow | OutOfProcess), Scripting:Runner:Seams, Scripting:Worker:*
  (Shards 2, HardGrace max(1 s,T), HeapLimitBytes 512 MiB, QuarantineAfterKills 2, UnavailableGrace 5 s,
  IsolationProbationRuns 20, PeCacheDir validated absolute + outside the content root (fail closed),
  ExecutablePath). Shadow is not implemented yet: runs InProcess with a startup Warning.
- Core.Scripting: IScriptRunner seam + InProcessScriptRunner (today's guard, the parity baseline).
- Core.Scripting.Protocol: protobuf envelopes, 4-byte LE framing (zero/oversize/truncated/garbage refused),
  Variant carrying the exact CLR type; unsupported types are explicit failures.
- ScriptWorker app: compiles in the worker, P-thread scheduler, host-driven soft cancel, fail-fast
  watchdog, heartbeats, ctx.Logger forwarding, exits on pipe EOF / parent gone, forbidden-assembly
  self-check; Akka and Grpc.Core.Api excluded from its folder. Shipped in script-worker/ (Build + Publish).
- Core.Scripting.OutOfProcess: K shards + isolation worker, soft T / hard T+G kill, attribution
  (deadline kill = one key; crash = suspects -> isolation; isolation crash = strike), kill-strike
  quarantine, backoff 250 ms -> 30 s, fail closed, cleared allow-listed env, nonce + pid handshake on a
  current-user pipe, Windows Job Object.
- VirtualTag + Calculation seams use the runner when selected; a runner-side failure (worker restarted /
  runner unavailable) publishes BadResourceUnavailable (0x80040000) — ScadaBridge#112. Script faults keep
  BadInternalError. The scripted-alarm seam stays in process (alarms last).

Tests: new ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests (Category=ScriptWorker, real processes; green on macOS and
on windev/Windows), seam tests in Host/Runtime/Calculation suites; mutation-checked.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 23:33:08 -05:00
Joseph Doherty f5c30ed0ef feat(sql): authored write block — Update/Insert/Upsert/Procedure + timestamp stamp; ODBC scope + login docs (lmxopcua#781)
v2-ci / unit-tests (push) Has been cancelled
Item 2: a tag's optional `write` block picks the statement the driver builds from its own
catalog-validated identifiers (no authored SQL; values always bound):
- Update (default, unchanged back-compat UPDATE ... WHERE key; 0 rows = BadNoData)
- Insert (key+value[+stamp]; append for a WideRow topByTimestamp tag)
- Upsert: SQL Server MERGE WITH (HOLDLOCK), PostgreSQL INSERT ... ON CONFLICT, MySQL
  ON DUPLICATE KEY UPDATE (1-or-2 rows), Oracle MERGE from DUAL
- Procedure: named stored procedure resolved against the routine catalog by the catalog gate
  (INFORMATION_SCHEMA.ROUTINES / ALL_PROCEDURES), value/key/timestamp params by name (MySQL positional)
- stampTimestamp sets the tag's timestamp column to the write time (UTC)
Odbc serves Update/Insert only (shared SqlWriteSupport table). Constraint failures map to
BadEntryExists (unique) / BadOutOfRange (other integrity, data exception); everything else,
permission denied included, stays BadCommunicationError. WriteIdempotent stays false.
The parser refuses malformed blocks (strict keys/enums/param charset); DraftValidator emits
blocking SqlWriteBlockInvalid / SqlWriteModeUnsupported from the driver's own parse; the AdminUI
editor surfaces the same message.

Items 1 + 4 (docs/drivers/Sql.md): ODBC stays the ANSI SQL:2008 profile (native providers for
SQL Server/MySQL, DB2 unsupported) with an ops note on driver manager + vendor driver on every
Odbc and AdminUI host; read-only drivers require a SELECT-only login, writable drivers a login
limited to their write targets.

Tests: SqlWriteModeTests (parse, every dialect's SQL, gate, status map, SQLite end-to-end),
DraftValidatorSqlWriteBlockTests, AdminUI round-trip; live SqlWriteModesLiveTests on SQL Server,
PostgreSQL, MySQL, Oracle (seeds gain write_targets + set_target) and an Odbc write leg.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 22:59:09 -05:00
Joseph Doherty b0ef23c087 test(mqtt): Sparkplug array metrics in the simulator + live gate (lmxopcua#770)
The simulator now packs Int32/Boolean/Float/Double/String arrays per the
Sparkplug B spec (independent of the driver codec) and seeds Profile, Flags
and Curve in the standalone catalog. SparkplugLiveTests gains a live test that
asserts typed int[]/bool[]/float[] values from the birth, a changed vector
(including a different Boolean length) from alias-only DATA, the read path,
and BadTypeMismatch for an array metric bound without isArray. Run against a
throwaway local Mosquitto; mutation-checked (scalar binding / seed removed).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 18:15:13 -05:00
Joseph Doherty 2331f7ed14 feat(sql): opt-in writes — allowWrites + per-tag writable, single-row parameterised UPDATE (lmxopcua#753)
v2-ci / unit-tests (push) Waiting to run
allowWrites is no longer inert: SqlDriver implements IWritable, default off.

- Gates (outermost first): node Writable + WriteOperate (server), driver
  allowWrites, the tag's own TagConfig "writable": true (strict JSON boolean;
  anything else refuses the tag, and the deploy gate says why), and a model that
  names exactly one row — KeyValue, or WideRow by where-pair. topByTimestamp and
  Query are BadNotWritable.
- SqlWritePlanner: the only statement a write can become is
  UPDATE <table> SET <value col> = @v WHERE <key col> = @k, built from the
  catalog-gate-canonical identifiers (dialect-quoted) with value and key bound;
  value-before-key marker order so positional ODBC binds correctly. No authored
  write SQL, INSERT, UPSERT or procedure.
- Executed in a transaction that commits only when exactly one row was touched:
  0 rows -> BadNoData (never inserts), >1 -> rolled back, BadConfigurationError,
  unknown count -> rolled back, BadNotSupported. Bounded by operationTimeout;
  provider errors -> BadCommunicationError; health untouched.
- Discovery advertises Operate only where a write could succeed.
- AdminUI: driver form's Allow writes switch is live; tag editor gains a
  per-tag write switch; model validates writable strictly (parity corpus).
- Factory no longer warns "allowWrites ignored"; it logs whether writes are on.
- Tests: SqlWriteTests (planner SQL/bind order/injection/coercion, every gate,
  readback, absent key, two-row rollback, gate-rejected tag, discovery);
  MySqlReadTests write legs against InnoDB (keyed row only, two-row rollback,
  server-side grant refusal, default-off). Seed adds a setpoints table and the
  one UPDATE grant.

(lmxopcua#753)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:30:31 -05:00
Joseph Doherty 3c9ab4b668 feat(sql): Odbc provider — ANSI SQL:2008 profile over System.Data.Odbc (lmxopcua#753)
SqlProvider.Odbc is now constructed; every enum member is a working backend.

- OdbcDialect: System.Data.Odbc 10.0.14 (no vulnerable packages), ANSI double-
  quote quoting, positional `?` markers, INFORMATION_SCHEMA catalog (system
  schemas of the common backends excluded by name — the live gate caught
  psqlODBC listing pg_catalog), FETCH FIRST n ROWS ONLY limits, a conservative
  1000-parameter chunk. Positional binding is safe because every caller adds
  parameters in marker order (planner IN list, catalog schema-then-table).
- Named queries on a positional provider are checked by `?` count against the
  declared params (literals/comments ignored), since there is no name to match.
- Supported backends are those whose SQL accepts that profile (PostgreSQL via
  psqlODBC proven, Snowflake-like); SQL Server/MySQL/DB2 should use their native
  provider. The host needs the ODBC driver manager + vendor driver.
- Reserved-provider controls in factory/probe/browser tests now use an
  undeclared ordinal (99); the AdminUI form offers Odbc and a test pins the
  dropdown theory to SqlDialects.Supported.
- Tests: OdbcDialectTests (unit, any host). OdbcReadTests + Docker/odbc/
  run-odbc-gate.sh run the built suite in a throwaway Linux SDK container with
  unixODBC + psqlODBC against a throwaway seeded PostgreSQL: KV IN-list
  positional binding, where-pair, topByTimestamp via FETCH FIRST, a two-
  parameter named query, and the browse walk. macOS has no vendor ODBC driver,
  so there the suite skips.

(lmxopcua#753)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:30:31 -05:00
Joseph Doherty 63372e757a feat(sql): MySql provider over MySqlConnector, one shared provider map (lmxopcua#753)
SqlProvider.MySql is now a constructed backend instead of a reserved name.

- MySqlDialect (Driver.Sql.Dialect): MySqlConnector 2.6.2 (MIT, fully managed,
  no vulnerable packages), backtick quoting with MySQL's 64-char / no-trailing-
  space rules, information_schema catalog SQL with the database as the schema
  (system databases excluded, aliases pinned), trailing LIMIT, a type map that
  also reads the reader-side spellings (INT UNSIGNED -> UInt32, BOOL -> Boolean).
- SqlDialects: the one provider -> dialect map. The factory, the Test Connect
  probe and the browse picker each carried their own switch (and drifted once);
  they now all read this, and their refusals list the supported/reserved sets
  from it.
- AdminUI driver form offers MySql.
- Tests: MySqlDialectTests (golden quoting/catalog/type map/planner SQL); the
  provider-gate tests in factory, probe, browser and form now treat MySql as
  supported and Odbc as the reserved control. MySqlReadTests + Docker/mysql
  (throwaway mysql:8.4, SELECT-only login): liveness, KV/wide-row/topByTimestamp,
  INT keys vs authored text, case folding against case-sensitive table names,
  catalog rejection, browse walk, factory+probe, named query with a bound
  parameter, and a writing stored function whose effect the rolled-back
  transaction discards.

(lmxopcua#753)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:30:31 -05:00
Joseph Doherty a68593e714 feat(sql): named-query tag model — read-only SELECTs declared on the driver (lmxopcua#753)
SqlTagModel.Query (design §5.4) is now served end to end instead of refused.
The driver config declares `queries` (name -> sql, params, maxRows); a tag names
the query, the result `column` it reads and optionally a `rowKey` picking its row.

- Parser/deploy gate: query + column required, rowKey all-or-nothing; Inspect
  explains each refusal. Table is not read for this model.
- SqlReadOnlyStatement: single SELECT/WITH only, no data/schema-changing keyword
  outside literals/comments (refuses SELECT INTO, FOR UPDATE, DML CTEs). Shared by
  the factory and the AdminUI driver form (SqlNamedQueryRules.Describe), so the
  form refuses what the driver refuses. One bad entry fails the driver.
- Parameters are always bound; the factory refuses a declared parameter whose
  dialect marker the statement never uses.
- Reader: one execution per query per poll, inside a transaction that is always
  rolled back; result columns matched exact-then-unique-CI; no row -> BadNoData,
  ambiguous row / missing column / over maxRows -> BadConfigurationError.
- Catalog gate passes Query tags through (nothing is quoted into SQL); a
  Query-only driver skips the catalog load. A tag naming an undeclared query is
  skipped with the reason on /hosts.
- AdminUI: typed editor offers Query (query/column/rowKey); driver form edits
  `queries` as JSON and keeps the last valid set on a typo.

(lmxopcua#753)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:30:31 -05:00
Joseph Doherty 0702b31029 fix(alarms): refuse a keyed native ack with no Galaxy identity synchronously, from a cached key set (lmxopcua#765)
v2-ci / unit-tests (push) Waiting to run
Item 3 of #765. A keyed (API-key) session whose key had no Galaxy ack identity
got Good and then a revert, because whether the key has an identity lives in
the Secrets store, which must not be read under the SDK lock.

GalaxyAckIdentityDirectory (Driver.Galaxy) keeps a node-wide set of the key ids
that have a galaxy-ack-user secret. It lists this node's Secrets store
(metadata only, nothing decrypted) and is read lock-free.
GalaxyDriver.GetAcknowledgeRefusal (IAlarmAcknowledgePreflight, published
through NativeAlarmAckPreflightView as in #760) now refuses a keyed session
whose key the directory reports Absent, when the driver has no fallback ack
account. A refusal also asks for a refresh.

Fails open: before the first refresh, once the snapshot is older than
StaleAfter, and from a signalled identity-secret change until the next
refresh, the lookup answers Unknown and the ack takes the old Good-then-revert
path.

Refresh: GalaxyAckIdentityRefreshService runs on driver nodes, every
Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds (default 30;
StaleAfterSeconds 0 = three intervals; validated at start). It also refreshes
early on secret changes. The Secrets library has no change event, so
SecretsRegistration decorates ISecretCacheInvalidator, which is called by
AdminUI /api-keys, /admin/secrets and the replication appliers. API-key
changes need no event: a revoked key is already denied before the pre-check.

Tests: Galaxy.Tests GalaxyAckIdentityDirectoryTests (16) and Host.Tests
GalaxyAckIdentityCacheWiringTests (9). Seven separate mutations each turn at
least one test red: pre-check ignores the directory, factory drops it, no
invalidator decoration, no staleness check, no change-pending check, no
refresh on refusal, loop never wakes early.
Docs: Galaxy.md, AlarmTracking.md, Configuration.md, .claude/rules/alarms.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 16:38:57 -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