Static Node Providers

The mesh ships with a stable set of built-in MeshNodes — NodeType definitions (Markdown, Agent, Code, …), platform agents, language-model definitions, embedded documentation, partition meta-nodes. None of these live in persistence. They are declared at configuration time and surfaced uniformly through a single, lightweight abstraction: IStaticNodeProvider.

Static Node Provider Architecture StaticMeshNode ListProvider (AddMeshNodes) BuiltInAgent Provider (platform agents) DefaultPartition Provider (meta-nodes) + MyBuiltInsProvider, … } } } IStaticNodeProvider (DI singletons) StaticNodeProvider Extensions FindStaticNode / EnumerateStaticNodes EnrichWithNodeType (node activation) Application code & other consumers *Multiple `IStaticNodeProvider` singletons are fanned out through two extension-method helpers; all consumers read through these — there is no central dictionary.*

The contract

public interface IStaticNodeProvider
{
    IEnumerable<MeshNode> GetStaticNodes();
}

Every provider is registered as a DI singleton. The mesh runtime enumerates all registered providers on demand through two extension methods in MeshWeaver.Mesh.Services.StaticNodeProviderExtensions:

// Resolve a specific static node by path
var node = serviceProvider.FindStaticNode("Markdown");

// Walk every registered static node across all providers
foreach (var node in serviceProvider.EnumerateStaticNodes()) { … }

These are the only way application code should read static nodes. There is no MeshConfiguration.Nodes dictionary or any other central registry to reach into.

Why the central dictionary was removed

MeshConfiguration used to carry an IReadOnlyDictionary<string, MeshNode> Nodes populated by MeshBuilder.AddMeshNodes(...), with a GroupBy(Path).Last() de-dup rule baked in. That dictionary has been removed for three interconnected reasons:

Problem Consequence
Duplicated the provider abstraction IStaticNodeProvider already offered "find by path" and "iterate all". The dictionary was a parallel pipe that some sources fed (AddMeshNodes) and others never did (BuiltInAgentProvider, DefaultPartitionProvider). Consumers reading only the dictionary saw half the nodes.
Ambiguous de-dup semantics GroupBy(Path).Last() imposed a silent "last-write-wins" ordering on all callers. Per-provider iteration lets each provider own its ordering; de-dup happens exactly once, at the call site that needs it.
Coupled config to a runtime-mutable concept Tests that need different static nodes could not easily swap a dictionary entry; they can register an additional IStaticNodeProvider.

How AddMeshNodes still works

MeshBuilder.AddMeshNodes(params MeshNode[]) is unchanged — all existing call sites keep compiling. At MeshBuilder.Build time, the accumulated list is wrapped in a StaticMeshNodeListProvider and registered as an IStaticNodeProvider:

.AddSingleton<IStaticNodeProvider>(new StaticMeshNodeListProvider(MeshNodes))

StaticMeshNodeListProvider.GetStaticNodes() applies the same GroupBy(Path).Last() de-dup the old dictionary used at build time. The semantic is preserved — just deferred to iteration.

🚨 One path, one static node — the precedence rule

More than one contributor can offer a node at the same path: a host calls AddMeshNodes for a path a platform provider already claims, or a node type registers its declaration twice (several do, on purpose — AddPartitionType seeds CreateMeshNode() and registers a provider that yields it again). Something has to decide who wins, and every reader has to decide it the same way.

There is exactly one rule, implemented once, in StaticNodeProviderExtensions.ResolveStaticNodes(providers):

  1. the MeshBuilder.AddMeshNodes(...) seed wins every tie — it is the host's own declaration, and it is the bucket that carries registration-node semantics (suppressed under context:search / is:content) at the query seam;
  2. among the remaining providers, first registered wins;
  3. within one provider, its own GetStaticNodes() order decides (the seed provider applies last-write-wins by path before it yields).

A definition-only entry still claims its path — it is not skipped in favour of a lower-precedence provider's served node. The host declared that path DB-backed; a second provider must not win it back. FindServedStaticNode takes the winner first and applies the IsDefinitionOnly test second, in that order.

Both readers of "which static node is at this path" call that one resolution: StaticNodeQueryProvider (queries, autocomplete) and FindStaticNode / FindServedStaticNode (hub activation via MeshDataSource.WithMeshNodes, the create path's already-exists check, the persistence-sampler gate, the plugin installer's pre-flight).

Why this is a rule and not a detail

They used to differ. The query provider gave the seed priority and excluded every other provider's node at a seed-claimed path; FindServedStaticNode took a bare FirstOrDefault in DI-registration order. Registration order is not something a host controls — a provider registered by AddPersistence lands before the mesh builder's own deferred registrations — so appending a second static node at a platform seed's path was served by one reader and not the other, in one live process:

reader served
StaticNodeQueryProvider the appended node
POST /api/mesh/get → hub activation → FindServedStaticNode the platform's node

Nothing errored. No warning, no ambiguity diagnostic, no last-one-wins log. The path simply resolved to different content depending on which way you arrived at it, and it made "override a platform default by appending a node at its path" look like a supported pattern — the append was accepted, so the natural conclusion was that it had worked (#2908).

A contested path is loud

Overriding another contributor's node by adding a second one at its path is not supported, so a duplicate registration says so:

// One line per path claimed by more than one provider WITH DIFFERENT CONTENT.
foreach (var line in serviceProvider.DescribeStaticProviderCollisions())
    logger.LogWarning(line);

StaticNodeQueryProvider runs this once per mesh at construction and logs each line as a warning naming the contested path, the winner, and the claimant that is being dropped. DescribeStaticServeCollision(path) carries the same detail, so a create refused at that path names it too.

The comparison is content, not claim count: two claimants offering byte-identical declarations are redundant, not a dropped contribution, and warning on those would fire for every built-in type and be ignored within a week. Equality is PartitionSourceFingerprint.ComputeNodeToken — the repo's deterministic per-node source token, which excludes the un-serialisable HubConfiguration delegate (two calls to one factory produce different delegates and would otherwise never compare equal).

Regression guard: test/MeshWeaver.Hosting.Test/StaticNodePrecedenceTest.cs.

Adding a new built-in node

Two paths are available, and the right choice depends on complexity:

Option A — quick, for a single node. Call builder.AddMeshNodes(myNode) from an extension method. The node flows through the list provider automatically and requires no extra class.

Option B — explicit provider (preferred for any non-trivial set).

public sealed class MyBuiltInsProvider : IStaticNodeProvider
{
    public IEnumerable<MeshNode> GetStaticNodes()
    {
        yield return new MeshNode("ThingOne") { … };
        yield return new MeshNode("ThingTwo") { … };
    }
}

// Register alongside other singletons
services.AddSingleton<IStaticNodeProvider, MyBuiltInsProvider>();

Use option B whenever the nodes depend on configuration, embedded resources, or constructor parameters. Option A is fine for stateless, one-off registrations from a NodeType.Add…Type() extension method.

🚨 A static node and a durable row must never claim the same path

A static node is not persistence-backed. When one is served at a path, MeshDataSource.WithMeshNodes seeds that path's per-node hub from the static node via WithInitialData and skips persistence entirely — and the persistence sampler is gated off for the same hub, deliberately (a type-def path routes to a schema that is by design never provisioned). So if durable content ever lands at that same path, it is unreachable: the hub emits one Full snapshot at v0 and never again.

That state is total and silent. The path becomes simultaneously

Nothing in that chain says "collision"; downstream it surfaces only as a timeout on whatever waited for the node's stream to carry the durable state. That is exactly how the Agent/Skill plugin packages failed on a host calling bare .AddAI(): a deterministic 30 s TimeoutException per package, 0 nodes imported, no diagnostic (#1209).

The cure is per-host configuration. Pass the partition in serveFromPartition (AddAI(serveFromPartition: […]), driven by Features:StaticRepoSync:Partitions). That marks the in-memory type definition IsDefinitionOnly — it still supplies its HubConfiguration by name, but it is no longer the runtime node at its path, so the durable row owns it. See NodeType Catalogs.

One predicate answers "who serves this path". Every serve seam reads the same helper, which skips definition-only entries:

// The static node genuinely SERVED at a path — null when persistence owns it
var served = serviceProvider.FindServedStaticNode("Agent");

// The canonical operator-facing diagnostic (null when there is no collision)
var detail = serviceProvider.DescribeStaticServeCollision("Agent");

FindServedStaticNode — not FindStaticNode — is what WithMeshNodes, the persistence-sampler gate, the create path's already-exists check and the plugin installer's pre-flight all consult, so "served static" ⇔ "not persistence-backed" cannot drift apart.

The collision is refused, not written. PackageInstaller sweeps a package's node paths against that predicate before the first write (zero I/O) and fails the install immediately, naming the contested path, the claiming provider, and the serveFromPartition setting. The CreateNodeRequest handler distinguishes the two ways a create can be refused for the same reason code: a durable duplicate still reads Node already exists at path: X, while a static claim over empty persistence gets the collision message and a Warning. Regression guard: test/MeshWeaver.PluginCatalog.Test/StaticShadowedInstallTest.cs, which also pins that static-only serving — the legitimate arrangement on every host that serves Doc/Agent/Harness/Skill from memory and installs no durable package there — is unaffected.

What happens when a node type is missing

When a MeshNode references nodeType = "X" and no provider returns a node at path X, EnrichWithNodeType runs a 3-second existence probe (path:X against IMeshQueryCore). If the probe returns empty, activation fails fast with a clear error overlay:

NodeType 'X' is not registered (referenced by instance '<path>').
Either register the type via AddXxxType() in your mesh builder, or fix the instance's NodeType field. Activation cannot proceed.

Before this probe existed, the slow path waited the full SlowPathTimeout (NodeTypeEnrichmentHelpers30 s today, deliberately inside the 60 s hub RequestTimeout so the overlay can actually be delivered) for a typeStream emission that would never come, and activation enriches the same node twice, so a second wait used to stack on top of the first. The stuck per-node hub then jammed the routing action block, cascading 10-second timeouts to every other activation posted through the same client. (The stacking is gone — a re-enrichment short-circuit returns once HubConfiguration is set; the regression guard is NodeTypeEnrichmentDoubleCallTest.)

See test/MeshWeaver.Persistence.Test/UnregisteredNodeTypeTest.cs for the regression guard.

Symbol Location
IStaticNodeProvider namespace MeshWeaver.Mesh.Services (assembly MeshWeaver.Mesh.Contract)
StaticNodeProviderExtensions same namespace/assembly — FindStaticNode / EnumerateStaticNodes / FindServedStaticNode / DescribeStaticServeCollision helpers
StaticMeshNodeListProvider same namespace/assembly — wrapper bridging AddMeshNodes to the provider model, registered in MeshBuilder.Build
NodeTypeEnrichmentHelpers MeshWeaver.Graph.Configuration — existence probe and slow path
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.