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.
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).
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.
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].
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().
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.
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.
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'.
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.
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).
.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.
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.
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.