Satellite Node Patterns

A satellite node is any node whose MainNode points to a parent node. Threads and their messages, documents and their comments, approvals, activities — all follow this shape. The pattern gives each child its own hub, its own persistence, and a well-defined ownership boundary.

This page covers the invariants that every satellite type must respect, the pitfalls that are easy to hit, and reference examples from the two canonical implementations: Thread/ThreadMessage and Comment/Reply.

Two satellite pages, two scopes: this page covers the operational invariants — hub ownership, persistence/table routing, content-update mechanics. Its companion Satellite Entity Patterns covers the data model, handler, access-control, and test patterns. Build with that one; debug ownership/persistence with this one. Parent Node User/alice/_Thread/chat-1 Hub · Workspace · Persistence Satellite Node …/chat-1/msg1 (ThreadMessage) Satellite Node …/chat-1/msg2 (ThreadMessage) Satellite Node …/chat-1/msg3 (ThreadMessage) Hub · Workspace · Persistence Hub · Workspace · Persistence Hub · Workspace · Persistence threads table path contains _Thread → routed here comments table path contains _Comment → routed here

Each satellite node owns its own hub, workspace, and persistence; path-segment routing maps the whole subtree to the correct PostgreSQL table.


Hub Ownership and Persistence

Every node in MeshWeaver has its own hub, created on demand when a message is routed to its address. The hub is the sole owner of the node's persistent state.

Rules every satellite type must follow


Never Await in Hub Handlers

Hub message handlers run on the hub's serial execution block. Any await that waits for the same execution block to process another message will deadlock — the block is already occupied. Everything is IObservable<T> end-to-end: compose and Subscribe, never await.

// WRONG — deadlocks: the await parks the action block that must process
// the very response this call is waiting for.
private static async Task<IMessageDelivery> HandleRequest(IMessageHub hub, ...)
{
    await meshService.CreateNodeAsync(node);
}

// CORRECT — return immediately; the observable chain does the work off the
// execution block and posts the response from its terminal events.
private static IMessageDelivery HandleRequest(IMessageHub hub, ...)
{
    meshService.CreateNode(node)
        .Subscribe(
            _  => hub.Post(new SomeResponse { ... }, o => o.ResponseFor(delivery)),
            ex => logger.LogError(ex, "create failed"));
    return delivery.Processed();
}

🚨 meshService.CreateNodeAsync(...) still exists as a back-compat Task shim over the observable (MeshServiceExtensions). It is not the pattern — a Task on the hub path is the deadlock. Use meshService.CreateNode(node) and Subscribe. It survives only because 58 in-mesh call sites in MeshWeaver.Reinsurance still name it and would stop compiling at runtime; see AsynchronousCalls → "The absolute rules", rule 2, for the exit plan.

Allowed Patterns

Pattern When to use
hub.Post(message) Fire-and-forget to same or another hub
hub.Observe<TResponse>(request, options?).Subscribe(onNext, onError) Request/response — the only request/response primitive
.SelectMany(...) / .Select(...) Chain dependent work into one observable
hub.InvokeAsync(action, exceptionCallback) Marshal an external callback back onto the hub's action block (both arguments are required)
stream.Subscribe(callback) React to workspace stream changes

🚨 RegisterCallback and AwaitResponse do not exist. They were deleted from IMessageHub, not deprecated — the interface states it plainly: "No Task-returning request/response API on the interface anymore … There's no callback registration, no TaskCompletionSource, and no Task." Code written against either name does not compile. hub.Observe(request, options?) (AsyncSubject-backed) is the whole surface; tests use MonolithMeshTestBase.AwaitResponseAsync(...).

Forbidden Patterns

Pattern Why
await in hub handlers Deadlocks the execution block
Task.Run(async () => ...) Breaks workspace stream propagation
.GetAwaiter().GetResult() Blocks the execution thread
.ContinueWith(t => ...) A Task continuation on the hub path — compose with SelectMany instead
Observable.FromAsync(...) Runs the prologue on the subscribing (hub) thread and is unbounded — go through IIoPool

Updating Node Content

To update a node's content — for example, appending a message ID to a Thread's list — use the one mutation API, GetMeshNodeStream(path).Update(...). It works for the hub's own node and for any other node in the mesh; the owning hub's single-threaded action block serialises every writer, and only an RFC 7396 merge patch of the fields you actually changed crosses the wire.

// Cold — the write runs on Subscribe. Always subscribe, always with an error handler.
workspace.GetMeshNodeStream(path)
    .Update(node => node with { Content = newContent })
    .Subscribe(_ => { }, ex => logger.LogWarning(ex, "update failed for {Path}", path));

Do not reach for DataChangeRequest from application code, and never read a node's current content with GetStream<MeshNode>().Take(1) before writing — Take(1) on a live stream freezes the binding, and the read-modify-write races every other writer. stream.Update is the read-modify-write, done on the owner. DataChangeRequest/PatchDataRequest are the plumbing Update itself uses.

Never serialize manually. Let the framework's polymorphic converter emit the $type discriminators; hand-rolled JsonSerializer.SerializeToElement(...) of a node's content produces a payload the deserializer can reject.

Moving a node's MainNode

MainNode is what makes a node a satellite at all, so re-pointing it is re-parenting, not an ordinary field edit. It is writable through both mutation paths, with one asymmetry worth knowing:

// Re-parent to another node — works through either path.
workspace.GetMeshNodeStream(path).Update(n => n with { MainNode = newOwnerPath });
hub.CreateOrUpdateNode(node with { MainNode = newOwnerPath });   // since #2631

🚨 A full-instance upsert can move MainNode anywhere EXCEPT back onto the node's own path. MeshNode.MainNode is not nullable — it is initialised to the node's own path — so "the writer never touched it" and "the writer set it to this node itself" are the same value on the wire. The upsert therefore applies it only when MeshNode.HasExplicitMainNode holds, i.e. when it names something other than the node itself; any other rule would silently promote every satellite an upsert touched into a main node (is:main is SQL n.main_node = n.path), dropping it out of its owner's listings and re-scoping its grants, which project at COALESCE(main_node, namespace).

To turn a satellite back INTO a main node, say so explicitly through the stream — that path carries the intent, and so does patch, which can see the key was present:

workspace.GetMeshNodeStream(path).Update(n => n with { MainNode = n.Path });

Before #2631 an upsert could not move MainNode at all: IsNoOpUpsert did not compare it, so a write whose only change was MainNode was skipped and reported as a successful no-op.


Thread + ThreadMessage Pattern

Threads are satellite nodes stored under User/{userId}/_Thread/. Each Thread owns an ordered list of ThreadMessage children:

User/Roland/_Thread/hello-world-4651          (Thread node)
User/Roland/_Thread/hello-world-4651/msg1     (ThreadMessage node)
User/Roland/_Thread/hello-world-4651/msg2     (ThreadMessage node)

Data Flow

  1. Thread.Messages stores an ordered ImmutableList<string> of child message IDs (src/MeshWeaver.AI/Thread.cs). Queued-but-not-yet-started input sits separately in Thread.PendingUserMessages.
  2. Submission is a node write, not a wire message. Callers use the canonical extensions in src/MeshWeaver.AI/HubThreadExtensions.cshub.StartThread(...) / hub.SubmitMessage(...) — which write the thread node via GetMeshNodeStream(threadPath).Update(...). There is no SubmitMessageRequest-shaped handler to write.
  3. The per-thread submission watcher reacts to that state change: it drains PendingUserMessages into Messages, allocates the user + response cells, and invokes ThreadExecution.ExecuteMessageAsync(execHub, RoundParams, AccessContext?) directly as a method — no message dispatch. It returns IObservable<Unit>; the watcher subscribes and treats completion (gated on the terminal Status write) as round-done.
  4. The _Exec hosted hub owns the round: its round watcher sees Status = StartingExecution and dispatches, so the streaming loop never runs on the thread node's own action block.
  5. Blazor view data-binds a ThreadViewModel that wraps the messages list.

Full reference: Thread Operations.

ThreadViewModel and Data Binding

Raw arrays cannot be deserialized by GetStream<object>. ThreadViewModel (src/MeshWeaver.AI/ThreadViewModel.cs) wraps the list and overrides Equals so a re-emission with identical contents does not churn the UI:

public record ThreadViewModel
{
    // ... bubble list + status state ...
    // Custom Equals compares element-wise to suppress redundant UI updates
}

Push via host.UpdateData() with DistinctUntilChanged(). The Blazor view binds via JsonPointerReference and a converter that extracts the typed object.


Comment + Reply Pattern

Comments are satellite nodes stored under {docPath}/_Comment/. Replies are children of the Comment node:

Doc/MyDoc/_Comment/abc123              (Comment node)
Doc/MyDoc/_Comment/abc123/reply1       (Reply node)

Key Differences from Threads

Aspect Thread/Message Comment/Reply
Mutation entry point The hub.StartThread / hub.SubmitMessage extensions (HubThreadExtensions) Click actions in layout areas
Child list Indexed Thread.Messages on the parent Discovered by querying the comment's direct-child Comment nodes
Text edits stream.Update on the response cell, driven from the _Exec hub Direct stream.Update
Node creation meshService.CreateNode(...) composed into the round's observable chain CreateNode (Active) → edit via stream.Update

Comment.Replies still exists on the record, but the current renderer does not read it — it discovers replies with a live child query so a reply written by any writer shows up without the parent's list being maintained in lockstep (CommentLayoutAreas).


PostgreSQL Table Routing

Both Thread/ThreadMessage and Comment/Reply nodes are stored in satellite tables. The default layout is SatelliteTableMapping.Defaults; a partition may override it through PartitionDefinition.TableMappings:

{ "_Thread": "threads", "_ThreadMessage": "threads", "_Comment": "annotations" }

The routing is path-based, so children automatically inherit the parent's table:

Path Table
User/alice/_Thread/chat-1 threads
User/alice/_Thread/chat-1/msg1 threads (path contains _Thread)
Doc/MyDoc/_Comment/abc123 annotations
Doc/MyDoc/_Comment/abc123/reply1 annotations (path contains _Comment)

🚨 There is no comments table. _Comment shares the annotations table with _Approval and the legacy _Tracking — see SatelliteTableMapping.Defaults (src/MeshWeaver.Mesh.Contract/SatelliteTableMapping.cs), which is the single source of truth for segment → table. Other segments in the same set: _Activityactivities, _UserActivityuser_activities, _Accessaccess, _Notificationnotifications, and Source/Testcode.


ConfigureDefaultNodeHub

MeshBuilder.ConfigureDefaultNodeHub() registers configuration that applies to all node hubs. Both Monolith and Orleans routing must compose this overlay with the node's own HubConfiguration — not replace it:

// Correct: compose default config with the node's own config
var hubConfig = defaultConfig != null
    ? config => nodeConfig(defaultConfig(config))
    : nodeConfig;

Skipping this composition means the shared registrations — type-registry entries such as config.TypeRegistry.AddAITypes(), default layout areas, and the framework's own watchers — are absent from the node hub, so cross-hub messages arrive as raw JsonElement and areas silently fail to resolve.

🚨 Exactly one component composes it: MeshNodeHubFactory — the single funnel both activation paths (Monolith routing, MessageHubGrain) go through. Everything that produces a node's own HubConfiguration — the compilation-error overlay, the compilation-in-progress overlay, static/dynamic NodeType configurations, the self-heal wraps — returns its own delta only. Composing DefaultNodeHubConfiguration a second time runs every ConfigureDefaultNodeHub lambda twice for that hub. Lambdas that only add views or types absorb that silently; one that contributes a type source does not: DataContext.Initialize keys TypeSources by collection name, so the second application contributes a second entry for the same collection and hub creation fails outright — the node never comes up, and the message (An item with the same key has already been added. Key: Approval) names neither the lambda nor the node. That is issue #1684, seen on production memex's Doc hub and on three further addresses in the plugin gate; the victim is simply whichever NodeType happened to take the overlay path.

Corollary for anyone writing a ConfigureDefaultNodeHub lambda: give any data source you contribute a stable id (DataExtensions.DefaultId is a fresh Guid, which defeats DataContext.Initialize's keep-last-by-id dedupe), and prefer the already-idempotent AddMeshDataSource.


Type Registry

AI types must be registered on all three hub boundaries or cross-hub messages will arrive as raw JsonElement and fail to deserialize:

Hub Registration call
Mesh hub ConfigureHub(config => config.TypeRegistry.AddAITypes())
Client hub configuration.TypeRegistry.AddAITypes() in AddChatViews()
Node hubs Inherited via ConfigureDefaultNodeHub composition (see above)
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.