Query Provider Parity
The query language has ONE grammar and TWO executors, and they live in different
repositories. QueryParser turns a query string into an AST in
MeshWeaver.Mesh.Contract; what happens to a selector after that is decided twice:
| executor | where | runs for | |
|---|---|---|---|
| A | QueryEvaluator |
core, src/MeshWeaver.Hosting/Persistence/Query/ |
in-memory, FileSystem and static-node hosts; and, on every backend, live-query relevance and merged-result ordering |
| B | PostgreSqlSqlGenerator.MapSelector |
MeshWeaver.Plugins, src/MeshWeaver.Hosting.PostgreSql/ |
SQL against the content JSONB on every portal |
Nothing compared them. They drifted.
What the drift was, and why nobody saw it
MapSelector has resolved a selector the same way from the start:
PropertyMap hit → n.name, n.node_type, n.display_order, …
starts with "content." → n.content->'X'->>'Y'
anything else → n.content->>'{selector}' ← the fallback
QueryEvaluator had the first two and not the third. GetDirectPropertyValue reflected
against the object it was handed — a MeshNode — and returned property?.GetValue(obj), so an
unknown selector was null. CompareEqual(null, "Error") is false, so the predicate matched
nothing.
That is not "half the queries broke". It is worse: the same query returned different result sets on a portal and on a dev Monolith, and the in-memory answer was always the reassuring one. A filter that should have found something found nothing, which is indistinguishable from there being nothing to find.
The instance that exposed it
AGENTS.md prescribed one instrument for the check that gates a production deploy — is any NodeType
in Error? — and argued for it by construction: the sweep reads the same field the readiness gate
reads, so the two agree by construction rather than by coincidence.
search 'nodeType:NodeType compilationStatus:Error'
compilationStatus is not a MeshNode property. It lives on NodeTypeDefinition, inside
Content. Measured on a live Postgres mesh, 2026-09-07:
| query | Postgres | QueryEvaluator |
|---|---|---|
nodeType:NodeType compilationStatus:Error |
5 (LinkedIn/Skill, Store/Catalog, Store/Maintenance, Store/Order, Store/Plugin) | 0, always |
nodeType:NodeType compilationStatus:Ok |
195, none of the five among them | 0, always |
nodeType:NodeType content.compilationStatus:Error |
the same 5 | the same 5 |
So the bare selector discriminated on the production path and was blind everywhere else — and a rehearsal of the sweep on a local or CI mesh was green by construction, proving nothing about the sweep it was rehearsing. That is the shape AGENTS.md forbids in a CI gate, in AGENTS.md's own prescribed instrument: it never ran and it passed paint the same colour.
🚨 The negated form was worse than blind. CompareEqual(null, "Ok") is false, so
NotEqual inverted it to true: in memory, -compilationStatus:Ok matched every node, and
-status:Decommissioned matched every deployment. A filter written to exclude returned the
things it was written to exclude.
The fix: the fallback, and why the order is that way round
QueryEvaluator.GetPropertyValue now resolves the first hop as the object's own property,
else the same name read out of its Content — the rule MapSelector always had, so
compilationStatus is reachable because the RULE reaches it, not because anybody named it. There
is no special case for compilationStatus anywhere.
Three decisions, each of which could have gone the other way:
1. The node's own field wins; content is only the fallback. This is MapSelector's order —
PropertyMap is consulted before the n.content->> default — and the only order that changes
nothing that already works. Content-first would silently re-point every live query whose selector
names both, and name, description, category, icon, order, state and version are
common content field names, so name:Foo would start filtering on the content's name for a large
part of the mesh. This way round, the only selectors whose resolution moves are the ones that
resolved to null before — i.e. the ones that matched nothing.
2. The fallback keys on the property being ABSENT, never on its value being null. SQL answers
the n.description column for description: and never falls through, so a node with no
description must keep answering "empty" rather than reaching into its content. This is why
GetDirectPropertyValue was split into a presence-reporting TryGetDirectPropertyValue: null
the value and null because there is no such member are different answers, and only the second
opens the fallback.
3. First hop only. SQL's default is a single root-level n.content->>'X', so a miss deeper in
a content.a.b walk stays a miss rather than re-entering some nested object's own Content.
What this changes on a running portal
The evaluator is not dev-only. PostgreSqlPartitionedMeshQuery holds one for IsRelevant —
the live-query change-feed filter — and MeshQuery.ClipMergedInitial builds one to order merged
results on every backend. So before the fallback, a portal could run a Postgres query whose
predicate discriminated while the in-memory relevance filter for the same query did not, and a
sort: on a content field (sort:CreatedAt-desc, the notification bell) was a silent no-op in the
merge step. Both converge now.
The in-memory evaluator also reads content in all three forms a node can carry: a typed CLR
object, a deserialized JsonElement, or an as-written JsonObject. The last form previously
fell through to CLR reflection, which cannot see JSON keys; a content.hostedIn:… filter then
silently rejected that node during live-query relevance checks. JSON object keys now resolve
exactly first and case-insensitively second, including nested fields, and free-text extraction
reads the same JSON strings from either DOM form (MeshWeaver#6049).
The fallback made the sort comparator's partiality reachable
sort: resolves through the same GetPropertyValue, so the fallback widened it from node fields
and explicitly dotted content.X to any selector. JSON has no schema: the same key arrives as a
string on one node and a number on another, and Comparer<object>.Default answers that pair with
InvalidOperationException: Failed to compare two elements in the array
---> ArgumentException: Object must be of type String
— which would turn a widened selector into a failed query rather than a differently-ordered
one, on the merge path (MeshQuery.ClipMergedInitial) that every backend runs. The same latent
hole already swallowed int against long.
OrderResults now orders through a total comparer. Uniform keys keep their existing order
exactly (same type + IComparable is dispatched first and untouched); only pairs the default
comparer refused are newly decided — mixed numerics numerically, anything else by its invariant
string form. Postgres has no equivalent hazard, because n.content->>'X' is always TEXT.
The corpus: one rule, both providers pinned to it
Prose parity is the state that produced this. The rule is now data, in a project both repositories reference:
test/MeshWeaver.Fixture/SelectorResolutionCorpus.cs
Each row is a selector plus the side each provider reads it from and the exact column expression
SQL must emit. Core's SweepSelectorReachesTheCompileStatusTest asserts QueryEvaluator resolves
every row on its recorded side; the Plugins-side twin asserts MapSelector emits every row's
SqlExpression. Neither suite can pass while its provider disagrees with the other's recorded
behaviour, and a row whose two sides differ is named out loud rather than merely absent.
This is the shape QueryRouteClassifier already uses for the router's routing and refusal rules.
🚨 Every row is MEASURED behaviour, not intent. Changing one is a claim that a provider changed; adding a divergence is a claim that one of them is wrong. Neither is a way to make a test pass.
The control that makes the corpus worth having
The test carries a row where a healthy type answers Ok rather than Error, and a control on
the negation arm. Without them, a fallback that had stopped resolving anything — or one that
answered Error for everything — would satisfy the headline assertion and score identically.
🚨 And the assertions stringify through interpolation, never ?.ToString().
GetPropertyValue(...)?.ToString().Should().Be("Error") short-circuits the whole chain when the
value is null: .Should() is never reached and the test passes having asserted nothing. Two
assertions shipped that way and were caught only by running the suite against the un-fixed
evaluator — with the fallback removed they stayed green. Removing the fallback now fails 7 of
36; before the assertions were repaired it failed 5.
Negation is the complement — on both executors
A negated wildcard (-content.apiKey:enc*) was a no-op on both executors (MeshWeaver#6581).
The parser keeps a negated wildcard as NotEqual with its * (there is deliberately no NotLike
member: QueryOperator is public, and widening it breaks every switch that consumes it), and both
executors compared that value literally — != 'enc*' — so the negation matched every node,
including the ones it was written to exclude. Measured on a live portal: 11 ModelProviders, 6 match
content.apiKey:enc*, and -content.apiKey:enc* returned all 11.
The rule both executors now implement: a * in a NotEqual value is the complement of the
glob. In memory that is QueryWildcard.IsNotLikeMatch; in SQL (sel NOT ILIKE p OR sel IS NULL).
A node that lacks the field does not match field:val*, so -field:val* includes it — the
same null-inclusive rule NotEqual and NotIn already followed.
The same parser pass dropped the operator of a negated single namespace: -namespace:X became a
positive path anchor and -namespace:*/X a positive namespace LIKE filter. Both are now a
namespace NotEqual filter, never an anchor — the shape #4296 gave -path:. Under
scope:subtree/scope:descendants the positive namespace:*/X is widened to self-or-below
(LIKE '*/X' OR LIKE '*/X/*'), so the negation is widened by De Morgan to the AND of the two
negated patterns — a node in Acme/X/topic is in exactly one of the pair.
test/MeshWeaver.Fixture/NegationCorpus.cs holds the node set and the exact expected result of
every case, positives beside their negations; core runs it through QueryEvaluator
(NegatedFilterIsTheComplementTest) and MeshWeaver.Plugins seeds the same nodes into Postgres. Each
suite also asserts on its executor's actual answers that every positive/negated pair partitions
the node set, so an executor that answers a negation with "everything" or "nothing" cannot pass.
* is the only wildcard — on every executor
The query language has one wildcard, * (QueryWildcard). The in-memory glob has always read
every other character literally. The SQL generators did not: they translated * to % and bound
the result to ILIKE, so a % or _ the user typed became a LIKE metacharacter too, and so
did the escape character \. code:50%* matched 50xoff, code:a_* matched axb, and
code:c\d* matched cd and missed c\d. Silent over- or under-matching, on Postgres and
Snowflake only. Cosmos never had the problem: it matches with STARTSWITH/ENDSWITH/CONTAINS or
an escaped regex, none of which has a second wildcard.
The rule both SQL generators now implement: escape \, % and _ in the user's value, then
translate only * to %, in the Like arm and in the negated-wildcard NotEqual arm alike.
Postgres's default LIKE escape is the backslash. Snowflake has none, so its escaped patterns
declare ESCAPE '\'.
The content.code cases in NegationCorpus pin it. Each value carrying %, _ or \ sits beside
a decoy that matches only when the character is read as a metacharacter, in both the positive and
the negated form. Both executors run them. The Postgres suite seeds them into a real database, and
the Snowflake and Cosmos generator tests assert the bound pattern.
A quoted value is literal — decided once, in the parser
field:"a|b" and field:"x*y" name exactly those strings (#6634). The decision is the PARSER's, so
no executor has to learn it: a quoted value is emitted only as operators every executor already
compares literally — Equal, or, for a negated value carrying a *, a one-value NotIn (the
negated-wildcard NotEqual arm above would read that * as a glob). A quoted namespace: becomes
the anchor path, never the In alternation filter or the LIKE pattern. The content.code cases
"a|b" and "x*y" in NegationCorpus pin it on both executors, each beside the decoy an
alternation or glob reading would return.
The list separator is the parser's too. Inside field:(A OR B) the word OR separates values only
when it stands alone; matching the two letters at the head of any value turned
-namespace:(acme/a OR orders) into NotIn [acme/a, ders], so the second value was never excluded
on either executor (#6844). The name:(Alpha OR Orion) cases in NegationCorpus, positive and
negated, pin every listed value on both executors.
What is still divergent
The fallback closes the case where a selector is unknown to both providers. It does not close
the opposite one: a selector that is a MeshNode property but is absent from MapSelector's
PropertyMap. There the evaluator reads the property and SQL reads the content field of the same
name — which is empty for essentially every node.
| selector | backing mesh_nodes column |
closable by widening PropertyMap? |
|---|---|---|
createdBy |
created_by |
✅ |
createdDate |
created_date |
✅ |
lastModifiedBy |
last_modified_by |
✅ |
desiredId |
desired_id |
✅ |
syncBehavior |
sync_behavior |
✅ |
excludeFromContext |
exclude_from_context |
✅ |
isDefinitionOnly |
— | ❌ MeshNode-only |
isSatelliteType |
— | ❌ MeshNode-only |
preRenderedHtml |
— | ❌ MeshNode-only |
hasExplicitMainNode |
— | ❌ computed, [JsonIgnore, NotMapped] |
The first six are the same defect one column over as Plugins#1310, where the authorship columns
were left out of a SELECT list and every queried node came back with CreatedBy = null while the
row held the value. The columns exist and are projected; PropertyMap simply does not list them.
🚨 createdBy is in live use — the chat history selector filters threads by
createdBy:{userId}. It happens to work on Postgres for Thread nodes because thread content
also carries a createdBy; for any node type whose content does not, the same selector reads
empty. Fixing it is a MeshWeaver.Plugins change (widen PropertyMap), not a core one, and the
corpus is the checklist.
The free-text divergence, and why it is not closable by widening a map
Everything above is about selectors. The bare-text half of a query diverged too, and that one cannot be fixed by teaching one executor another column name — the two are answering genuinely different questions:
QueryEvaluator.GetFuzzyScore (A) |
Postgres (B) | |
|---|---|---|
| free text | EVERY term must be a case-insensitive substring of the node's searchable text | cosine distance to the query's embedding, hybridised with a lexical tier |
| a semantic neighbour sharing no literal token | no match | ranked in |
| adding a word to the query | can only ever narrow | re-ranks |
Before MeshWeaver.Plugins#1493 both were lexical and agreed. #1493 gave the unpinned fan-out a vector branch — and that is what created the divergence, because A is not dev-only: it is the live-query relevance gate on every backend. SQL ranked a row in; A, asked about the same row, said no; the change notification was dropped and the live query never re-read.
🚨 The direction is the dangerous one. A stops the result set from ever updating, silently, on the exact queries the vector index was added to serve — and no test of the Initial read can see it.
A cannot be made to agree, because it has no embeddings in memory and no business calling an
embedding provider from a change-feed filter. So the resolution is not parity of answers but
parity of scope: for a query that takes the semantic route, A is asked only the structured
half (parsed with { TextSearch = null }) — the half both executors resolve identically — and the
free-text half is dropped rather than answered wrongly. One predicate,
PostgreSqlPartitionedMeshQuery.TakesSemanticRoute, is read by both the routing site and the gate,
so the two cannot drift; a host with no embedding provider keeps the lexical contract on both sides.
This is the general shape whenever B gains a capability A structurally lacks: narrow what the weaker executor is asked, never let it veto on a question it cannot answer. Erring toward an extra re-query is bounded; erring toward a veto drops rows and looks like an empty mesh.
See Vector Search for the routing and the no-provider fallback.
A third resolver exists
MeshSearchView.razor.cs (MeshWeaver.Plugins) carries its own private
GetPropertyValue(MeshNode, string) for grid grouping and sorting. It already implements
node-property-then-content — but it falls back when the property's value is null, not when the
property is absent, so it diverges from both executors on an empty node field. It should
delegate rather than reimplement.
Reading this before you touch a selector
- Adding a selector to
PropertyMapin Plugins changes what an existing query means on Postgres, from "the content field" to "the column". That is usually the fix, and it is never silent — add the corpus row in the same change set. - Adding a public property to
MeshNodecreates a divergence by default: the evaluator will find it by reflection and SQL will not. Either add it toPropertyMapin the paired change or record it inKnownDivergences. - A query in a documented procedure, a script, or an in-mesh Code node should use the explicit
content.Xform. It reaches the field on every backend and every deployed image, and it needs no reader to know which of these two executors is running.
Related
- Query Syntax Reference — the grammar, and the three-step selector resolution rule as an author sees it
- Build Identity Admission — the pre-deploy sweep this divergence sat under, and what else that sweep cannot see
- CQRS and Content Access — when a query is the wrong instrument regardless of which provider serves it