Closing a tab with the script editor open ended the circuit; MonacoEditor.DisposeAsync then
called MonacoBlazor.dispose through SafeInvokeAsync, which caught only JSDisconnectedException,
so the TaskCanceledException from the torn-down circuit escaped and was logged at [ERR].
DisposeAsync now treats JSDisconnectedException, OperationCanceledException (incl.
TaskCanceledException) and ObjectDisposedException as the expected gone-circuit outcome and logs
them at Debug; a real JSException from a live page is still logged (Warning); the
DotNetObjectReference is released in a finally. MonacoEditor is the only component in the repo
that uses JS interop, so there is nothing else to align.
bUnit tests: dispose throwing each of the three gone-circuit exceptions neither escapes nor logs
above Debug (with a positive control that the failing call ran), and a JSException still warns.
They fail against the previous DisposeAsync.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Review of e113faf5 found three gaps in the alarm-feed reverse map:
- A tag hand-authored as Galaxy!<Area>.tag.Attr stopped resolving once Galaxy moved the object to
another area: the feed's full reference named the new area and none of the fallbacks looked at the
authored provider form. Provider-form attributeRefs are now also indexed by their area-free
tag.Attr, and the ack dials the reference the feed now uses.
- A reference composed with an empty area (Galaxy!tag.Attr, the gateway's ComposeFullReference when
the area is blank) with the subtag provider's object-only source reference stripped "tag." and
missed. The part after the '!' is now also tried.
- Resolution ran after the subscription gate, so a transition seen with no handle registered taught
nothing for the ack. It now resolves first; fan-out is still gated.
A picker tag and a provider-form tag for the same alarm resolve deterministically and log a warning.
New tests cover re-area for both forms, subtag shapes, H6-style dotted refs, unmatched/malformed
refs, the collision, pre-subscription learning, and the #869 confirmation of a picker-form ack;
reverted-code runs fail them.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
vd03 runs master 6c4d2990 (#899/#898 read-only historian bindings), self-contained
win-x64, ~15 s downtime, ServiceLevel 250, FOCAS 21/22 Good as before, runner InProcess,
worker isolation re-applied after the folder swap. #898 preflight: 0 historized tags.
#700 second run on the deployed endpoint with historianReadOnly bindings: Boolean
(SysPulse) and Int32 (SysTimeSec) Raw/AtTime typed as the node DataType; native
TimeAverage/Total 9.5/29.5/49.5 and 190/590/990 (= in-process reference), WTC1
all-NULL intervals BadNoData. HistorianGateway /metrics: no EnsureTags/WriteLiveValues
or any write. Temp config + key removed, config back to rev fa9a2e28.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
A node declared numeric whose AVEVA tag is discrete (or string) is not refused up front by #897, so its native
TimeAverage / Total still reach AVEVA, which refuses them. A HistorianGateway at or after historiangw#135
(a220ec0) answers that FailedPrecondition with a google.rpc.ErrorInfo (reason AGGREGATE_NOT_SUPPORTED_FOR_TAG,
domain historian_gateway.v1) in grpc-status-details-bin. The Gateway backend's native processed read now maps
exactly that to HistoryAggregateNotSupportedException (BadAggregateNotSupported for the node) without recording
a health failure or treating it as transport. Any other FailedPrecondition and every Internal (an older gateway's
answer for the same case) keep today's handling.
Detection lives in one place, GatewayErrorReasons. The HistorianGateway.Client pin stays 1.0.0 (1.1.0 is not on
the feed): 1.0.0 keeps the gateway's RpcException, trailers included, as the InnerException of the
HistorianGatewayException it throws, so the reason is read with Grpc.StatusProto 2.76.0 (added via central
package management; nuget.org; same version as Grpc.Core.Api; depends on Google.Api.CommonProtos 2.16.0, which
accepts Google.Protobuf 3.34.1). With Client 1.1.0, ReasonOf becomes
`(ex as HistorianGatewayException)?.ErrorReason` and the StatusProto reference can go.
Tests:
- GatewayAggregateNotSupportedWireTests: in-process gRPC gateway sending the real status + ErrorInfo shape
(copied from historiangw HistorianRpcFaults.ToRpcException), read through the PUBLISHED Client 1.0.0 via the
production adapter: TimeAverage / Total -> HistoryAggregateNotSupportedException, 0 health failures; controls
(Internal; FailedPrecondition without ErrorInfo; other reason; other domain; the reason on Internal) are
rethrown, not IOException, and counted; positive control served.
- DiscreteTagTimeWeightedWireTests: over a real SDK session, a Double / Int32 node on the discrete tag ->
BadAggregateNotSupported with 0 failures; the older gateway's Internal answer keeps its status and counts.
- AvevaGatewayAggregateRefusalLiveGateTests: opt-in read-only live gate (OTOPCUA_AVEVA_GATE_900=true).
Mutations (catch removed; status ignored; domain ignored; reason ignored; inner-exception walk removed) each
fail at least one test. Release build 0 warnings; Historian.Gateway.Tests 601/607 (6 skipped), OpcUaServer
IntegrationTests 323/361 (38 skipped).
Live, read-only: private HistorianGateway container from a220ec0 (API-key auth disabled, live writes off,
removed after) against wonder-sql-vd03: Double and Int32 nodes on SysPulse, TimeAverage and Total ->
BadAggregateNotSupported, health failures unchanged (0); SysTimeSec TimeAverage Good (2 rows); SysPulse Average
served. Gateway traffic was ReadAggregate / ReadRaw / GetTagMetadata only.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
#899 — EnsureTags is an upsert, so binding a node to an existing AVEVA tag via
historianTagname re-typed/re-described it on every add and host start.
- TagConfig `historianReadOnly: true` (TagConfigIntent -> RawTagPlan.HistorianReadOnly):
HistoryRead from an existing historian tag that OtOpcUa never provisions and never
records into (AddressSpaceApplier.IsOwnedHistorizedValue gates both the EnsureTags
hook and the recorder feed). Node-inert: surgical, not mirrored on UNS refs, in the
RawTagPlan equality/hash. A non-bool value is the deploy error HistorianReadOnlyInvalid.
AdminUI: CSV column HistorianReadOnly (strict), TagHistorizeConfig reads via the
runtime parse.
- ServerHistorian:ProvisionTags = CreateOrUpdate (default, unchanged) | CreateOnly
(Gateway only: TagExistsAsync first, existing tags left alone, failed lookup not
sent) | Off. Unknown value / CreateOnly on a SQL backend fail host start.
- Default for an explicit historianTagname stays OWNED: overrides pin owned history
across renames, a read-only default would silently stop provisioning + recording.
#898 — the hook parsed Tag.DataType (an OPC UA name) as DriverDataType, so historized
Float/Double/Byte/SByte tags were never provisioned. New shared
HistorianDataTypeNames.TryResolve: Float->Float32, Double->Float64, Byte/SByte->Int16
(lossless, provisioned as Int2); unmapped names logged at Information. Gateway now skips
Int64/UInt64 (HistorianGateway cannot create Int8/UInt8: lossless-cast-or-reject).
UPGRADE: the first host start after this provisions (upserts) every existing historized
Float/Double/Byte/SByte tag. Mark foreign-tag bindings historianReadOnly, or set
ServerHistorian:ProvisionTags=CreateOnly/Off, before upgrading (docs/Historian.md).
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
libplctag 2.6.0 cannot parse RTC/HSC/... names; tag creation failed with
PLCTAG_ERR_BAD_PARAM, which the status translation reported as BadConnection:
evicted handle, degraded health, counted toward marking a healthy device down.
The libplctag runtime now refuses function-file addresses before creating a
native tag (Unsupported -> BadNotSupported, health untouched), matching native.
Docs: function files are unsupported on both transports, not "unverified".
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
Temporary Calculation-driver nodes bound to existing AVEVA tags (Float ZIPSEN.WTC1,
Byte SysTimeSec/SysPulse, Double missing tag) on vd03, read over OPC UA from the Mac,
then removed; the config is back at its original revision fa9a2e28. Zero historian
writes (gateway metrics: Probe/GetTagMetadata/ReadRaw only). Int32/Boolean bindings
were not deployed because they would trigger an EnsureTags upsert of the existing
AVEVA tag (#899); Float/Double/Byte never provision (#898). Includes the read-only
probe used.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
TimeAverage / Total on a Boolean node over the Gateway backend (AVEVA discrete tag, live
SysPulse) went to the gateway since native TimeAverage/Total became default-on (#694). AVEVA
refuses both modes at StartQuery; the gateway reports that only as Internal /
HistorianServerOperationException (the same answer as a historian storage fault), so the
node answered BadHistoryOperationUnsupported and the read counted as a backend failure.
HistoryRead-Processed now checks the node's scalar DataType before calling any backend and
answers BadAggregateNotSupported (Part 11 6.5.4: "If the Aggregate is not valid for the Node
then the StatusCode shall be Bad_AggregateNotSupported"). Nothing is sent, nothing is counted.
- Shared, every backend: HistoryAggregateDataTypes (Core.Abstractions) applies Part 13
5.4.2.3 Table 50: the numeric-only aggregates (Average, TimeAverage(2), Total(2), Min/Max,
ActualTime, Range, Delta, DeltaBounds, Interpolative, stddev/variance) on a String or
DateTime node are refused. Client-visible: the SQL backends used to answer every interval
BadAggregateInvalidInputs.
- Per backend: IHistorianDataSource.SupportsAggregateFor(aggregate, TypeCode?) (default true;
every production backend declares it, guard-tested). Gateway: false for native
TimeAverage/Total on a Boolean node. SqlServer/QuestDb: true, since they serve Boolean as
0/1 (stepped), so Boolean stays served there, as do the Gateway's raw-evaluated aggregates.
The residual case (a node declared numeric whose AVEVA tag is discrete) cannot be told apart
from a historian fault on the wire and still counts; that needs the gateway to surface
AVEVA's native error code as a deterministic status.
Tests: HistoryAggregateDataTypesTests, guard test, node-manager String/DateTime and per-type
refusals, Gateway capability matrix, DiscreteTagTimeWeightedWireTests (production Gateway
backend over an AVEVA-shaped fake: unsent, 0 health failures, positive controls). Each
mutation (no check, no shared rule, no backend hint, Gateway hint true, shared rule always
true, SQL inheriting the default) fails at least one. Live gate re-run read-only against
AVEVA 2023 R2 via a private HistorianGateway fa9cf31 container: SysPulse TimeAverage on the
wire = BadAggregateNotSupported, health failures unchanged; #801 gate 244 cases pass.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
No public source exists for DNC_ERROR_CLASS / DNC_ERROR_GROUP or their numbers
(the table was written from memory). The iTNC 530 Technical Manual's PLC error
table documents the same class and group NAMES without codes; three real
iTNC 530 records corroborate 7/4, 7/3 and 1/3. DNC and LSV2 numberings are
known to differ (ProgramStatus vs R_RI 26), so the numbers stay unproven.
Docs/comments only; no behaviour change.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
#694 item 3 — historiangw#27 is fixed and now live-verified through OtOpcUa:
- ServerHistorian:Gateway:EnableNativeTimeWeighted defaults to true (false is
the kill switch for a gateway older than 9f711c1). TimeAverage / Total are
served and advertised again (12 aggregates).
- The gateway sends the interval as max(1, round(ms)) and AVEVA clips its last
interval at the request end (live). NativeAggregatePlan therefore sends a
multi-interval grid only for whole-ms intervals; a fractional interval is read
one interval per request (at most 1,000, else BadTooManyOperations), and every
one-interval request goes out rounded UP to whole ms so AVEVA answers one
clipped row. Live, a 3.0004 s clipped interval sent at its own width lost its
last 0.4 ms (Total 123 vs 123.0172).
#746 — HistorianGateway Contracts/Client 1.0.0 surface:
- Alarm history: GatewayAlarmHistorianWriter sends SendEvents batches of <=200.
A failed per-event result is re-sent alone via SendEvent (classified as
before); a refused batch (InvalidArgument etc.) goes event by event so a
poison event dead-letters alone; a systemic fault retries the rest without
sending more; Unimplemented (older gateway) falls back to SendEvent for good.
IHistorianGatewayClient.SendEventsAsync defaults to Unimplemented.
- Events reads: a capped page sends cap + 1 as ReadEventsRequest.max_events, so
the gateway completes the stream instead of the client cancelling it. An
ambiguous capped answer (foreign rows filtered out of the server's cut) or a
refused cap is re-read uncapped with the existing EventDrainBudget drain.
Tests: GatewayServerCappedEventsTests, GatewayAlarmWriterBatchTests,
GatewayNativeWholeMillisecondGridTests (gateway-faithful rounding fake), gate
tests flipped to the new default; every new test mutation-checked (each
mutation of the production change fails at least one). Live harness
AvevaGatewayHistorianFollowupsLiveGateTests (read-only) for #694/#700/#746.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Passed on all four real iTNC 530 controls (Hermle49/59/70/29): Family ITnc530,
ExecutionStateName Automatic, ProgramStateName Idle, Tool/AxisName Z (and
BadNotSupported alongside Tool/AxisCode on Hermle49, which refuses R_RI 51).
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
A UnsTagReference to a raw tag that carries an "alarm" object produced a UNS
value Variable that read BadWaitingForInitialData forever (G6 live gate,
TestAlarm002Condition on testmachine-015). BuildUnsReferenceVariables now skips
those references; the equipment gets the alarm only as the raw condition wired
as an event notifier of its folder, which is unchanged (the reference still
feeds RawTagPlan.ReferencingEquipmentPaths).
Why condition-only and not "feed the mirror":
- The raw side has no value node for such a tag: AddressSpaceApplier
materialises an alarm tag as a condition OR a value variable, never both.
There is no raw value for the UNS mirror to mirror.
- DriverHostActor skips alarm tags when it builds the value fan-out and write
routing maps (`continue` before the UNS registration), and the driver leaves
them out of its value subscription ("subscribed to 5 refs" with 6 tags in
G6). Feeding the mirror would mean subscribing a value the design says the
tag does not have.
- Docs already state condition-only: ScriptedAlarms.md ("instead of a value
variable"), AlarmTracking.md ("only the condition, with no sibling value
variable"), the v3 design (native alarm = one condition at the raw tag,
surfaced at equipment via notifiers).
- The composer tests that asserted the mirror were incidental: f4f3e17e
(v3 B4-WP1) put the alarm on the fixture's Speed tag to test raw alarm
attachment, and later tests reused Speed for writable / historian / array
checks of the mirror. No commit or doc chose a value mirror for alarm tags.
- ScadaBridge does not read it: its OtOpcUa alarm path is event subscriptions
on the equipment/raw folders (v3 alarm B/C live gate), and a node that never
had a value cannot be a working binding. G6 itself authored the alarm's
Boolean as a separate value tag (TestAlarm002), which is the supported way to
read it.
Tests: the composer fixture moves the alarm onto its own HighTemp tag
(Speed is now a plain historized value tag), with a new test asserting no UNS
Variable for the alarm reference, with positive controls (the raw tag still
carries the equipment path; the value references still emit). New real-SDK
test Alarm_bearing_reference_surfaces_as_the_condition_only_against_real_SDK:
no Variable at the UNS path, the Speed mirror present, one condition, the
equipment folder wired as its notifier. Both new tests fail with the skip
removed. Docs: Uns.md, ScriptedAlarms.md, G6 evidence note.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Compared every R_RI decoder we use (22-28, 30-34, 51) byte-for-byte with pyLSV2 (MIT,
97a3d691) and ran the captured iTNC 530 replies through pyLSV2 itself: no decode
disagreement, so no published value changes.
- Additive read-only String leaves Status/ExecutionStateName, Status/ProgramStateName and
Tool/AxisName (pyLSV2 ExecState / PgmState / decode_tool_info names; out-of-table code =
"Unknown"). They ride the raw leaf's feature, so no extra wire read; raw Int32 leaves unchanged.
- Refusals: 52/72 -> BadOutOfRange, 58 -> BadTimeout, 56 -> BadInvalidState, 57 (outside the
alarm walk) -> BadNoData. IsStable and the 57-only alarm-walk end are unchanged.
Lsv2RefusalCodes names every code (pyLSV2 LSV2StatusCode) in exception/log/probe text.
- Timers 30-34 documented as uint32be seconds (lifetime counters) per pyLSV2, hardware-unproven;
TimersEnabled stays false. Axis/tool units: none on the wire, none assumed.
- Lsv2ReadOnlyPolicy untouched (pinned by a test); R_RI 20/21 left out.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
- PLC bit areas (MARKER/INPUT/OUTPUT/COUNTER/TIMER) accept 00/01/FF: real iTNC 530s
return FF for the same operands over R_DP, and pyLSV2 decodes them as non-zero = true.
02..FE stay BadDecodingError.
- New PLC:INPUT_DWORD / PLC:OUTPUT_DWORD areas (pyLSV2 MemoryType 13/12): signed
int32le, element index x 4 over the input/output-word byte pools.
- R_PR offsets agree with pyLSV2. Its count units (BYTE x2, WORD/IW as word counts)
conflict with the captured controls, so we keep the captures and pin them in a test.
- Docs: pyLSV2 correspondence table, native-operand mapping (no aliases), and why
raw R_MB is refused (pyLSV2 logs in as the write-capable PLCDEBUG role). That stays
an owner decision; Lsv2ReadOnlyPolicy is unchanged.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
- HeidenhainControlFamily + HeidenhainControlFamilies.Classify (Contracts): one authority, ported from
pyLSV2 (MIT) dat_cls.py VersionInfo.control (strip spaces, case-insensitive prefix, ITNC530 first).
Families: ITnc530, Tnc (640/620/320/128), Tnc7, Lathe (CNC PILOT 640 / MANUALplus 620), MillPlus,
LegacyTnc (426/430, not a pyLSV2 class), Unknown.
- New additive read-only leaf Controller/Family (String, Identity feature, derived from the same R_VR
reply: no extra request). Lsv2Versions.Family is computed, so record equality and the #831
identity-change rediscovery signal are unchanged. Probe message + /hosts diagnostics (<host>.family).
- DATA gate now goes through SupportsDataPath(family) (pyLSV2 client.py read_data_path is iTNC-only):
non-iTNC and Unknown => Capabilities/DataLogin Unsupported, DATA login never sent. Replaces the
"model contains itnc" substring check (same result on the real iTNC530 controls).
- No per-family selector pre-gates: pyLSV2's TNC 640 observations (27/28/51/30-34 unknown telegram,
25/26 need DNC) are already handled by the reply-driven refusal/suppression path; tests pin it.
- Lsv2ReadOnlyPolicy, HeidenhainTagWritable and the no-IWritable rule are untouched.
- Simulator: per-family ControlProfiles (strings from the pyLSV2 README tables / protocol.rst), both
R_VR field counts, 124-byte R_PR; HeidenhainControlFamilyTests (classification, gates, decoding,
wire log inside the read-only set, refused selectors asked once).
- docs/drivers/Heidenhain.md: Control families matrix (iTNC 530 hardware-proven; the rest per pyLSV2,
hardware-unproven) + open questions (C_CC buffer size, encrypted passwords, R_RI 20/21).
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
The run covered browse (raw + UNS), read, subscribe, write and readback, and a native alarm
through Part 9 to an ack confirmed in Galaxy and a clear. The run found #895 (fixed in
c971b171) and #896 (open). DeployWatcher was not exercised: there is no headless Galaxy
redeploy.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
The gateway alarm feed names an alarm "Galaxy!<Area>.<tag>.<Attr>". The AdminUI Galaxy
picker authors attributeRef "<tag>.<Attr>". OnAlarmFeedTransition reverse-mapped only the
full reference, so a picker-authored native alarm never matched its condition and never
went active. Its ack also dialled the bare reference, which AcknowledgeAlarm refuses.
ResolveAlarmRawPath now matches the full reference, then SourceObjectReference, then the
full reference without its Provider!Area. prefix. A fallback match records the full
reference per RawPath, and AcknowledgeAsync dials it, so the gateway accepts the ack and the
#869 confirmation watch matches. Found and re-verified live in the #615 G6 gate on the
windev test Galaxy.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Hermle49 (iTNC 530 NC 340490 03 SP10) refuses the tool selector with code 24;
the driver correctly publishes BadNotSupported for every Tool/* leaf, so there
is no runtime type to check. The test required >0 Good leaves and failed there.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
One owner-approved ack of CVDAisle_1.TableAlarms_008.RuntimeAlert at
2026-11-13T04:17:22Z through docker-dev site-b-1 -> vd03 mxaccessgw 76a71bc8.
.Acked True, .AckMsg = comment, gateway feed ACTIVE_ACKED, v_AlarmEventHistory2
ACK_ALM with the NAM operator and OperatorNode MxGateway. The 11-11 failure
was the vd03 gateway build (4f931e4, pre-AckMsg wnwrap AlarmAckByName no-op),
not #226/#228.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Read-only ADS gate from WW_DEV_VM: port 10000 Config, port 851 absent (error 6).
Boot project is the committed OtOpcUaTwinCatFixture without GVL_Perf/FB_PerfChurn.
Member writes, coalescing and #761 array writes remain blocked; no multi-hop hardware.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
.dockerignore keeps .git/ out of the build context, so the in-image publish had
no repository and AssemblyInformationalVersion (Telemetry service.version) was a
bare 1.0.0. The Dockerfile now takes ARG SOURCE_REVISION and passes it to
dotnet publish as SourceRevisionId; docker-compose.yml forwards it as a build arg
for the host and migrator images; the new docker-dev/compose.sh wrapper exports
git rev-parse HEAD (suffixed -dirty for an uncommitted tree; an explicit value
wins) and runs docker compose with the base file. Without the arg the build
still succeeds as 1.0.0. The ARG sits after restore so a new commit does not
bust the restore layer. Mirrors ScadaBridge 7e34d7c2 (ScadaBridge#134).
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Re-ran every gate docker-dev and the fixtures can run, on master 412da27c plus the
deploy-API fix: A (now with dotnet-counters), B, C, D check 4 (after #785), E, H1
live end-to-end over OPC UA (N=0 year window: 6 pages <= 10k, continuation points,
all 52,560 values), the gate-F removal drop on the VD03 gateway (4 -> 2 within 15 s),
and all fixture lanes incl. the first G1 MTConnect run. All pass.
Recorded: the AbCip "writes" combination ran no writes (AB_DIFF_ENDPOINT gates them
off), a C-style pause past 15 s leaves a split SITE-B pair, and simulator evidence for
four of the five hardware userGates (TwinCAT has no simulator). The hardware
userGates stay open.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Found on the docker-dev rig during the #761 Wave-4 gate run (2026-11-12): the documented
bodyless headless deploy (docker-dev/deploy.sh, README "Headless deploy") returned 404 on
both centrals, while the same POST with a JSON body was accepted.
Cause: the optional body was an inferred `DeployRequest?` parameter, which stamps
Accepts(application/json) metadata on the endpoint. For a request with no Content-Type the
routing AcceptsMatcherPolicy prefers endpoints that declare no consumes metadata, and on a
fused admin+driver host MapOtOpcUaMeshGrpcServices has mapped grpc-dotnet's
`{unimplementedService}/{unimplementedMethod}` catch-all, so the request was routed away
from the deploy handler and 404'd. The deploy API on its own does not reproduce it.
The handler now reads the optional body itself: no body (absent or zero-length) is the
headless form, a non-JSON body answers 415 and malformed JSON 400, as the framework's
binding did. New DeployApiBodylessPostTests compose the deploy API with the mesh gRPC
services and drive real HTTP; with the fix reverted the two bodyless cases fail with 404.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
The consumer matrix minted a 55-character otua_matrix_ key, which stays
under SDK 2's 64-byte RsaEncryptedSecret threshold and so could not show
lmxopcua#880 from the consumer side. It now uses the ApiKeySecret.Generate
shape (65 characters), as opcua-sdk-compatibility already does.
README records the 2026-11-12 run with ScadaBridge on SDK 2.0.1
(a2e9d718): matrix 11/11 x3 modes on 83ed880e and 412da27c, ACL off and
on; 8/8 trust controls fail closed; failover 8/8; a mutation with the SDK
default UserName handler fails every baseline leg with
BadIdentityTokenInvalid.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
0.3.1 stops the gRPC/HttpClient instrumentation tracing telemetry's own
OTLP export calls (the Serilog log sink's LogsService/Export spans).
The app's own outgoing gRPC/HTTP spans are unchanged.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Pins ZB.MOM.WW.Telemetry(.Serilog) 0.1.0 -> 0.3.0. The OTel Resource now names the
node: site.id = its application Cluster id (the cluster-<ClusterId> role suffix,
omitted on a legacy node), node.role = its plain roles in canonical order
(admin / driver / admin,driver), service.version = the entry assembly's informational
version (the git SHA). Program passes its resolved EffectiveRoles. The whole
OtOpcUa:Telemetry section goes through the shared ApplyConfiguration, so SiteId /
NodeRole / ServiceVersion / ResourceAttributes:* are operator overrides, and
Exporter=Otlp now also sends the Serilog logs to the same collector.
ObservabilityResourceIdentityTests cover the mapping and the built container's
Resource. Host.Tests 288/288, Host.IntegrationTests 325/327 (2 skipped).
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
ZbTelemetryOptions.Meters/ActivitySources are strict allowlists. The host named only
ZB.MOM.WW.OtOpcUa and the LocalDb meter, so the in-process Galaxy driver's galaxy.* spans
and its event pump's galaxy.events.* counters (both on "ZB.MOM.WW.OtOpcUa.Driver.Galaxy")
were silently dropped from /metrics and OTLP. Found exercising OTLP end to end against a
collector (scadaproj#13).
- Galaxy driver: public GalaxyDriverTelemetryNames (MeterName / ActivitySourceName);
GalaxyTelemetry and EventPump reference it.
- Host: ObservabilityExtensions.ObservedMeters / ObservedActivitySources include it.
- Host.IntegrationTests: ObservabilityAllowlistTests assert on the listener side (span
sampled, instrument enabled) with an unregistered-name negative control; dropping the
Galaxy entries fails 3 of 8.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
- #890: installer at f75ee7d0 re-applied on both nodes with -DenyPaths root, pki, data and
C:\ProgramData\ZB\kek (the Secrets KEK sits outside the install root); second apply 12/12 unchanged.
- Script-runner canary authored in the AdminUI and deployed (Calculation tag + VirtualTag, 1 s timers):
Good and advancing on both endpoints; kept on QA as a runner monitor.
- #794 step 5: the live worker token is restricted (SeChangeNotify only; identity/Everyone/Users/RESTRICTED)
and is denied read and write on pki, data, app, logs and the KEKs; network sockets none. Finding #891.
- #780: QA runaway drill passed (killed at the 4000 ms hard deadline twice, quarantined, siblings Good,
ServiceLevel 250/240 unchanged); drill removed, redeploy back to the canary revision 82dd0598.
- #780 P4 recorded: InProcess stays the default and the rollback; StuckOrphanQuarantineFactor documented
as in-process only.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
The installer denied NT SERVICE\OtOpcUaScriptWorker only on -InstallRoot. On the wonder layout the node's keys and
secrets sit beside it (E:\ApiInstall\OtOpcUa\pki with the application certificate's private key, data\ with
secrets.db and LocalDb), inheriting BUILTIN\Users:RX. Users is a restricting SID of the worker token, so a worker
could read them.
- Install-ScriptWorkerIsolation.ps1 -DenyPaths <dir>,...: each folder gets the same inherited deny as InstallRoot
(new SensitiveDirDeny steps). Refuses drive/share roots, script-worker\ and the PE cache; folders must exist;
a comma/semicolon string works for powershell -File.
- The deny is read AND write (0xD0157: ReadData, WriteData, AppendData, WriteEA, DeleteChild, WriteAttributes,
Delete, WRITE_DAC, WRITE_OWNER). Traverse/Execute, ReadAttributes, ReadControl and Synchronize stay so the
loader still reaches script-worker\. The write half stops a worker planting a cert in pki\trusted\certs where
a data drive lets Users create files in subfolders.
- The list is recorded under the anchor service's key (Parameters\DenyPaths, step DenyPathRecord): a re-run
removes the deny from folders dropped from the list, -Uninstall removes every recorded one without repeating
-DenyPaths. -WhatIf/-PlanOnly print every step.
- After an apply, WARN for each protected-DACL item under a denied folder that the inherited deny cannot reach;
without -DenyPaths, WARN for pki/data/logs/backup/config/secrets siblings of InstallRoot.
Tests:
- Dry-run harness 46 -> 86 assertions (QA layout plan, mask bits, refusals, stale removal, record-driven
rollback, host account never denied). Mutations (no sibling deny, deny->allow, mask back to ReadData, no
stale removal, rollback ignoring the record, script-worker not refused) each turn it red.
- Test-ScriptWorkerIsolation.Windows.ps1 (Windows PowerShell 5.1, windev): 34/34, incl. -DenyPaths apply,
11/11 unchanged re-apply, shorter-list re-apply, uninstall from the record, second uninstall unchanged.
- New InstallerLayoutIsolationTests (OTOPCUA_ISOLATION_INSTALLER_E2E=1, elevated Windows): a scratch wonder
layout under the real restricted worker token. Before install and after uninstall the token reads pki/data/
logs/backup/appsettings and plants a trust-list file; installed it can do neither, still reads script-worker\,
a real worker launched from the layout runs a script, and the host still reads everything. Mutating the
-DenyPaths denies to a no-op mask turns it red on pki\own\private\otopcua.pfx (reproduces #890).
QA vq03/vq04 are NOT re-applied; the command is in docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Owner scope 2026-11-12 (Windows x64 + Linux, unchanged cycle-3 caps, >=7200 s, SDK 1.5 comparator).
- Linux ARM64 (late-slope, 7200.12 s, 1964 cycles / 491 restarts, shutdown verified, analyzer-validated):
first rejection RSS at cycle 247 / 900.73 s, 358,682,624 > 358,678,528 (+4 KiB); peak 382.98 MiB vs
limit 342.06 MiB; late plateau medians 344.4/341.1/341.9 MiB, OLS -1.90 MiB/h. FAIL.
- Windows x64 on windev (same workload, working set as RSS, handle table as descriptors): first rejection RSS
at cycle 285 / 1130.76 s, 303,505,408 > 302,174,208; peak 319.49 vs limit 288.18 MiB; plateau ~314-315 MiB,
+0.93 MiB/h. FAIL. Also incomplete: the launcher's wall-clock outer deadline fired early after a +552 s
w32time step and ended the run at test-phase 7014 s (rejection had already latched); launchers now use a
Stopwatch.
- SDK 1.5.378.182 comparator (efb9197a publication, Windows, 7201 s): RSS peak +65.8 MiB (never over its cap),
plateau ~+48 MiB, but the +32 handle cap tripped at cycle 17 (max 674 vs 642).
- Managed flat on both SDK2 runs; growth is the generated-factory JIT burst (+40k/+44k methods) - Linux JIT
doublemapper ~180 MiB; Windows private bytes grew ~25 MiB of a ~132 MiB working-set rise.
- Retained: b61f3e05 cycle-1 fixture failure (#808 unplaced UNS folder), Windows/SDK1 smoke diagnostics.
No cap raised, no warm baseline, no failed run counted as passing.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Pins ZB.MOM.WW.LocalDb and .Replication at 0.3.7 (published 2026-11-12).
Wire-compatible; no code change needed.
- 0.3.6 (scadaproj#36): the library sync service enforces
LocalDb:Replication:ApiKey itself. LocalDbSyncAuthInterceptor is kept for
its refuse-every-stream-when-blank rule; doc comment updated. docker-dev
site-a pair: both nodes now read one variable,
OTOPCUA_SITE_A_LOCALDB_SYNC_KEY (same dev default as before), so an
override can never split the pair.
- 0.3.7 (scadaproj#16): a tombstone is held until the peer has it, so a
node offline past TombstoneRetention no longer brings a pruned
deployment back. The ledger grows while a peer is down; optional
TombstoneHardCap (documented in Configuration.md) bounds it.
Runbook, Configuration.md and the cluster-topology rule updated.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Copies the reviewed baseline-canonical SDK1 port and replaces its 1..4-cycle smoke entry with the late-slope
loop (same cycle body, every-fourth-cycle active restart, 30/25/120 s bounds, 7200 s phase, cycle-3 cold
baseline with unchanged +64 MiB managed / +128 MiB RSS / +32 descriptor caps, latched first rejection,
bounded shutdown checks). Run against the immutable efb9197a publication (#807 fix included), never rebuilt.
The held-reference closed-session list assertion is recorded instead (SDK1 Close keeps lists until Dispose,
per OWNERSHIP.md) and the managed-monotonic guard is latched, so the comparator yields numbers for the whole
phase. acceptance=false by construction. Windows descriptor observer shared byte-for-byte with the SDK2 test.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Acceptance runs for #799 (owner scope 2026-11-12: Windows x64 + Linux) on current master.
Fixture: since #808 an UNS folder the authority snapshot does not place is denied under enforcement (fail
closed). The canonical soak creates its own "SoakEquipment" folder but never placed it, so on master the very
first cycle's UNS monitored item came back bad (retained failed Linux launch on b61f3e05, cycle 1). The rig
gains AdditionalFolderScopes (merged into FolderScopes by Enforce) and the soak places its folder there, as a
deployed generation does - the same fixture gap #888 fixed for the SDK matrix. No server authorization code,
cap, baseline or canonical operation changed; the soak test bodies are otherwise byte-identical to b18f9723.
Windows port (test/harness only, opt-in):
- DescriptorCountsAsync gains a Windows branch: total = every handle in the process handle table, PKI = disk
file/directory handles whose GetFinalPathNameByHandle path lies under a canonicalised PKI root; dedicated
thread under the same 5 s bound as the macOS lsof probe.
- Descriptor controls enabled on Windows (symlink alias; timeout control via cmd.exe + ping).
- LateSlopeRun.ValidateRequest accepts Linux ARM64 or Windows x64; duration/runtime/leak tracking unchanged.
- late-slope/analyze.py: expected architecture is a module constant (Linux default Arm64 unchanged).
- windows/: prepare.py (win-x64 self-contained 10.0.14 bundle + manifest), launch.ps1 (container.sh
equivalent: bundle re-verification, 2/2 controls first, PID/apphost/coreclr verification, external
sampler, 7440 s outer deadline), analyze.py (Linux row/window logic, X64), README with every adaptation.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
GalaxyAlarmPriority, the one table live Galaxy alarms (MxAccessSeverityMapper)
and AVEVA A&E history (EventMapper, #886) map the AVEVA priority through, ranked
a higher number as more severe. AVEVA ranks priority 1 as the most urgent, so a
priority-1 alarm read 250 and a priority-999 system event 1000.
It now uses AVEVA's default priority ranges, most urgent first:
AVEVA priority AVEVA severity bucket OPC UA Severity
1-250 1 Critical Critical 1000
251-500 2 High High 750
501-750 3 Medium Medium 500
751-999 4 Low Low 250
(history only) 0 not an alarm - 1
Out-of-range priorities clamp (<= 0 Critical, > 999 Low). Evidence, vd03
Runtime.dbo.Events read-only: priority 1/100 alarms carry AVEVA severity 1,
400/500 severity 2, 600/700 severity 3, and every priority-999 System.* /
User.* row severity 0 with IsAlarm 0.
- Live and history now use the same number. GalaxyDriver passes the table's
exact value as AlarmEventArgs.OpcUaSeverity. Before, it passed only the bucket,
which NativeAlarmProjector re-mapped through its generic 200/500/700/900
ladder, so live and history disagreed even before the direction fix.
- History: a row whose AVEVA severity is 0 (System.Start/Stop/Deploy,
User.Write...) is not an alarm and reads Severity 1, below every alarm band.
For alarms AVEVA's 1-4 severity is still never the value: the live feed
carries only the priority, so it cannot use it, and history must agree.
- Docs: docs/Historian.md gains "Galaxy alarm priority -> Severity
(lmxopcua#887)"; docs/AlarmTracking.md updated.
- Live re-run of the #801/#886 harness against vd03 HistorianGateway
ea9f21c (read-only, network-only): 224 of 233 cases identical to the #886
run, the 9 others differ only in Severity (priority-700 alarm rows 750 ->
500, 19 system/user events 1000 -> 1). Record: aveva-history-801.md
"#887 re-run", report aveva-gate-sdk2-887.json.gz.
Client-visible: every OPC UA client that ranks or filters Galaxy alarms by
Severity sees the order reversed (live and HistoryReadEvents). ScadaBridge has
no code that depends on the old values; its own mixed severity directions are
ScadaBridge#129.
Tests updated because they pinned the inverted table:
- Driver.Galaxy.Tests MxAccessSeverityMapperTests (rewritten: edges 1/250/251/
500/501/750/751/999, 0/-100/int.MinValue/1000/int.MaxValue clamps,
monotonic-decreasing sweep, equals GalaxyAlarmPriority for -2..1001)
- Driver.Galaxy.Tests GatewayGalaxyAlarmFeedTests (750 and 600 -> Medium)
- Driver.Historian.Gateway.Tests EventMapperTests (band table, priority 999
-> 250, 1 -> 1000; system events -> 1)
- OpcUaServer.IntegrationTests GalaxyEquipmentEventHistoryWireTests (700 ->
500, 100 -> 1000, System.Stop -> 1; the fake now stores AVEVA's real 0-4
severity)
New tests:
- Runtime.Tests NativeAlarmRestatementEndToEndTests
.Galaxy_live_condition_severity_is_the_AVEVA_priority_band (8 rows: the real
feed -> real GalaxyDriver -> host -> projector; the same rows EventMapperTests
pins on the history path)
- EventMapperTests.Aveva_severity_zero_marks_an_event_that_is_not_an_alarm
(5 rows, incl. the priority-999 alarm positive control)
- GalaxyDriverAlarmSourceTests: OpcUaSeverity passes through
Mutation check, 8 mutants, all killed: the pre-#887 inverted table; the
driver dropping OpcUaSeverity; no not-an-alarm rule; each of the three band
edges off by one; history ignoring priority; a missing AVEVA severity read
as 0.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
The SDK memoizes INodeManager3.ValidateEventRolePermissions per (item, event type,
SourceNode). #826 kept it fresh by reflecting into the private
MonitoredNode2.InvalidatePermissionCaches (plus a detach/re-attach fallback). Since #855
condition events never take that channel, and the search for #878 found nothing
production-raised that does except a condition's SDK method audits, which reach it only
with auditing on, SignAndEncrypt and a SecurityAdmin session (nothing grants that role).
Under enforcement the channel verdict is now a flat deny for items on our nodes, so no
cached verdict depends on a grant, and InvalidateFolderEventVerdicts, the reflection,
the fallback, its counter and the guard test are removed. Method audits still reach
Server-object items, decided per event.
Tests: FolderEventVerdictInvalidationTests -> FolderEventVerdictFreshnessTests; new
revoked-grant test (positive control: a still-granted session) and method-audit test
(SecurityAdmin mapped for the test; positive controls: Server-object item, flag off);
the plain-event test runs each mode on a fresh server. Mutation-checked.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
A notifier folder read one event source, its NodeId identifier (a RawPath or an EquipmentId). AVEVA stores a
Galaxy object's A&E history under the object's tag_name (Source_Object), so AVEVA's own alarm and event
history reached no production folder. Its lower-case property keys were also missed, so every row read
Severity 1 with no ConditionName.
Sources. A folder's event history now reads its own source plus the Galaxy object of every native condition
that fires on it live: those parented under it and those WireAlarmNotifiers wired to it. Live and history use
the same membership. The object comes from the condition's Galaxy alarm reference, its raw tag's attributeRef,
up to the first '.':
- The composer reads it with the driver's own parser (GalaxyAttributeRef, shared with GalaxyDriver), only for
a GalaxyMxGateway tag with an alarm (RawTagPlan.NativeAlarmReference, part of plan equality).
- The applier binds it after materialising the condition (IOpcUaAddressSpaceSink.BindNativeAlarmHistory,
forwarded by the deferred sink). A rebuild or the condition's removal forgets it.
Each object is read with the #882 rule: its own events and <Object>.<Attribute> rows, never a prefix sibling.
Fan-out. historiangw's ReadEvents filter is a single predicate, so there is one request per source; the wire
contract is unchanged. EventSourceFanOut merges the reads as one source:
- order is (time, source position, arrival), reversed for a reverse read; ties are never re-sorted by EventId,
so the boundary skip stays stable;
- duplicates by EventId are dropped and the merge is cut to the cap;
- a truncated source bounds the page at the time it stopped.
Paging, continuation points (bound to the whole source set), the walked-back reverse read and the tie-cluster
over-fetch are unchanged. A folder with one source is read exactly as before.
ACL. Every source is read inside the folder's own HistoryRead, after AuthorizeHistoryRead (#826). A denied
folder reads nothing and fails closed.
Mapping (EventMapper, property names now case-insensitive):
- Severity: an AVEVA row's priority goes through GalaxyAlarmPriority, the table live Galaxy alarms use
(MxAccessSeverityMapper now delegates to it), so history matches live. Otherwise severity (OtOpcUa's own
rows, which AVEVA stores lower-cased), clamped. AVEVA's own 1-4 severity is ignored. The table ranks a
higher priority as more severe, which inverts AVEVA's convention: filed as #887.
- An AVEVA row whose source_conditionvariable names a native condition that fires on the folder takes that
condition's SourceNode and live ConditionName. Otherwise ConditionName is alarmname or the condition
variable's attribute.
- alarm_id/alarmid map to AlarmId.
Tests:
- GalaxyEquipmentEventHistoryWireTests (4, real SDK session, production Gateway backend, AVEVA-shaped fake
gateway): equipment EqA backed by objects Oven_007 + OvenDoor_07. Covers object and attribute events from
both objects, the prefix sibling excluded, cross-object and in-object ties, forward paging at 1-4 a page,
reverse whole and paged, SourceNode/ConditionName/Severity, the raw device folder, ACL deny with no backend
call plus a granted positive control, and a rebuild forgetting the bindings.
- EventSourceFanOutTests (8), EventMapper AVEVA cases (11), GalaxyAttributeRefTests (16), a composer test and
an applier ordering test.
- Mutation-checked, 17 mutants, all killed. Covered: parent-only membership, no scope, a case-sensitive
condition match, no condition rewrite, no truncation bound, EventId tie order, no sort, no cap, no stop at
the bound, unreversed source order, no dedupe, no '.' boundary, priority ignored, case-sensitive keys, no
applier bind, no deferred forward, and a composer that ignores the driver type. The forget-on-rematerialise
guard survives as belt-and-braces: the rebuild clear covers it.
- Live, read-only, no WinRM, vd03 HistorianGateway ea9f21c: equipment folder EQ-886-ZipsenLeft (objects
DevWinPlatform + Left_001). It read 0 events before and 92 now, equal to the unfiltered read's rows for both
objects. 1/7-event pages and reverse whole/5-event pages agree, and 73 rows resolve to their live condition.
diff_runs vs the #882 run: 221/226 identical. The 5 that differ are expected: DevWinPlatform Severity
1 -> 750/1000, and #881 capabilities.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Since #808 an UNS folder with no placement in the generation is denied (fail
closed), so the matrix's ad hoc "Eq" folder made every UNS case fail with
BadUserAccessDenied on the candidate server. The fixture now supplies
unsFolderScopes (+ the alarm's tagReferencingEquipment) as a deployed
generation does. An unplaced "Unplaced" UNS folder is kept as a negative
control: the Shared client leg requires it denied against a #808+ server with
the ACL on. No server authorization code changed.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi