NodeType instance locations — the fourth fan-out narrowing
Issue #1127. An unanchored mesh query — one with no concrete path: and no concrete
namespace: — is answered by PostgreSqlCrossSchemaQueryProvider as one UNION ALL over every row
of public.searchable_schemas: 192 branches on memex-cloud. Opening one deck on 2026-09-01
produced 822 portal log lines, 400 of them [CrossSchema], each 1.0–3.2 s, for 17.4 s to first
content and 27.1 s to ready.
This page is the design of the fourth narrowing, the invariant it shares with the three before it, and — the part that cost the most to establish — the measured reason it cannot make those 400 lines go away by itself.
Read first: core's Cross-Schema Fan-Out Elimination (the census and the lock mechanism) and Unanchored Security Reads (which of them must not be eliminated the obvious way).
The invariant all four narrowings share
Quoted from #983, and it is the whole safety argument:
the branch set is a strict SUBSET of what this query would have run anyway, and every branch it drops is one whose schema cannot satisfy the filter — so the ROW SET IS PROVABLY UNCHANGED.
The four, in PostgreSqlPartitionedMeshQuery.EnumerateFanOutAsync, each computing the full schema
list first and then intersecting:
| # | Narrowing | Where the bound comes from |
|---|---|---|
| #843 | namespace-anchored | the query's own namespace:A\|B\|C |
| #844 | multi-path | the query's own path:A\|B\|C |
| #983 | partition-access | public.partition_access — the same table the generated SQL consults |
| #1127 | declared instance locations | the NodeType's own declaration |
🚨 Intersect, never substitute. Resolving the derived names directly would be one round-trip
cheaper and is deliberately not done: it bypasses searchable_schemas, whose ExcludedSchemas
(auth, admin, …) would then become newly visible to whatever produced the names. For #1127 that
matters more than for its siblings, because its bound comes from authored data rather than from
the query or the database. DeclaredInstanceLocationFanOutTests.ADeclarationNamingAnUnknownSchema_CannotAddItToTheUnion
pins it, mutation-proven: substituting the declared set turns that test red.
What #1127 is, mechanically
INodeTypeInstanceLocations.LocationsFor(nodeType) returns the location queries a type declares —
namespace:Admin/Menu, namespace:A|B|C, path:Ops/Mail. Each is read by the same parser and
the same three resolvers the mesh uses to route a real query (ResolveMultiPathPartitions,
ResolveNamespaceAnchoredPartitions, ResolvePinnedPartition), so a declaration cannot express a
narrowing the query language itself could not. The union of the bound types' declared schemas is
intersected with the list the query was going to run.
Every uncertainty widens. Undeclared type, unparseable declaration, wildcarded first segment
(namespace:*/_Notification), empty namespace, a nodeType condition that is a negation or a LIKE,
a disjunction with one unbounded arm — each answers "cannot narrow", and the full fan-out runs. A
type this mechanism has never heard of is answered slowly, never partially, so a newly-installed
package whose declarations have not landed is safe.
⚠️ A single
namespace:Xis NOT a namespace filter.QueryParserassigns it toParsedQuery.Path; only theA|B|Calternation becomes an exact-membership filter withPathnulled. Resolving declarations through the namespace-anchored resolver alone therefore narrows nothing at all — silently. That cost a full red suite to find and is pinned byASingleValueLocation_ResolvesWhicheverQualifierNamesIt.
The hazard, and the refusal that contains it
An over-stated declaration (naming a partition with no instances) costs one zero-row branch and
changes no answer. An under-stated one silently loses rows — nothing errors, nothing logs,
and the caller cannot tell a narrowed answer from a complete one. That is pinned, deliberately, by
AnUnderStatedDeclaration_LosesRows_WhichIsWhyTheFoldIsRefused.
🚨 So the security fold's own types are REFUSED outright — NeverNarrowedNodeTypes: Role,
GroupMembership, AccessAssignment, PartitionAccessPolicy, plus every type in
MeshConfiguration.NodeTypeGates. Whatever any declaration says, those fan out. In that fold "no
result" and "not allowed" are the same value: a short read makes a group-derived permission vanish
(#2011) and — worse — makes a group-scoped deny fail OPEN, leaving a viewer reading content the
revocation was written to take away. Core's UnanchoredSecurityReads records the same temptation
arriving as a QueryRoutingHints rule and refuses it for the same reason; a declaration is that hint
wearing different clothes.
The refusal is what makes "a wrong declaration degrades to MORE BRANCHES, never FEWER ROWS" true for the one class where fewer rows is a security hole rather than a slow page. Everywhere else the declaration is a contract, owned by whoever owns the type and installed with it.
🚨 The finding: none of the seven measured shapes can be narrowed this way
#1127 states that the seven shapes it measured "are not scattered — every one of them lives somewhere known". Measured against the code, that is false for six of them, and true for the seventh only after a core data-model change. This is the expensive part of this page; do not re-derive it.
| Shape (measured 2026-09-01) | Where instances actually live | Narrowable by a declaration? |
|---|---|---|
nodeType:AccessAssignment |
{any}/_Access → the access table of every partition, plus the global system_access |
No — and refused outright (fold) |
nodeType:GroupMembership |
under the GROUP node, in any partition; the grant that names the group lives elsewhere | No — refused (fold) |
nodeType:PartitionAccessPolicy |
{any}/_Policy, an ordinary mesh_nodes row in every partition |
No — refused (fold) |
nodeType:Role (SecurityQueries.Roles) |
any partition | No — refused (fold) |
nodeType:Notification |
{addressee}/_Notification/{id} — the READER's own partition, since MeshWeaver#3156 (it used to be {mainNodePath}/…, under whatever entity notified, scattered mesh-wide) |
Moot. Every reader now names its partition, so nothing issues a path-less nodeType:Notification to narrow. namespace:*/_Notification would still be the honest declaration and would still correctly narrow nothing |
nodeType:Thread |
{owner}/_Thread in every partition |
No |
nodeType:UiContribution |
authored wherever the contributing plugin lives | No |
nodeType:Email |
{username}/_Email, Admin/Inbox, outbound under the relevant scope |
No — EmailNodeType's own docstring says a global nodeType:Email → Admin rule "would hide user mail" |
The satellite segments _Access, _Notification, _Thread are per-partition containers whose
rows live in that partition's own satellite table (SatelliteTableMapping.Defaults), not global
ones. _Access is the only segment with a global partition beside the per-partition ones
(system_access, DefaultPartitionProvider), and it holds the ROOT-scope grants only.
So the mechanism ships inert, and that is the correct outcome, not a shortfall. What actually removes those seven fan-outs is core's own plan, in its order:
- The fold (
Role,GroupMembership, rootAccessAssignment, root_Policy, gated types) — apublicmaterialization on thepartition_accessprecedent, maintained by the write path. Neither anchoring nor paging is available; core'sUnanchoredSecurityReadssays so in writing, andCrossSchemaFanOutEliminationhands the provider-side half of that work to this repo. - The bell — deliver a notification to the RECIPIENT's partition
(
{recipient}/_Notification/{id}), after which the bell becomesnamespace:{viewer}/_Notificationand is pinned byResolvePinnedPartitionwithout needing a declaration at all. CoreNotificationService. Thread,UiContribution,Email— anchor at the CALL SITE (the reads are per-node or per-viewer; none of them genuinely wants a mesh-wide answer).
What core must add for parts 1 and 2
Part 3 — the planner — is complete and tested here. Parts 1 and 2 of #1127 are core's:
- Declaration. A field on
NodeTypeDefinition(MeshWeaver.Graph.Contract), e.g.IReadOnlyList<string>? InstanceLocations { get; init; }, carryingnamespace:query strings.SkillNodeType.SkillQueriesis the precedent for a NodeType declaring queries about itself, but it cannot carry this: it is an AI-engine type in this repo, not a property of the generalNodeTypeDefinitionevery NodeType node has. - Installation. Nothing new — a NodeType node already installs with its package; the field rides along.
- Projection. Something must expose
nodeType → locationsto the storage layer. The natural seam isMeshConfiguration(it already derivesIsExcludedFromContext,IsSatelliteNodeTypeand the routing rules from the registered nodes), plus the dynamic types read from storage. - Registration. Register an
INodeTypeInstanceLocationsin DI.PostgreSqlExtensionsalready resolves it withGetServiceat both construction sites, so the day an implementation is registered the narrowing starts working and no wiring change is needed here.
🚨 And a gate, in the same change: a declaration must never be authored for a type the fold reads.
NeverNarrowedNodeTypes enforces that at query time; core should refuse it at AUTHORING time too, so
the mistake is a red PR rather than an inert declaration.
Evidence
src/MeshWeaver.Hosting.PostgreSql.Test/NodeTypeInstanceLocationNarrowingTests.cs— 37 pure tests: the lattice (AND bounds when any conjunct does, OR only when every arm does), the fail-open floor, the refusal measured againstSecurityQueries.AllShapesitself, and the subset invariant.src/MeshWeaver.Hosting.PostgreSql.Test/DeclaredInstanceLocationFanOutTests.cs— 5 tests on the RUNTIME path against a real 11-partition mesh: identical row sets with and without the declaration, the intersect-not-substitute proof, the fail-open default, and the under-statement hazard.- Mutation-proven red-then-green, 2026-09-02: resolver →
return nullreds 18; refusal removed reds 7; intersect → substitute redsADeclarationNamingAnUnknownSchema_CannotAddItToTheUnion. Restored: 77 green, including the #843 and #983 suites.