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.

  1. An unanchored, unlisted query is served by the fan-out, and the fan-out never sees the system schemas. The planner's Judge returns Refused for nodeType: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 over public.searchable_schemas and reported at Error in the portal's own log. That registry is built by PostgreSqlCrossSchemaQueryProvider.IsSearchableSchema, whose ExcludedSchemas drops admin, auth, kernel, portal, source, test, … by design (auth is a mirror that would surface every access object twice; admin is platform-only). Admin/_LogIncident lives in admin, so the UNION cannot reach it. The Error line names the caller's query; the caller gets count: 0. Every one of those lines folds into the incident Admin/_LogIncident/d4c8f6f74ecfa422 (#3545), which is how operator searches were re-lighting an issue about a different caller.
  2. A declared fan-out has the same reach. partitions:all turns the verdict into Served, 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.
  3. A routing rule can pin a query to a partition the caller cannot read. UserNodeType registers nodeType:User (no path) → the Auth mirror, so the planner serves it from one schema — and every UNION branch a signed-in caller gets opens with a public.partition_access membership test for that schema. Core's Auth partition definition (DefaultPartitionProvider) declares no read policy; the one place the mirror is readable to a signed-in caller is PerSchemaAccessClauseLeakTests, which seeds a Public grant on auth by 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 measured nodeType:User → 0 (whether the mirror is empty there or ungranted cannot be told from outside; both read the same), and partitions:all did 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:

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 is coverage.partitions.length of 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:

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

Tests

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.