Search Coverage and Refusal
A mesh query is answered by whichever partitions the store happens to enumerate for it. On a
partitioned backend that is never the whole mesh: the system schemas are excluded from every
fan-out by design, and row-level security drops every partition the caller cannot read before the
SQL is written. An envelope that says count: 0, truncated: false on top of such a read is a
partial answer wearing the costume of a total one.
This page records the measurement that made that visible, the mechanism behind it, and the two
changes that follow from it: the search tool refuses a query that does not say where to look,
and every envelope it does return carries its coverage.
The measurement
The control instance, 2026-09-14, one credential, calls seconds apart (MeshWeaver #4274):
| query | count |
|---|---|
nodeType:LogIncident |
0, truncated: false |
nodeType:LogIncident partitions:all |
0 |
namespace:Admin nodeType:LogIncident |
0 |
namespace:Admin scope:descendants nodeType:LogIncident |
truncated at any limit |
nodeType:User |
0 |
path:rbuergi nodeType:User |
1 |
nodeType:Hosting/Deployment |
matches |
nodeType:NodeType, nodeType:Thread |
matches |
get @Admin/_LogIncident/0b1877ae6c51353f returned the node stamped "nodeType":"LogIncident".
The bare filter returned 0 for nodes it itself labels with the filtered type, and basePath: @Admin
did not rescue it. (nodeType:Deployment also read 0 in the issue; that type is spelled
Hosting/Deployment, so that row was a mis-spelled type, not this defect — a positive control worth
keeping because it is the kind of zero that reads the same.)
Three mechanisms, one symptom
All three are in PostgreSqlPartitionedMeshQuery (MeshWeaver.Plugins) and none of them is a bug in
that planner — each is a documented decision whose consequence the search envelope did not carry.
- An unanchored, unlisted query is served by the fan-out, and the fan-out never sees the system
schemas. The planner's
JudgereturnsRefusedfornodeType:LogIncident; under the production hosts'UnanchoredQueryPolicy.ServeAndReport(the 2026-09-03 availability trade, documented on that enum) a refused query is served by the cross-schema UNION overpublic.searchable_schemasand reported at Error in the portal's own log. That registry is built byPostgreSqlCrossSchemaQueryProvider.IsSearchableSchema, whoseExcludedSchemasdropsadmin,auth,kernel,portal,source,test, … by design (authis a mirror that would surface every access object twice;adminis platform-only).Admin/_LogIncidentlives inadmin, so the UNION cannot reach it. The Error line names the caller's query; the caller getscount: 0. Every one of those lines folds into the incidentAdmin/_LogIncident/d4c8f6f74ecfa422(#3545), which is how operator searches were re-lighting an issue about a different caller. - A declared fan-out has the same reach.
partitions:allturns the verdict intoServed, and the same UNION runs over the same registry — so the declaration silences the log line and changes nothing about coverage. To reach a system partition the query must anchor to it. - A routing rule can pin a query to a partition the caller cannot read.
UserNodeTyperegistersnodeType:User(no path) → theAuthmirror, so the planner serves it from one schema — and every UNION branch a signed-in caller gets opens with apublic.partition_accessmembership test for that schema. Core'sAuthpartition definition (DefaultPartitionProvider) declares no read policy; the one place the mirror is readable to a signed-in caller isPerSchemaAccessClauseLeakTests, which seeds aPublicgrant onauthby SQL to exercise the generator. The middleware the rule exists for reads as System and gets no access clause. On the control instance a signed-in platform admin measurednodeType:User→ 0 (whether the mirror is empty there or ungranted cannot be told from outside; both read the same), andpartitions:alldid not help because the hint was resolved before the declaration was considered.
And a fourth, on the tool itself: basePath became a bare namespace:{base}, which is immediate
children. Admin/_LogIncident/… is two levels down, so the parameter whose documented purpose is
scoping did not reach what it scoped to.
What changed
search refuses what it cannot cover
MeshOperations.Search — the MCP search tool and the agents' Search — now judges the query
before any backend sees it, with the planner's own CI verdict:
served ⇔ ParsedQuery.IsSufficientlySpecified() ∨ a routing rule names the partition
IsSufficientlySpecified is the one definition of "this query says where to look", and it lives on
the query model (ParsedQuery, MeshWeaver.Mesh.Contract) so that the planner, the test fixture's
QueryRouteClassifier and this entry point cannot drift the way the two executors of the query
language once did (#3511). Four ways to satisfy it, and a query needs exactly one:
| form | example | what it pins |
|---|---|---|
| a concrete anchor | namespace:Admin scope:descendants …, path:Admin/_LogIncident |
one partition |
| a multi-path anchor | path:Acme/Docs\|Shared/Docs |
the set |
| the explicit request | … partitions:all |
every searchable partition the caller can read |
| a namespace filter | namespace:A/Agent\|B/Agent, namespace:*/_Thread |
the listed namespaces / the pattern |
Everything else comes back as an Error: string that names both remedies. The grace list the
planner also consults (unanchored-queries.allow) is deliberately not consulted here: it names
the in-repo code callers still carrying known debt, and an operator's search is not one of them — a
graced shape served from this entry point would be exactly the partial answer the refusal exists to
stop. So search 'nodeType:NodeType' is refused even though the planner would grace it.
A refusal is an answer. A red is fine. What is not fine is a zero that cannot be told from "none exist".
Every envelope carries its coverage
{
"count": 0, "limit": 200, "truncated": false,
"coverage": { "scope": "partition", "partitions": ["Admin"] },
"results": []
}
coverage.scope |
when | coverage.partitions |
|---|---|---|
partition |
the query anchors | what the query named — or, when the provider reported, what it actually read |
declared |
partitions:all |
what the provider reported reading; null when it did not |
routed |
a routing rule pinned it | the rule's partition (["Auth"]) |
partitions is the denominator. The list the storage provider reports on
QueryResultChange.Partitions wins, because it is taken after every narrowing — the registry's
exclusions and the caller's read grants — so it is the set the rows came from; what the query named
is the fallback; and a declared fan-out on an image whose provider does not report is explicitly
null. Null is the honest value: an absent or empty list would read as "all" or "none", which is
the same reasoning that leaves count out of a search_chunks answer under "searched": false
(#2741). The aggregator unions the lists of the providers that reported and stays null when none
did (MeshQuery.MergeProviderObservables).
A listed partition can still be read only in part
coverage.partitions names the partitions a read was run against. It does not say every row
in them was visible. Row-level security narrows twice. A partition the caller holds no grant in at
all is dropped before the SQL is written, so it never appears in the list. Inside a listed
partition, the row filter (the same per-subject longest-prefix fold as the permission evaluator)
hides every row under a sub-path the caller's grants do not reach. The partition-level membership
test admits a schema as soon as the caller holds any grant inside it.
Measured on memex.meshweaver.cloud, 2026-10-12 (#6564), with one global-admin credential:
| read | answer |
|---|---|
get MeshWeaver/samples/Graph/Data/Type/article |
the NodeType, compilationStatus: Error |
nodeType:NodeType content.compilationStatus:Error partitions:all |
count: 0, and meshweaver is in coverage.partitions |
namespace:MeshWeaver scope:descendants nodeType:NodeType |
13 sample types, none under Type/ |
whoami at …/Type/article / at …/ACME/Article / at MeshWeaver |
[] / Read, Execute, Api / [] |
The row was indexed. The caller's grants (a Public grant on ACME/, FutuRe/, … and none on
Type/) covered only some sub-trees of the partition, so the sweep's zero was read against a
partition it had seen only in part.
The envelope therefore carries a third field:
"coverage": { "scope": "declared", "partitions": ["acme", "meshweaver"], "partial": ["meshweaver"] }
coverage.partial |
meaning |
|---|---|
| a list | the listed partitions whose root the caller's grants do not cover (or where a deeper Read deny cuts into the covering grant): rows under ungranted sub-paths were not counted there |
[] |
assessed, and every partition read is covered |
null |
the provider did not assess it (a system read, an in-memory store, an older image). This is unknown and never means "none partial" |
The provider computes it (QueryResultChange.PartiallyReadPartitions) from the caller's own
grant rows, with the same subjects and the same fold the row filter uses. It discloses nothing: it
says "your grants do not reach the whole of X" and never what lies outside them. It is opt-in per
request (MeshQueryRequest.ReportPartialReads), because it costs one read of the caller's grant
rows per partition. search sets it, and interactive listings do not. When it is
non-empty the envelope also carries a partialHint sentence. The aggregator unions the
reporting providers' lists, because a partition one provider read in part is not made whole by
another. If any provider that read partitions did not assess them, the merged verdict is null:
an empty list from one provider must not vouch for partitions another provider never assessed.
basePath reaches the whole subtree
basePath now becomes namespace:{base} scope:descendants unless the query states its own
scope:. "Search from here" means the subtree.
basePath never rewrites the query's own location
A query that names its own location (path:, namespace:, a membership or wildcard namespace)
keeps it when that location lies at or under the base — it is the narrower statement — and is
refused, naming both, when it lies outside: no answer can honour both. Only a query that names
no location of its own (an exclusion like -namespace:X is a filter, not a location; an empty
namespace: names nothing) is scoped by the base.
Three edges keep that honest:
- A location must bound the WHOLE query.
namespace:<base>/* OR nodeType:Markdownnames a location under the base in its left branch only; the right branch would read everywhere, so the query is refused. The test is the sufficiency predicate itself (an anchor, or a namespace filter on everyORbranch), withpartitions:allset aside. - A multi-value exclusion is a filter too. The parser emits
-namespace:A|B(and-namespace:(A OR B)) as anamespace NotIn (…)filter and leaves the anchor and scope alone; it used to defer it with the positive alternation, which cleared the anchor — so the composed query lostnamespace:<base>and was refused as unanchored, or answered from outside the base. - The rest of the text is kept verbatim. Dropping an empty
namespace:is quote-aware: a split-and-rejoin collapsed the spaces inname:"Jane Q Example"and dropped anamespace:written inside a quoted value.
Before #6828 the tool deleted every namespace: from the text before prefixing the base, so
namespace:<space B> name:"…" under basePath: @<space A> became the FREE-TEXT term <space B>
read inside A — a semantic search flooded with whatever mentioned the words, reporting
coverage.partitions: ["<space A>"] for a query that named B — and -namespace:X became a search
FOR X. The coverage was honest about where it read; the composition was wrong about where to read.
Reading a zero — how to state the denominator
The pre-deploy NodeType sweep is now written in the declared form, and its zero is stated against two numbers that the same instrument gives you:
search 'nodeType:NodeType content.compilationStatus:Error partitions:all' limit:200
search 'nodeType:NodeType partitions:all' limit:200
0 of N over M readable partitions — N is the second call's
count(the NodeTypes this credential can see at all; a truncated second call means N is a floor, raise the limit), M iscoverage.partitions.lengthof the first.
When coverage.partitions is null, the image serving the query does not report what it read;
say "M unknown — coverage not reported by this image" and do not pass the sweep on that zero
alone.
🚨 And M counts only the partitions read in FULL. Subtract every partition in
coverage.partial, and state them: "0 of N over M fully readable partitions; P read in part:
…". A partition in coverage.partial may hold a broken type the sweep could not see (#6564). A
coverage.partial of null means the image does not assess partial reads, so the same rule as a
null denominator applies and the zero does not pass the sweep on its own. The system-side census on /health (bake-report) is the instrument that enumerates as the
process and sees past row-level security — A Census That Counts Must
Name says what it can and cannot name.
The unanchored form now answers Error: Query is not sufficiently specified … rather than 0. If a
sweep you inherit from an older note still reads search 'nodeType:NodeType content.compilationStatus:Error' with no partitions:all, the note predates this change; the
refusal names the fix.
🚨 A tool wearing this refusal must re-house it in its OWN container. The Error: … string is
the surface-wide failure convention of MeshOperations and the MCP search tool keeps it
(McpRemoteMeshClient recognises a refusal by that prefix). But the chat agents' tool answers a
JSON envelope, and its consumers parse it — so handing the prose through threw
JsonReaderException: 'E' is an invalid start of a value in every one of them, on the platform's
own release bake: main-cd #8577 and #8581 (2026-09-14) each failed the AI module's tests on
it and each skipped the Plugins publication for its platform identity. MeshPlugin.Search now
answers {success: false, refused: <bool>, error: <the operation's text>} — success:false because
the engine's ThreadExecution.ExtractToolResult records a JSON object WITHOUT it as a successful
call, and no count/results because an empty result list would let a consumer read a refusal as
"none exist", which is the misreading this refusal exists to stop. The design record is
AI/ToolFailureContainer.md in MeshWeaver.Plugins (Plugins#1852).
Two things the declared sweep still cannot see, and no query form can:
- A NodeType in a system partition. The fan-out never enumerates
admin,auth,kernel,portal; a type there is reached only by anchoring (namespace:Admin scope:descendants nodeType:NodeType …). Measured 2026-09-14 on the control instance: 0 NodeTypes underAdminfor this credential, which is itself a zero with the RLS caveat. - A NodeType in a partition you hold no grant on. That is the denominator M; the census is the only instrument that counts past it.
- A NodeType under a sub-path of a listed partition that your grants do not reach. The sweep
cannot see it either, but it can now SAY so: that partition is in
coverage.partial.
The cap is the other half of the same rule
A count is read against coverage.partitions; a truncated: true is read against the ORDER the
result was clipped over. The failure has the same shape (MeshWeaver #4950): a limit:25 listing of
Hosting/InstanceAction came back truncated, its newest row six days old, and was read as "no
action for six days" — the rows that explained the outage were there, outside the first 25, in an
order nobody had asked for. A filter-only query now defaults to newest first at every clip site
(ParsedQuery.EffectiveOrderBy, Query Result Scoring), so a truncated
page is at least the recent end of the set — but it is still a page, and a reader who needs the
whole set raises the limit until truncated reads false, exactly as the sweep above raises it
until N stops being a floor.
What is still open
- The provider's report is the Plugins half.
QueryResultChange.Partitionsis the core seam;PostgreSqlPartitionedMeshQuerypopulates it with the schema list it actually ran over (after the namespace, multi-path, declared-location and partition-access narrowings), and the per-schema route reports its one schema. Until that half is on the image,coverage.partitionsis what the query named, andnullfor a declared fan-out. - A routing hint overriding an explicit declaration (mechanism 3) is a planner decision to
revisit in the same half: a caller that wrote
partitions:allasked for the fan-out, and theAuthpin is for the callers that did not say where to look. nodeType:Userfor a signed-in caller stays 0 through the mirror on a deployment whoseauthpartition carries noPublicread grant (the control instance measured that way). The envelope namesAuthas the partition the zero was read against, and the zero is the mirror working as designed: a signed-in user may read a User node they can address by path (UserNodeType's access rule admits every authenticated caller on a root User node), but the mirror is an enumeration, and enumeration is the disclosure surface #3890 closed. Code that resolves a PERSON from that shape must therefore not issue it as the caller (#4309). The callers in core were the invite-by-email lookups —SpaceInviteService.GrantOrScheduleAccess(Space invites and Access Control's grant-by-email) andGroupInviteExtensions.InviteToGroup— which read the zero as "no account" and invited a person who was already here; both now issueSpaceInviteService.AccountLookup(email), a request stampedAsSystem(), the same infrastructure identityUserIdentityCache.DirectoryQueryand the developer-login page (MeshWeaver.Plugins#712) use. Stamping the REQUEST keeps the identity on the read alone; the grant composed on its result still runs as the inviter. The rest of the corpus was already system-side (UserIdentityCache,DeviceSeed, the inbound mail/Teams processors) or is a listing that is genuinely the caller's own view (the recipient picker inSendDocumentLayoutArea, the Access Control subject picker'sAccessSubjectQueries.Users), where 0 means "not visible to you" and the grant-by-email path is the way to reach a person the picker does not show. 🚨 The in-memory provider cannot reproduce this zero — it re-decides each row through the type's C# access rule, which the mirror's SQL filter never consults — which is why the identical code was green on every unit test and is whyInviteResolvesTheInviteeAsSystemTestpins the lookup's viewer as a property of the request throughQueryIdentityResolver, not as a listing count.
Tests
ParsedQueryAnchoringTest(MeshWeaver.Hosting.Test) — the predicate andNamedPartitionsover the four specified forms and the two look-alikes (path:*, a bare filter).SearchRefusesUnanchoredQueriesTest(Memex.Portal.Shared.Test) — on a real Monolith mesh: the bare form is refused naming both remedies; the same filter anchored and declared is answered, with the coverage each states; a routed query names its partition;basePathreaches a node two levels down, and a caller-stated scope is kept.MeshQueryMergeContractTest.ReportedPartitions_*— the aggregator unions reported partitions and stays null when no provider reported.MeshQueryMergeContractTest.PartiallyReadPartitions_AreUnionedOntoTheMergedInitial/AssessedAndFullyCovered_StaysEmptyNotNull— the partial-read verdict survives the merge, and "assessed, none partial" stays distinct from "not assessed".MeshQueryMergeContractTest.AProviderThatReadPartitionsWithoutAssessing_KeepsTheMergedVerdictUnknown— a provider that read partitions and left its verdict null keeps the merged verdict null, so another provider's empty list cannot turn it into "none partial".SearchReportsPartialReadsTest— thesearchenvelope itself:partialis null,[]or a list as the provider reported,partialHintappears only for a non-empty list, and the opt-in reaches the provider.- The Postgres provider's half, which computes the verdict from
user_effective_permissions, is tested in MeshWeaver.Plugins (MeshWeaver.Hosting.PostgreSql.Test).
Related: Cross-Schema Fan-Out Elimination (the lock-bomb this refusal keeps operator searches out of), Query Provider Parity (the executor split), Measuring a Live Portal Read-Only, Query Syntax.