ModelingEvolution.Chat
1.0.0-preview.7
See the version list below for details.
dotnet add package ModelingEvolution.Chat --version 1.0.0-preview.7
NuGet\Install-Package ModelingEvolution.Chat -Version 1.0.0-preview.7
<PackageReference Include="ModelingEvolution.Chat" Version="1.0.0-preview.7" />
<PackageVersion Include="ModelingEvolution.Chat" Version="1.0.0-preview.7" />
<PackageReference Include="ModelingEvolution.Chat" />
paket add ModelingEvolution.Chat --version 1.0.0-preview.7
#r "nuget: ModelingEvolution.Chat, 1.0.0-preview.7"
#:package ModelingEvolution.Chat@1.0.0-preview.7
#addin nuget:?package=ModelingEvolution.Chat&version=1.0.0-preview.7&prerelease
#tool nuget:?package=ModelingEvolution.Chat&version=1.0.0-preview.7&prerelease
ModelingEvolution.Chat
Event-sourced chat — workspaces → channels → participants — with MudBlazor 9 Blazor components:
ConversationList, ConversationView, ChatWidget, ChatInput, ChatMessageView (Markdown messages).
Server-side command handlers and read models over MicroPlumberd 1.2.x / KurrentDB.
The published language (identifiers, commands, events) is ModelingEvolution.Chat.Types, a dependency of this package.
What a host needs — nothing else
This is the whole composition. A fresh Blazor Server host with only these lines renders a working
multi-channel conversation (that claim is tested: testing/FreshChatHost in the source repository is
exactly this and nothing more).
<PackageReference Include="ModelingEvolution.Chat" Version="X.Y.Z" />
Program.cs
using KurrentDB.Client;
using MicroPlumberd.Services;
using ModelingEvolution.Chat;
using MudBlazor.Services;
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddMudServices(); // MudBlazor
builder.Services.AddMudMarkdownServices(); // MudBlazor.Markdown — message bodies render as Markdown
builder.Services.AddPlumberd(KurrentDBClientSettings.Create(builder.Configuration["KurrentDB"]!));
builder.Services.AddChatServer(); // command handlers + read models (AddChatClient() = read models only)
builder.Services.AddHealthChecks().AddPlumberdHealthChecks(); // readiness — see "Before you send" below
…and map it: app.MapHealthChecks("/health", …) with a response writer that names each check (the default prints only the status word) — see testing/FreshChatHost/Program.cs.
App.razor (or your layout's <head> / <body>)
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<link href="_content/MudBlazor.Markdown/MudBlazor.Markdown.min.css" rel="stylesheet" />
…
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="_content/MudBlazor.Markdown/MudBlazor.Markdown.min.js"></script>
<script src="_framework/blazor.web.js"></script>
Layout — MudBlazor's providers, once:
<MudThemeProvider />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
_Imports.razor
@using MudBlazor
@using ModelingEvolution.Chat
@using ModelingEvolution.Chat.Components
@using ModelingEvolution.Chat.ReadModels
A page (interactive — the components bind input events and take EventCallbacks; static SSR will not run them):
@page "/chat"
@rendermode InteractiveServer
<ConversationList WorkspaceId="@Workspace" @bind-SelectedChannelId="_channel" />
<ConversationView ChannelId="@_channel" SenderId="@Me" />
Seeding a conversation
Everything goes through ICommandBus (MicroPlumberd) with the commands from ModelingEvolution.Chat.Types:
await bus.SendAsync(workspaceId, new DefineWorkspace { Name = "Support" });
await bus.SendAsync(channelId, new DefineChannel { WorkspaceId = workspaceId, Name = "general" });
await bus.SendAsync(participantId, new RegisterParticipant { Email = "alice@example.com", Name = "Alice" });
await bus.SendAsync(channelId, new SendMessage { ChannelId = channelId, SenderId = participantId, Content = "**hello**" });
Before you send. MicroPlumberd's command handlers subscribe from the end of the app command stream once the host
is running; a command sent before they are ready is never answered (a 2-minute timeout, not an error). Wait for
/health to report Healthy (or for AddPlumberdHealthChecks's check) before the first SendAsync — on startup, and
in any test that boots the host and seeds it.
Stream naming: Workspace-{WorkspaceId}, Channel-{ChannelId}, Participant-{ParticipantId}; the read models
subscribe by event type and need KurrentDB's standard projections running ($by_event_type).
What is in the contract (frozen at 1.0.0)
- The components'
[Parameter]s andEventCallbacks — see each.razor— not their pixels. - Workspaces / channels / participants; the multi-channel surface; send + live update; Markdown; participant name resolution.
- Not in the contract: typing indicators (do not exist), notification hooks (deleted), read receipts (half-built — not asserted).
Latest is a standing constraint
The components are built on MudBlazor 9's supported primitives (MudPaper, MudStack, MudText, MudMarkdown) — never on
a MudBlazor "chat" component. In the source repository Directory.Build.props makes a Razor element that resolves to no
component (RZ10012) a build error, so a future MudBlazor deletion cannot ship as inert markup.
Rendering user-authored Markdown — the one policy (preview.3 / preview.4)
Every message goes through ChatMarkdown — MudMarkdown with MarkdownPipeline="@ChatMarkdown.Pipeline" (raw HTML
disabled: tags are literal text) and Props="@ChatMarkdown.Props" (every link/image URL through ChatMarkdown.SafeUrl).
MudMarkdown on its own passes raw HTML through and puts any URL scheme into href (P0, 2026-08-17: stored XSS across
participants); a host that renders chat content itself must use the same two knobs.
- Links (
ChatMarkdown.IsAllowedUrl):http/https/mailtoor a same-origin relative path. Anything else —javascript:,data:,vbscript:,file:, and any spelling of protocol-relative (//evil,/\evil,\\evil,/%2F%2Fevil,/%5Cevil—\,%2F,%5Care read as/and control/whitespace dropped before the test) — renders with an EMPTY href. - Images (
ChatMarkdown.IsAllowedImageUrl) — hazard 5: a link is a choice, an image is a FETCH on render. A remote image from a stranger is a tracking pixel (viewer IP/UA) on a page that may carry a buyer capability, so image sources are allowed ONLY when same-origin relative (/media/a.png);https://…and everything with a scheme or an authority renders<img src="">(a browser fetches nothing for it). - External links carry
rel="noopener noreferrer"+target=_blank(asserted per link in the rendered DOM).
The payload sweep (ChatMarkdownXssTests, structural DOM assertions) is the guard; a host's own renderer of chat content
belongs behind the same class, and the source repository pins that no raw-markup sink takes user text any other way.
preview.5 — additive, for a host whose buyer has no address and whose command server is elsewhere (design §4.6c)
RegisterParticipant.Emailis optional: a participant may be registered WITHOUT an address (a portal buyer keyed by its enquiry, whose address lives sealed elsewhere). Address-less participants are never indexed by address (two of them do not collide); an address that IS given must be valid.ConversationView/ChatMessageViewOwnLabel(optional): what the viewer's OWN bubbles are labelled — "You" in the viewer's language — instead of the stored name/address; others keep their stored name; nothing stored ⇒ "Unknown".ConversationView.FireAndForget(optional, default false): dispatchSendMessagewithout awaiting the handler, so an out-of-process command server that is down never stalls the sender's UI until the bus timeout. The command is on the app command stream and is handled when the server is up (see item 10 above: handlers subscribe from the END — a command sent while the server is DOWN is not replayed to it; a host that needs delivery-while-down keeps the default and shows the timeout, or queues on its own side).
preview.6 — channel tags (design §4.6d) and the estate's one tag type
DefineChannel.Tags / ChannelDefined.Tags carry ExternalReference tags from the ModelingEvolution.Tags package —
the SAME type a booking's tags are — bounded at 16, duplicates collapsed, '@' refused by the type;
ChannelReadModel.ByTag(tag) / ByTagKind(kind) answer "the chat for this offer" exactly as BookingLookupModel.ByTag
answers "the meeting for this offer". Channels defined before tags existed read as having none. Add
using ModelingEvolution.Tags; (Chat.Types depends on the package).
preview.6 — a sent message is local until the store acks it (design §4.6c, lead's ruling)
ConversationView AWAITS the send with a bounded SendTimeout (default 5 s) and shows the message the viewer typed as a
LOCAL bubble — data-chat-delivery="sending" — until the store folds it (the command's id IS the message id: the handler
writes it onto MessageSent, the read model folds once per id, and the local bubble is dropped when its stored twin
appears). A fault or the timeout ⇒ data-chat-delivery="failed" with NotDeliveredLabel + a RetryLabel button
(data-chat-retry) that re-sends the SAME message (SendMessage.MessageId, stable) as a NEW command (SendMessage.Id,
fresh) — never a second message. Why a new command (preview.7, measured on TEST): a command appended while the server
is DOWN is NOT replayed to it — MicroPlumberd's handlers subscribe from the END of the app command stream (item 10) — and a
retry that re-used the command id was refused by the bus as in-flight / de-duplicated by the store, so it did nothing:
"retry" that never leaves "failed". A retry must therefore be a command nobody has seen; the stable MessageId is what
keeps a retry after a landed-but-unacked attempt from showing twice (the read model folds once per MessageSent.Id). Labels (SendingLabel, NotDeliveredLabel,
RetryLabel) are the host's, per language; OnSendFailed (EventCallback<Exception>) fires as well, so a host can keep
the draft or toast — in addition to the visible state, never instead of it. FireAndForget still exists as a flag but the buyer page does not use it:
a possibly-lost message is worse than a visible "not delivered".
preview.7 — stranded commands are reconciled (§6.124), interim
A SendMessage appended while the chat server is unavailable is durably stored and never handled — after a restart
(handlers subscribe from the END of the app command stream, item 10) and after a dropped subscription (a paused
container). AddChatServer therefore registers StrandedSendMessageReconciler: at start and every Interval (60 s) it
reads $et-SendMessage and, for every command older than Grace (30 s) whose message id has no MessageSent on its
channel, runs the real ChannelCommandHandler — one path, idempotent by the stable message id. /health entry
"Chat stranded commands": stranded: N (Degraded while the last sweep found any; Healthy at 0; Unhealthy before the first
sweep or after a failed one). Requires the standard projections ($by_event_type). Interim — the framework-level fix
(command handlers resuming from a checkpoint) is micro-plumberd's; this reconciler is deleted when it lands.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Markdig (>= 0.45.0)
- MicroPlumberd (>= 1.2.2)
- MicroPlumberd.Services (>= 1.2.2)
- MicroPlumberd.SourceGenerators (>= 1.2.2)
- ModelingEvolution.Chat.Types (>= 1.0.0-preview.7)
- ModelingEvolution.Observable.Blazor (>= 0.0.12)
- MudBlazor (>= 9.7.0)
- MudBlazor.Markdown (>= 9.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-preview.11 | 25 | 8/19/2026 |
| 1.0.0-preview.10 | 29 | 8/19/2026 |
| 1.0.0-preview.9 | 36 | 8/19/2026 |
| 1.0.0-preview.8 | 60 | 8/17/2026 |
| 1.0.0-preview.7 | 48 | 8/17/2026 |
| 1.0.0-preview.6 | 44 | 8/17/2026 |
| 1.0.0-preview.5 | 44 | 8/17/2026 |
| 1.0.0-preview.4 | 50 | 8/17/2026 |
| 1.0.0-preview.3 | 53 | 8/17/2026 |
| 1.0.0-preview.2 | 57 | 8/17/2026 |
| 1.0.0-preview.1 | 53 | 8/17/2026 |