Satellite entities — Comments, Threads, Tracked Changes — are secondary nodes that live under a primary content node. They share a consistent set of patterns for data modeling, handler implementation, access control, and reactive testing. This page is the canonical reference for all three.
Two satellite pages, two scopes: this page covers the data model, handler, access-control, and test patterns. Its companion Satellite Node Patterns covers the operational invariants — hub ownership, persistence routing, and the table-routing rules. Read this one when building a satellite feature; read the other when debugging where a satellite lives and who owns it. Satellite nodes live under a content node (solid arrows = ownership by namespace path); each satellite sets
MainNodeback to the content node (dashed arrows) so access control resolves correctly.
Data Model: Parent Tracks Children
A parent node holds an ImmutableList<string> of child IDs. The layout area reads this list from the workspace stream and renders a LayoutAreaControl for each entry — it never issues a query to discover children.
| Entity | Parent field | Child node type |
|---|---|---|
| Thread | Messages: ImmutableList<string> |
ThreadMessage |
| Comment | Replies: ImmutableList<string> |
Reply Comment |
When a child is created, the handler appends its ID to the parent list via workspace.GetMeshNodeStream(parentPath).Update(...). The update flows immediately into every subscribed Blazor client through the sync stream — no polling, no page refresh.
Top-level vs. reply detection
A comment is top-level when its namespace ends with _Comment (e.g. Doc/MyDoc/_Comment). A reply's namespace is the parent comment's path (e.g. Doc/MyDoc/_Comment/c1). No parent load is required — the shape of the path is the signal.
Comments and changes are anchored, NOT injected into the document
A text-range comment is never written into the document's markdown — the document stays clean. The satellite carries its own anchor: Start/Length (the captured character range in the document's clean text), the Version it was captured against, the AnchorText (the document text at that version), and the HighlightedText. At render time the effective range is recomputed against the current text: while the document is still at the captured Version the stored offsets are used verbatim; once it has moved ahead, AnchorMath diffs AnchorText against the current text and maps the offsets through that diff (diff_xIndex-style), exposed as EffectiveStart/EffectiveEnd. The comment highlight (CommentRendering) is overlaid for that one render by CollaborativeRenderer — never persisted. This decouples annotating from the document: a Comment-only user (no document Update permission) can comment, and edits above an annotation don't strand it.
Tracked changes are NOT satellites — they are a view model.
ChangeProjectiondiffs a baseline version of the node against its current text and attributes each hunk to the version-history step that introduced it, producingTrackedChangeview models with the same anchor shape (soChangeRendering.ResolveEffectivere-locates them the same way). Nothing is stored, so nothing can go stale or orphan; "reject" isChangeRendering.Revert, a normal versioned write. The persisted_Trackingsatellites are legacy: nothing writes them, and their registrations survive only so existing rows stay readable.
Handler Pattern: Synchronous, Reactive, Error-Safe
Satellite entity handlers must be fully synchronous. Running await inside the hub execution pipeline causes deadlocks in Orleans distributed mode. The right shape is: start an IObservable chain, subscribe, and return immediately — the response is posted from inside the callback.
Reference implementation
// The submission watcher invokes ExecuteMessageAsync directly after writing
// PendingUserMessages via stream.Update on the thread node — no wire message,
// no handler dispatch. It returns IObservable<Unit>; the watcher subscribes and
// treats completion (gated on the terminal Status write) as "round done".
internal static IObservable<Unit> ExecuteMessageAsync(
IMessageHub hub,
RoundParams request,
AccessContext? userAccessContext)
{
var meshService = hub.ServiceProvider.GetRequiredService<IMeshService>();
var workspace = hub.GetWorkspace(); // Capture on the handler thread
// 1) Start both node creates concurrently (IObservable — cold until Subscribe)
var inputObs = meshService.CreateNode(new MeshNode(...));
var outputObs = meshService.CreateNode(new MeshNode(...));
// 2) Zip waits for both; 3) COMPOSE the parent update into the chain —
// never mutate inside a Subscribe callback (wrong thread in Orleans).
return inputObs.Zip(outputObs)
.SelectMany(_ => workspace.GetMeshNodeStream().Update(node =>
{
var thread = node.ContentAs<Thread>(hub.JsonSerializerOptions, logger);
if (node.Content is not null && thread is null) return node; // never clobber
thread ??= new Thread();
return node with
{
Content = thread with
{
Messages = thread.Messages.AddRange([userMsgId, responseMsgId])
}
};
}))
.Select(_ => Unit.Default);
// The CALLER subscribes — errors flow to its OnError, completion signals done.
}
Handler rules
- Synchronous signature, observable result — return
IObservable<T>(orvoidfor fire-and-forget), neverasync Task<T>. - No
awaitanywhere in the message pipeline —awaitdeadlocks in Orleans. This applies to handlers, Blazor components, layout areas, and any code on the hub execution path. Request/response ishub.Observe<TResponse>(request).Subscribe(onNext, onError)—RegisterCallbackandAwaitResponseare[Obsolete]and deadlock. - No permission checks in handlers or layout areas — access control is enforced by the delivery pipeline via partition access policies. If a user lacks the
CommentorThreadpermission, the request is rejected before reaching the handler. Handlers assume the caller is authorized. - Never use
IMeshStorageor persistence directly — useIMeshServicefor CRUD andworkspace.GetMeshNodeStream(path).Update(...)for node updates. - Capture
workspaceon the handler thread —var workspace = hub.GetWorkspace()must be called in the handler body, not inside a callback closure. - Compose, don't nest — chain dependent writes with
SelectManyinto one observable; mutating state inside aSubscribecallback runs on the wrong thread and deadlocks (see Asynchronous Calls). - Subscribe with an error handler —
Update/CreateNodeare cold; the side effect only runs on Subscribe, and an unhandled OnError means a silent failure.
Blazor component pattern
Blazor components follow the same rule — observe, never await:
// CORRECT: reactive request/response
Hub.Observe<CreateCommentResponse>(new CreateCommentRequest { ... },
o => o.WithTarget(new Address(hubAddress)))
.Subscribe(
response =>
{
if (!response.Message.Success)
logger?.LogWarning("Failed: {Error}", response.Message.Error);
},
ex => logger?.LogWarning(ex, "CreateComment failed"));
// WRONG: AwaitResponse blocks the Blazor circuit if response never arrives
await Hub.AwaitResponse(request, o => o.WithTarget(address), default); // DEADLOCK ([Obsolete])
Anti-patterns to avoid
// WRONG: await in handler — deadlocks in Orleans
private static async Task<IMessageDelivery> Handle(IMessageHub hub, ...)
{
await persistence.GetNodeAsync(path, ct); // DEADLOCK
}
// WRONG: using persistence directly
var persistence = hub.ServiceProvider.GetService<IMeshStorage>();
await persistence.SaveNodeAsync(node, ct); // WRONG
// WRONG: posting response before the work completes
hub.Post(response, o => o.ResponseFor(request)); // too early
meshService.CreateNode(node).Subscribe(...); // not done yet
// WRONG: mutating state inside the Subscribe callback — wrong thread, deadlocks
meshService.CreateNode(node).Subscribe(_ =>
{
/* direct workspace mutation */ // ← composes into the chain instead
hub.Post(response, o => o.ResponseFor(request));
});
// CORRECT: compose the dependent write, respond from the chain's terminal events
meshService.CreateNode(node)
.SelectMany(_ => workspace.GetMeshNodeStream(parentPath).Update(n => ...))
.Subscribe(
_ => hub.Post(successResponse, o => o.ResponseFor(request)),
ex => hub.Post(failureResponse, o => o.ResponseFor(request)));
MainNode: the node-identity invariant
🚨 Invariant:
MainNode == Pathfor every node — EXCEPT satellites. A node'sMainNodeis its identity for access control and for querying. For an ordinary main node (the common case)MainNodeequals the node's ownPath. Only a satellite setsMainNodeto a different path (the content entity it belongs to). So the rule is bidirectional:MainNode != Path⟺ the node is a satellite.
Satellite-ness is decided by the IsSatelliteType NodeType flag — NOT by a _-prefix on the path. A leading _ on a path segment (_GitSync, _Provider, …) is only a naming convention meaning "internal / not a user-facing page." Whether a node is a satellite is decided solely by its NodeType:
IsSatelliteType = true(e.g.Thread/_Thread,_PullRequest,_Issue,_Access) → satellite →MainNode = owner, soMainNode != Path; dropped byis:main; skipped by the repo export.IsSatelliteType = false(e.g.GitHubSyncConfig=_GitSync) → main node →MainNode == its own Pathis CORRECT. It legitimately appears inis:main. To keep such an internal node out of a user-facing listing (e.g. a course rail), filter it out by the_-prefix convention — do NOT forge a satelliteMainNode(e.g. pointing_GitSyncat the partition root). That would violate the invariant, misrepresent a main node as a satellite, and break access-control identity.
Corollary — is:main: the query filter is:main keeps exactly the nodes where MainNode == Path (see PostgreSqlSqlGenerator / CosmosMeshQuery: IsMain && node.MainNode != node.Path is excluded) — every main node, no satellite. Any "main descendants" listing (a space/course rail, an activity feed) relies on this. If an internal main node leaks into such a listing, the fix is a path-convention filter on the reader, never a MainNode change on the node.
Satellites: MainNode points at the owning content entity
Every satellite node MUST set
MainNodeto the content entity it belongs to. Without this, access control fails because the hub uses the node's own path as its identity — a path that has no permissions attached.
For example, a thread under Acme/AiConsulting must carry MainNode = "Acme/AiConsulting", not the thread's own path. The same requirement applies to sub-threads, thread messages, comments, and tracked changes.
// CORRECT: MainNode points to the owning content entity
var threadNode = new MeshNode(threadId, ns)
{
NodeType = "Thread",
MainNode = contextPath, // e.g., "Acme/AiConsulting"
Content = new Thread()
};
var msgNode = new MeshNode(msgId, threadPath)
{
NodeType = "ThreadMessage",
MainNode = contextPath, // same content entity — NOT the thread path
Content = new ThreadMessage { ... }
};
// WRONG: MainNode defaults to the node's own path — no permissions, access denied
var node = new MeshNode(id, threadPath) { NodeType = "ThreadMessage" };
// MainNode = "Acme/.../threadId/msgId" — not a real entity; has no permissions
This applies to all satellite types: Thread, ThreadMessage, Comment, Approval (and the legacy, no-longer-written TrackedChange).
SwitchAccessContext: Scoped Identity in Callbacks
Code that runs outside the hub delivery pipeline — such as inside Subscribe callbacks — does not automatically inherit the caller's AccessContext. Use SwitchAccessContext to establish a scoped identity for the duration of the callback body.
var accessService = hub.ServiceProvider.GetService<AccessService>();
childStream.Subscribe(change =>
{
using var _ = accessService?.SwitchAccessContext(userAccessContext);
// Operations here run under the correct user identity
workspace.GetMeshNodeStream(parentPath).Update(node => { ... })
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "update failed"));
});
Remote Stream Subscription Pattern (Delegation)
When a thread delegates to a sub-thread, subscribe to the child's MeshNode stream and keep the subscription alive for the lifetime of the operation. Never await completion inline — the AI framework owns the await; MeshWeaver code only supplies a Task via TaskCompletionSource.
var tcs = new TaskCompletionSource<DelegationResult>();
// 1. Create sub-thread node (Observable — no await)
meshService.CreateNode(subThreadNode).Subscribe(_ =>
{
// 2. Subscribe to child stream — AddDisposable keeps it alive
var childStream = workspace.GetRemoteStream<MeshNode>(
new Address(subThreadPath), new MeshNodeReference());
workspace.AddDisposable(childStream);
childStream.Subscribe(change =>
{
using var _ = accessService?.SwitchAccessContext(userContext);
var childThread = change.Value?.Content as Thread;
if (childThread == null) return;
// Mirror child progress into the parent node
workspace.GetMeshNodeStream(parentPath).Update(node => { ... merge child progress ... })
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "mirror failed"));
// On completion, resolve the TCS
if (!childThread.IsExecuting)
tcs.TrySetResult(new DelegationResult { ... });
});
// 3. Submit via stream.Update on the sub-thread. The sub-thread's
// submission watcher reacts to PendingUserMessages and invokes
// ExecuteMessageAsync directly.
ThreadInput.AppendUserInput(workspace, childAddress.Path, userMessage);
},
error => tcs.TrySetResult(new DelegationResult { Success = false }));
return tcs.Task; // The AI framework awaits this — our code never does
Test Pattern: Reactive Verification
Tests for satellite entities verify state through reactive streams, not QueryAsync. This exercises the same code path as the GUI and eliminates timing-dependent polling.
Orleans test setup
Both the silo and the client must register domain types. Without AddGraph() on the client, type names diverge and deserialization silently fails.
// Silo: registers handlers and persistence
public class MySiloConfigurator : ISiloConfigurator, IHostConfigurator
{
public void Configure(ISiloBuilder siloBuilder)
{
siloBuilder.ConfigureMeshWeaverServer()
.AddMemoryGrainStorageAsDefault();
}
public void Configure(IHostBuilder hostBuilder)
{
hostBuilder.UseOrleansMeshServer()
.AddFileSystemPersistence(dataPath)
.ConfigurePortalMesh()
.AddGraph() // Registers ALL domain types
.ConfigureDefaultNodeHub(config => config.AddDefaultLayoutAreas());
}
}
// Client: MUST also register domain types for serialization alignment
public class MyClientConfigurator : IHostConfigurator
{
public void Configure(IHostBuilder hostBuilder)
{
hostBuilder.UseOrleansMeshClient()
.AddGraph(); // Required for type registry alignment
}
}
Verification via the node stream
Subscribe to the node's stream before triggering the operation, then reactively wait for the expected state to appear. This applies to operations that mutate the document text — e.g. a tracked change, which embeds <!--insert:…-->/<!--delete:…--> markers:
// 0) Subscribe BEFORE sending the request — never after.
// GetMeshNodeStream(path) is the shared per-path handle; do NOT hand-roll
// GetRemoteStream<MeshNode>(address) + FirstOrDefault(n => n.Path == …),
// which pulls the whole collection and re-opens its own subscription.
// The assertion subscribes SYNCHRONOUSLY on the calling thread, so arming it here
// really does attach the observer before step 1 sends the request. It also owns the
// wait: no FirstAsync + ToTask bridge, which is forbidden repo-wide (2026-08-30).
var markersAppeared = workspace.GetMeshNodeStream(docPath)
.Select(node => (node?.Content as MarkdownContent)?.Content ?? "")
.Should().Within(30.Seconds())
.Match(content => content.Contains($"<!--insert:{markerId}"));
// 1) Send the request. In TESTS the sanctioned bridge is the test base's helper;
// production code subscribes to hub.Observe(...) instead of awaiting.
var response = await AwaitResponseAsync(request, o => o.WithTarget(address), ct: ct);
// 2) Wait for the stream to reflect the change
var updatedContent = await markersAppeared;
Comments are different: they do not change the document text, so don't wait on the doc stream. Verify the Comment satellite node instead — assert it carries the anchor (HighlightedText, Start/Length, Version) via GetDataRequest below.
Verification via GetDataRequest
For verifying the content of an individual node (mirrors the pattern in OrleansChatTest):
private async Task<T?> GetHubContentAsync<T>(IMessageHub client, string path, CancellationToken ct)
where T : class
{
var nodeId = path[(path.LastIndexOf('/') + 1)..];
// Test-only bridge. `client.AwaitResponse(...)` no longer exists — the framework
// has no Task-returning request/response API; `hub.Observe(...)` is the surface,
// and MonolithMeshTestBase.AwaitResponseAsync is the sanctioned await at the
// assertion edge.
var response = await AwaitResponseAsync(
new GetDataRequest(new EntityReference(nameof(MeshNode), nodeId)),
o => o.WithTarget(new Address(path)), hub: client, ct: ct);
var node = response.Message.Data as MeshNode;
if (node == null && response.Message.Data is JsonElement je)
node = je.Deserialize<MeshNode>(hub.JsonSerializerOptions);
return node?.Content is T typed ? typed
: node?.Content is JsonElement contentJe
? contentJe.Deserialize<T>(hub.JsonSerializerOptions)
: null;
}
Complete test flow (Comment example)
1. Deploy Orleans cluster with domain types registered on silo AND client
2. Create client hub, register for streaming
3. Ping target grain to activate it
4. Send CreateCommentRequest (or create the Comment node directly via meshService.CreateNode)
5. Assert CreateCommentResponse.Success == true
6. GetDataRequest on comment path → verify Comment content AND its anchor
(HighlightedText, Start/Length, Version)
7. Assert the document text was NOT mutated (no `<!--comment:{markerId}` injected)
8. (Optional) Subscribe to comment layout area to verify the highlight renders
9. (Optional) Send reply CreateCommentRequest, verify the parent's Replies list grew
Type Registration
All satellite entity types must be registered in the mesh builder so Orleans serialization works end-to-end.
// In AddCommentType() — called by AddGraph()
builder.ConfigureHub(config => config
.WithType<Comment>(nameof(Comment))
.WithType<CreateCommentRequest>(nameof(CreateCommentRequest))
.WithType<CreateCommentResponse>(nameof(CreateCommentResponse))
// ... all request/response types
);
Both the silo and client must call AddGraph() (or the equivalent domain registration). Without this, the client serializes types with fully qualified names that the silo cannot match.
Cross-Grain Live Updates (Critical for Orleans)
Pushing data from one grain to another — for example, a thread execution grain updating the response message node — requires care. Two approaches that appear to work but do not trigger live updates to Blazor clients:
workspace.GetRemoteStream().Update()— creates a local proxy; change does not propagate to other subscribers.DataChangeRequestposted to another grain — updates the entity store but does not fire the sync stream.
Both persist data correctly (the change is visible after a page refresh) but the UI does not update in real time.
Correct pattern: custom message + local workspace update
// 1. Define a message type
public record UpdateMyContent { public string Text { get; init; } }
// 2. Register a handler ON the target hub (runs on the target grain)
config.WithHandler<UpdateMyContent>((hub, delivery) =>
{
hub.GetWorkspace().GetMeshNodeStream().Update(node =>
node with { Content = /* updated content */ })
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "update failed"));
return delivery.Processed();
});
// 3. Post FROM the calling grain
hub.Post(new UpdateMyContent { Text = "hello" },
o => o.WithTarget(new Address(targetPath)));
Why this works: GetMeshNodeStream().Update(...) on the own node updates the local data source stream on the target grain, which fires a DataChangedEvent on the sync stream. Blazor clients that subscribed via GetRemoteStream receive this event over SignalR and re-render without any polling. The update path is: grain workspace → sync stream → Orleans routing → Blazor SignalR → UI render.