Commit Graph

885 Commits

Author SHA1 Message Date
Joseph Doherty 1806af54f0 fix(ab): Task 22 review r1 — release-note timing and AbLegacy regressions, probe E2E on both transports
- I1: the upgrade restart itself moves every no-key AbCip/AbLegacy driver to Native (every process
  start constructs drivers, incl. boot-from-cache). Docs + CLAUDE.md now say so and tell operators
  to set "transport": "Libplctag" BEFORE upgrading (older builds ignore the unknown key).
- I2: AbLegacy release note leads with the status-bit write refusal (T4:0.DN, C5:0.CU, R6:0.EN, ...)
  on defaulted SLC/MicroLogix drivers and the fallback. The real client status is
  BadCommunicationError, not BadNotSupported (the driver's write catch-all maps the runtime's
  AbTransportException like any transport failure, as #571 does for PLC-5), with handle eviction
  and Degraded health; pinned by a driver-level test (no 0xAA write on the wire, with a positive
  control). ST string byte order stated as unverified. Also corrects the docs' "T4:0/DN" examples:
  the driver's address grammar is "T4:0.DN" ("/DN" is a malformed address).
- I3: DriverProbeHandshakeE2eTests' two AbCip probe tests are a [Theory] over Native and
  Libplctag. Run against ab_server controllogix: all 4 rows pass with identical exact messages.
- M1: documented (docs + resolver XML doc): the AdminUI merges only the probed device, so a
  mixed-family defaulted AbLegacy driver can probe Native while running Libplctag.
- M2: factory ParseEnum rejects undefined integer strings ("7") via Enum.IsDefined (both drivers).
- M3: AbCip driver-level bad value reads "AB CIP config in '<id>' has unknown Transport ..."; the
  named constant is gone (tagName: null now means driver-level).
- M4: the explicit-Native limited-family Warning is once per instance, not per (re)init.
- Nit: EffectiveTransport docs note that injected factories bypass it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:06:40 -04:00
Joseph Doherty ed0881d672 feat(ab): native managed transport is the default for AbCip and AbLegacy; libplctag remains as fallback
Task 22 (spec section 8 P5), with the controller rulings that exclude PLC-5 and LogixPccc.

- Transport is nullable on both option records; null (key absent) means "use the default" and is
  never serialized. Factory DTO parse: absent -> null; a bad value still fails via ParseEnum.
- One resolver per driver (AbCipTransportResolver / AbLegacyTransportResolver), used by the driver
  constructor, the Test Connect probe and (through the driver) the CLI.
  AbCip: null -> Native. AbLegacy: null -> Native unless any device is Plc5 or LogixPccc, then the
  whole driver resolves to Libplctag. Explicit values always win; explicit Native with a
  Plc5/LogixPccc device logs one Warning at init.
- Init log shows configured vs effective, e.g. transport=Native (default).
- AdminUI forms: "Default (...)" / "Native (managed)" / "libplctag (fallback)". Default omits the
  key on save (reverses Task 19's explicit re-emit, which would have pinned every edited driver).
- CLIs: --transport default|native|libplctag, default "default" via the resolver, so
  --plc-type Plc5 with no --transport runs libplctag.
- Tests pinned to Libplctag where a default-path runtime was created: found by instrumenting the
  native factories with the current xUnit test name (one unit test, AbLegacyReinitConfigAdoption;
  plus the two fixture-gated AbCip probe E2E tests whose exact messages were proven on libplctag).
  TransportMatrix and the differential harness already pass explicit transports.
- Switch/resolver/CLI/form tests are falsified by mutating the resolver (default -> Libplctag,
  exclusion removed, AbLegacy default -> Libplctag): each mutation turns them red.
- Replaces the inline role-name literal in AbCipDriverFactoryExtensions' ParseEnum call with a named
  constant; RoleAuthorityGuardTests was already red on the base commit for that line.
- Docs: AbCip.md / AbLegacy.md default + release note; CLAUDE.md AB section.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:49:33 -04:00
Joseph Doherty b8ebafc642 feat(adminui): Transport dropdown on AbCip/AbLegacy driver forms (live-verified on docker-dev)
- AbCip/AbLegacy driver forms gain a Transport InputSelect; FromOptions/ToOptions now carry
  Transport, so saving any other field no longer silently reverts Native to libplctag.
- Both drivers log the constructed transport once per init.
- AbCip factory now takes the host ILoggerFactory (it ran on NullLogger in production, hiding
  every AbCip log line); DriverFactoryBootstrap passes it.
- Gate results: AdminUI /run (T19) native, inverted (dead port) and libplctag legs.
2026-10-07 19:05:50 -04:00
Joseph Doherty c657b2e03d fix(acl): make the API-key store deadline a HARD race (R3-05 S18 live-gate failure)
BoundedApiKeyStore bounded ConfigDbApiKeyStore with a
CancellationTokenSource(TimeSpan, TimeProvider) linked into the inner call. That
bounds the call only if the callee chooses to observe the token, and
Microsoft.Data.SqlClient does not while a connect/login is in flight on a
black-holed socket. The 2026-08-20 live gate paused the SQL container and watched
a keyed activation hang 42.9 s past its 2000 ms bound — the "did not answer
within 2000 ms" Warning fired only at unpause — and because this runs inside the
SDK's synchronous SessionManager-lock-holding impersonation callback, an
unrelated already-open subscription stalled 57 s behind it. That is precisely the
hazard the bound exists to prevent, and the TTL cache cannot help a miss.

BoundAsync now races the inner call against Task.Delay on the injected clock with
Task.WhenAny. Inner wins: stop the timer, await it, faults and genuine caller
cancellation propagate unchanged. Delay wins: cancel the linked token anyway (a
cooperative inner store should still stop), hand the abandoned task to
ObserveAbandoned, meter outcome=timeout, log the now-honest Warning, and throw
OperationCanceledException — the same exception the authenticator's catch-all
already maps to the standard opaque denial. Fail-closed is unchanged: nothing
here can admit.

ObserveAbandoned disposes the CTS and TOUCHES finished.Exception (the observation
itself), so a late SqlClient fault can never become an UnobservedTaskException.
Debug, not Warning — the timeout is already reported at Warning, and an outage
must not produce two lines per activation for one event.

An abandoned answer is NEVER cached: it was taken before the deadline and would
be served after it, so a key revoked or expired in the meantime could be admitted
from a lookup nobody was still waiting for. StoreTimeoutMs <= 0 still
short-circuits to the unbounded call byte-for-byte (the documented rollback).

RED BEFORE GREEN. Every pre-existing deadline test drives GatedApiKeyStore, whose
waits take the token, so a cooperative CancelAfter unblocks them and all 15
passed against the defective build — the suite could not see the gate's failure
at all. The new UncooperativeApiKeyStore ignores ct entirely and completes only
when the test releases it. Against the unmodified store the three new tests fail
3/18 with "should throw OperationCanceledException but threw TimeoutException"
under a test-side WaitAsync(2 s), so the red is a failure and not a hang — and
that exception swap IS the finding restated as an assertion.

Host.Tests 137/137.
2026-08-20 13:29:21 -04:00
Joseph Doherty e280db3753 fix(runtime): comms_latched_refs meter tag key 'driver' -> 'driver_instance' — the R3-04 bare-role-literal guard caught the R3-02 tag key at merge (cross-branch collision; neither branch saw the other) 2026-08-20 11:55:16 -04:00
Joseph Doherty 157d01c2c2 Merge branch 'fix/r3-02-stale-good-family' — R3-02: one quality contract at four seams — comms loss marks raw+UNS value variables BadNoCommunication (WriteStatuses sink member, recovery audit), Calculation + VT hold on Bad / worst-of sub-code / (value,status) dedup, MQTT initial push + ConnectionLost stale, Sparkplug birth replay (archreview 03/S15, 05/STAB-19, 05/STAB-18, 02/S14) 2026-08-20 11:49:06 -04:00
Joseph Doherty 886082fec7 fix(review): calc ReadOne reports an inputs-Bad publish; VT inputs-bad counter ticks only on a real report 2026-08-20 11:48:41 -04:00
Joseph Doherty 62c5bc3aad fix(runtime): a stopping driver child announces comms loss so restart/respawn latches its nodes Bad 2026-08-20 11:07:02 -04:00
Joseph Doherty f1b976f5e2 Merge branch 'fix/r3-04-one-role-authority' — R3-04: EffectiveRoles is the one role authority (Cluster:Roles wins over OTOPCUA_ROLES, normalised, validated, fail-start on unknown/overlay-changed roles); Program.cs + all four validators consume it; case-insensitive gossip predicates; source-scan guard (archreview 01/S-12 = 03/C5, 01/C-9) 2026-08-20 11:05:09 -04:00
Joseph Doherty 1d251cc037 Merge branch 'master' into fix/r3-02-stale-good-family
# Conflicts:
#	src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
#	tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs
2026-08-20 11:03:02 -04:00
Joseph Doherty e3a72dc385 fix(runtime): virtual tags hold on Bad inputs and inherit the worst input status (archreview 02/S14) 2026-08-20 10:53:26 -04:00
Joseph Doherty cc9b845f99 feat(engines): IVirtualTagEvaluator snapshot overload; Roslyn read cache stops fabricating Good (02/S14) 2026-08-20 10:45:11 -04:00
Joseph Doherty d79d51dd4f Merge branch 'fix/r3-01-mesh-grpc-allowanonymous' — R3-01: mesh gRPC + LocalDb sync endpoints opt out of the AdminUI cookie FallbackPolicy (PSK interceptors are the gate); real-auth-stack boundary tests + map-convention guard; rig DisableLogin parameterised (archreview 03/S14 Critical) 2026-08-20 10:43:49 -04:00
Joseph Doherty c8cf5c8032 test(host): the R3-01 falsifiability control asserts the cookie-challenge signature (a bare Exception was satisfiable by an anonymised endpoint too); docs wording 2026-08-20 10:43:49 -04:00
Joseph Doherty 851e4cf5e0 Merge branch 'fix/r3-03-restart-resubscribe-watchdog' — R3-03: SpawnChild always sends the desired set (operator Restart + supervision restart re-provision, stale-alarm-set re-subscribe, incarnation check, 10 s backoff); watchdog grace = max(LastSuccessfulRead, connectedSince) (archreview 03/S16, S17, U6) 2026-08-20 10:40:11 -04:00
Joseph Doherty 0793b0cba8 fix(runtime): rate-limit the supervision reprovision — deferred, not dropped (review F3)
The default decider restarts a faulting child immediately and without limit, and
the hook makes every restart do ShutdownAsync+InitializeAsync+SubscribeAsync, so a
driver faulting on every value became a reconnect storm. Per-driver last-reprovision
stamp + DefaultReprovisionBackoff (10s, matched to DefaultReconnectInterval);
a held request is re-delivered by a single per-driver timer carrying the original
incarnation ref, so the F2 identity check still applies.

Seen RED with the backoff disabled: InitializeCount == 3 (second applied
immediately) where the test requires 2-then-3.

Also: the _children.Keys union-arm test (seeder gained taglessDrivers) and the
_desiredByDriver xmldoc note that it lingers on last-known-good after a failed apply.
2026-08-20 10:39:13 -04:00
Joseph Doherty 3f8d5815b3 fix(runtime): mux adapter forwards the dependency's real status code (05/STAB-19) 2026-08-20 10:30:14 -04:00
Joseph Doherty ae97b32c82 test(runtime): comms-loss → identical-value recovery contract + silent-resubscribe control (03/S15) 2026-08-20 10:27:48 -04:00
Joseph Doherty 2b7100cf19 fix(runtime): route a reprovision by incarnation, not driver id (review F2)
A ReprovisionRequested in flight from an old incarnation could reach the NEW child
after HandleRestartDriver swapped it in. In Connecting that child DOES handle
InitializeRequested, so it started a second concurrent InitializeAsync, racing the
#527 dispose-before-overwrite.

Seen RED: InitializeCount == 2 on a child held in Connecting by a gated init
(an already-connected child swallows InitializeRequested and passes vacuously).
2026-08-20 10:27:02 -04:00
Joseph Doherty 659d5f51e4 test(integration): a Cluster:Roles-only node wires the driver stack 2026-08-20 10:26:34 -04:00
Joseph Doherty f3832aa48c test(host): a mesh gRPC map added outside MeshGrpcEndpoints fails the build
MeshGrpcMapConventionTests - source-scan of Program.cs (solution-root walk,
following FlushLoggingConventionTests.cs's idiom): zero direct
MapGrpcService</MapZbLocalDbSync( occurrences, exactly one
MapOtOpcUaMeshGrpcServices( call.

Complements the endpoint-metadata census (T1-T3): the census catches a map
added to MeshGrpcEndpoints without the AllowAnonymous convention, but cannot
see a map that bypasses the helper and goes straight into Program.cs - that
shape never reaches MapOtOpcUaMeshGrpcServices at all. Verified red against
the pre-T1 Program.cs (five inline map sites, zero mapper calls) and green
against the current extracted form.
2026-08-20 10:25:02 -04:00
Joseph Doherty edd01fcc34 fix(runtime): a changed alarm set re-subscribes instead of latching (review F1)
The alarm subscribe gated on 'a handle exists', which is correct for an idempotent
re-push and wrong for a deploy that widens/narrows the set. Combined with the
spawn-time desired set, a respawning deploy could latch the PREVIOUS revision's
alarm refs for the life of the connection; sourceNodeIds-filtering drivers
(OpcUaClient/AbCip/Focas) would never deliver the new alarm.

Seen RED: LastSubscribedAlarmRefs stayed [alarmX] after a deploy widening to
[alarmX, alarmY].
2026-08-20 10:24:52 -04:00
Joseph Doherty 306ea1aecf test(host): driver-only nodes carry no authorization middleware (03/S14 exemption pinned)
MeshGrpcDriverOnlyPipelineTests - the driver-role shape (no AddOtOpcUaAuth, no
UseAuthentication/UseAuthorization at all) behaves identically to the fused
shape's T4 positive/negative tests for NodeCommandService and
TelemetryStreamService: a correctly-keyed call succeeds, a no-key call gets
the interceptor's PermissionDenied + detail string.

Also asserts the STRUCTURAL reason it is unaffected - no
IAuthorizationPolicyProvider registered at all - so a future "register auth
everywhere for consistency" change fails this test before it ever reaches a
live site outage. Confirmed (by temporarily swapping in the pre-T2/T3 mapper)
that this class passes identically on both builds: driver-only nodes were
never exposed to 03/S14 in the first place, with or without
.AllowAnonymous().
2026-08-20 10:20:56 -04:00
Joseph Doherty 62515f3279 fix(security): pin the cached-key expiry guard, meter cache_hit after it, and close two wiring gaps (review fixes) 2026-08-20 10:18:52 -04:00
Joseph Doherty 7d98ef9332 feat(runtime): audit refs still BadNoCommunication after a reconnect (dedup-trap tripwire) 2026-08-20 10:16:01 -04:00
Joseph Doherty 8471db4b1d test(host): no-PSK mesh calls report the interceptor's verdict, not a cookie 401/redirect
Extends MeshGrpcAuthStackTests (T5): a no-bearer or wrong-bearer call to
DeploymentArtifactService.Fetch, TelemetryStreamService.Subscribe and
NodeCommandService.Execute fails with the INTERCEPTOR's detail string
("Config-serve authentication failed.", "Telemetry stream authentication
failed.", "Node command authentication failed."), asserted alongside the
status code rather than in place of it - the vacuity trap the plan calls out
(ConfigServeAuthInterceptor throws Unauthenticated, and Grpc.Net.Client can
map an HTTP 401 to the same status, so status alone would not discriminate a
broken build).

Verified red-then-green against the T1 (pre-AllowAnonymous) mapper: all 6 new
theory cases fail there with StatusCode.Internal ("HTTP/2 server reset the
stream, PROTOCOL_ERROR") instead of Unauthenticated/PermissionDenied - the
cookie challenge breaking the h2c stream before any gRPC trailers are
written, never a clean auth denial. That is the exact signature the live
gate (T10) should grep for; recorded in the class remarks.
2026-08-20 10:14:55 -04:00
Joseph Doherty 97f4ea0c9e fix(runtime): comms loss marks raw + UNS value nodes BadNoCommunication, value retained (archreview 03/S15) 2026-08-20 10:07:12 -04:00
Joseph Doherty 458c84c34f test(host): mesh gRPC boundary under the production auth stack (FallbackPolicy live)
MeshGrpcAuthStackTests - the CI-shaped guard 03/S14 asked for. A real
AddOtOpcUaAuth(config) + UseAuthentication/UseAuthorization pipeline (cookie
scheme, DisableLogin=false, EnvironmentName=Production) with the production
interceptor set, driving DeploymentArtifactService.Fetch,
CentralCommandService.ReportApply and NodeCommandService.Execute through
MapOtOpcUaMeshGrpcServices.

Verified red-then-green by hand: with the T1 (pre-AllowAnonymous) mapper
swapped in, all three positive calls fail with
"HTTP/2 server reset the stream, PROTOCOL_ERROR" (RpcException/Internal) -
the cookie challenge breaking prior-knowledge h2c, not a clean auth denial.
With the current mapper (T2/T3) all three succeed. That asymmetry is the only
thing distinguishing the two builds; every other assertion here (cookie
handler identity, the falsifiability control) is unchanged by the fix and
proves nothing on its own.

Falsifiability control: LocalDbSyncService is mapped directly (bypassing
MapOtOpcUaMeshGrpcServices) with no .AllowAnonymous(), and a correctly-shaped
call to it is refused - proving the fixture's auth stack is genuinely live,
not accidentally permissive.
2026-08-20 10:06:47 -04:00
Joseph Doherty 5c7e5725a5 test(runtime): first supervision-restart test — child re-initialises + re-subscribes (archreview 03/U6)
Crash seam: the planned null-Snapshot OnDataChange (no SupervisorStrategy fallback needed).
Seen RED with T6 reverted: InitializeCount 1, not 2 — with a preceding ShutdownCount>=1
assertion proving the restart really happened, so the red is not ambiguous with 'never crashed'.
2026-08-20 10:05:45 -04:00
Joseph Doherty e46c1db5e5 fix(cluster): gossiped-role predicates compare case-insensitively — a miscased peer no longer un-elects the Primary 2026-08-20 10:04:16 -04:00
Joseph Doherty 04019e9414 feat(runtime): publish actor routes status-only batches to the sink 2026-08-20 10:03:25 -04:00
Joseph Doherty dfab7f614d test(integration): key expiry stops a live subscription over the wire, with no deploy 2026-08-20 10:00:52 -04:00
Joseph Doherty ec5b48bfcd feat(opcua): IOpcUaAddressSpaceSink.WriteStatuses — status-only batch write retaining the node value (03/S15) 2026-08-20 10:00:47 -04:00
Joseph Doherty a4f12bccef fix(host): run monitored-item revalidation on a low-frequency timer (archreview 03/S19) 2026-08-20 09:55:01 -04:00
Joseph Doherty d97e441979 test(runtime): operator restart re-subscribes the respawned child (archreview 03/S16, U6)
Seen RED against a T3-reverted build: all 4 failed, Created[1].SubscribeCount == 0
on the headline case. 20x isolation run green after the fix was restored.
2026-08-20 09:53:38 -04:00
Joseph Doherty e3cf363703 fix(host): LocalDb sync endpoint opts out of the cookie fallback via an empty-prefix anon group
MapZbLocalDbSync returns IEndpointRouteBuilder, not an IEndpointConventionBuilder,
so .AllowAnonymous() cannot be chained onto it the way the other four mesh maps
are (T2). app.MapGroup(string.Empty).AllowAnonymous().MapZbLocalDbSync() carries
the same convention while leaving the gRPC route pattern byte-identical -
verified directly (LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical).

The census's RealMethodEndpoints filter had to move off a "route pattern starts
with /" heuristic to an exact-route-pattern-set match: inside the empty-prefix
group, grpc-dotnet's own "Unimplemented service"/"Unimplemented method" fallback
endpoints also gain a leading "/", so the heuristic let them back in and
over-counted (caught by DriverOnly_WithASyncPort_AlsoMapsTheLocalDbSyncEndpoint
and LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical going red
against the naive filter before this fix).

All five mesh gRPC endpoints now carry IAllowAnonymous (archreview 03/S14, T3).
2026-08-20 09:51:33 -04:00
Joseph Doherty b09f78396f test(opcua): pin clock-driven expiry in the revalidation pass; correct the only-trigger remark 2026-08-20 09:50:39 -04:00
Joseph Doherty bd80ff96ca feat(security): AclRevalidationTimer — clock-driven monitored-item revalidation (archreview 03/S19) 2026-08-20 09:46:56 -04:00
Joseph Doherty b05ebd6353 fix(host): mesh gRPC endpoints opt out of the AdminUI cookie FallbackPolicy (archreview 03/S14)
.AllowAnonymous() on DeploymentArtifactGrpcService, CentralCommandGrpcService,
TelemetryStreamGrpcService and NodeCommandGrpcService — the four MapGrpcService
maps that are chainable. On a login-enabled admin node the global cookie
FallbackPolicy (RequireAuthenticatedUser) applies to every endpoint without
explicit authorization metadata, gRPC included; the PSK bearer interceptors run
INSIDE the endpoint invocation, strictly after that middleware, so a
correctly-keyed node call was challenged before the interceptor's verdict could
matter. Safe because all four interceptors fail closed on an unset key.

The census now asserts IAllowAnonymous on all four (red before this change,
green after) plus a scope guard proving the opt-out never leaks onto an
unrelated endpoint. MapZbLocalDbSync (T3) is not chainable the same way and is
asserted absent here on purpose, so this test cannot start passing for the
wrong reason once T3 lands.
2026-08-20 09:46:30 -04:00
Joseph Doherty 39a10a277a fix(security): wrap the ConfigDb key store on admin nodes + registration guard 2026-08-20 09:45:08 -04:00
Joseph Doherty 6048257dfe feat(observability): meter bounded API-key store lookups 2026-08-20 09:43:07 -04:00
Joseph Doherty 6dab72a479 refactor(host): extract MapOtOpcUaMeshGrpcServices (one authority for the five mesh maps)
Behaviour-preserving: Program.cs:743-784 collapses to a single call, same gates,
same order, every existing per-map rationale comment carried over. A new
endpoint-metadata census (MeshGrpcAnonymousMetadataTests) drives the SAME
production mapper across all three role shapes (admin-only / driver-only /
fused), so the guard can never assert a shape production no longer has -
archreview 03/S14, T1.
2026-08-20 09:42:49 -04:00
Joseph Doherty b3a71f6655 test(runtime): recording subscribable driver factory + enabled-driver v3 seed 2026-08-20 09:42:33 -04:00
Joseph Doherty 37288d4df5 feat(security): short TTL read-through cache in front of the ConfigDb key store 2026-08-20 09:40:56 -04:00
Joseph Doherty 3b56e5bd01 fix(security): bound the API-key store call under the SessionManager lock (archreview 03/S18) 2026-08-20 09:38:41 -04:00
Joseph Doherty 894444376e feat(security): register TimeProvider + S18/S19 option knobs (archreview 03/S18,S19) 2026-08-20 09:36:14 -04:00
Joseph Doherty a801e2aefb fix(runtime): comms-loss grace is per connection — max(LastSuccessfulRead, connectedSince) (archreview 03/S17) 2026-08-20 09:32:30 -04:00
Joseph Doherty 89630ee418 fix(acl): browse transit reaches the shared root folder (narrow Browse grants were unreachable)
With Security:Acl:Enabled=true, a session holding any grant narrower than
Cluster could not browse the served address space at all — confirmed by the
live gate.

Mechanism. Both realm trees hang under ONE anchor folder minted in
CreateAddressSpace: NodeId("OtOpcUa", <raw ns>). ResolveParentFolder parents
every top-level raw folder AND every top-level UNS area under it, and the
Browse override throws BadUserAccessDenied for the WHOLE call when its source
node is denied. DataPlaneAuthorizer.BuildScope split that identifier like a
path, producing {Kind: Raw, Segments: ["OtOpcUa"]} — but trie grant paths are
["raw", <RawPath segments…>] and "OtOpcUa" is never a path segment, so
PermissionTrie.HasBrowseTransit failed at the realm-child lookup and
CollectMatches yielded only Cluster-scoped grants. Deny, at the front door.

The trie already handled a zero-segment scope as "the realm root, itself an
ancestor of everything granted in that realm" (PermissionTrie.cs:101-118) —
no production path had ever produced that scope. This wires it up.

- V3NodeIds.SharedRootFolderId names the anchor identity; CreateAddressSpace
  now mints the NodeId/BrowseName/DisplayName from it (behaviour-identical,
  makes the coupling explicit).
- BuildScope maps that identifier (realm-qualified to Raw — in the UNS
  namespace s=OtOpcUa is an ordinary Area) onto the zero-segment Raw scope.
- Evaluate adds a second-realm OR-leg for Browse on the anchor only: it fronts
  both trees, so a principal whose only Browse grant lives in the UNS tree
  still enters through this raw node. Browse only; transit confers navigation,
  never read/write/subscribe.
- No trie, evaluator or builder change. No reordering above the decision cache
  — key revocation is still re-checked before the lookup — and the cache key
  is unchanged (the combined verdict caches under the anchor's own
  identifier/realm; a generation advance clears it).

Falsifications actually run (implement-then-revert-the-line, plus one
genuine red-first):

- DataPlaneAuthorizerTests: the 7 new facts were written BEFORE the fix and 4
  went red (the 3 deny-only ones passed vacuously, as a deny-only test must).
- Deleting the UNS OR-leg reddens exactly one fact,
  The_shared_root_folder_is_browsable_on_a_uns_only_browse_grant (26/27 pass).
- Dropping the realm qualification from IsSharedRootFolder reddens exactly
  A_uns_area_named_like_the_anchor_gets_no_anchor_semantics. Its first draft
  did NOT catch this — a raw-only grant is refused either way; the
  discriminating grant had to be an unrelated UNS one, whose own transit marks
  the zero-segment UNS scope the un-qualified mapping would land on.
- Disabling the BuildScope anchor branch reddens the new end-to-end fact
  NodeManagerStage6EnforcementTests.The_shared_root_folder_is_browsable_on_a_narrow_grant_and_still_filters
  (real booted server, real Browse dispatch: BadUserAccessDenied on the whole
  call), which also pins that the reference filter still withholds the
  non-granted sibling.
- Core PermissionTrieBrowseTransitTests gains 3 characterization facts pinning
  the previously-dead realm-root branch (transit true for the granting realm's
  root, false for the other realm's, false without the Browse bit). These were
  green on arrival — the trie was always correct; nothing called it.

Tests: Core 351 -> 354 passed; OpcUaServer 482 -> 490 passed. Build clean.
2026-08-18 09:45:04 -04:00
Joseph Doherty 837d8a17eb test(scripting): timeout under starvation is not a leak; share the exhaustion-reason constant
Review follow-ups to 34829ea0.

IMPORTANT - the leak filter had its own flake vector. This test saturates the
thread pool by design, and TimedScriptEvaluator starts its 5s clock at WaitAsync
rather than when the inner Task.Run gets a thread, so a legitimately-enqueued
evaluation can blow the budget while still queued and surface "script timed out
after 5.0s". The reason != exhaustion filter classified that as a raw disposed
escape and hard-failed on it - handing the test back the load-fragility the
reshape removed. Timeouts are now their own bucket, tolerated and rate-bounded
exactly as exhaustions are; a raw disposed escape still fails on first occurrence.
Doc-comment names this second residual vector alongside the exhaustion calibration.

MINOR (scope extension authorised by review) - "evaluator disposed during
evaluation" existed as three unshared literals. Promoted to
ScriptEvalFailureReasons alongside VirtualTagEvalResult in Commons.Engines (the
assembly all three already reference) and referenced from both evaluators and the
test. The timeout prefix joins it, since the test now classifies on it too. String
values are byte-identical - no behaviour change.

Falsified: with a 12ms script budget the race produced real timeouts (3/2695).
Classified as leaks (the pre-review filter) they hard-failed the test 6/6 items;
bucketed as starvation they sit 0.11% under the 10% bound and the test is green.
2026-08-18 08:05:51 -04:00
Joseph Doherty 34829ea0d7 test(scripting): Evaluate_racing_ClearCompiledScripts no longer assumes a single clear per evaluation
The racing test encoded two timing assumptions and failed under full-solution
parallel load while passing in isolation. Both are removed; no production code
changed.

1. Assertion shape. failures.ShouldBeEmpty() assumed at most ONE apply-boundary
   clear can land inside a single evaluation, so the evaluator's one-shot retry
   always suffices. Under contention two can land and the retry legitimately
   exhausts. The test now asserts the invariant the retry actually guarantees and
   which is timing-independent: no raw disposed-script failure ESCAPES the retry.
   Every failure must be the documented exhaustion outcome ("evaluator disposed
   during evaluation") and never the pre-fix generic escape ("script threw:
   Cannot access a disposed object..."), plus a deliberately loose bound on the
   exhaustion rate so the check cannot go vacuous.

2. Workload shape. The fixed 12x400 evaluations against a fixed 45s WaitAsync
   budget is CPU-dependent (every clear forces a recompile). Four concurrent
   instances of this class reproduced a hard TimeoutException 4/4 - the likely
   real shape of the reported load failure. The loops are now duration-bounded
   (10s race window or 400 iterations, whichever first); the 45s WaitAsync stays
   purely as a hang detector.

Renamed to Evaluate_racing_ClearCompiledScripts_never_leaks_a_raw_disposed_failure
so the name matches what it now asserts; the old name is cited in the doc-comment
for traceability.
2026-08-18 07:48:44 -04:00