Extensible Defaults
Some features need to work on a blank mesh — no database rows, no user configuration — but they also need to grow: customers and tenants must be able to add their own instances anywhere in the node hierarchy. The Extensible Defaults pattern satisfies both requirements without compromise.
Core idea: the framework ships built-in entities via a read-only static provider; the mesh allows user-defined extensions at any namespace. Every per-node hub sees one live synced collection that unions both layers. Built-ins are visible the instant a consumer subscribes; user extensions stream in as they are created.
When to use this pattern
Apply Extensible Defaults whenever a feature has:
- A small set of canonical entities the platform must ship so the feature works out-of-the-box on a blank mesh, and
- An open extension point so customers or tenants can add their own instances at any node in the hierarchy.
Current callers in the codebase
| Entity | NodeType | Root namespace | Static provider | Picker projection |
|---|---|---|---|---|
| Agent | Agent |
Agent |
BuiltInAgentProvider |
AgentPickerProjection.BuildAgentQueries |
| Model / Provider | ModelProvider + LanguageModel |
Provider (models nested under their provider) |
BuiltInLanguageModelProvider |
AgentPickerProjection.BuildModelQueries |
| Role | Role |
Role |
RoleNodeType.BuiltInRolesProvider |
(none — PermissionEvaluator consumes the roles directly through IMeshNodeStreamCache.GetQuery) |
The three layers
Built-in entities (static provider) and user-defined extensions (any namespace) merge into one per-hub synced collection; ancestor traversal surfaces extensions defined at any level of the hierarchy.
┌─────────────────────────────────────────────────────────────┐
│ Static Repo (code-shipped — IStaticNodeProvider)│
│ - Read-only _Policy at root namespace │
│ - Built-in instances (Admin, Editor, … / GPT-4, Claude …) │
└──────────────────────┬──────────────────────────────────────┘
│
▼ folded into IMeshQueryCore by the
routing layer (StaticNodeQueryProvider)
┌─────────────────────────────────────────────────────────────┐
│ Synced query union (three queries, one subscription) │
│ 1. namespace:{root} nodeType:{T} │
│ 2. namespace:{currentPath} nodeType:{T} scope:sAA │
│ 3. namespace:{nodeTypePath} nodeType:{T} scope:sAA │
└──────────────────────┬──────────────────────────────────────┘
│ workspace.GetQuery(id, queries)
▼
┌─────────────────────────────────────────────────────────────┐
│ Per-hub replicated collection (SyncedQueryMeshNodes) │
│ Local read-only view in every consuming hub's workspace. │
│ First emission = built-ins (instant from static provider) │
│ + any matching user-created nodes already in the index. │
└─────────────────────────────────────────────────────────────┘
scope:selfAndAncestors on queries (2) and (3) means a hub at acme/team/proj sees extensions defined at acme/team/proj, acme/team, acme, and the root — closest-wins behaviour is the caller's responsibility (the same convention used by AccessAssignment).
The union is computed by MeshQueryEngine inside a single IMeshQueryCore.Query call — see Synced Query Data Source for the delta protocol. Static-provider nodes participate via StaticNodeQueryProvider, so a query against namespace:Agent returns built-in Agents without touching persistence.
Agents use a per-partition registry, ONE query.
AgentPickerProjection.BuildAgentQueryemits a singlenamespace:{user}/Agent|{space}/Agent|Agent nodeType:Agentsearch. Agents live in a dedicated/Agentsub-namespace per partition — platform defaults in the bareAgentnamespace, a space's own under{space}/Agent, a user's own under{user}/Agent. Thenamespace:A|B|Calternation (see Query Syntax → "Multi-valuenamespace:") is a singlenamespace IN (...)exact-membership filter — no graph/ancestor walk. The AI model/provider catalog mirrors this shape under the top-levelProviderpartition (providers hold the credentials; onlyLanguageModelmodels nest beneath their provider). Roles still use the multi-query form shown above.
Why this shape
Instant first emission. Static nodes are in-memory; the union's first emission carries every built-in synchronously on first subscribe. No permission check, no first-render path waits on a Postgres round-trip. The synced query is a Replay(1).RefCount stream, so subsequent consumers in the same workspace get the cached snapshot immediately.
Zero-config defaults. A fresh mesh works without any AccessAssignment, Agent, or Model rows in Postgres — the static repo covers the baseline. The framework never blocks on "did the database warm up yet?"
Mesh-level customisation. Users create a Role, Agent, or LanguageModel MeshNode anywhere in their hierarchy. The synced query picks it up on the next IDataChangeNotifier tick and emits an Added delta; every consuming hub re-projects automatically.
Read-only built-ins. The static provider ships a PartitionAccessPolicy named _Policy at the root namespace with Create/Update/Delete/Comment/Thread = false. That makes namespace:Agent (or :Role, :Model) unmodifiable — extensions must live in user namespaces.
Replicate, don't reinvent. New entities replicate the same wiring verbatim. No bespoke service, no per-feature cache layer, no special deadlock-handling.
Anatomy of an Extensible Default
Three pieces of code per entity.
1. Static provider — the built-ins
IStaticNodeProvider is a singleton that returns the MeshNodes the framework wants visible on every mesh. GetStaticNodes runs synchronously at routing time — keep it cheap.
private class BuiltInRolesProvider : IStaticNodeProvider
{
private static readonly MeshNode[] Nodes =
[
new("_Policy", "Role")
{
NodeType = "PartitionAccessPolicy",
Content = new PartitionAccessPolicy
{
Create = false, Update = false, Delete = false,
Comment = false, Thread = false,
},
},
new("Admin", "Role") { NodeType = "Role", Content = Role.Admin },
new("Editor", "Role") { NodeType = "Role", Content = Role.Editor },
new("Viewer", "Role") { NodeType = "Role", Content = Role.Viewer },
new("Commenter", "Role") { NodeType = "Role", Content = Role.Commenter },
];
public IEnumerable<MeshNode> GetStaticNodes() => Nodes;
}
Register in the NodeType's AddXxxType<TBuilder> builder extension:
builder.ConfigureServices(services =>
services.AddSingleton<IStaticNodeProvider, BuiltInRolesProvider>());
2. NodeType — the extension surface
Register the NodeType MeshNode itself so the routing layer knows the content type and how to host the per-instance hub. This is the same shape every NodeType uses — see RoleNodeType.AddRoleType.
3. Picker / projection — the consumer entry point
A small static helper that builds the three query strings and projects the resulting MeshNode snapshot into the typed view the feature actually needs. Modelled on AgentPickerProjection:
public static class RolePickerProjection
{
public const string RolesQueryId = "Roles";
public const string RootNamespace = "Role";
public static string[] BuildRoleQueries(string? currentPath = null,
string? nodeTypePath = null)
{
var queries = new List<string>
{
$"namespace:{RootNamespace} nodeType:{RoleNodeType.NodeType}",
};
if (!string.IsNullOrEmpty(currentPath))
queries.Add($"namespace:{currentPath} nodeType:{RoleNodeType.NodeType} scope:selfAndAncestors");
if (!string.IsNullOrEmpty(nodeTypePath))
queries.Add($"namespace:{nodeTypePath} nodeType:{RoleNodeType.NodeType} scope:selfAndAncestors");
return queries.ToArray();
}
public static IObservable<IReadOnlyList<Role>> ObserveRoles(
IWorkspace workspace, IMessageHub hub,
string? currentPath = null, string? nodeTypePath = null) =>
workspace.GetQuery(RolesQueryId,
BuildRoleQueries(currentPath, nodeTypePath))
.Select(snapshot => ProjectRoles(snapshot, hub.JsonSerializerOptions));
public static IReadOnlyList<Role> ProjectRoles(
IEnumerable<MeshNode> snapshot, JsonSerializerOptions options) =>
snapshot.Where(n => n.NodeType == RoleNodeType.NodeType)
.Select(n => ToRole(n, options))
.Where(r => r is not null).Select(r => r!)
.ToList();
}
Using the same query id everywhere means a single shared upstream subscription via the workspace's per-id cache. Every consumer in the same hub — chat picker UI, permission evaluator, RLS validator — gets the cached Replay(1) snapshot at no extra cost.
Hot mistakes — and why this pattern fixes them
| Mistake | Symptom | What this pattern enforces |
|---|---|---|
Per-user MemoryCache with a Timeout() fallback. |
First permission check after process start waits the full timeout (e.g. 2 s) while the upstream synced query warms; the fallback emits empty roles and the UI looks "logged out". | The Replay(1) is fed by the static provider's nodes synchronously on first subscribe — there is no warm-up window to time out against. |
| Reading the entity via a one-shot CQRS query instead of the synced collection. | Index-lag staleness after writes; missed Initial emissions. | Reads come from the local workspace's synced collection, which folds Added/Updated/Removed deltas verbatim. See CQRS and Content Access. |
| Resolving configuration per per-node activation. | Every grain activation does a Postgres round-trip plus an async resolution before the hub can answer any messages. | The static repo carries enough state for activation; user extensions arrive lazily via the same synced collection. |
| Application-level caching in the permission evaluator. | Cache invalidation is its own deadlock surface; runtime updates need a separate invalidation hook. | No application cache. The synced collection is the cache, kept consistent by IDataChangeNotifier. |
Roles & AccessAssignments — already migrated
PermissionEvaluator (src/MeshWeaver.Mesh.Contract/Security/PermissionEvaluator.cs) no longer
hand-rolls a per-user MemoryCache with a 2 s Timeout() fallback. Its own summary now reads
"no per-hub service instance, no IMemoryCache layer": per-scope state lives entirely in the
shared IMeshNodeStreamCache via narrow per-scope queries —
cache.GetQuery($"$security-access:{partition}", …) and cache.GetQuery($"$security-policy:{partition}", …) —
which is this pattern applied. RoleNodeType.BuiltInRolesProvider ships the canonical roles plus the
read-only _Policy.
The one piece never built is a
BuiltInAccessAssignmentProviderfor baseline assignments (e.g.Public → Vieweron shipped namespaces) — it does not exist insrc/. If you want a non-empty Initial for assignments on a blank mesh, that is still to be written.
See Access Control for the role / assignment data model and the
per-hub PermissionEvaluator that consumes the projection.
References
AgentPickerProjection— canonical caller (Agent & Model). Note it exposes bothBuildAgentQuery(the single-string canonical form) andBuildAgentQueries(the array form forhub.GetQuery(id, params string[])); the per-partition registry shape uses the former.BuiltInAgentProvider,RoleNodeType.BuiltInRolesProvider— static repo examples. NoteIStaticNodeProvideris described in-code as legacy in places that have moved toIStaticRepoSource/AddMeshNodes; check which surface a new entity should use before copying.StaticNodeQueryProvider— how static nodes fold intoIMeshQueryCore.- Synced Query Data Source — delta protocol, gating, Replay semantics.
- Access Control — the role/permission evaluator that will consume this pattern.
- CQRS and Content Access — when to use synced collections vs
GetRemoteStreamvsQueryAsync.