Creatable Types
What may be created under a node has exactly one answer, and it is
ICreatableTypesProvider.GetCreatableTypes(nodePath, parentNode). The Create form
(CreateLayoutArea) asks it and renders the result; nothing else computes a second answer.
// MeshWeaver.Graph/CreateLayoutArea.cs β the type picker's only source
var creatableTypes = host.Hub.ServiceProvider
.GetRequiredService<ICreatableTypesProvider>()
.GetCreatableTypes(parentPath, currentNode);
The picker therefore carries items and no queries. That is not a style choice: a
MeshNodePickerControl.Queries leg runs on the client and merges its rows into the same candidate
set, so a query alongside the provider would re-admit by discovery exactly what a restricting
parent excluded.
π¨ The picker is not the enforcement point β the form's type VALUE is. The Create button reads
form["type"], never the picker's item list, and that value is seeded before the provider has
answered (it cannot be otherwise β the answer is reactive). So when the resolved offer does not
contain the seed, the form replaces it with the first offered type, or with nothing when the parent
offers none, so a Required field blocks the submit. Without that, a parent declaring
CreatableTypes with IncludeGlobalTypes: false would render a picker holding only its declared
types and still create Markdown for anyone who submitted without touching the field β the menu
honouring the declaration and the write ignoring it.
The resolution rule
Four sources are merged, deduplicated by NodeType path, and ordered by Order then name.
| # | Source | Governed by |
|---|---|---|
| 1 | Ancestor-scoped discovery β nodeType:NodeType scope:selfAndAncestors namespace:{nodePath} context:create |
the mesh |
| 2 | Child types of the parent's own type β namespace:{parentNode.NodeType} nodeType:NodeType context:create, so an ACME/Project instance offers ACME/Project/Todo |
the mesh |
| 3 | The parent type's CreatableTypes β NodeTypeDefinition.CreatableTypes on the NodeType of parentNode |
the type author |
| 4 | The global set β MeshConfiguration.GlobalCreatableTypes (Markdown, Thread, Agent, NodeType by default) |
the host, opt-out per type |
Plus every static registration the host contributed through AddMeshNodes /
IStaticNodeProvider that has not opted out of context:create. At the ROOT path (no parent) there
is no namespace to scope to, so source 1 becomes the declared mesh-wide NodeType catalog
(MeshWideQuery) β a catalog is mesh-wide by nature and says so (see
Cross-Schema Fan-Out Elimination).
What a parent can restrict
CreatableTypes is a WHITELIST, not merely an addition. When the parent node's NodeType
declares one, sources 1, 2 and the static bucket are filtered down to it. A type the
ancestor-scoped query found and the parent did not declare is withheld.
{ "creatableTypes": ["Crm/Question"] }
Under an instance of that type, Crm/Question is the only discovered type offered β the global set
still rides along (below).
What a parent can extend
The same list ADDS. A declared path that no query returned is offered anyway, resolved from the
static registry when it is there and synthesised from the path when it is not. This is the half
that no namespace-scoped query can reach: in Systemorph/MeshWeaver.Crm, Crm/Offer declares
Crm/Question while the instances live in a different partition (PearlTechnology/Commercials), so
Crm/Question is in no ancestor chain of the node being created under. The declaration is the only
thing that puts it in the menu.
What the default is
A parent that declares nothing restricts nothing. With CreatableTypes absent, every discovered
type and every static registration is offered β exactly the set the Create form offered before it
asked the provider. CreateMenuHonoursTheParentTypeTest.AParentDeclaringNothingLosesNothing pins
that as a SUPERSET assertion against the two query literals the form used to run, because the
failure it guards against is invisible: a narrower source would shrink every Create menu in the
fleet with no error and no empty state.
IncludeGlobalTypes
NodeTypeDefinition.IncludeGlobalTypes defaults to true and rides along with a whitelist β a type
that restricts discovery to ["Crm/Question"] still offers Markdown, Thread, Agent and NodeType.
Set it to false to seal that off. The property carries
[JsonIgnore(Condition = JsonIgnoreCondition.Never)] on purpose: the initializer defaults to true,
so an explicit false equals default(bool) and the hub's WhenWritingDefault policy would
otherwise omit it and silently round-trip the opt-out back to true.
Opting a type out of creation entirely
ExcludeFromContext: ["create"] on the NodeType is how a type says it is not creatable β
Release, Build, ModuleBuild and Partition all use it. Every query above names
context:create, and the static bucket applies the same filter, so the opt-out is honoured on both
legs.
π¨ A type's own opt-out beats a list that names it. Sources 3 and 4 resolve through the same
exclusion-aware lookup, so a parent's CreatableTypes β or a host's GlobalCreatableTypes β
naming Partition does not resurrect it. A whitelist ADDS types the queries could not reach; it
does not overrule a type's own statement that instances of it are made by the platform rather than
by a person. See Query Syntax for the context: qualifier and the other contexts.
Two rules that look like details and are not
π¨ The static bucket is filtered by the create-context opt-out, NEVER by NodeType == "NodeType".
A built-in type registration is AddMeshNodes(new MeshNode("Group") { HubConfiguration = β¦ }) β the
PATH is the type name and there is no self-typing stamp at all. Measured on a running mesh (#4040),
filtering the static bucket on that stamp kept 7 of 42 registrations and silently dropped
Markdown, Group, Role, Redirect, UiContribution, HomeTab, License and WhatsNew β
every one a type the Create form has always offered.
π¨ There is no root-namespace query leg, and adding one back would achieve nothing.
namespace: with an empty value leaves ParsedQuery.Path empty, which is precisely the shape the
Postgres planner refuses as unanchored (see
Cross-Schema Fan-Out Elimination) β so on a partitioned portal that
leg has never returned a row, whatever it looked like it was doing. Root-level built-ins reach the
menu through the static bucket, which is where they actually live.
Where the code is
| Concern | File |
|---|---|
| The contract | src/MeshWeaver.Mesh.Contract/Services/ICreatableTypesProvider.cs |
| The resolution | src/MeshWeaver.Graph/Configuration/CreatableTypesProvider.cs |
| The declaration | src/MeshWeaver.Graph.Contract/NodeTypeDefinition.cs (CreatableTypes, IncludeGlobalTypes) |
| The global set | src/MeshWeaver.Mesh.Contract/MeshConfiguration.cs (GlobalCreatableTypes) |
| The form | src/MeshWeaver.Graph/CreateLayoutArea.cs |
| The tests | test/MeshWeaver.Graph.Test/CreateMenuHonoursTheParentTypeTest.cs |
Related: Adding a New Node Type Β· CQRS and Content Access