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 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
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
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
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
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
ConfigurationBinder appends configured collection items to an existing list, so
the pre-filled [None, Sign, SignAndEncrypt] default made "drop None" a no-op:
every deployment that listed only the secure profiles still advertised an
unsigned/unencrypted None endpoint.
EnabledSecurityProfiles is now null by default; ResolveEnabledSecurityProfiles()
applies the unchanged three-profile default only when nothing is configured.
A set-but-empty list fails startup validation and BuildSecurityPolicies no
longer falls back to a None endpoint on empty input (it throws). Sweep of all
options classes found no other non-empty pre-filled collection default.
Tests: options binding (indexed, JSON, env forms, default, empty, bad name), a
booted-server wire test (no None endpoint, None connection refused with
BadSecurityPolicyRejected, secure connection works; default case is the control).
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
SDK 2.0.1's UserNameIdentityTokenHandler encrypts a password longer than 64 bytes
(a private RsaEncryptedSecretPasswordThreshold, no setting) as an RsaEncryptedSecret
with EncryptionAlgorithm=null. SDK 1.5.378 (.106 and .182) decrypts only the legacy
format under RSA policies; its SecurityPolicies.Decrypt answers a null algorithm with
BadIdentityTokenInvalid ("Unexpected encryption algorithm") before any key check.
Every production API key is 65 chars, so no SDK 2 OtOpcUa client could use one
against QA (b8b4c18f), a rollback target or an older third-party server.
LegacyRsaUserNameIdentityTokenHandler (Driver.OpcUaClient.Session, plugged in through
the public UserIdentity(IUserIdentityTokenHandler) ctor) always uses the legacy
format, RSA-OAEP(len|password|serverNonce) under the server certificate, under an RSA
policy. That is what SDK 2 sends for passwords of 64 bytes or less and what SDK 1.5
clients always send. The legacy format has no size limit (the plaintext is
length-framed over as many RSA blocks as it needs), and an SDK 2 server reads both
formats. Policy None, the ECC policies and a missing certificate go to the SDK
handler unchanged.
OpcUaUserIdentityFactory.UserName is now the one UserName identity the driver, the
AdminUI browse and Client.Shared (CLI/UI) present. Client.Shared references .Session
for it.
Tests: handler unit tests (all three RSA policies, the 1.5-required algorithm URI,
SDK 2 server-side decrypt, the session Copy() keeps the type, None unchanged, and an
SDK control pinning the 64/65-byte switch); a Client.Shared identity test; and an
SDK2<->SDK2 integration test where a 65-char key is admitted in both formats and a
wrong 65-char key reaches the key check (BadIdentityTokenRejected). The #800 Windows
gate's SDK 2 CLI 65-char case now gates against the 1.5 server too.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
The SDK starts Server.ServiceLevel at 255 (full service), and OpcUaPublishActor publishes nothing
until the node's first redundancy snapshot arrives, so a restarting pair node advertised 255 for the
~10-13 s it took to join, out-ranking the real Primary (250) for any client that picks the highest
ServiceLevel.
OtOpcUaSdkServer now writes StartupServiceLevel = 0 in OnNodeManagerStartedAsync: after the SDK's
CreateServerObjectAsync wrote 255 and before the server goes Running, so no session can ever read
255. 0 is the design's existing Down / Detached tier (ServiceLevelCalculator returns 0 for a member
that is not Up/Joining, and a node with no snapshot has no role yet). It is below the SDK 2
session-headroom floor (200), so it stands through session churn before the #884 claim, and the
claim's bind replays any level already requested. Single-node deployments are unaffected beyond
the window: the first snapshot publishes 250 as before.
New Node_with_no_requested_level_advertises_the_startup_level_until_one_is_published: reads 0
before the bind, 0 after a bind with nothing to replay, then 250 once published. Without the
startup write it reads 255 on the first session (mutation-checked). docs/Redundancy.md updated for
#884 and #885.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
The claim test disposed its sessions, which may leave them to time out rather than raise
SessionClosing, so only the SessionCreated half of the SDK's session-headroom overwrite was
proven. Sessions now close explicitly, a later level (230, inside the SDK's >= 200 overwrite
band for every case, where the old level-10 left the 200 case at 190 and so unexercised) is read
back on a surviving session after another one closes on its own. Probed without the claim: the
close-only read fails (255) in all three cases.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
SDK 2.0.1 recomputes Server.ServiceLevel from session headroom on every session create/close and
overwrites any value >= 200 unless a provider has claimed it through
IServerServiceLevelControl.ClaimServiceLevelControl. SdkServiceLevelPublisher never claimed it, so on
the docker-dev pair both nodes read 255 as soon as a client connected while their own metrics showed
250 (Primary) and 240 (Secondary); the Stale tier's 200 was lifted to 255 too. Levels below 200 (100,
0) were untouched, which is why the bootstrap 100 showed briefly.
The publisher now claims the value at construction and writes through the SDK's writer; a server
without the claim (test doubles) keeps the diagnostics-guarded write. New
Published_level_survives_later_sessions (250/240/200) fails without the claim (reads 255) and passes
with it.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
HistorianGateway answers a per-source ReadEvents (Source_Object = DevWinPlatform) with the
object's own events (SourceName DevWinPlatform) AND its attribute alarms, which it reports as
<Object>.<Attribute> (Source_ProcessVariable, e.g. DevWinPlatform.CPULoadAvg). The Gateway
backend's defensive client-side source filter compared SourceName for equality, so it dropped
the attribute alarms: 70 of the 72 DevWinPlatform events on vd03.
The filter (and the past-the-cap "more events" signal) now uses EventSourceMatch: the source
itself, or <source>.<anything> on the '.' boundary, ordinal. DevWinPlatform never matches
DevWinPlatform2 or DevWinPlatform2.X. This needs no wire change and still makes one request.
Each event keeps its own SourceName. SourceNode stays the condition its stored alarm id names,
which is null for AVEVA rows. The attribute events ride the notifier's own read, so the
per-folder HistoryRead verdict (#826) governs them: a denied folder answers
BadUserAccessDenied before any backend call. The SqlServer and QuestDB backends still match
exactly; they hold only OtOpcUa's own events.
Tests:
- GatewayAttributeEventSourceTests (16): the boundary theory, attribute-in/sibling-out through
the data source, the drain signal, and an exact attribute-named read.
- AttributeEventHistoryWireTests (2, over a real SDK session with the production Gateway
backend on an AVEVA-shaped deterministic gateway): object + attribute events with their own
SourceName, a prefix sibling excluded, paging, and the ACL-on deny with no backend call plus a
granted positive control.
- Mutation-checked: exact match fails both wire tests; dropping the '.' boundary fails the
wire sibling check and 6 unit tests.
- Live AVEVA harness re-run against vd03 HistorianGateway ea9f21c (read-only, no WinRM):
folder DevWinPlatform 2 -> 72 events (whole, 72 1-event pages, reverse); the new #882 check
passes; 221 of 225 shared cases are identical to the #801 SDK2 baseline, and the 4 that
differ are exactly the DevWinPlatform event cases. Recorded in aveva-history-801.md.
Follow-up #886: production folders name RawPaths/EquipmentIds, not Galaxy objects, plus the AVEVA
severity/condition property mapping.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Every Boolean under Server/ServerCapabilities/HistoryServerCapabilities read
false (the SDK default) even though data and event history were served, so a
spec-following client skipped HistoryRead entirely.
Each backend now declares what it serves through its own interface:
- IHistorianDataSource.ReadCapabilities (Data / Events / ServerTimestamps):
Gateway Data|Events; SqlServer + QuestDb add ServerTimestamps (they store
ServerTimeUtc, the Gateway stamps read time); Null declares None. A
reflection guard requires every production source to declare it itself.
- IHistorianDataUpdater.Capabilities (required member): SqlServer and QuestDb
declare Insert/Replace/Update/DeleteRaw/DeleteAtTime; the Gateway provider
has no updater.
The node manager writes the flags on every source/updater assignment
(AdvertisedHistoryCapabilities), and the same declarations drive dispatch:
an update the updater does not declare is BadHistoryOperationUnsupported
without reaching it, the HistoryWrite AccessLevel bit follows a non-empty
declaration, and event-history notifier promotion follows the Events flag.
Event and annotation update flags stay false (nothing dispatches them).
Tests: booted-server wire test per backend + Null + a SqlServer->Gateway
rewire pairing on one session; dispatch gating unit test; the #801 live
gate now asserts the Gateway flags (not run here).
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
AvevaGatewayHistoryLiveGateTests boots the server in-process, wires the production
Gateway backend (HistorianBackendRegistry -> GatewayHistorianDataSource) to a real
HistorianGateway and drives HistoryRead over a real SDK session: raw (all, paged,
reverse, bounds), modified, the ten served aggregates and seven refused ones, AtTime,
events (all, 1-event pages, reverse), a missing tag, HistoryServerCapabilities and
node metadata, and a HistoryUpdate that must be refused (sandbox-bound node only).
Every wire answer is compared with the backend's direct answer; the run is dumped as
JSON for cross-SDK and SQL comparison. Skips unless HISTGW_GATEWAY_ENDPOINT and
HISTGW_GATEWAY_APIKEY are set; read-only by construction.
scripts/validation/aveva-history-801: run/compare instructions, the SDK 1.5.378
baseline port patch (API spelling only), and the diff / SQL / aggregate / histgw-cli
comparison scripts.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Profiled the remaining lmxopcua#789 misses on windev, a VMware VM, using per-thread CPU and context-switch accounting
plus ETW CPU sampling.
- One blocking hand-off costs ~26 us of latency and ~18 us of CPU there, about 10x macOS.
- An idle evaluation makes four hand-offs in a row: host writer -> worker reader -> evaluation thread -> host reader
-> dispatcher.
- 48% of host CPU at 10k/s is in the kernel: the per-RunAsync NtWriteFile, thread-pool wakes, reads and waits.
Spin-before-block was measured at every hand-off and is not kept. It meets 150 us idle p50 (118 us) only by keeping
about two host cores and four worker cores busy for one stream. At 10k/s it reads +250 points.
Harness: new "paced 10,000/s, batched" row. It uses the same rate and keys, with each 1 ms tick submitted as one
RunBatch call, which is the production seams' shape. It runs after the original four phases, so their rows stay
comparable. On windev it halves host CPU, to +36..+45 points against +71..+84 unbatched. That is still above 25.
Design doc:
- 12.8: the record.
- 14 #15: the owner's decision on the Windows budgets. Recommended: measure on bare-metal Windows, then state the two
budgets per platform, with the batched row as the gate. The alternative is a shared-memory transport.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
A folder event item under enforcement withholds an event that names no live
condition; flag off, the SDK's own type + SourceNode check applies. Positive
controls: the same event reaches the item flag-off and a condition event
reaches it flag-on. Mutation-checked: IsNotifierEventVisibleTo returning true
for a non-condition event fails the test.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Adds GatewayGalaxyStreamAttachedAckLiveTests (skip-gated on MXGW_ENDPOINT +
GALAXY_MXGW_API_KEY, like the other gateway live tests). Proves what the unit
suite cannot: the request flag reaches a real gateway and the sentinel comes back.
- live session advertises stream-events-attached-ack; GatewayGalaxySubscriber reports it
- raw stream: sentinel is the first message when requested (family Unspecified,
worker_sequence 0, live_after 0) and never sent when not requested (control,
with a data event as the positive control)
- real GalaxyDriver + EventPump + GatewayGalaxySubscriber behind a pass-through
recorder: exactly one sentinel, ack observed before the first SubscribeBulk,
pump StreamAttached completed, sentinel never dispatched nor counted as
received (galaxy.events.stream_attached=1, received == non-sentinel events),
no missing-ack warning within the production 1 s budget, and the static
attribute TestMachine_002.Tagname delivers its initial value
- control: a subscriber that opts out gets no sentinel and the pump never
reports one, while the #865 fallback still delivers
Run 2026-11-12 against windev 10.100.0.48:5120 (mxaccessgw 447070a5): 4/4.
Mutation checks, each caught: subscriber not requesting the ack (sentinel
count 0); driver skipping the ack wait (wire order became stream | subscribe |
ack, 2/2 runs, so the race is real on a live gateway); pump not intercepting
the sentinel (received=2 vs 1 non-sentinel).
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
mxaccessgw#227 (Contracts/Client 0.4.0) adds an opt-in attach acknowledgement on
StreamEvents: a StreamAttached sentinel MxEvent sent first, only after the
stream's subscriber is registered on the session. This closes the #865 attach
race that waiting for "stream requested" only narrowed.
- Bump vendored MxGateway.Client/Contracts 0.3.0 -> 0.4.0 (nuget-packages/,
pulled from the Gitea feed; local-mxgw stays the mapped source).
- IGalaxySubscriber.SupportsStreamAttachedAck (default false, so fakes and older
seams keep the fallback); GatewayGalaxySubscriber reads it from the live
session's capabilities (stream-events-attached-ack) and passes
emitStreamAttached accordingly; TracedGalaxySubscriber forwards it.
- EventPump: records AttachAckExpected before the stream opens (published
before StreamRequested completes); intercepts ev.StreamAttached ahead of the
ReplayGap check - completes StreamAttached and continues: never dispatched,
never counted as received, never a resume cursor (worker_sequence 0). The
signal also completes when the loop ends so no waiter hangs. One pump = one
stream, so a reconnect's new pump has a fresh signal. New counter
galaxy.events.stream_attached.
- AwaitCurrentEventStreamAsync: with the capability, waits on StreamAttached
within the same StreamRequestWait budget (1 s); a missing ack logs a
rate-limited Warning and proceeds instead of hanging. Without the capability
the old StreamRequested wait is the fallback.
Tests (StreamAttachedAckTests, 6): ack-gated subscribe gets its initial value;
capability absent uses the fallback; missing ack times out with a warning; the
sentinel is never dispatched; a dead stream releases ack waiters; reconnect
gives each stream a fresh signal. With the ack wait skipped, 3 of them fail.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
The rule fix itself landed in 1a08b4f7 (detail-0 failure rows route on
category); its test only exercised raw_detected_by = 3. Add per-source
coverage for every MxStatusSource value mxaccessgw's MapSource emits
(-1, 0..5, and an out-of-range value) across the failure categories, plus
the live-captured shape the old rule got wrong (OperationalError detected
by the responding automation object, mxaccessgw docs/AlarmProbeFindings.md)
and a RequestingLmx CommunicationError positive control. All 10 new cases
fail against the old 'detail 0 && raw_detected_by != 0 => comms' rule.
Comment in FromMxStatus now cites MapSource and says why NMX (2/3) is not
a transport signal. Item 1 (Warning/Pending) stays open pending a capture.
Co-Authored-By: Claude <noreply@anthropic.com>
OpcUaApplicationHost left ServerConfiguration.MaxRegistrationInterval at the
SDK default (30000) with a null RegistrationEndpoint, so SDK 2.0.1's
StandardServer armed its registration timer against opc.tcp://localhost:4840,
which on an OtOpcUa node is the server itself (no LDS). It answered
BadServiceUnsupported and the SDK retried forever, visible since #874.
Registration is now opt-in: ApplyDiscoveryRegistration sets
MaxRegistrationInterval = 0 (the value that keeps StandardServer from arming
the timer) unless OpcUa:DiscoveryServerUrl is set, in which case the server
registers against that URL every OpcUa:DiscoveryRegistrationIntervalMs
(default 30000) over SignAndEncrypt/Basic256Sha256, like the SDK's own
default endpoint. The validator requires an absolute opc.tcp:// URL and a
positive interval when the URL is set. Documented in Configuration.md and
OpcUaServer.md.
Co-Authored-By: Claude <noreply@anthropic.com>
- Host reader: the frame read and its handling are caught separately. Only the
read's I/O exceptions mean the pipe ended; anything HandleFrame's inline
completion work throws now kills that worker as ProtocolFault (outstanding
runs -> worker restarted / BadResourceUnavailable) instead of escaping a
dedicated thread (process crash) or being swallowed as a clean end (an
InvalidOperationException used to be, leaving the worker unread until a Crash
2 s later).
- Worker reader: the same catch-all around HandleFrame (exit ProtocolFault).
- Worker: Cancel() runs on the pool, so a script's blocking
ctx.CancellationToken callback cannot stop the worker reading its pipe.
- Host write-stall check: ScriptWorkerFrameWriter.WriteInProgressFor; the
heartbeat monitor kills a worker whose pipe write has been blocked longer than
max(10 s, silence limit) as KilledUnresponsive, which fails the write and
releases the producer (seam submit, deploy load, Cancel). Fixes the wrong
'heartbeat monitor ends it' comment on the pipe buffer quota.
- StopAsync sends Shutdown off the caller so its 1 s budget holds on a full pipe.
- WindowsBlockingPipeStream refuses the async surface (Stream's base versions
serialise a write behind a waiting read).
- Tests: PipeFaultTests (throwing completion callback -> ProtocolFault and a
BadResourceUnavailable sibling, shard serves again; a worker that stops reading
but heartbeats is killed within the bound and the blocked producer returns,
via the Hello.test_stall_reader_on_diag seam); BlockingPipeStreamTests dispose
with a write blocked on a full pipe, and with a read and a write both pending;
Category=ScriptWorker on the protocol, handshake and pipe test classes.
Mutation checks: no stall kill -> stall test red; catch-all without the fault
-> callback test red (Crash).
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
Gate F (#761) against the VD03 gateway: Galaxy initialize (session open +
platform seed) took 4–5 s against Tier A's 5 s Initialize timeout, so 10 of 40
restarts failed, paid a 10 s retry and left a killed gateway worker.
DriverFactoryRegistry.Register takes optional capabilityDefaults per driver
type (GetCapabilityDefaults); DriverResilienceOptionsParser layers them over the
tier table and under the per-instance ResilienceConfig; the Host wires the
registry into DriverCapabilityInvokerFactory. Galaxy registers Initialize =
20 s (no retry, breaker 2), under the actor's 30 s pessimistic bound.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
The host handed the SDK a provider-less telemetry context, so SDK-side
refusals — identity-token decryption (the SDK 2 RsaEncryptedSecret clock
window, #873), certificate validation, secure-channel faults — left no server
log line (found on windev during the #800 Windows validation). The hosted
service now creates one ServerSdkTelemetry context from its ILoggerFactory
(Warning and above, SDK categories, rate-limited 20/min per category) and
shares it between OtOpcUaSdkServer and the ApplicationInstance; released on
dispose. Test: an untrusted client's rejection reaches the captured log.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
The fixed name lives on the SQL Server shared by every checkout and CI, so a
concurrent run's create or finally-drop could collide (failed once in the
local pre-push suite, 3/3 green on rerun; cause plausible, not proven).
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
Since #855 condition events are queued to folder event items under the node
manager Lock. At a full DiscardOldest queue the SDK's EventQueueHandler
dequeues the head of EventMonitoredItemQueue, a List.RemoveAt(0) shift of the
whole queue (~1 us/item at QueueSize 1000, ~10 us at 10000).
OtOpcUaSdkServer.CreateMonitoredItemQueueFactory now wraps the SDK factory in
RingEventQueueFactory: event queues are RingEventMonitoredItemQueue (O(1)
discard), data-change queues, durable requests and disposal go to the SDK
factory unchanged. A factory set through StandardServer.MonitoredItemQueueFactory
is used as-is.
The SDK queue is storage only (decompiled 2.0.1): DiscardOldest, overflow-event
positions and discarded/overflow counters live in EventQueueHandler, which is
unchanged. The ring reproduces QueueSize 0 initial + BadInternalError enqueue,
drop-oldest at Count == QueueSize, FIFO dequeue, SetQueueSize keep-newest /
keep-oldest, the newest-1000 duplicate window via IsEventRaisedFor, no-op
Dispose. RingEventMonitoredItemQueueParityTests drive both queues, and the SDK
EventQueueHandler over both factories, with the same random sequences.
FolderEventDeliveryPerfTests gains Ring_vs_sdk_event_queue (A/B in one process
via the internal OtOpcUaSdkServer.RingEventQueues switch).
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
Every raw detected_by value names an MXAccess layer (requesting/responding
LMX, NMX, automation object); the gateway's own failures arrive as
ProtocolStatus. The 'non-zero source = transport failure' rule made a failure
the responding object detected a comms error. Item 1 of #872 (Warning/Pending
categories) stays open pending a captured row.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
The gateway fills Statuses on every data change (live OnDataChange, value-cache
replay and session snapshot all carry Quality too), and EventPump used the status
row whenever one was present, so Quality was never read: an off-scan, Bad or
Uncertain attribute with an OK status row was published Good. A failure status
row still wins; otherwise the quality byte grades the value (legacy parity:
quality >= 192 Good).
Live, read-only on VD03 (DEV): healthy ScanState and _TestArea attributes stay
Good (0x00000000) via quality 192; the stopped JoePlatform/JoeAppEngine ScanState
now publish BadCommunicationError (0x80050000) instead of generic Bad, with no
"unrecognised quality byte" warning.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
- MeshPskProviderRefreshTests: GatedResolver.Entered was set by the priming
refresh and never reset, so Wait() returned before the held refresh reached
the store and the mid-refresh call-count snapshot raced its increment
(expected 1, was 2). Hold() now resets it.
- FailedCredentialLockoutTests all-bad burst: the 15 waiters could queue
before a slot holder ran the increment after BeginAsync (authenticated 0
vs 5). The wait now covers both conditions; the security assertions are
unchanged.
Both looped 20x locally, 0 failures.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
The gateway copies MXSTATUS_PROXY.detail verbatim, so a failure row's detail is
always an MxStatusDetail code. FromMxStatus routed any detail <= 0xFF through the
OPC DA quality table: MX_E_PlatformCommunicationError (2) on a stopped platform's
ScanState logged "Unrecognised MXAccess quality byte 0x02" and published generic
Bad (VD03 live capture), MX_E_InvalidObjectId (8) became BadNotConnected,
MX_E_Overflow (20) BadCommunicationError, and a detail in the Uncertain band
published Uncertain for a failure.
Every non-zero detail now maps per the gateway.md vocabulary (timeout,
communication, configuration, type, range, access, initialising), then by
category; the category-fallback warning logs once per (detail, category).
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
- The filter-group registry holds groups weakly (the filter->group table is
the only strong reference), sweeps dead entries amortised and counts only
live shapes against the 4096 cap; filters past the cap are counted on the
new meter otopcua.opcua.condition_events.filter_groups_unshared.
- A group validates its where clause too (restored, never-validated filters
evaluate like validated ones).
- Test: value-equal filters cost one projection per locale set per report.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
- Filter-group buckets include the where clause's (value-based) hash, so
per-source filters sharing a select list are not all scanned under Lock.
- Locale keys are cached per session array.
- Tests: a notifier reached twice in one walk (Line above two wired
equipment) gets each event once on both paths (fails without the
per-report set); queued events re-projected by a modify are identical on
both paths; locale keys unambiguous and where clauses hash by value.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
- A filter group owns a deep clone of its first filter, validated once and
evaluated under the group's lock: no item-owned filter is mutated or raced
(Validate mutates a select clause).
- The projection is computed in the SDK's order: where clause first, fields
only for an item that will queue.
- Unambiguous locale key; counter for filters given a private group past the
registry cap; DeliveryCache getter returns its own allocation.
- UseProjectedEventDelivery=false also asks the role and grant checks per
item, so the equivalence test compares against the pre-optimisation path.
- Equivalence test: same select list under three where clauses, a filter
modified mid-run, a session with other locales, an ungranted anonymous
Server-object item, per-rig counter; plus a small overflowing queue with
DiscardOldest true and false.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
Condition events delivered to many event items (folder and Server-object)
no longer pay the SDK's per-item QueueEvent(IFilterTarget) work under the
node-manager Lock: the role-permission verdict is shared per (session,
identity), the #826 grant check per identity per hop, and the where clause +
select-clause projection per (value-equal EventFilter, locales); each item
gets its own EventFieldList over the shared read-only fields. The SDK's
QueueEvent body is reproduced under the item's own lock (three private SDK
members, guard-tested); anything outside that shape falls back to the SDK.
The per-report duplicate check replaces the SDK's O(queue) scan.
ProjectedEventDeliveryWireTests compares both paths over the wire.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk