Unanchored Security Reads

The companion to Cross-Schema Fan-Out Elimination. That page says eliminate the fan-outs. This one says which ones you must not eliminate the obvious way, and why β€” because the obvious way is invisible when it is wrong.

Issue #2640 measures a ~2 s floor on every page render, caused by unanchored mesh queries that UNION ALL every partition schema. Its body proposes, first candidate:

anchor the permission/notification/thread queries to the viewer's partition ({user}/…) + the group-grant partitions

The access bucket is the largest of the four (465 of 1 030 measured fan-outs, 45%), so it is the natural place to start. It is the one that must not be done that way. Three separate sessions reached that conclusion independently; this page exists so a fourth does not have to.

The rule

🚨 In the security fold, "no result" and "not allowed" are the same value β€” so a read that comes back SHORT is indistinguishable from a read that came back EMPTY, and both read as "denied".

That is why SecurityQueries exists at all, and why several of its reads carry no path: and no namespace:. From the class itself:

Several of these reads are GLOBAL by necessity β€” a GroupMembership lives under the group node, which may sit in a different partition than the grant that names the group, so the query carries no path: and no namespace:.

Anchoring the membership read to the viewer's partition IS truncation. A membership record living in the group's partition is not in the viewer's, so it is not returned. Two failures follow, and they point in opposite directions:

Direction What happens What you see
Grant A group-derived permission simply vanishes; every surface gated on it disappears at once Nothing. No log, no exception, no red test
Deny A group-scoped AccessAssignment with Denied = true is applied only to the viewers the membership read says are in the group β€” so a revocation FAILS OPEN Nothing. The viewer keeps reading content the deny was written to take away

Issue #2011 is the first half. The second half is worse and is the reason paging is off the table too: SecurityQueries.Enumeration overwrites any limit: in a fold query rather than honouring it, deliberately, because in this fold a page IS the bug.

The trigger is GROWTH, not a change. It fires the moment a mesh's Role or GroupMembership set outgrows whatever bound was introduced β€” so it appears on the largest install first, the one where it is most expensive, and nobody will have touched anything.

The census, and why each entry is global

Pinned executably by SecurityQueryShapesTest (test/MeshWeaver.Hosting.Test), which parses every shape with the real QueryParser and fails when a NEW unanchored security read appears without a declared reason. The test also carries a positive control β€” the anchored per-scope legs, which are the overwhelming majority at runtime β€” so "everything is unanchored" cannot pass vacuously.

Shape Why it cannot be anchored
SecurityQueries.Roles a custom Role definition may live in any partition; a truncated role set silently drops every permission derived from the missing role
SecurityQueries.Memberships the group and the grant that names it live in different partitions (above)
SecurityQueries.GatedNodes(type) instances of a gated NodeType are authored wherever their owner lives, and the gate map is matched against target paths from every partition

Struck from this table on 2026-09-02 (#2194) β€” the two ROOT legs. Earlier revisions listed namespace:_Access ("resolves to no partition") and namespace: id:_Policy here. Both were wrong about the router, not about the data: a _-prefixed first segment is resolved through the REGISTERED global-satellite definitions (DefaultPartitionProvider: _Access β†’ schema system_access), so the grants leg (SecurityQueries.RootAssignments) was always served by ONE schema. The policy leg had no first segment at all and DID fan out β€” 179 [CrossSchema] SLOW lines in five minutes on memex-cloud, for a row that cannot exist on Postgres (an unregistered _ first segment is unroutable for writes too). It is now SecurityQueries.RootPolicy = path:_Policy …: the same node, with a first segment, so the router never UNIONs for it. Neither move anchors a read to the VIEWER β€” the root scope has exactly one home β€” so neither is the truncation this page is about. Measurement and attribution: Cross-Schema Fan-Out Elimination β†’ "The 2026-09-02 census".

Note what is NOT on the list: the per-partition legs (path:{partition} scope:descendants nodeType:AccessAssignment, and its _Policy twin) pin their partition through the first path segment. The global legs are three process-wide cached subscriptions, not a per-render storm β€” but a process-wide subscription still RE-RUNS on every relevant change, and on a multi-pod portal every cross-process notification counts as relevant (it carries no entity to classify); that multiplier, not the subscription count, is what the Loki census counts. See the same section.

The distinction that makes one anchoring sound and the other a regression (#3093)

Those per-partition legs used to be per-scope legs β€” one cached query per scope on the target path's chain β€” and that shape was O(nodes), not O(partitions): a node's own path is always the LEAF of its own scope chain, so every node ever permission-checked minted its own live $security-access:{path} + $security-policy:{path} pair. The per-user RLS filter on a shared synced query checks EVERY node in a snapshot before its first emission, so a listing paid that twice per row. Measured (SecurityQueryScaleTest): 13 security queries for a 4-node listing, 69 for a 32-node one; 5 either way after the fold started reading per partition.

Anchoring those legs is not the truncation this page forbids, and the difference is worth stating precisely, because "anchor it to a partition" is the sentence that means both things:

Subject Anchoring to a partition is…
Memberships, Roles, GatedNodes a record that may live in any partition β€” a GroupMembership under the GROUP node, a Role wherever it was authored truncation. The record is elsewhere, so it is not returned: the grant vanishes and the group DENY fails open
the per-partition grant/policy legs {scope}/_Access where the scope is a prefix of the target path exact. The subject is in that path's own partition by construction, so a partition-wide read is a strict SUPERSET of the per-scope walk

The test to apply is not "is it anchored" but "can the subject live outside the anchor". Where it can, anchoring loses a permission. Where it provably cannot, per-scope reads were only ever paying for the same rows N times.

The sign-in path is anchored β€” and fan-out is opt-in now (#3202)

On 2026-09-03 every signed-in user of a portal built from MeshWeaver.Plugins main β‰₯ fe20fe2a got a 503 on every page. Plugins #1231 had made fan-out opt-in: the Postgres planner refuses a query that names no partition and did not ask to span them (UnanchoredQueryException), because a silent 199-schema UNION locks 500+ relations and queues every other query behind it (the 2026-09-02 census above). The sign-in role read β€” OnboardingMiddleware.LoadUserRoles, nodeType:AccessAssignment content.accessObject:"{user}" scope:subtree β€” was exactly that shape, and the middleware did what #637 requires with an infrastructure fault: 503, never "you have no account". Both production portals were frozen on older images until the fix landed.

The refusal's rule, which every core query now satisfies. A query is served iff IsSufficientlySpecified(parsed) || ResolvesByRoutingHint(parsed): a concrete path:/namespace: first segment, a multi-path, a wildcard namespace pattern, the explicit partitions:all β€” or a registered QueryRoutingRule that pins the node type (nodeType:User β†’ Auth, nodeType:Invitation β†’ Admin; the "inert hint" note that used to stand on this page is out of date β€” the planner consults the rules before refusing, and SignInReadsAreAnchoredTest pins that UserNodeType's rule resolves the account lookup's exact text). QueryRouteClassifier (test/MeshWeaver.Fixture) is the one test-side mirror of that decision, and UnanchoredQueryCensusTest (test/Memex.Portal.Shared.Test) feeds it every runtime query core issues β€” the refused set is printed on every run and asserted empty.

Why the sign-in fold is ANCHORED and not declared. LoadUserRoles folds a user's platform roles, and AccessContext.Roles is read by no permission decision β€” PermissionEvaluator ignores claim roles on purpose (a claim role used to be a global, undeniable grant: the 2026-08-05 paywall bypass), and its only consumer in core or MeshWeaver.Plugins is the per-viewer access-cache key. A platform role is granted in exactly three places by contract (Access Control β†’ "Where to look"), so the complete read is three single-schema legs, via SecurityQueries:

Leg Query Schema
Root scope RootAssignmentsFor(user) β€” namespace:_Access … content.accessObject:"{user}" system_access (the registered global satellite)
Platform admin PartitionAssignmentsFor("Admin", user) β€” path:Admin scope:descendants … admin β€” excluded from cross-schema search, so reachable ONLY anchored
Own home PartitionAssignmentsFor(user, user) β€” path:{user} scope:descendants … {user}

partitions:all would have restored the per-request 199-schema UNION, not removed it. The #2011 deny hazard does not apply: FoldRoles honours no deny at all (it collects non-denied role names and subtracts nothing), so no scope's deny can be lost by not reading that scope. A grant in an arbitrary space is a data permission the fold evaluates from that space's own _Access at check time; folding it into the platform roles was incidental to the mesh-wide query.

What is DECLARED instead, and why. MeshWideQuery.Declare / .OfType (MeshWeaver.Mesh.Contract) appends partitions:all for the reads whose answer genuinely lives in every partition, each with its reason at the call site: the NodeType catalog (pre-warm, compile sweep, recompile, prebuilt adoption, cell surfaces, the create menu, the MCP catalog), UiContribution, every Space root (the home page's root leg, the namespace picker, the sitemap), every {Space}/_GitSync config, the outbound-mail watch, the event-subscription runner, the stranded-instance probe, the root subject picker, the plugin-catalog watcher, the What's New type lane, and the home's "shared with me" leg (a share grant lives in the granting partition β€” see below). The instance registry's id lookups (MeshWeaverInstance id:…) are declared too, with the durable fix named at the site: an id β†’ owner index in a pinned partition, the way RegistrationKeyIndex already indexes keys.

Fan-out grace in the storage layer. Because a cross-repo caller sweep cannot land atomically and a refusal on a live request path must never be the first signal, the planner carries an explicit allow-file of offender shapes (unanchored-queries.allow, MeshWeaver.Plugins) for which it fans out with a warning instead of throwing; the file may only shrink, a listed shape no longer issued is a stale entry that fails the build, and an empty file is the refuse-everything default.

What IS tractable, and what needs a decision

Measured on origin/main, core only. The provider (MeshWeaver.Hosting.PostgreSql) left this repo in the carve-out, so the provider-side levers β€” skipping empty schemas, a public materialization of the fold's global sets on the partition_access precedent β€” belong to MeshWeaver.Plugins.

Tractable in core

Needs a decision, not a patch

The other half: what an area SHOWS when the store cannot be reached

Issue #2876 is the same story from the render side. A Catalog render died on an NpgsqlException raised while opening a connector, twice in 21 seconds on one pod.

The retry was not missing. MeshQuery.MergeProviderObservables wraps every provider observable in TransientStorageFaults.RetryTransientConnect (#2521, merged three days before that capture): 250 β†’ 500 β†’ 1000 ms of backoff, then the last error surfaces. A database unreachable for 21 s outlives 1.75 s of budget, so the fault reached the render exactly as designed.

What was missing is the answer to "what does the area show when the bounded retry is honestly spent". It showed the generic panel β€” ⚠️ This area failed to render. plus ex.Message, i.e. the driver's own text and the database host the pod could not reach, rendered to an end user β€” under a log line naming the AREA as the thing that failed. Both halves are wrong about what happened, and the log line sends whoever reads it hunting for a bug in the Catalog view.

The fix is a fifth area frame, AreaFrameClassifier.StorageUnavailableId:

Why these two issues are one story

The Catalog render in #2876 failed inside GetSchemasWithTableAsync β€” the call that enumerates the schemas a fan-out is about to UNION. An anchored query never makes it. So the render-path fan-outs of #2640 are not only the ~2 s floor: they are the reason a single transient connect timeout can take a whole area down, and (per the 2026-08-31 pg_stat_activity sampling on the elimination page) the reason ordinary pinned point-reads queue for seconds behind LWLock/LockManager while a handful of 192-schema UNIONs hold relation locks.

Eliminating the fan-outs removes both. Anchoring the ones on this page removes a permission instead.

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