The read gate is a per-provider obligation
Core's half of this story is MeshWeaver#3890 / #3914 — the pedestrian
StorageAdapterMeshQueryProvider.AutocompleteranuseSecurityFilter: falsewithUserId = null. This page is the general rule that made that one defect reach further than its own backend, and the inventory of every provider it applies to.
The fan-out has no post-filter
MeshQuery is an aggregator, not a gate:
SelectMatchingProvidersreturns every registered provider. The oldMatchespre-filter was removed deliberately, so each provider owns the "is this mine?" decision — which means each provider is asked about every read.Query(request)andAutocomplete(basePath, prefix, …)subscribe all of them, seed each with.StartWith(empty)so theCombineLatestcan emit progressively, and merge the snapshots byQueryResult.Path, highest score wins (MergeSnapshots/MergeAutocompleteSnapshots).- Nothing in that merge consults a permission. The only access decision in the whole pipeline is the one each provider makes about its own rows.
Two consequences, and they are the whole reason this page exists:
- The weakest provider decides what the mesh discloses. A portal running Postgres has the
pedestrian provider registered alongside the native one
(
AddPartitionedCoreAndWrapperServicesadds it; the native backend does not replace it). The native provider's filtered rows were merged with the pedestrian's unfiltered ones, and the union is what the caller saw. That is exactly how core's defect reached a Postgres portal. - "This backend is only used locally" is not a defence. A provider contributes into the same
merged set wherever it is registered, and where it is registered is a deployment choice made
long after the code was written.
SqliteVectorMeshQuery's class doc scoped it to "local/single-user scale"; it was still one of the two gaps #1600 closed.
A provider has more than one read path, and the second one is where the gate goes missing
Every backend implements at least Query<T> and Autocomplete over the same storage. They are
built at different times, by different changes, and only one of them takes a userId on its
signature — Autocomplete has no identity parameter at all, so its viewer can only come from the
ambient AccessContext. In all three known instances of this defect the secured path was fine and
the other path, reading the same rows out of the same store, had no gate:
| Provider | Repo | Autocomplete resolves the viewer | Autocomplete filters rows |
|---|---|---|---|
StorageAdapterMeshQueryProvider (pedestrian) |
core | QueryIdentityResolver.Resolve at call time |
yes — useSecurityFilter: true (MeshWeaver#3914) |
CosmosMeshQuery |
Plugins | ResolveIdentity → QueryIdentityResolver (#1600) |
yes — IsReadableAsync per row (#1600) |
SqliteVectorMeshQuery |
Plugins | ResolveIdentity → QueryIdentityResolver (#1600) |
yes — ValidateRead per ranked candidate (#1600) |
PostgreSqlMeshQuery |
Plugins | _accessService.Context only — not the resolver, not CircuitContext |
yes — user id pushed into the SQL predicate |
PostgreSqlPartitionedMeshQuery |
Plugins | Context ?? CircuitContext |
yes — top-level via AutocompleteTopLevelAsync, within-partition by delegating to the per-schema provider |
SnowflakeMeshQuery |
Plugins | _accessService.Context only — same narrowing as PG |
yes — user id pushed into the SQL predicate |
SnowflakePartitionedMeshQuery |
Plugins | Context ?? CircuitContext |
yes — same two shapes as PG partitioned |
StaticNodeQueryProvider |
core | no identity on either path | no — see below |
That is the complete denominator: eight production implementations of IMeshQueryProvider
(grep -rn ": IMeshQueryProvider" over both repositories' src/, 2026-09-10), plus three
test-local canned providers in core's test/ tree that ship nowhere.
The three rules a read path must follow
1. Resolve the viewer at CALL time, on the caller's thread. Every provider returns a COLD
observable — _ioPool.Invoke(...), Observable.Defer, a pooled embed — whose body runs at
SUBSCRIBE time, on a pool thread whose ambient AccessContext is not the caller's to rely on.
Resolving inside the lambda is a coin flip; resolving before it and capturing the result in the
closure is what makes the answer the caller's own.
2. Stamp only what actually resolved. QueryIdentityResolver.Resolve never widens: the worst
case is Anonymous, flagged Unresolved. Pinning that fallback onto the request would make the
resolution site the LAST word on the viewer, and it is not entitled to be — a caller whose ambient
context is empty at call time can still have one at subscribe time (the plugin installer builds its
queries outside the ImpersonateAsSystem scope it subscribes them in). This is
MeshService.StampViewer's rule and it holds for every provider.
3. Filter BEFORE the limit, never after. A page cut first and filtered second under-fills
silently, and the size of the hole is itself a disclosure of what is being hidden. The corollary is
that a SQL-side TOP/LIMIT must be dropped while filtering in memory (CosmosMeshQuery.QueryAsync
does exactly this), and that an in-memory rank must apply the permission before its Take.
And the filter is per ROW, not per partition. A grant can sit on a single node BELOW a partition root, so narrowing the walk to "partitions this caller may read" — the cheaper shape — hides documents their owners deliberately shared. Both the Cosmos and SQLite controls read that case out of a single snapshot for exactly this reason.
Why the validator chain, and why it is copied
Postgres and Snowflake push a permission predicate into SQL because both maintain a denormalized
user_effective_permissions relation plus a partition_access membership table, and both can
express the longest-prefix fold as a correlated subquery. Cosmos SQL has neither a correlated
subquery nor a cross-container join, and a brute-forced cosine scan over SQLite embeddings has no
predicate to push anywhere. Those two therefore apply the backend-NEUTRAL definition of the same
rule — the INodeValidator chain, which is in fact the RICHER one: transitive group closure,
PartitionAccessPolicy caps, NodeTypeGate grants and the self-partition Admin, every one of which
the SQL predicate only approximates via its rebuilt projection.
The ValidateRead body now exists three times: StorageAdapterMeshQueryProvider (core),
CosmosMeshQuery and SqliteVectorMeshQuery (this repo, separate assemblies). That is an assembly
boundary, not a choice — core exposes no public helper for it. Consolidating it into one public
extension in MeshWeaver.Hosting is the obvious follow-up, and it is a core change with a
cross-repo pair, not something a fix in this repo can do.
What is still inconsistent (measured 2026-09-10, both deliberate to leave)
PostgreSqlMeshQuery/SnowflakeMeshQueryautocomplete read_accessService.Contextonly — notContext ?? CircuitContext, and not throughQueryIdentityResolver, which their ownQuery<T>does use. In a Blazor circuit where onlyCircuitContextis set they therefore degrade toAnonymous. That fails CLOSED: the caller sees LESS than they may, never more. It is a "your own content reads as absent" bug of the kindQueryIdentityFallbackexists to name, not a disclosure — and correcting it WIDENS results, which does not belong in the same change as a security fix.- The MCP
autocompletetool does not wrap inMcpMeshPlugin.AsCaller, unlikeGet,Search,SearchChunksand every version tool — the helper that re-establishes the caller'sAccessContextfor the duration of a tool call, and whose absence on the READ tools was once a paywall bypass. It is correct today for a specific reason: the orchestrator is resolved from the ROOT hub, whereUserContextMiddlewarehas already established the context, and every provider now resolves its viewer at CALL time on that same request thread. So it is a structural outlier, not a live gap — but the consequence of the identity being lost there has CHANGED with this fix: before, a lost identity produced a full answer; now it produces the ANONYMOUS answer, i.e. silently nothing, which is the failure modeQueryIdentityFallbackexists to name. Wrapping it is defence in depth; it is left out of the security fix because there is no MCP-level harness to control it in either direction, and an unverified change to a shipped tool does not belong in a change whose whole point is a measured control. StaticNodeQueryProviderapplies no permission on either path. It serves framework-declared static nodes — built-in roles, type definitions,MeshConfiguration.Nodesseeds — and it is self-CONSISTENT: a suggestion and a query from that provider agree, because neither filters. The question it raises ("should declared platform content be RLS-filtered at all?") is a core design question, not the Query/Autocomplete divergence this page is about. Core left it untouched in #3914.
The property to preserve, and how to test it
A suggestion and a
getof the same path must agree.
Not "autocomplete returns fewer rows" — agree. That makes the control two-sided by construction,
and a one-sided test is worthless here: a filter that returns nothing passes every denial assertion
ever written. The suites that pin this are
MeshWeaver.Hosting.Cosmos.Test/CosmosAccessControlTests (the Autocomplete_* facts) and
MeshWeaver.Hosting.Sqlite.Test/SqliteVectorAccessControlTest. Both:
- seed two siblings under one partition root the caller is refused, with a node-scoped grant on exactly one of them, and read BOTH directions out of the SAME snapshot;
- assert on the node's Name as well as its path — a title is business content, and it is the half that makes a leak a disclosure rather than an enumeration;
- prove the instrument is armed before believing an empty answer (
TheVectorProviderIsArmed, and the positive half of every denial fact), because an inert provider answers empty and would make every denial assertion pass having measured nothing.
🚨 Assert.SkipWhen reports as PASSED through the VSTest bridge. The Cosmos suite green-skips
when no Docker endpoint is available, and dotnet test then prints Passed! … Skipped: 0 for a run
that asserted nothing — a verification that cannot fail. Read the per-test durations, or run the
xUnit v3 executable directly (bin/<config>/net10.0/MeshWeaver.Hosting.Cosmos.Test -class …), which
reports skips honestly and shows the container starting.
🚨 And the sabotage control has a trap of its own: reverting it can leave a STALE binary. The way
to prove a denial fact still discriminates is to force securityFilter to false, rebuild, and
watch it go red naming the leaked path — measured 2026-09-11, that reds
ADeniedNodeIsNotSuggestedByVectorAutocomplete and
VectorSearchDoesNotReturnANodeTheViewerCannotRead while all three positive facts stay GREEN, which
is what proves the two halves are not coupled. But restoring the file with mv …bak puts back its
ORIGINAL mtime, older than the assembly just built from the sabotaged text, so MSBuild judges the
project up to date and the next run re-reports Failed: 2 against a binary the source no longer
matches. touch the file (or clean) before rebuilding. A git status that reads clean is NOT
evidence the binary does — the same "verification that cannot fail" shape as the green-skip above,
wearing the opposite colour.