AccessContext Propagation β€” the identity baton

A user's identity (AccessContext) flows from the authentication boundary through every async hop, every message hand-off, every handler, and every downstream post. The framework makes this look like a single-threaded execution under one principal β€” even when work crosses thread pools, schedulers, hubs, and grain activations.

This document explains the model, the six propagation phases, the sanctioned exceptions, and the anti-patterns to avoid.

🚨 For the OWNER case β€” read Owner Injection. When a node's own hub acts with no live caller (a watcher tick, a deferred sync write, a cold-start activation), the node owner is the standing identity, injected everywhere and carried forward via CircuitContext (not Context, which is wiped across Rx hops). Genuine infra (doc sync, cache hydration) runs as System; an empty context is rejected, never faked. That page covers the cold-start submit deadlock this prevents.


🚨🚨🚨 THE INVARIANT: AccessContext must ALWAYS be set β€” never null

Every message a hub posts carries an AccessContext. There is no null, and there is no fourth source β€” exactly three:

Source Who How
User User-facing hubs β€” the per-circuit portal hub, HTTP-request hubs, per-node hubs handling a user's request The user's AccessContext is live on the AsyncLocal (AccessService.Context / CircuitContext) when the hub posts. This is the default posting identity.
System Framework infrastructure β€” routing (the courier) and persistence (the store) The hub is declared WithPostingIdentity(PostingIdentity.System); its own otherwise-unattributed posts are stamped system-security automatically (bypasses RLS β€” the courier/store is not user-gated).
Owner Threads & activities AccessContextScope.FromNode(node) β†’ node.CreatedBy β€” the post runs under the thread/activity owner's credential. 🚨 In a REACTIVE pipeline resolve the identity and use access.RunAs(owner, () => work), never Observable.Using(() => AccessContextScope.FromNode(…), …): FromNode/AsSystem are SwitchAccessContext/ImpersonateAsSystem under another name, and Using latches the identity on the subscribing thread (#1790).

Posting identity is a per-hub CONFIGURATION decision, declared at hub startup

It must be unambiguous which identity a hub posts under β€” this is a config property, not a per-callsite concern. Set it once when the hub is built:

// DEFAULT β€” user-facing hub. Posts as the ambient user; UNHAPPY when no user context.
config                                              // PostingIdentity.User (implicit)

// Framework infrastructure (routing, persistence). Posts as system-security.
config.WithPostingIdentity(PostingIdentity.System)

The three modes (PostingIdentity enum, consumed by UserServicePostPipeline):

  1. User (default). The hub posts as the user β€” it wants the identity set on the AsyncLocal AccessService.Context. When that AsyncLocal is not set and the message is not exempt, the post is UNHAPPY: the PostPipeline logs an error (the CI tripwire on the MeshWeaver.AccessContext channel) and fails the delivery immediately β€” no identity, no delivery. The awaiting hub.Observe(...) gets a clean OnError; the post is never thrown out of Post (Post is fire-and-forget from countless callsites β€” a synchronous throw would be unobserved or crash an unrelated path) and never silently left null (that masked the empty-agent-registry / prod EventCalendar bug).
  2. System. Routing and persistence. The hub's own otherwise-unattributed posts are stamped system-security. Never overwrites a user identity already on the delivery (a forwarded user delivery, or a response inheriting the request's identity via ResponseFor) β€” System is only the fallback for the hub's OWN posts.
  3. Cannot post β†’ fail the delivery. The User-mode fallback above is this: a hub that can resolve no identity does not get to post a non-exempt application message.

Exempt traffic β€” the only messages that may carry a null context

Genuinely identity-free framework traffic is exempt from the never-null rule and is delivered (not failed) even with no context:

🚨 Cross-cutting: only hubs REGISTERED IN THE MESH may post

This invariant only holds if every posting hub is a real, registered participant in the mesh β€” exactly how the portal hub is keyed and registered (PortalApplication β†’ hub.GetHostedHub(CreatePortalAddress(circuitId), …), one hub per circuit, addressable + routable). A hub that is not registered in the mesh is not allowed to post β€” except a hosted hub, which is registered with (and owned by) its parent via GetHostedHub. The rule:

This is a cross-cutting concern: it touches hub construction (MessageHubConfiguration.WithPostingIdentity), the post pipeline (UserServicePostPipeline), the circuit/auth boundary (CircuitAccessHandler β†’ CircuitContextAccessor), routing and persistence (declared System), and every hub-registration site. Reference: memory feedback_access_context_always_set; tests AccessContextNeverNullTest, PostPipelineAccessContextTest, SubscribeRequestIdentityRoutingTest.


Mental model β€” piecewise single-threaded flow

Think of the system as a chain of short, synchronous pieces of work. At any moment, exactly one piece is running on one thread under one principal. That principal is the identity baton.

The baton follows a predictable life cycle:

Inside any single piece, application code can ignore identity entirely β€” it's already correct. The framework owns every hand-off.

sequenceDiagram autonumber participant U as User participant MW as Auth Middleware<br/>(Blazor / API) participant H1 as Hub A action-block<br/>(thread-1) participant Q as Message queue<br/>(across boundary) participant H2 as Hub B action-block<br/>(thread-2) participant W as Watcher / Rx<br/>(thread-3) U->>MW: HTTP request (auth claims) activate MW MW->>MW: AccessService.SetContext(user)<br/>(AsyncLocal) Note over MW: piece-1 begins<br/>baton: user MW->>H1: hub.Post(MessageX) Note over MW,H1: PostPipeline reads AsyncLocal<br/>β†’ delivery.AccessContext = user deactivate MW activate Q Note over Q: delivery in flight<br/>baton on delivery Q->>H2: deliver(MessageX) deactivate Q activate H2 H2->>H2: SetContext(delivery.AccessContext)<br/>before handler body Note over H2: piece-2 begins<br/>baton: user H2->>H2: handler runs<br/>(under user identity) H2->>H1: hub.Post(MessageY) Note over H2,H1: PostPipeline reads AsyncLocal<br/>β†’ delivery.AccessContext = user H2->>W: stream.Update(fn).Subscribe(callback) Note over H2,W: Update primitive captures<br/>user identity into closure<br/>(CarryAccessContext) H2->>H2: try/finally:<br/>SetContext(prev) Note over H2: piece-2 ends deactivate H2 activate W Note over W: later, on Rx scheduler<br/>(AsyncLocal would be wiped) W->>W: SetContext(captured user)<br/>before callback Note over W: piece-3 begins<br/>baton: user (preserved) W->>H1: hub.Post(MessageZ) Note over W,H1: PostPipeline reads AsyncLocal<br/>β†’ delivery.AccessContext = user W->>W: SetContext(prev) Note over W: piece-3 ends deactivate W

The baton is never absent during a piece's execution. Application code can trust AccessService.Context to reflect the originating user β€” that's the whole point.

IDENTITY BATON — 6-PHASE PROPAGATION Phase 1 Auth Middleware sets AsyncLocal Phase 2 PostPipeline AsyncLocal→delivery Phase 3 In-flight delivery baton on message Phase 4 Receiver dispatch delivery→AsyncLocal Phase 5 Handler body runs under user Phase 6 Post / Rx → Phase 2 again loop: handler posts again → baton continues Blazor/API circuit entry hub.Post / meshService.* ActionBlock / grain / network NotifyAsync / HandleMessageAsync AccessService .Context = user CarryAccessContext wraps Rx hop

The identity baton travels through six phases; Phase 6 loops back to Phase 2 whenever the handler posts further messages or starts reactive chains.


Security guarantees

The baton model exists to enforce a small set of strong security promises. Each is enforced by a specific framework primitive and verified by a specific test class β€” break one and the corresponding test fails loudly.

Guarantee How it's enforced Violation test
Identity is never lost across an async hop. Every write is attributed to the originating user, even across Rx scheduler hops, hub-to-hub routing, and grain reactivation. delivery.AccessContext is part of the message payload. Framework write primitives wrap return observables with CarryAccessContext so AsyncLocal is restored on every emission. SubscribeRequestIdentityRoutingTest and ExecuteThreadMessageTest.SubmitMessage_PersistsMessageNodes_WithUserIdentity β€” every CreatedBy must be a user identity, never sync/, mesh/, node/, or activity/.
No write is attributed to a hub address by accident. Hub addresses are never CreatedBy on stored data. AccessService.SetContext logs an Error with stack trace when a hub-shaped principal lands on AsyncLocal. PostPipeline reads AsyncLocal β€” the wrong principal can't be stamped on outgoing deliveries. The error log itself β€” CI parses for [Error] [MeshWeaver.AccessContext] SetContext: hub-shaped principal and fails the run if any appear outside the sanctioned-init phase.
Sanctioned non-user identities have minimum-necessary permissions. A read-only hydrator can't write; an onboarding creator can't read another user's data. Each sanctioned identity has per-NodeType access rules granting ONLY its specific operations. No wildcard ("all sync/* get protocol perms") β€” every grant is exact. One test per sanctioned identity verifies that misuse fails with UnauthorizedAccessException and a meaningful message.
Cross-user reads are gated at every cache hit. The MeshNodeStreamCache hydrates under a sanctioned read-only identity, but every GetStream(path) call re-validates the requesting user's Read permission. MeshNodeStreamCache.GetStream always wraps the upstream in an access gate keyed on (path, userId). The cache is the hydrator; the user is the reader. UserAccessTests.SecurePersistence_NodeInPrivateNamespace_HiddenWithoutGrant β€” caches loaded under a sanctioned identity must still return UnauthorizedAccessException to unauthorized callers.
Fail-closed under uncertainty. No silent fallback. On a User-mode hub with no resolvable context and a non-exempt message, UserServicePostPipeline fails the delivery β€” return d.Failed(reason) β€” rather than delivering a null-context message that would fail closed deep in AccessControl. The "stamp hub-self as principal" fallback was removed on 2026-05-21. Watch the MeshWeaver.AccessContext channel for [Error] PostPipeline: AccessContext must never be null for an application post β€” no identity, no delivery. Every occurrence is either a missing wrap (fix it) or a framework-lifecycle message that should be exempt ([SystemMessage]).

The whole model rests on failure modes being loud and tested, not silent. The error log, the violation tests, and the fail-closed default together make a privilege-escalation regression visible the moment it's introduced.


Phase-by-phase contract

Phase 1 β€” Entry: authentication middleware sets AsyncLocal

Authentication happens at the platform boundary. Exactly one place per platform sets the baton:

Platform Where AsyncLocal gets set
Blazor circuit CircuitAccessHandler β€” CreateInboundActivityHandler wraps every inbound activity in using accessService.SwitchAccessContext(userContext)
Minimal API UserContextMiddleware β€” wraps each request in a SetContext scope
API token ApiTokenAuthenticationHandler after ValidateTokenRequest resolves the user
Background timer / cron Explicit accessService.ImpersonateAsSystem() at the entry point β€” see Sanctioned exceptions
Test base TestUsers.DevLogin(Mesh) calls SetCircuitContext(Admin) once at fixture init

After this phase, the entire AsyncLocal-respecting execution path runs under the user.

Phase 2 β€” Posting: AsyncLocal β†’ delivery.AccessContext

Any call to hub.Post(...), hub.Observe(...), or meshService.CreateNode/Update/Delete/CopyNode(...) reaches the PostPipeline (MessageHubConfiguration.UserServicePostPipeline). The decision order is:

  1. If delivery.AccessContext is already set (e.g. via PostOptions.WithAccessContext(...), ImpersonateAsHub, or ImpersonateAsSystem at the post site) β†’ use it; do not overwrite.
  2. Else read accessService.Context ?? accessService.CircuitContext; if non-null β†’ stamp on the delivery.
  3. Else, if the hub is PostingIdentity.System and the message is not exempt β†’ stamp the well-known system-security identity (the routing/persistence fallback).
  4. Else, if the message is not exempt β†’ log an Error on the MeshWeaver.AccessContext channel and FAIL the delivery (d.Failed(reason)), short-circuiting the rest of the pipeline. NotifyAsync sees State == Failed and ReportFailure posts a DeliveryFailure back, so an awaiting hub.Observe(...) gets a clean OnError. It does not throw (Post is fire-and-forget from countless call sites) and does not deliver a null-context message.
  5. Exempt messages ([SystemMessage], [CanBeIgnored], DeliveryFailure) keep their null context and are delivered normally β€” they carry no security-relevant payload.

The baton is now on the delivery, ready for hand-off.

The PostingIdentity used here is read from syncPipeline.Hub.Configuration, the final with-copied configuration β€” not from the configuration instance the pipeline delegate was captured against, which predates any WithPostingIdentity(...) call.

Phase 3 β€” Crossing the boundary: delivery carries the baton

delivery.AccessContext is part of the message payload. It survives:

If you find a place where identity is lost during Phase 3, it's a framework bug β€” patch the carrier, not the application.

Phase 4 β€” Receiver: delivery.AccessContext β†’ AsyncLocal (beginning the next piece)

Before the handler body runs, two cooperating pieces of code set AsyncLocal from delivery.AccessContext:

Both wrap their inner work in try/finally so the previous value is restored when the piece finishes β€” the action-block thread that dispatched message N is correctly re-entrant for message N+1.

After this phase, application code runs with AccessService.Context = delivery.AccessContext.

Restoring the caller on the receiving hub

There is a third restore site, and it exists because it runs before the two above. AccessControlPipeline β€” the [RequiresPermission] gate β€” is added with AddDeliveryPipeline, and the pipeline list composes outside-in (Aggregate), so the gate is the outermost wrapper and UserServiceDeliveryPipeline the innermost. At the moment the gate evaluates, Phase 4 has not happened yet: AccessService.Context still holds whatever the action-block thread was left with β€” typically system-security from the hub's own bootstrap ImpersonateAsSystem, or nothing at all.

That matters because the permission fold snapshots the caller on the calling thread (PermissionEvaluator.ResolveFoldServices captures accessService.Context ?? accessService.CircuitContext, because AsyncLocal does not flow through the Rx schedulers cache.GetQuery uses), and it reads exactly two flags off that snapshot:

Flag What it decides
IsApiToken The API-token clamp β€” zeroes the permission set for a Bearer context this path's live policy does not admit (api: false).
IsHub The hub-credential early return β€” a hub reading its own vertical chain.

So the gate restores delivery.AccessContext itself, before subscribing the fold. Two rules:

🚨 Why this is written down: the gate's own input used to decide whether the gate ran (#2976). The condition was delivery.AccessContext is { Roles: { Count: > 0 } } β€” restore only when the caller carries role claims. Its comment justified that in terms of claim-based role resolution, a mechanism that no longer exists (the 2026-08-05 paywall fix took claim roles out of node permissions; #2974 removed their last foothold). AccessContext.Roles is read nowhere in PermissionEvaluator β€” so the condition outlived its reason, and it was never the right one.

The consequence was silent and permissive. Most identity providers emit no role claims, so ApiToken.Roles is normally empty; such a token reached a per-node hub with Roles = [], the restore was skipped, capturedContext was null, IsApiToken was never seen, and the clamp did not run β€” the Bearer delivery was evaluated as an interactive session. The exact-read path was never exposed (MeshNodeStreamCache.GetStreamRaw captures the caller itself and wraps the evaluation in SwitchAccessContext), so api: false held for a read that named the page and was skipped for every message-routed check against it.

This is the runtime cousin of the CI rule in AGENTS.md β€” a gate never tests its own inputs β€” because "the check did not run" and "the check passed" were indistinguishable from outside: no exception, no log, a served read. When a condition guards whether a security decision is made, it must name the thing the decision needs (here: a principal to evaluate), never a field the decision does not read.

Pinned by RoutedApiTokenClampTest, whose control arm is the same person, same node, same routed message without the Bearer flag β€” so the pin measures a capability decision rather than a blanket deny.

Phase 5 β€” Handler runs under user identity

The handler body executes synchronously (or as a single observable chain β€” no await; see AsynchronousCalls.md). Every read of AccessService.Context returns the originating user. Every check (securityService.HasPermission(...), RlsNodeValidator.Validate(...)) is evaluated under the right principal.

Phase 6 β€” Handler posts β†’ back to Phase 2 (chain continues)

The handler typically posts further messages or starts reactive chains. Two cases arise:

Direct post within the synchronous body. hub.Post(...) reads AsyncLocal β†’ stamps the new delivery. Identity baton carries forward.

Cold observable + Subscribe (the Rx case). A framework write primitive returns a cold IObservable<T> whose side effect runs on Subscribe β€” but Subscribe may emit the callback on a different thread (an Rx scheduler) where AsyncLocal is wiped. The framework solves this internally via CarryAccessContext:

// AccessContextCaptureExtensions.CarryAccessContext (cross-cutting wrap)
public static IObservable<T> CarryAccessContext<T>(
    this IObservable<T> source, AccessService? access, bool restoreNullCapture = false)
{
    if (access is null) return source;
    // Capture Context ONLY β€” never `?? CircuitContext`. PostPipeline already reads
    // `Context ?? CircuitContext` at post time, and synthesising CircuitContext here
    // would leak the Blazor circuit identity into background Subscribe paths.
    var captured = access.Context;
    if (captured is null && !restoreNullCapture) return source;
    // Each OnNext/OnError/OnCompleted runs inside a SwitchAccessContext SCOPE that is
    // disposed as the callback returns β€” AsyncLocal is touched only for the callback.
    return new CarryAccessContextObservable<T>(source, access, captured);
}

🚨 Never re-implement this as source.Do(_ => access.SetContext(captured)). That was the earlier shape and it was reverted (2026-05-22 / re-fixed 2026-05-28): SetContext without a matching restore mutates AsyncLocal on whatever thread Subscribe ran on β€” often the caller's β€” and leaves the captured identity live for every subsequent operation on that logical execution context. That is the McpUpdate user1/user2 cross-contamination bug. The scope-per-callback shape above is the fix; a bare Do(...) re-introduces the leak.

restoreNullCapture is the second half of the contract. The write-result observables (MeshNodeStreamHandle.Update/Overwrite) pass true: a null capture is restored as null, so the framework identity ambient on the emission thread (the stream cache's read path runs ImpersonateAsSystem) cannot leak into the caller's callback and turn a nested write into a post-as-System escalation. Read/query pipelines default to false β€” a null capture passes through unwrapped, preserving whatever identity is ambient at emission. Flipping that default to clamp is blocked on migrating the ops/MCP call sites that currently depend on the leaked ambient identity.

This is applied by every mesh write primitive β€” MeshService.CreateNode/Update/Delete/CopyNode, MeshNodeStreamHandle.Update, IMeshNodeStreamCache.Update, and the content-file writes hub.ImportContent(path)…Post() / hub.SyncContentFiles(path)…Post() β€” so callers keep writing the natural shape:

meshService.CreateNode(node).Subscribe(_ => …);   // identity preserved across the Subscribe seam

Eager capture is half the contract, and the half that is easy to forget. A primitive that only wraps its result with CarryAccessContext still reads the ambient AsyncLocal when its Observable.Defer body runs β€” i.e. on the subscribing thread. The two content-import builders shipped that way and were the last write primitives without an eager snapshot: an import that built its operations under ImpersonateAsSystem and then drained them through a Concat pump had every node write land (those capture eagerly) and every content write failed closed for a null AccessContext (MeshWeaver.Reinsurance#46 β€” 412 applied, 409 refused, one call site). Snapshot the context where the caller calls you; pin it on the post with o.WithAccessContext(captured); wrap the result with CarryAccessContext. All three, or the primitive is not identity-safe. Pinned by ContentImportAccessContextTest.

Phase 2 then happens again from the Subscribe callback's thread, under the right identity. The chain continues.


Sanctioned exceptions β€” fine-grained, exact, controlled

Some components are the actor, not a proxy for a user: cache hydrators, redistributor hubs, framework bootstrapping. These are sanctioned by giving the component a named, dedicated identity β€” never an accidental hub-address-derived shape β€” paired with precise access rules that grant only what the component actually needs.

Three rules apply to every sanctioned identity:

  1. Named, dedicated identity β€” e.g. cache/mesh-node-cache, not mesh/xxx. The address reflects the component's role, not the hub it happens to live on. Hub addresses (mesh/xxx, sync/xxx, node/xxx) are accidental β€” they describe placement, not purpose.
  2. Fine-grained permissions β€” each identity is granted ONLY the specific operations it actually needs. Not "all protocol operations for sync/*"; rather "cache-read for cache/mesh-node-cache on these specific paths".
  3. Tested boundary β€” every sanctioned identity has a test verifying that misuse fails. Posting a write under a read-only identity must yield UnauthorizedAccessException. Without this test, the sanctioning is voodoo.

Example 1 β€” cache/mesh-node-cache (read-only hydrator)

The MeshNodeStreamCache pre-loads MeshNodes from storage to serve cache hits for every user. It cannot run under a user identity because it services many. It is sanctioned via:

If anyone else tries to impersonate cache/mesh-node-cache (the constant is internal to the cache assembly), they fail at AccessControl because the address grants only what the cache needs.

Example 2 β€” IsPortalIdentity (user-node onboarding)

User onboarding is the canonical example of a hub-as-actor seat configured correctly:

Leave this pattern in place. The fine-grained refactor (dedicated component address, as in Example 1) applies when the hub address is accidental β€” the code happens to live on a mesh hub or sync hub but the role has nothing to do with message routing or sync streaming. When the hub IS the role, IsXxxIdentity(hub-prefix) is the right shape.

Example 3 β€” sync stream protocol vs sync stream user-data

This is the bug to be careful about. Sync streams carry two completely different kinds of traffic:

Traffic type Originator Who should be on AccessContext
SubscribeRequest, UnsubscribeRequest, HeartBeatEvent (protocol meta) The sync hub itself A dedicated identity, e.g. protocol/sync-stream, granted only protocol operations
SetCurrentRequest carrying a user's data binding update The user behind the data binding The user β€” never sync/xxx, never protocol/sync-stream

A SynchronizationStream.OnNext that unconditionally stamps ImpersonateAsHub(Hub.Address) collapses both onto the same hub address. The receiving owner then sees sync/xxx for a user's edit, and CreatedBy on the resulting MeshNode is sync/xxx. That's the user-identity-disappears bug.

Correct shape: protocol messages stamp protocol/sync-stream; user-data messages carry the user via CarryAccessContext (no impersonation at the post site). Two message types, two code paths, two identities β€” never collapsed.

Example 4 β€” system-security (true infrastructure)

accessService.ImpersonateAsSystem() switches the baton to "system-security", granted Permission.All unconditionally by PermissionEvaluator. Use only for genuine framework infrastructure with no user context AND no narrower sanctioned identity that fits β€” schema migration, framework-level bootstrap, internal recursive lookups inside PermissionEvaluator itself.

Every ImpersonateAsSystem callsite is a deliberate choice to bypass all access checks. Grep for them; review each. If a narrower identity would suffice, use that instead.

🚨 An impersonation scope must not ESCAPE the operation it was opened for

accessService.RunAsSystem(work) β€” not a hand-written Observable.Using(access.ImpersonateAsSystem, _ => work) β€” at every site, whether the scoped observable is returned to a caller or subscribed on the spot.

The hand-written idiom is correct for the work itself and leaks in two directions at once β€” forwards onto what the subscriber composes, and BACKWARDS onto the thread that subscribed:

// ❌ the scope escapes: Rx forwards OnNext to the subscriber BEFORE Using disposes its resource,
//    so the write below is CONSTRUCTED while the impersonation is still open β€” and the write
//    primitives eager-capture AccessService.Context when they are CALLED.
Observable.Using(access.ImpersonateAsSystem, _ => ReadGatedSource())
    .SelectMany(rows => meshService.CreateNodes(Plan(rows)));   // lands as `system-security`

// βœ… the scope covers the read and stops at its boundary
access.RunAsSystem(ReadGatedSource)
    .SelectMany(rows => meshService.CreateNodes(Plan(rows)));   // lands as the CALLER

Because those primitives also re-stamp the captured identity around their own emissions (CarryAccessContext β€” "MessageHub sets, framework primitive preserves"), a leaked System identity is not merely inherited one hop β€” it is re-acquired at every hop after it. That is how a package install wrote its home root, five copy batches and its manifest as system-security while _UserActivity β€” the one write on that path posted directly from the request thread rather than composed on an emission β€” attributed to the real user (#1444).

It is an authorization concern, not only an audit one. AccessControlPipeline.HandleGetPermission carries the scar: SecurityService's bootstrap-time system scope leaked past its using-block onto the action-block thread, and trusting the ambient there returned Permission.All for every caller, anonymous included. That call site defends itself by resolving the identity explicitly; RunAsSystem fixes the class at the source so the next one does not have to know Rx's disposal order.

…and the other direction: the SUBSCRIBING thread keeps the identity (#1790)

Impersonation is an AsyncLocal store/restore pair, so both halves must land on ONE logical flow. Observable.Using runs its resource factory on the subscribing thread and disposes the resource when the inner observable terminates β€” for a cross-hub request/response, the owning hub's response thread. Nothing ever disposes it on the subscriber, so:

// ❌ opens on THIS thread, closes on the hub's response thread
Observable.Using(access.ImpersonateAsSystem, _ => hub.Observe(request)).Subscribe(...);
// …everything after this line on this thread runs as system-security

Measured consequences, all silent:

RunAsSystem / RunAsHub / RunAs open the scope at Subscribe and close it on the way out of that same Subscribe. That still covers the work: everything a cold pipeline does eagerly happens inside Subscribe β€” the factory runs, the primitives eager-capture the identity, the post is stamped, and any scheduled continuation captures the ExecutionContext as it stands right then. A captured ExecutionContext is an immutable snapshot, so restoring the subscribing flow afterwards cannot un-elevate work already scheduled.

🚨 ContainIdentity does not close this half β€” it restores around NOTIFICATIONS, never around the Subscribe that opened the scope. Where the capture is genuinely synchronous, a plain using around the call and its Subscribe is equally correct (ActivityLogLogger).

The second half of the leak β€” the caller's previous identity being written onto the terminating thread, which is an identity injection into a hub action block, not a cleanup β€” is closed inside AccessService itself: a scope's restore is thread-affine, so a dispose on a flow that never carried its store writes nothing. ImpersonationScopeSiteRatchetGuard + test/ImpersonationScopeSites.allow keep the inventory of sites still written the old way from growing.

API Use it for
access.RunAsSystem(work) A system read/write, returned to a caller or subscribed on the spot. Enters the scope at Subscribe (so the cold work is covered), LEAVES it on the way out of that Subscribe, and delivers every notification under the subscriber's identity.
access.RunAsHub(hub, work) The same seal for a hub identity.
access.RunAs(identity, work) / access.RunAs(resolver, work) The same seal for an explicit identity the caller carries β€” an export rendering as the requesting user, a Blazor view re-establishing the durable circuit user. The resolver overload reads the identity on the subscribing thread.
source.ContainIdentity(access) Sealing an already-composed chain β€” several system calls where the caller builds on the LAST one's emission. Forward direction only.

🚨 It never invents an identity. What is restored is exactly what was ambient at Subscribe: a real user for a user-driven flow, system-security for a genuinely system-initiated one, and nothing at all when the subscriber had no identity β€” so a background worker that never had a user is not fail-closed by adopting it. The only behaviour that changes is the defect: a caller who had an identity, silently continuing as System.

Pinned by SystemScopeDoesNotEscapeTest (test/MeshWeaver.Messaging.Hub.Test), whose first case asserts the raw Rx ordering every other case rests on.

Implementation: define + grant + test

For each sanctioned identity, you need all three:

1. Define an internal const string for the identity's name inside the component's assembly:

// src/MeshWeaver.Hosting/MeshNodeCacheIdentity.cs
internal static class MeshNodeCacheIdentity
{
    internal const string Address = "cache/mesh-node-cache";
}

Keeping it internal means only the assembly that defines it can stamp the identity β€” external code cannot impersonate.

2. Grant precise permissions via per-NodeType access rules:

config.AddAccessRule(
    [NodeOperation.Read],
    (_, userId) => userId == MeshNodeCacheIdentity.Address);

3. Test the boundary:

[Fact]
public async Task MeshNodeCache_Identity_CannotWrite()
{
    using (accessService.SwitchAccessContext(new AccessContext { ObjectId = "cache/mesh-node-cache" }))
    {
        // 🚨 Materialize, not act.Should().ThrowAsync() over an Rx ToTask bridge:
        // ToTask is forbidden repo-wide (2026-08-30), and folding OnError into a
        // value keeps the ORIGINAL exception type assertable. The write is cold, so
        // the assertion's Subscribe IS the write.
        var error = await meshService.CreateNode(someNode).Take(1).Materialize()
            .Should().Match(n => n.Kind == NotificationKind.OnError);
        error.Exception.Should().BeOfType<UnauthorizedAccessException>();
        error.Exception!.Message.Should().Contain("Access denied");
    }
}

The triple β€” define / grant / test β€” is the contract. Without all three, the sanctioning is voodoo and the system is one change away from privilege escalation.


Persistence runs as System β€” the persistence queue

Authorization is enforced once, at the user-facing write β€” the app handler / stream.Update on the owning hub's action block, where the user's identity is live and RLS gates the write. Once a change is accepted there it is already authorized. The durable DB write happens later, on the dedicated persistence hub (CreatePersistenceAddress()) inside DataSourceWithStorage.Synchronize, which fires on the workspace emission scheduler β€” a background thread that has wiped the AsyncLocal identity.

So the persistence write must run under System:

protected override void Synchronize(ChangeItem<EntityStore> item) =>
    persistenceHub.InvokeAsync(async ct =>
    {
        using (accessService?.ImpersonateAsSystem())   // already authorized β†’ durably store as System
            await UpdateAsync(item, ct);
    }, ex => { logger.LogWarning(ex, "Updating {DataSource} failed", Id); return Task.CompletedTask; });

If you instead let the persistence write inherit the (null) ambient context, it fails closed and the change is silently dropped β€” a write that "succeeded" upstream never lands. When the dropped write is an _Activity node, every progress reader then subscribes to a node that does not exist β†’ a [ROUTE] NotFound resubscribe storm that wedges the partition (production outage 2026-06-17, compile + import activities). Persistence is the bottom of the stack: it stores what was already approved; it never re-gates and never fail-closes.

Posting/redirecting to a node: do it inside Create().Subscribe()

A SubscribeRequest β€” or any post/redirect β€” to a node that does not exist is the root of the resubscribe-storm/wedge class. Never advertise, subscribe, or redirect to a node path until you have proof it exists, and the only proof is the create's own completion:

// βœ… The node provably exists inside the Subscribe β€” only THEN advertise / redirect / let readers subscribe.
meshService.CreateNode(activityNode).Subscribe(
    created => { /* now stamp the path, point readers at it, redirect, … */ },
    ex => logger.LogWarning(ex, "create failed β€” advertise NOTHING; no reader can storm a phantom"));

// ❌ Stamp a `_Activity/compile-*` path BEFORE/without the create landing β†’ readers subscribe
//    to a phantom node β†’ endless [ROUTE] NotFound β†’ wedge.

RunCompile (NodeTypeCompilationHelpers) is the worked example: it provision-orders the create, observes it (bounded), and stamps LastCompilationActivityPath only on the confirmed create β€” null otherwise, so no reader ever subscribes to a path that was never written. Combined with "persistence runs as System" above (so the create actually lands), there is nothing to storm.


Anti-patterns (the baton drops)

Anti-pattern Why it's wrong Fix
OnNext on a sync stream stamps ImpersonateAsHub(Hub.Address) for a user-driven update User identity is replaced by the sync hub's address. Receiving side sees sync/xxx. User's writes get attributed to a hub. Carry the user's AccessContext through the Rx chain via CarryAccessContext; post SetCurrentRequest with the user's identity.
Watcher subscriptions in hub-init code capture the hub-init AsyncLocal Every later emission runs under hub identity even if a user caused the trigger. Read identity from the event (e.g. delivery.AccessContext of the trigger message), not from the AsyncLocal captured at subscribe time.
Setting AccessService.Context to a hub-shaped principal (sync/, mesh/, node/, activity/, portal/) These are hub addresses, not user identities. They leak into downstream writes as fake CreatedBy. Only set user-shaped or system-security on AsyncLocal. AccessService.SetContext logs an error on violations.
Per-callsite .PreserveAccessContext(...) / .Do(_ => SetContext(...)) litter Framework primitives already handle this. Adding it at the callsite is redundant and drifts. Remove the redundant calls.
meshService.CreateNode(node, ...) with an explicit o.WithAccessContext(captured) The framework wrap already handles this implicitly. Remove the explicit WithAccessContext unless you have a non-AsyncLocal source for the context.
accessService.ImpersonateAsHub(...) in application code Application writes should ride the user's identity. Propagate the user's identity correctly (default path), or use ImpersonateAsSystem for genuine infrastructure, or grant the hub via a per-NodeType access rule if it's truly a redistributor.
Catching UnauthorizedAccessException from a write and falling through silently Hides denials β€” the root cause of the prod EventCalendar bug. Surface the denial: empty-state the UI, navigate to AccessDenied, or rethrow.
Reading MeshNode.Content from a query row Query rows are stale and not gated per-user at the row level. Use workspace.GetMeshNodeStream(path) or IMeshNodeStreamCache.GetStream(path) for live, access-checked content.

Debug aid: the hub-shape error log

AccessService.SetContext / SetCircuitContext log an ERROR with a stack trace whenever a hub-shaped principal (sync/, mesh/, node/, activity/, portal/) is set on AsyncLocal:

[Error] [MeshWeaver.AccessContext] SetContext: hub-shaped principal sync/xxx set as AccessContext β€” must never happen. Source stack: …

When this fires:


IMeshNodeStreamCache.GetStream is access-checked

Beyond ensuring writes preserve user identity, reads through the process-wide IMeshNodeStreamCache are also gated by the caller's effective Read permission on the path.

🚨 The gate is evaluated LOCALLY β€” it does NOT post a GetPermissionRequest to the path's hub. MeshNodeStreamCache.ProbeEffectivePermissions calls meshHub.GetEffectivePermissions(path, userId) (β†’ the static PermissionEvaluator) directly. Only when the result has Permission.Read does the gated observable forward upstream emissions; otherwise it terminates with UnauthorizedAccessException.

Why local, and why the hub round-trip was removed: GetEffectivePermissions walks the path's whole scope hierarchy β€” root, partition, every ancestor, the node itself β€” so access defined on the main node already covers every satellite under it ("who can read the main node can read all its satellites"). Asking for the PATH is therefore sufficient. The old probe targeted new Address(path), and a satellite / cell sub-path with no hub of its own β€” a {thread}/{messageId} the GUI subscribed to that was never persisted, or a brand-new thread β€” routes to NotFound, so nothing answered and the probe blocked for the full 15 s timeout: the side-panel "thread won't open" spinner. Do not reintroduce a leaf-hub permission probe on the read path.

The caller's captured AccessContext is restored around the synchronous evaluator capture (accessService.SwitchAccessContext(captured)) so claim-based (Bearer-token) roles on AccessContext.Roles resolve β€” PermissionEvaluator snapshots accessService.Context on the calling thread before any Rx scheduler hop.

Per-(path, userId) validations are cached in-process for 30 s (the AccessTtl constant in MeshNodeStreamCache.cs). Revocation surfaces within at most that window; the cache is not invalidated reactively. The shared upstream is unchanged β€” only the returned subscriber-side observable is gated.

GetPermissionRequest contract

GetPermissionRequest still exists as a message β€” it is how a caller asks a specific per-node hub what it grants (the test helper PermissionTestExtensions, and the request-time pipeline). It is simply no longer what the stream cache uses.

public record GetPermissionRequest : IRequest<GetPermissionResponse>;
public record GetPermissionResponse(Permission Permissions);

The request carries no path β€” the receiving hub answers for its own path. Callers route the request to the per-node hub at the path they care about; routing decides which hub responds. The handler (AccessControlPipeline.HandleGetPermission) resolves the per-hub-scoped PermissionEvaluator and evaluates it against the caller's delivery.AccessContext.


Worked example β€” Blazor data binding β†’ SyncStream β†’ owner update

Walking through all six phases with a concrete scenario:

  1. Phase 1. User logs in. CircuitAccessHandler sets accessService.CircuitContext = user for the circuit's lifetime. Each inbound circuit activity wraps the dispatched callback in using SwitchAccessContext(user) so AsyncLocal is set per-render too.

  2. Phase 5 (inside a layout area render). The user edits a text field bound to JsonPointerReference("$.title"). The binding pushes the new value into a SynchronizationStream<MyContent>.

  3. Phase 6 (Rx hop). The stream's OnNext fires. The Rx scheduler's thread has wiped AsyncLocal β€” but the stream's subscribe-time wrap captured user into a closure (via CarryAccessContext on the cold pipeline that fed OnNext). Before posting SetCurrentRequest, AsyncLocal is restored to user.

  4. Phase 2. Hub.Post(new SetCurrentRequest(value), o => …) reaches PostPipeline β†’ reads AsyncLocal β†’ stamps delivery.AccessContext = user. No ImpersonateAsHub.

  5. Phase 3. Delivery travels to the owner hub.

  6. Phase 4. Owner hub's MessageHub.HandleMessageAsync reads delivery.AccessContext β†’ SetContext(user) β†’ runs HandleSetCurrent.

  7. Phase 5. HandleSetCurrent writes through the owner-side data layer. AccessControlPipeline validates that user has Update on the path. CreatedBy / LastModifiedBy on the resulting MeshNode rows is user.

If step 3 had stamped ImpersonateAsHub(Hub.Address) instead of carrying user, step 4 would set AsyncLocal to sync/xxx (caught by the error log), step 5 would validate sync/xxx (no grant configured) and either deny or β€” if a misconfigured rule grants it β€” attribute the write to the sync hub. That's precisely the bug this model is designed to prevent.


Reconnecting…
The server was updated. Reloading the page to pick up the latest version.