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'.
The soft deadline T was armed on the host at submit, so the pipe round trip
and the time a starved worker waits to be scheduled counted against the
script: under load a trivial script reported 'script timed out', in Shadow
mode that was a script mismatch, and at T + G the host killed the worker and
struck the key.
- Protocol (additive, negotiated): Hello/Ready.worker_soft_clock and
EvalResults.soft_expired_eval_ids. protocol_version stays 1; a worker that
does not echo the flag keeps the pre-#827 host clock.
- Worker: the watchdog runs each run's soft clock from the moment an
evaluation thread starts it and reports the crossing (woken early for
budgets shorter than its 50 ms poll). It still never cancels by itself;
its fail-fast is anchored at the soft notice + G + margin.
- Host (PendingEval): the soft notice answers the caller and arms the kill G
later. The host's own timer becomes a transport deadline, T + G from submit
(restarted at a deferred run's start); passing it with no result and no
notice yields TransportTimedOut -> Unavailable 'script worker unresponsive'
(BadResourceUnavailable, recovery = the run's completion), no strike, no
kill, no Cancel. A re-trigger refused by that still-unanswered run is
Unavailable too, not 'previous run still running'. Counted as
otopcua.script.ipc.timeouts{kind}.
- Frame writer: coalescing now carries soft_expired_eval_ids (it silently
dropped them); the host processes soft notices before results in a frame.
- Shadow: unresponsive is shadow_unavailable (Unavailable), never a mismatch.
Tests: WorkerSoftClockTests suspends the real worker (SIGSTOP via libc kill /
NtSuspendProcess) - a stall past T still returns 7; a stall past T + G is a
runner fault with nothing struck; a late-starting runaway is still timed out
at T from its start and killed G after; Shadow stays match /
shadow_unavailable; both clocks enforce T and G; PendingEval state machine.
- RawTagRowRules: the ONE driver-dispatched authority on Tag-ROW rules (access level /
Tag.DataType). RawTreeService's save path, the CSV import's staged review grid
(RawTagCsvMapper) and the manual-entry grid all call it; no call site names a driver.
A ReadWrite or mistyped Heidenhain row is now invalid IN THE PREVIEW instead of only at Commit.
- Manual-entry grid + tag modal: when the driver dictates the data type for the row's
address (RawTagRowDefaults.DictatedDataType), the field is a locked read-only value instead
of a select that commit silently replaced; the grid runs the row rule client-side and no
longer rewrites the row at commit.
- Driver/device modals: the credential refusal is shown once (the modal's save-gate notice);
HeidenhainDriverFormModel / HeidenhainDeviceModel.Validate report only ranges / endpoint.
- docs/drivers/Heidenhain.md: #831 alarm conditions + rediscovery, #830 authoring behaviour.
Tests: HeidenhainAuthoringUxTests (CSV preview == ImportTagsAsync row-for-row, dictated type
is what the save path accepts, single credential surface, ALARM: seeding, CSV alarm rows,
AlarmMs round-trip); HeidenhainAuthoringTests updated for the single-notice rule.
docs/security.md gains the canonical table of per-driver namespaces, the five
enforcement seams, the out-of-scope cases (appsettings ${secret:}, Sql
connectionStringRef) and the migration steps for existing configs. Galaxy,
OpcUaClient, Heidenhain, Configuration and driver-specs docs, the Galaxy
options XML docs, the drivers rule file and the docker-dev compose comment
point at it. docker-dev's env:GALAXY_MXGW_API_KEY stays valid (allowed legacy
prefix), so no rig change is needed.
Co-Authored-By: Claude <noreply@anthropic.com>
#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.
#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>
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).
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.
- Review 1: RawTreeService Create/UpdateDriver and Create/UpdateDevice refuse a Heidenhain
config whose password fields are not blank / secret:heidenhain/... / env:OTOPCUA_HEIDENHAIN_...
(DriverConfigSaveGate over HeidenhainOptionsParser.DescribeCredentialProblems: top level,
Access, Devices[], case-insensitive keys; the value is never echoed), so a literal password
never reaches the draft DB. The driver and device modals show the reason and disable Save.
- Review 8: CreateTag/UpdateTag/ImportTags (tag modal, manual entry, CSV import, browse commit)
refuse a ReadWrite Heidenhain tag or a Tag.DataType that disagrees with what the driver
publishes (HeidenhainTagRules: catalog type, Float64 => Double, or the binding's dataType).
The tag modal and manual-entry grid fix the access level at Read and derive the data type from
the address (RawTagRowDefaults). New deploy gate HeidenhainTagDataTypeMismatch.
- docs/drivers/Heidenhain.md: credential namespace convention + why, the save-path and deploy
enforcement, optional-role credential behaviour, BadConfigurationError, backoff/ordering notes.
Tests: HeidenhainSavePathTests (service save path; red with the gates disabled) and new
DraftValidatorHeidenhainTests cases.
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).
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.
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.
docs/drivers/OpcUaClient.md, docs/plans/2026-05-28-driver-browsers-design.md and
docs/Client.CLI.md: one OpcUaEndpointUrlRewriter for driver, AdminUI browser and
Client CLI/UI; configured host+port win, advertised path kept for opc.tcp; policy/mode
selection and certificate validation unchanged; NAT test recipe against the #813 fixture.
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.
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).