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 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.