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.
π¨ Within the form, the picker is not the deciding surface β the 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.
π¨ Say plainly what this is: CURATION, not access control β and note that it is now enforced in
both halves. CreatableTypes shapes what the Create form offers and submits, and
CreateNodeRequest refuses a type the parent's NodeType does not allow
(CreatableTypesCreationValidator, issue #4077) β
so a caller posting the verb directly, an agent tool call, or a forged /data/{form}/type value no
longer writes a type the declaration withholds. What it is still NOT is an access control:
Permission.Create is what decides whether a caller may create anything at all, it runs on the same
boundary, and it is the fail-closed one. Do not reach for CreatableTypes to keep a type out of a
partition β reach for permissions. The boundary rules are in
What the boundary enforces below.
π¨ Both shapes of the field come out of the same resolved set, and that is why the type field is
one branch rather than two. ?types=X (the MeshSearch "+" button) RESTRICTS: a single value renders
as a read-only label, and only when the parent allows it β the label used to render without asking
the provider at all, which made a URL parameter a way around the parent's declaration. ?type=X
(the NodeType page's Create link) only PRE-SELECTS: the full picker still renders, restricted to
what the parent allows, and the pre-selected value is replaced when the parent does not allow it.
The two parameters have always differed this way; the restriction holds for both because both come
out of the same resolved set.
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.
That holds for a RUNTIME NodeType too, and it costs a read to make true. A platform type's opt-out
is visible in the static registry with no I/O; a persisted type carries it on its node, where the
create-filtered queries see it and this path would not. So the paths a config source NAMES and the
static registry does not hold β and only those β are looked up: one anchored path: query each,
never a point read. A declared type may legitimately not exist yet (synthesising an entry for it
is deliberate), and a point read of an absent node answers a routing NotFound that terminates the
stream and opens the storm-breaker on that path; a path:a|b|c alternation names no first segment
and would fan out over every schema (#3202). One path per query keeps each anchored on its own
partition.
π¨ Three outcomes, not two. That per-path lookup distinguishes the node from a confirmed absence from a probe that did not complete, and the third fails CLOSED. Folding a timeout or a fault into "no such node" would synthesise the entry and offer a type whose opt-out simply could not be read β the opt-out holding while storage is healthy and lapsing under load, which is the one condition it most needs to hold under. A host with no query core at all is a configuration fact, not a failure: it has no persisted nodes, so every declared non-static path is a confirmed absence.
The exclusion itself is MeshConfiguration.IsExcludedFromContext(node.NodeType, "create") or
the node's own ExcludeFromContext β the same two halves every query backend applies, written once
so the menu and the queries feeding it cannot answer differently. Applying only the second half is
what let the retired form pass 25 instances of opted-out types (every */_Access/Public_Access,
the Admin/Partition/* records, the */_Policy nodes, the Templates/Import/* templates) into a
picker that is supposed to list types. See Query Syntax for the context: qualifier and the other contexts.
What the boundary enforces
CreateNodeRequest (and the bulk CreateNodesRequest, and CreateOrUpdateNodeRequest's create
branch β all three run the same INodeValidator chain) refuses a create whose namespace resolves
to a node whose NodeType declares an explicit CreatableTypes list not containing the incoming
NodeType. The refusal is NodeCreationRejectionReason.InvalidNodeType and names the type, the
parent TYPE that declared the restriction, and the allowed set.
The governing parent is the NAMESPACE, never the page the form was opened on: the form lets a person pick a namespace other than the node they started from, so the boundary judges where the node is actually going.
The allowed set needs no discovery query
A whitelist only ever NARROWS auto-discovery β BuildInfos filters the query rows and the static
bucket down to it β so when one is declared, the offered set is exactly
CreatableTypes βͺ (globals when IncludeGlobalTypes). That is computable from the parent's
NodeTypeDefinition alone, so the boundary costs at most two anchored reads (the parent, then its
type definition), both resolved for free when the parent or the type is a static registration, and
only for a parent that declares a list.
π¨ Both reads are anchored path: queries, never point reads: the parent of a create MAY NOT
EXIST, and a point read of an absent node answers a routing NotFound that terminates the stream AND
opens the storm-breaker on that path β which fast-fails the very write being gated (see
CQRS and Content Access).
What is never refused
| why | |
|---|---|
A parent that declares no CreatableTypes |
the documented default: declaring nothing restricts nothing. The validator is a no-op. |
| A root-level create (no namespace) | there is no parent node to carry a declaration. |
A sibling satellite β {parent}/_Access, _GitSync, _Policy, _Entitlements β¦ |
governance bookkeeping filed BESIDE a node, not content created under it. Refusing these would break the partition bootstrap on any type that declares a list. |
| An untyped node | a whitelist of types has nothing to say about a node that names none, and untyped nodes are legal everywhere. |
| A write by the platform itself | see below. |
The import / sync bypass is the SYSTEM identity
The package installer, GitSync, plugin installs, migrations, repair services and seed providers write
under WellKnownUsers.System (or a hub credential). They are never curated. Curation describes
what a person may create through the product; a whitelist that refused an import would turn a
presentation setting into fleet-wide data loss wearing the look of corruption.
That choice also pays for itself: those writers are exactly the ones that fan a create out over
hundreds of paths, so the bulk path pays no read per node. The bypass is the identity rather than
a request flag because the bulk verb rebuilds an inner CreateNodeRequest per node, so an outer flag
would not survive the fan-out.
π¨ When the parent cannot be read, the create PROCEEDS
If the parent β or the parent's type definition β cannot be read, the validator allows the create and logs a warning naming the path.
That is deliberately the opposite of the house default for a validator
(NodeRejectionReason.Unavailable, #1446,
which fails closed), and the reason is what this control is. Permission.Create gates creation, it
runs on the same boundary, and it already fails closed β so failing closed here would add nothing to
the security posture while converting a transient read failure into "no creates under this parent at
all": an availability incident wearing a policy decision's clothes, which is the collapse
Unavailable exists to prevent.
It is also why this differs from BuildInfoFromConfig, which fails CLOSED on an unprobed
declaration. Same rule, honestly applied to two different consequences: there, failing closed means
"do not OFFER a type" and costs a person one menu entry; here it would mean "refuse a WRITE".
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 boundary | src/MeshWeaver.Graph/Configuration/CreatableTypesCreationValidator.cs |
| The tests | test/MeshWeaver.Graph.Test/CreateMenuHonoursTheParentTypeTest.cs (the form) Β· test/MeshWeaver.Graph.Test/CreateBoundaryHonoursCreatableTypesTest.cs (the boundary) |
Related: Adding a New Node Type Β· CQRS and Content Access