NodeType Catalogs
A NodeType catalog is a partition that ships instances of a NodeType with the build: Agent (the agent catalog), Harness, Skill, and Provider (the AI model/provider catalog — it ships two companion types, ModelProvider providers with LanguageModel models nested beneath them). The instances are authored content materialized into the partition by the Static-Repo Import and served from the database at runtime — like every other partition. The built-in/config provider is then only a sync source: it materializes the catalog into the DB on boot, after which the DB is the catalog of record.
This page defines how such a catalog is rooted. Get it wrong and the partition's bare address (@Harness) is claimed by two nodes; a GetDataRequest to it can never settle on one owner, the mesh routing-loop guard fires, the partition's data source (ds/Harness) faults, and every subscriber — the live picker binding plus the NodeType compile/sources/release watchers — dies with it. Symptom: the harness selector disappears until refresh.
The rule
A NodeType catalog's partition root is a single persisted
nodeType:NodeTypenode whoseidis the type name. It IS the routable partition root AND the NodeType definition, and it LINKS to the registered static C# node-type for itsHubConfiguration. Postgres owns this node — it is the sole runtime owner of the bare partition path. The in-memory static definition is NOT a served runtime node; it serves only as (a) the definition the root links to forHubConfiguration, and (b) the sync source that materializes the root.
So for the Harness catalog there is exactly one node at path Harness:
@Harness id=Harness nodeType=NodeType (PG-served partition root + the "Harness" NodeType definition)
└─ links to → the registered static C# node-type "Harness" (supplies HubConfiguration)
@Harness/MeshWeaver nodeType=Harness (an instance — a child of the catalog)
@Harness/_Policy nodeType=PartitionAccessPolicy (publicRead)
MeshNode.HubConfiguration is a C# delegate (Func<MessageHubConfiguration, MessageHubConfiguration>) — it cannot be persisted. So a built-in NodeType node resolves it from the registered static type by name, via NodeTypeDefinition.StaticTypeName; a dynamic NodeType node compiles it at runtime from NodeTypeDefinition.Configuration (the lambda's C# source text, stored as a string) plus NodeTypeDefinition.Sources (see Static-Repo Import). Both are nodeType:NodeType nodes; they differ only in where the delegate comes from — StaticTypeName set means "look it up", not "compile it".
⚠️
NodeTypeDefinition.Configurationis C# living inside a JSON string field, so no.cs-shaped tool sees it: notdotnet build, notgrep --include='*.cs', not any compile gate that only scansSource/. When you rename or delete a framework symbol, search the node JSON too.
The anti-pattern this replaces (why the loop happens)
Historically a NodeType catalog registered two nodes that landed on the same path:
- The in-memory NodeType type-definition —
AddMeshNodes(CreateMeshNode()), registered at path = the NodeType discriminator (new("Harness")), carrying theHubConfigurationdelegate. - The DB-imported partition root —
IStaticRepoSource.PartitionRoot, anodeType:Spacenode at path = the RootNamespace.
When NodeType == RootNamespace == the partition name (true for Harness, Agent, Skill), both nodes occupy the bare partition path. Once the DB root wins the address resolution, the runtime disagrees with itself: routing serves the DB node, but MeshDataSource.WithMeshNodes/FindStaticNode/NodeTypeEnrichmentHelpers still find the in-memory type-def at the same path. A GetDataRequest for the bare partition bounces between hubs, re-enters one already in its RoutingPath, and the routing-loop guard (MessageService) fails it → ds/<Partition> faults.
The model/provider catalog never had this exact collision, because its type discriminators (ModelProvider, LanguageModel) differ from its partition name (Provider) — so the type-defs (@ModelProvider, @LanguageModel) and the partition root (@Provider) never shared a path. That non-collision is the proof — the path clash, not anything Harness-specific, is the defect: when the discriminator and the partition name diverge, there's nothing to collide. But "no collision" is not the same as "served twice is fine": even without a path clash, an in-memory type-def left registered on the synced path gets auto-written by the per-node-hub persistence sampler to a phantom schema named after its lowercased discriminator (modelprovider / languagemodel) that is never provisioned → 42P01. The fix for both failure modes is the same dissociation principle below.
The principle: in-memory is dissociated from runtime
For a DB-synced partition, an in-memory static node definition is a sync / definition root only — never a served runtime node:
- It is not returned by runtime node resolution for the bare partition path (
FindStaticNode/TryResolveStaticNode/MeshDataSource.WithMeshNodesmust not serve it as the node). Postgres is the sole runtime owner. - It is still consulted as a definition — the persisted
nodeType:NodeTyperoot links to it to obtain the C#HubConfigurationfor enriching the catalog's instances. - It is still the sync source — the importer writes the persisted root + instances from it once per content-version.
This is the same dbSynced boundary that already drops the in-memory content/storage providers (AddHarnessType/AddAgentType/AddModelProviderType/AddLanguageModelType skip IStaticNodeProvider + StaticNodePartitionStorageProvider when the partition is synced); the rule extends that boundary to the type-def node so it stops squatting on the DB partition's path.
There are two concrete ways to dissociate the in-memory type-def, and a catalog uses whichever its shape calls for:
nodeType:NodeTyperoot — when the discriminator equals the partition name (Harness,Agent,Skill), the persisted root and the type-def must be the same node, so the root is anodeType:NodeTypenode that links to the static C# type for itsHubConfiguration. This is what removes the path collision.IsDefinitionOnly = true— when the discriminator differs from the partition name (theProvidercatalog'sModelProvider/LanguageModel/ModelProviderSelection), there is no root collision, but the type-def must still be dropped from runtime serving/persistence so the sampler doesn't write it to a phantom schema.AddModelProviderType/AddLanguageModelTyperegister these defs withIsDefinitionOnly = truewhendbSynced(exactly asHarnessNodeTypedoes): the def still supplies itsHubConfigurationby name (the catalog's instances enrich through it) and proves the type exists, but it is NOT served or persisted at its bare discriminator path (@ModelProvider/@LanguageModel). Postgres owns the real catalog under the top-levelProviderpartition.
A definition is not a node — and every existence probe must agree
IsDefinitionOnly is not a decoration: it is the declaration that an entry in the static-node
registry is a type definition, not a node at that path. The platform has exactly one name-keyed,
delegate-carrying registry — IStaticNodeProvider fanned in through
serviceProvider.FindStaticNode(typeName) — and HubConfiguration is a C# delegate, so a
platform-defined NodeType must have an entry keyed by its own name. Registering it through a
bespoke IStaticNodeProvider instead of AddMeshNodes changes nothing: it lands in the same
enumeration under the same key. IsDefinitionOnly is therefore the mechanism that makes a type
platform-defined without materialising a node at the type's path — and it only works if every
seam honours it.
The seams that do:
| Seam | Effect |
|---|---|
StaticNodeQueryProvider |
a definition never becomes a query result |
MessageHubGrain.TryResolveStaticNode |
never activates a per-node hub from a definition |
MeshDataSource.FindServedStaticNode |
never served as WithInitialData, never auto-persisted |
HandleCreateNodeRequest / batch create |
never counts as an "already exists" |
The one that did not — and the deepest cause of #902. EnsurePartitionBootstrap's root-existence
probe (ReadNodeAuthoritative) fell back to FindStaticNode(path) with no guard. For a catalog
whose discriminator equals its partition name, the definition at @Agent therefore answered "the
root exists", so HealPartitionRoot never ran: no durable root was written (and, until #3451
took provisioning off the repair path, no schema either), while every other seam correctly saw
nothing. That is the ghost precisely — present to
the existence check, absent to reads, un-creatable ("already exists"), no version history, and no
route back. The probe now skips definition-only entries, so the bare partition path is free to be an
ordinary root.
The lesson generalises: when you add a seam that asks "is there a node at this path?", a
definition-only entry must answer no. Only the seams asking "does this TYPE exist?" (NodeType
resolution, StaticTypeName linking, creatable-type lists) may see it.
"Can't we just drop the definition, since the platform knows Agent anyway?"
Reasonable question — the platform does reference Agent and Skill intrinsically
(AgentPickerProjection, the AI settings skill sources, the harness). Those references are all
query-side: they filter by the nodeType string. None of them supplies the type's
HubConfiguration delegate, and none registers the type as existing.
Measured, not assumed — dropping AddMeshNodes(typeDefinition) for Agent on the DB-synced path
fails 5 of 5 PreInstalledPackageInstallTest cases:
PackageInstaller's required-node-type gate (FindStaticNode(t) is null) rejects the package, so theAgentcatalog does not install at all;AgentPickerProjection.ObserveAgents— the pipeline the chat combobox binds to — emits[].
So the definition entry is load-bearing, and IsDefinitionOnly is what lets it be load-bearing
without occupying the partition-root path. With the existence-probe guard above in place, the bare
path is owned by the durable row: on a real portal the Agent partition root reads back as
Agent | Store/Plugin | v2 from Postgres, next to the installer-written Agent/_Policy.
Collecting across namespaces (the registry)
A catalog's effective set at runtime is never one partition — it is collected from a collection of namespaces: the user's own, the active space, and the platform tier. The default tiers, in precedence order:
| Tier | Namespace | Who writes it |
|---|---|---|
| User | {user}/{Type} |
the user (their own additions/overrides) |
| Space | {space}/{Type} |
space members (shared within a space) |
| Platform | {Type} |
platform admins (the shipped defaults) |
One registry query resolves the union — exact-membership, per-user RLS, no graph walk:
namespace:{user}/{Type} | {space}/{Type} | {Type} nodeType:{Type}
AgentPickerProjection.BuildRegistryQuery (run via hub.GetQuery) is the reference implementation; Model / Provider / Harness / Skill unify onto the same shape. The platform tier is publicRead (everyone sees the defaults); the user/space tiers are owner-scoped by RLS. (The {Type} platform schema must be searchable for the unscoped fan-out to find it — see Postgres Schema Architecture.)
Managing the platform tier (platform-admin settings + API)
The platform tier ({Type} namespace — platform-wide keys, models, providers, agents, harnesses, skills) is governed content. It is managed by platform admins (hub.IsGlobalAdmin(); the Admin partition — see Access Control), through two surfaces that every catalog shares:
- Settings GUIs — node-bound editor tabs built from the framework's standard data-binding (the
Editmacro / node-content editor controls), gated to platform admins. Never hand-rolled selects/forms/save-loops. - Management APIs — the existing platform-config surface. Platform-tier mutations go through it (and
GetMeshNodeStream(path).Update(...)), not bespoke request/response handlers.
Writes to the platform tier require platform admin; user/space tiers are owner-scoped. Secrets (provider API keys) are never stored in plain node content — they reference the secret store.
How to ship a NodeType catalog
- Author the instances and register the catalog as an
IStaticRepoSource(see Static-Repo Import). The source'sPartitionRootis anodeType:NodeTypenode (id = the type name) that names the registered static type it links to — not anodeType:Spacenode. - Register the type's
HubConfigurationas a definition keyed by type name (the static registry), not as a served node at the partition path. - Gate it into
Features:StaticRepoSync:Partitionsso Postgres serves it. - The catalog's instances carry
nodeType = <TypeName>(unchanged); enrichment resolves theirHubConfigurationthrough the@<TypeName>NodeType root → its static link.
Adding a new such catalog is then collision-free by construction — there is one node on the partition path, owned by the database.
Status / migration
The Provider catalog (AI providers + models) is fully migrated: the built-in/config provider is a sync source only (ModelStaticRepoSource imports it into the top-level Provider partition on boot; the DB serves it thereafter), the ModelProvider / LanguageModel / ModelProviderSelection type-defs are registered IsDefinitionOnly = true when synced, and the catalog lives at Provider/{provider} (providers) with models nested at Provider/{provider}/{model}, plus a Provider/_Policy (PublicRead, lifted write caps). It moved here from the older Admin/Provider / _Provider satellite layout. Platform admins get standing write on the Provider partition via the Provider/_Access Admin grant seeded by GlobalAdminSeed; non-admins are read-only. See Model Providers.
Harness, Agent, Skill are being migrated from the Space-root-plus-served-type-def shape to the unified nodeType:NodeType root above; the migration only rewrites the partition root (nodeType:Space → nodeType:NodeType) — instance nodes are unchanged.
See also
- Static-Repo Import — how catalog content is materialized into a partition.
- Postgres Schema Architecture — per-partition schemas + which schemas are searchable.
- MeshNodeStreamCache — the per-path handle the live picker binding subscribes to.