The read gate is a per-provider obligation

Core's half of this story is MeshWeaver#3890 / #3914 — the pedestrian StorageAdapterMeshQueryProvider.Autocomplete ran useSecurityFilter: false with UserId = 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:

Two consequences, and they are the whole reason this page exists:

  1. The weakest provider decides what the mesh discloses. A portal running Postgres has the pedestrian provider registered alongside the native one (AddPartitionedCoreAndWrapperServices adds 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.
  2. "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 ResolveIdentityQueryIdentityResolver (#1600) yes — IsReadableAsync per row (#1600)
SqliteVectorMeshQuery Plugins ResolveIdentityQueryIdentityResolver (#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)

The property to preserve, and how to test it

A suggestion and a get of 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:

🚨 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.

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