Notifications
A notification is just a mesh node β a satellite under the thing it notifies about. Nothing about the pipeline is special-cased: creation is a node create, the bell is a reactive query, mark-as-read is a stream.Update, and routing to external channels is an agent reading rule nodes. Everything composes from primitives you already know.
1. Emitting β a satellite create
When a thread round reaches a terminal state, ThreadExecution.EmitCompletionNotification creates a Notification node under the thread (the same surface is open to any feature):
NotificationService.Dispatch(
hub,
recipient: addressee, // WHO it is for β null means the platform operators
mainNodePath: threadPath, // WHAT it is about
title: $"\"{threadName}\" is ready",
message: preview, // first 120 chars of the response
type: NotificationType.ChatReady,
targetNodePath: threadPath, // where clicking navigates
createdBy: agentName,
icon: "/static/NodeTypeIcons/chat.svg")
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "notification failed"));
π¨ The node lands at {addressee}/_Notification/{id} with MainNode = the addressee, and the _Notification path segment routes persistence to that partition's dedicated notifications satellite table. The entity the notification is ABOUT is a reference on the content (TargetNodePath). recipient: null means the PLATFORM β the Admin partition, read-scoped to hub.IsGlobalAdmin(). Creation is fire-and-forget in the sense that a failed notification never fails the round β but the observable is COLD, so it must still be subscribed: a discarded Dispatch writes nothing at all.
π¨ Who can see it is decided by the PATH. No SatelliteAccessRule is registered for Notification, so RlsNodeValidator falls through to the ordinary path-based permission fold on the notification's own path. Under the addressed model that is the correct answer β the addressee, plus whoever can read their partition β which is why no rule is needed. Before addressing it was the wrong one: an "Update available" notification written under a plugin record reached every viewer who could read the plugin catalog. See Addressed Notifications.
2. The bell β a reactive query
The portal's notification center subscribes once and re-renders on every change β new notifications appear without polling, and the unread badge is just a count over the same emission:
// One live feed, two ANCHORED legs β the shell's NotificationFeed.ForViewer.
NotificationFeed.ForViewer(Hub, MeshQuery, Access)
.Subscribe(items =>
{
notifications = items;
InvokeAsync(StateHasChanged);
});
Behind it, NotificationQueries.For(viewer, viewerIsGlobalAdmin) yields the legs, each built by core's NotificationService.BellQuery:
namespace:{viewer}/_Notification nodeType:Notification sort:CreatedAt-desc
namespace:Admin/_Notification nodeType:Notification sort:CreatedAt-desc β global admins only
This is the set side of CQRS β a query is right here because the bell wants all notifications addressed to the viewer, live. (For one specific thread's notifications: path:{threadPath}/_Notification scope:children nodeType:Notification.)
π¨ Each leg names ONE partition, and that is not merely an optimisation. The previous spelling β a bare nodeType:Notification sort:CreatedAt-desc β named no partition and UNIONed every partition schema on the server, per circuit, on every notification write anywhere: measured on memex-cloud at 4 476 rows across 201 of 201 schemas, 9β10 s per render, filtered to 0 rows in memory, on an idle replica. And because Admin is excluded from public.searchable_schemas, that fan-out could never read admin.notifications at all, so every platform-admin notification was written and shown to nobody.
π¨ Two queries, never one namespace:A|B alternation. A single concrete namespace: folds into ParsedQuery.Path and pins to one schema without consulting searchable_schemas; an alternation leaves Path null, takes the fan-out route, and is narrowed by INTERSECTION with that registry β which excludes Admin, so it would drop the platform bell again, silently. Pinned by NotificationBellLegsTest.
π¨ The platform leg is issued only for a viewer hub.IsGlobalAdmin() confirms POSITIVELY β the one canonical platform-admin predicate, never an ad-hoc role-name or root-scope check β and the gate fails CLOSED. RLS refuses those rows to a non-admin independently; the gate decides what is even asked for. See Addressed Notifications and Cross-Schema Fan-Out Elimination.
3. Mark-as-read β stream.Update, like everything else
Clicking a notification navigates to its TargetNodePath and flips the scalar through the canonical mutation API:
Hub.GetMeshNodeStream(node.Path)
.Update(n => n with { Content = ((Notification)n.Content!) with { IsRead = true } })
.Subscribe(_ => { }, ex => Logger.LogWarning(ex, "mark-read failed"));
A scalar flip is race-safe across mirrors (RFC 7396 merges object keys), so the bell, the panel, and any other reader converge on the next emission.
4. Routing beyond the bell β rules, channels, triage
Where a notification also goes is the user's data, not code:
| Node type | Lives at | Holds |
|---|---|---|
NotificationRule |
{user}/_NotificationRule/β¦ |
Plain-English routing intent ("approvals β Teams immediately", "thread completions β email digest"), with order precedence |
NotificationChannel |
{user}/_NotificationChannel/β¦ |
A channel: kind (InApp / Email / Teams), optional target, enabled |
The NotificationTriage agent reads the recipient's rules and channels, applies them to the event, and dispatches to the chosen channels β email delivery rides Sending Email. Users manage their rules and channels in settings β see Notification Preferences.
This whole lane β the two node types plus the NotificationTriageService watcher that starts the agent β ships as the MeshWeaver.Notifications.Channels module (Modules); the bell and the deterministic email preferences stay core. The watcher self-skips unless Email:Enabled.
Cross-references
- Satellite Entity Patterns β the satellite shape notifications follow.
- Thread Operations β where completion emission sits in the round lifecycle.
- CQRS β Queries vs. Content Access β why the bell queries but mark-as-read streams.
- Addressed Notifications β where notifications actually live today, and the design that lets the bell name its partition.
- Implementation:
src/MeshWeaver.Graph/NotificationService.cs(core) Β·src/MeshWeaver.Blazor.Portal/Components/NotificationCenter.razor/NotificationCenterPanel.razorandNotificationQueries.cs(MeshWeaver.Plugins β the Blazor portal shell lives there).