Unanchored query policy — refuse in CI, serve and report in production

Read first — the core side of the same mechanism: get Doc/Architecture/PinBoundaryContracts (Pin-Boundary Contracts). This page is the policy — what the planner does when a query arrives unanchored, and why that answer differs between CI and production. That page is the boundary — why the pin moves in BOTH directions, so a test encoding a core contract is correct on only one side of it, and a mixed image pairs two halves that were each green alone. The window this incident opened is an instance of exactly that: the grace list cannot cover it by construction, because the two halves never existed together in any tree a gate could have run against.

Incident: memex.systemorph.com, 2026-09-03 17:02–17:22Z. Image memex-portal-ai:3.0.0-rc9.ci.7658 paired core commit e7f1d699 (built 08:05Z, before MeshWeaver #3206 anchored the platform's sign-in read) with Plugins 12500c9 (13:31Z, after #1263 made the unanchored-query refusal live). The platform's OnboardingMiddleware.LoadUserRoles issued nodeType:AccessAssignment content.accessObject:"…" scope:subtree limit:all, the planner refused it, and the middleware answered 503 "temporary problem on our side" to every signed-in request. The Store page died the same way on nodeType:PluginCatalog; GitHubSyncConfig, InstanceSyncConfig, Notification, Email, ModelCreditCharge and free-text search all faulted. Loki: 138 refusals and 19 identity 503s on two pods in twenty minutes. Even on the fixed image (ci.7693) a free-text query 'MessageHubGrain' was still being refused in a background path.

The maintainer's directive, verbatim intent: "this must not completely break us — we must have some form of higher up try/catch if such stuff happens."

This page is the design that follows from it. Read first: core's Cross-Schema Fan-Out Elimination (why fan-out is a hazard at all) and this repo's NodeType instance locations (the narrowings that shrink it).

The trade a hard refusal makes

An unanchored query — no concrete path:/namespace: first segment, no routing hint, no partitions:all — is answered by a UNION ALL over every searchable partition schema. That is a performance hazard: one such union takes heavyweight locks on hundreds of relations against a backend's sixteen fast-path lock slots, the lock manager serialises, and every other query queues behind it (measured on memex-cloud 2026-09-02: 7 786 fan-outs in thirty minutes, pinned point-reads waiting 2–5 s on LWLock/LockManager).

#1231 made fan-out opt-in by refusing such a query with UnanchoredQueryException. Correct in CI — a refusal is a red test naming the caller. On the request path of a running portal it converts the performance hazard into a total availability outage: the refused read was the one every signed-in request depends on. That trade is never acceptable in production.

Why the grace list cannot cover the window — by construction

unanchored-queries.allow (#1263) names the offender shapes the current census of callers still issues, and UnanchoredQueryAllowFileTest regenerates it from that census and asserts equality — a listed shape nobody issues is stale and red. So the moment core anchors a caller, the shape leaves the list. But a deployment is two halves built at different times: an image whose core half predates that anchoring keeps issuing the old shape, against a Plugins half whose list has already dropped it. That is exactly ci.7658. No list regenerated from the current tree can describe what an older tree still does; the window is structural, not an oversight in the census.

The policy

PostgreSqlPartitionedMeshQuery carries an init property (not a constructor parameter — a published module binds the constructor's signature in its IL, so a new parameter is additive here and breaking there):

public UnanchoredQueryPolicy UnanchoredPolicy { get; init; } = UnanchoredQueryPolicy.Refuse;
Verdict on the query Refuse (default) ServeAndReport
Anchored, declared, or rule-pinned served served
Unanchored, shape listed in the allow file served, [FanOut] GRACE … at Warning served, [FanOut] GRACE … at Warning
Unanchored, shape not listed stream faults with UnanchoredQueryException served by the same fan-out, [FanOut] UNANCHORED … at Error, with UnanchoredQueryException attached

Three properties of the ServeAndReport arm are load-bearing:

Where a host states it

The registration (AddPartitionedPostgreSqlPersistence, both overloads) resolves the policy from the host's IConfiguration through UnanchoredQueryPolicyConfiguration.Resolve:

"Graph": {
  "Storage": {
    "Type": "PostgreSql",
    "UnanchoredQueryPolicy": "ServeAndReport"
  }
}

What the allow file governs after this change

Nothing about the list's lifecycle changed: it may only shrink (scripts/check-unanchored-queries.py --base-ref fails any added line), a stale line fails UnanchoredQueryAllowFileTest, and a caller that genuinely reads the whole mesh declares it (partitions:all, core's MeshWideQuery) rather than getting a line. What changed is what a line means on each side:

The guards, both arms

A single-arm test passes against a policy that stopped being read at all, so every claim above has a test on each side of it (src/MeshWeaver.Hosting.PostgreSql.Test):

Claim Test
A default-constructed planner refuses, and logs nothing UnanchoredQueryPolicyTests.ADefaultConstructedPlanner_Refuses_AndReportsNothing
ServeAndReport runs the fan-out and logs exactly one Error naming the shape, the query, and the exception; a second subscription is a second report …ServeAndReport_ServesTheFanOut_AndReportsExactlyOneError_NamingTheOffender
A listed shape is still a Warning under ServeAndReport; a declared fan-out is reported under neither policy; a mixed request takes every verdict before reporting any …AListedShapeIsStillGrace_WarningNotError, …ADeclaredFanOut_IsNeitherReportedNorWarned, …AMixedRequest_TakesEveryVerdictBeforeReportingAny
A mesh the test base builds over Postgres refuses, end to end through IMeshService UnanchoredQueryPolicyTestMeshTest
Every production host that wires Postgres states ServeAndReport in its committed appsettings.json (hosts found from the source, not a remembered list; refuses to pass over an empty set) UnanchoredQueryPolicyHostContractTest.EveryProductionHostThatWiresPostgres_StatesServeAndReportInItsImage
The real registration, both overloads, answers ServeAndReport against the host's file and Refuse against no configuration and against the fixture's …TheRegistrationReadsTheHostsConfiguration
The resolver: absent/empty ⇒ Refuse; spellings accepted case-insensitively; a value naming no policy is a fault the resolver theories in the same class
The grace-list contract (empty ⇒ refuse everything in CI; listed ⇒ Grace; malformed line ⇒ fault; the pure Judge and the query path agree) UnanchoredQueryGraceTests, unchanged in substance, now stating Refuse explicitly

Operating it

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