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:

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

Static Provider IStaticNodeProvider Built-in Agents · Models · Roles User Extensions MeshNode created anywhere in the namespace hierarchy Synced Query Union ① namespace:{root} nodeType:{T} ② namespace:{currentPath} scope:selfAndAncestors ③ namespace:{nodeTypePath} scope:selfAndAncestors Per-hub Replicated Collection Replay(1).RefCount — first emit = built-ins (instant) User extensions stream in via IDataChangeNotifier Ancestor traversal acme (root) acme/team acme/team/proj hub sees extensions at all ancestor levels

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.BuildAgentQuery emits a single namespace:{user}/Agent|{space}/Agent|Agent nodeType:Agent search. Agents live in a dedicated /Agent sub-namespace per partition — platform defaults in the bare Agent namespace, a space's own under {space}/Agent, a user's own under {user}/Agent. The namespace:A|B|C alternation (see Query Syntax → "Multi-value namespace:") is a single namespace IN (...) exact-membership filter — no graph/ancestor walk. The AI model/provider catalog mirrors this shape under the top-level Provider partition (providers hold the credentials; only LanguageModel models 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 BuiltInAccessAssignmentProvider for baseline assignments (e.g. Public → Viewer on shipped namespaces) — it does not exist in src/. 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

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.