Owner Injection
π¨ The rule, in one line: every operation that runs on a node's hub (a thread, an activity, any per-node hub) runs under that node's OWNER as the access context β resolved from the node, injected everywhere, and carried forward across deferred / Rx-hop continuations. Genuine infrastructure (documentation sync, cache hydration, heartbeats) runs as System. An empty access context is never faked into something β it is rejected instantly.
This is the companion rule to AccessContextPropagation (how a user's identity rides a call) and to the never-null invariant in CqrsAndContentAccess. Owner injection answers the question those leave open: whose identity does a node's own hub act under when there is no live caller β a watcher tick, a deferred sync write, a cold-start activation, a streaming continuation.
The three rules
Owner is the standing identity. A per-node hub (thread / activity / any owned node) resolves its owner from the node (
MeshThread.CreatedByβMeshNode.CreatedBy) and stamps it as the hub's access context. Every context-less operation on that hub β the submission watcher's claim write, the round dispatch, the data-source sync propagation β runs as the owner. The owner is who the work is for; the access check that admitted the work already happened upstream.Carry it forward β
SetStandingIdentity(hub, owner), not justContext.AccessService.Contextis anAsyncLocalthat is wiped across every Rx hop (aSubscribecallback, aThrottletick, a remote-stream initial-snapshot continuation, a deferred sync write). The hub's standing identity survives those hops. Owner injection therefore stampsSetStandingIdentity(hub, owner)(the carry-forward slot), not onlySetContext(owner). A write that only setContextis lost the moment it crosses a scheduler boundary.π¨ Keyed by hub β never process-wide. This slot used to be
SetCircuitContext(owner), which wrote a single sharedpersistentCircuitContextfield on the mesh-wideAccessServicesingleton. That made the owner of whichever thread hub activated last the ambient fallback identity for every other hub, every other user, and every anonymous render in the process β a cross-user identity bleed reaching RLS, write attribution and the permission fold.GetStandingIdentity(hub)can only ever yield that hub's owner.CircuitContextno longer has any process-wide fallback on a server: off a circuit's own call tree it isnulland identity resolution fails closed.Empty β reject instantly. Never fake an identity. If no owner can be resolved and there is no live caller, the operation is rejected closed β the never-null
PostPipelineguard fails the delivery; the update delegate does not run. We do not silently stamp the hub's own address or fall back to System for a user hub (that "hub-self fallback" masked a prod data-attribution bug and was deliberately deleted β seefeedback_access_context_always_set). The only sanctioned non-owner identity is explicit System for genuine infrastructure.
What runs as System (the carve-out)
Some streams are not owned by a user and legitimately run under the well-known System
identity, wrapped explicitly with AccessService.ImpersonateAsSystem() /
PostOptions.ImpersonateAsHub(...):
- Documentation sync β the embedded
Doc/content streams are platform-owned, not a user's. - Cache hydration β
IMeshNodeStreamCacheopens its shared upstream underImpersonateAsSystem; per-user enforcement happens at the subscriber boundary, not the shared pump. - SyncStream heartbeats / resubscribes β infrastructure refresh, no user on the stack.
The litmus test: can you name a user this work is for? Yes β inject that owner. No (it is
platform plumbing) β ImpersonateAsSystem, explicitly. Never leave it empty and never invent a
hub-self identity.
The motivating bug β cold-start submit deadlock (FIXED; kept as the worked example)
OrleansChatHistoryTest.ColdStart_AgentSeesAllPreviousMessages (2-core) is the canonical failure
this rule fixes. A thread is seeded in persistence; on a cold start (grains inactive) a user
submits a message:
ThreadInput.AppendUserInputruns withContext=null,CircuitContext=TestUser, and writes the pending message viaGetMeshNodeStream(threadPath).Update(...).- That write reaches the freshly-activated owner's data-source sync stream
(
ds/TestUser/_Thread/history-cold-start, whoseHostIS the thread hub), which posts an internalUpdateStreamRequest. On the deferred continuation the liveAsyncLocalis gone β so the post must fall back to the hub's standing owner identity. - The race.
SetThreadHubIdentityresolves the owner from the node asynchronously (hub.GetMeshNode(...).Subscribe(...)β aGetDataRequestround-trip). On a cold start the first submit write reaches the sync stream before that response lands, so the owner is not yet on the hub'sCircuitContextβ the post carries a null AccessContext. - The never-null guard fails it closed β the patch never commits β the thread node never gets
PendingUserMessagesβ the submission watcher observespending=0forever β no round is dispatched βMessages.Countis stuck below the expected count β 30 s timeout.
Proven with a probe on the data-source sync stream β two writes on the SAME stream:
[SYNCUPD] owner=ds/β¦/history-cold-start host=β¦/history-cold-start hub=sync/2odβ¦ hubCtx=(null) creation=(null) hostReal=(null) final=(NULLβFAIL) β first write loses the race
[SYNCUPD] owner=ds/β¦/history-cold-start host=β¦/history-cold-start hub=sync/2odβ¦ hubCtx=TestUser creation=TestUser hostReal=TestUser final=TestUser β 200 ms later, owner now established
The fix is not a System fallback at the sync-write layer (that would make a user write run
as System and violates rule 3 + the StreamUpdate_WithoutAsyncLocalIdentity_FailsClosed
contract). Nor is it "capture from a different hub" β the Host is already correct. The fix is to
establish the owner before the first write can be processed: resolve it from the node
synchronously (the node is already in the data-source stream's Current when the submit lands β
its CreatedBy is right there), rather than via the async SetThreadHubIdentity round-trip that
loses the cold-start race.
Where it is wired (implementation map)
| Layer | What injects the owner |
|---|---|
| Thread hub | ThreadExecution.SetThreadHubIdentity β reads the thread node's CreatedBy and stamps it as both Context and this hub's standing identity (SetStandingIdentity(hub, owner), the carry-forward slot) on hub activation. |
| Activity hub | The activity control-plane establishes the activity owner the same way (resolve from the activity node, inject as the hub's standing identity). |
| Per-node data source / sync stream | SynchronizationStream.Update resolves the node OWNER synchronously from the node already in its own Current when neither a live AsyncLocal context nor a captured creation context survives β via IStreamOwnerResolver, resolved off Host.ServiceProvider. Genuine infra streams (doc sync) carry System. |
| One-shot helpers | AccessContextScope.FromNode(node, accessService) β runs a synchronous block under the node's owner (CreatedBy/LastModifiedBy), falling back to System only for an unattributed node. π¨ Never as an Observable.Using resource factory β it is SwitchAccessContext under another name, and Using opens the scope on the subscribing thread and disposes it wherever the work terminates (#1790). Reactive callers resolve the identity and use access.RunAs(owner, () => work) / RunAsSystem; ImpersonationScopeSiteRatchetGuard fails a new site. |
β Status: shipped. The synchronous owner resolver is
IStreamOwnerResolver(src/MeshWeaver.Data/Serialization/IStreamOwnerResolver.cs), implemented byMeshNodeStreamOwnerResolverinMeshWeaver.Graph(the layer that knowsMeshNodeβMeshWeaver.Datasits belowMesh.Contractand cannot read it), registered inGraphConfigurationExtensionsand consumed bySynchronizationStream.Update. Because the node is ALREADY in the stream'sCurrentat write time, itsCreatedByis available with no async round-trip and no race β which closes the cold-start FIRST-write race described above. The result is still filtered through the real-user invariant by the caller, so a hub/system principal can never leak intoCreatedBy.
See also
- AccessContextPropagation β how a user's identity
rides a call across
.Subscribe()boundaries. - CqrsAndContentAccess β
GetStreamis access-checked; the never-null invariant. - SyncedQueryDataSource β
hub.GetQuery(), the access-checked synced collection cold-start data should read through.