F1: PendingEval.SoftExpired runs on the pipe reader and completed the
caller's outcome inline (production outcomes have no async continuations, by
design, because results resume on the ResultDispatcher). The caller's code
(the runner's Cancel write, the seam's publish) therefore ran on the reader,
which could hold up heartbeats (a false silence kill) and, with blocking I/O,
write to the pipe from the reader. The soft outcome is now posted to the
dispatcher like a result. Test: a 1.5 s continuation on a soft timeout with a
300 ms silence limit resumes on ScriptWorker[t].Results and the worker lives
(before: it resumed on the Reader).
F2: after a transport timeout ("worker unresponsive"), a late soft notice
armed G but nobody sent Cancel, so a polling script could not end itself and
the whole worker was killed at notice + G with a kill strike. PendingEval now
exposes LateSoftExpiry (asynchronous continuations, cancelled when the run
answers); the runner sends the Cancel and counts the timeout
(NoteTimeout) from it. A genuinely slow script whose notice is delayed past
T + G is thus still a timeout. Test: stall past T + G, thaw, a polling
script overruns T: the worker survives G and the key is quarantined for
timeouts, not a hard deadline (before: the worker was killed).
A_burst_of_cold_compiles_does_not_starve_the_heartbeat (50 ms heartbeat) and
A_caller_that_blocks_after_its_result_does_not_starve_the_reader (100 ms)
launched their worker under the tight silence kill, so its cold start (launch,
first compile, Roslyn JIT) had to stay within 150/300 ms of silence. Under 28
busy processes on 14 cores that failed 2 in 10 runs in the WARM-UP, killed
for ~165 ms of silence, and replacement workers were killed the same way.
New test seam: WorkerLaunchSpec.HeartbeatSilenceLimit (runner internal ctor
heartbeatSilenceLimit) and WorkerConnection.SilenceLimit. The tests launch
with a 30 s limit, warm, then tighten to three intervals for the phase they
are about; the assertions on that phase are unchanged.
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.
The test parks the removed child in PostStop (ShutdownGate) to hold the
retired-but-not-terminated window open, but PostStop abandons a shutdown after
DefaultShutdownTimeout (5 s). Under a loaded suite run more than 5 s can pass
between the stop and the host processing the stale burst; the child then
terminates, the host forgets it on Terminated, and the burst sent AS it is
forwarded and re-retained. Reproduced deterministically with a 6 s sleep
before the burst. Not a production path: a terminated actor cannot send.
DriverHostActor gains a childShutdownTimeout test seam (last parameter, passed
to DriverInstanceActor.Props). The test raises it to 5 min, asserts the child
was really stopped (ShutdownCount) before the burst, checks the premise as a
positive control (no Terminated by the time the fence has passed), and expects
Terminated once the gate is released. With an 8 s sleep before the burst the
fixed test passes.
The Test-Connect probe set is resolved inside the AdminOperations Akka starter; resolving
ISecretResolver there walks AddZbSecrets -> IAuditWriter -> ActorAuditWriter, whose ctor needs
AuditWriterActorKey (registered later in the same builder). Found by the docker-dev live gate.
Regression test: resolving the probe set never resolves ISecretResolver (fails before the fix).
Also: TagRowProblem doc comment re-attached; Heidenhain credential help text names the
secret:heidenhain/ and env:OTOPCUA_HEIDENHAIN_ namespaces.
- 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.
- 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.
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.
- HeidenhainDeployChecks (wired into DraftValidator): HeidenhainTagWritable refuses a
ReadWrite access level on any Heidenhain tag, so no node is ever advertised writable;
HeidenhainCredentialPersisted refuses a literal password in DriverConfig/DeviceConfig
(only blank / secret: / env: references; the value is never echoed);
HeidenhainConfigInvalid for out-of-range settings or a tagged device without an endpoint.
- EquipmentTagConfigInspector maps Heidenhain to HeidenhainTagDefinitionFactory.Inspect.
- Driver validates only devices that carry tags (an unconfigured default Device1 no longer
faults the driver).
- 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.
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.
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).
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).
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
Owner decisions §14 #5/#6 as built (design §12.5):
- Scripting:Worker:Identity (default unset, Windows only, fail-closed validation): each worker
starts under a restricted token derived from the host's. Every privilege but SeChangeNotify is
removed; the restricting SIDs are NT SERVICE\OtOpcUaScriptWorker's SID + Everyone + Users +
RESTRICTED; the token and default DACLs are host + SYSTEM + identity + RESTRICTED. The worker
is created suspended, put in its Job Object before it runs, inherits only its stdio pipes, gets
an ACL'd work dir as TEMP, and runs with DOTNET_EnableDiagnostics=0. The identity is granted on
the host's window station and desktop, because USER32 init made coreclr.dll fail with
ERROR_DLL_INIT_FAILED on windev. The logon SID is not used because the host's process DACL
gives it PROCESS_VM_READ.
- The worker pipe on Windows always has an explicit protected DACL: host account + identity
only. There is no owner ACE, so an elevated host's Administrators gets no access.
- The worker treats access denied on its parent-pid poll as alive; the Job Object covers that case.
- scripts/install/Install-ScriptWorkerIsolation.ps1 + ScriptWorkerIsolation.psm1 are idempotent
and support -WhatIf, -Uninstall and -PlanOnly. They create the disabled anchor service, the
PeCacheDir ACL (protected, no Users, key and markers host-only, pe\ Modify for the identity),
read+execute on script-worker\, a ReadData deny on the install root, the outbound firewall
block on the worker exe, and optionally the host-service env.
- Tests: the dry-run harness scripts/install/tests/Test-ScriptWorkerIsolation.ps1 (46
assertions, mutation-checked); ScriptWorkerIdentityTests; WorkerIsolationTests, which cover
the pipe ACL, token access and a real restricted worker running a script, all green on windev.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
ForbiddenTypeAnalyzer now denies System.AppContext, System.Console, GCSettings, ProfileOptimization,
EncodingProvider/CodePagesEncodingProvider, TypeDescriptor and CryptoConfig as types, and the ambient
CultureInfo.{DefaultThreadCurrentCulture,DefaultThreadCurrentUICulture,CurrentCulture,CurrentUICulture}
and TimeZoneInfo.ClearCachedData members (member deny-list now covers properties and member-access reads).
Shadow-mode tests diverge via culture-sensitive string.Compare (worker is InvariantGlobalization) instead of
AppContext.BaseDirectory, with a premise test. Mutation-proved: 21 new cases fail with the analyzer reverted.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
- ShadowScriptRunner (Core.Scripting): IScriptShadow.Mirror copies the inputs and queues the shadow run on the
pool; compares value (structural), CLR type, status and failure class (compile / budget / threw+message /
unsupported_type); runner-fault shadow outcomes (Unavailable, worker busy) are not compared. Bounded:
Scripting:Shadow:MaxInFlight (256) and one per script; drops counted (behind | key_busy).
- Metrics otopcua.script.shadow.{compared,mismatch{script},dropped,inflight}; one rate-limited Warning per
script and reason with a diff summary (Scripting:Shadow:MismatchLogsPerMinute, re-armed at the seam's deploy).
- The VirtualTag, Calculation and scripted-alarm seams recognise the shadow and keep their in-process path
byte-for-byte (no coalescer, one-at-a-time pump, in-process engine), mirroring after each compiled run.
- ScriptRunnerSelection builds the shadow over an OutOfProcessScriptRunner whose lines carry
ScriptRunnerRole=Shadow; Status() is null under Shadow so readiness never sees the shadow pool; the
script-runner health entry stays Healthy and reports the shadow counters as data.
- docker-dev/docker-compose.script-runner-shadow.yml; docs/VirtualTags.md "Shadow mode".
- Tests: comparison table, per-script metric + once-per-reason log, rate limit, hung/blocking/throwing shadow
never reaches the caller, MaxInFlight drop accounting, input copy; real-worker divergent + unsupported-type
scripts counted against exactly those scripts, a worker-only runaway killed/quarantined there with every
published value intact, alarm transitions identical to in process across a kill -9 of the shadow worker.
Mutation-proved (16 mutants, 15 red; the one survivor added an assertion).
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
- 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