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:X is NOT a namespace filter. QueryParser assigns it to ParsedQuery.Path; only the A|B|C alternation becomes an exact-membership filter with Path nulled. 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 by ASingleValueLocation_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 outrightNeverNarrowedNodeTypes: 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 NoEmailNodeType'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:

  1. The fold (Role, GroupMembership, root AccessAssignment, root _Policy, gated types) — a public materialization on the partition_access precedent, maintained by the write path. Neither anchoring nor paging is available; core's UnanchoredSecurityReads says so in writing, and CrossSchemaFanOutElimination hands the provider-side half of that work to this repo.
  2. The bell — deliver a notification to the RECIPIENT's partition ({recipient}/_Notification/{id}), after which the bell becomes namespace:{viewer}/_Notification and is pinned by ResolvePinnedPartition without needing a declaration at all. Core NotificationService.
  3. 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:

  1. Declaration. A field on NodeTypeDefinition (MeshWeaver.Graph.Contract), e.g. IReadOnlyList<string>? InstanceLocations { get; init; }, carrying namespace: query strings. SkillNodeType.SkillQueries is 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 general NodeTypeDefinition every NodeType node has.
  2. Installation. Nothing new — a NodeType node already installs with its package; the field rides along.
  3. Projection. Something must expose nodeType → locations to the storage layer. The natural seam is MeshConfiguration (it already derives IsExcludedFromContext, IsSatelliteNodeType and the routing rules from the registered nodes), plus the dynamic types read from storage.
  4. Registration. Register an INodeTypeInstanceLocations in DI. PostgreSqlExtensions already resolves it with GetService at 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

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.