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.

flowchart LR A[Agent round completes] -->|NotificationService.CreateNotification| B["{threadPath}/_Notification/{id}"] B -->|satellite routing| C[(notifications table)] B -->|reactive query| D[πŸ”” Bell] D -->|click β†’ stream.Update IsRead| B B -->|NotificationTriage agent + rules| E[Email / Teams]

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

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.