MeshWeaver uses a GitHub-style query syntax — space-separated terms that combine field filters, text search, and structural qualifiers — to find, filter, and navigate nodes across the mesh.

Query Term Building Blocks Field Filter nodeType:Organization Negation -status:Archived Wildcard / OR name:Acme* | a|b|c Scope namespace:X scope:desc Sort / Limit sort:name-desc limit:10 Text Search quarterly report Projection select:name,nodeType Source source:activity Filtered Node Results

All term types are space-separated and composable — each additional term narrows the result set.


Basic Syntax

Every query is a sequence of space-separated terms. Three building blocks cover almost every use case:

Form Example Meaning
Field filter nodeType:Organization Nodes where field equals value
Negation -status:Archived Exclude nodes where field equals value
Text search quarterly report Full-text match in name and description

Terms are case-insensitive and composable — additional terms narrow the result set.


Field Filters

Equality

nodeType:Organization
name:Acme
status:Active

Negation

-status:Archived

Wildcard Patterns

Use * anywhere in a value to do substring, prefix, or suffix matching:

name:*claims*     # Contains 'claims'
name:Acme*        # Starts with 'Acme'
name:*Corp        # Ends with 'Corp'

Comparison Operators

price:>100        # Greater than 100
price:<50         # Less than 50
price:>=100       # Greater than or equal
price:<=50        # Less than or equal

List Values (OR)

Two equivalent forms let you match any value from a set. Both parse to the same IN (...) query AST and produce a single indexed database lookup — not N separate queries.

Form Example
Verbose (explicit) status:(Active OR Pending OR Draft)
Concise (grep-style) status:Active\|Pending\|Draft
# These two are identical:
nodeType:(Organization OR Project)
nodeType:Organization|Project

# Negation works with both forms:
-status:(Deleted OR Archived)
-nodeType:Spam|Trash

The | form mirrors grep -E alternation. Choose whichever reads more naturally in context.

Multi-value path: — routing-layer batch lookups

When the parser sees path:a|b|c, it builds a multi-value path filter that pushes down as a single WHERE path IN (...) round-trip. The canonical use is the routing layer's "longest matching prefix" lookup:

path:foo/bar/baz|foo/bar|foo sort:length(path)-desc limit:1

One Postgres query, one indexed IN (...) scan, server-side sort by path length, single row returned.

Empty Values

description:       # Matches nodes with no description

Where a selector resolves

A selector — the token before the : — is resolved in three steps, in this order:

  1. A field of the node itself. name, nodeType, description, category, icon, order, lastModified, version, state, id, mainNode.
  2. content.X, or content.X.Y… — an explicit walk into the node's content.
  3. Anything else → the content field of the same name. compilationStatus is content.compilationStatus; status is content.status.

Step 3 is why nodeType:User email:alice@acme.com and nodeType:User content.email:alice@acme.com are the same query. Prefer the explicit dotted form in code and in documented procedures: it says where the field lives, and it reaches it on every backend and every deployed image — step 3 arrived in the in-memory evaluator late (#3511), so a portal running an older platform build answers the bare form only on Postgres.

🚨 Step 1 wins over step 3, and that matters. A node whose content carries its own name, state or version field is still filtered on the NODE's, never the content's — reach the content one with content.name. The order is not an implementation detail: reversing it would silently re-point every live query whose selector names both.

🚨 A field that exists but is empty does not fall through. description: matches nodes whose node-level description is unset; it does not then go looking for a description inside the content.

Two implementations, one rule. The rule above is executed twice — by QueryEvaluator for in-memory, FileSystem and static-node hosts (and for live-query relevance and result merging on every backend), and by PostgreSqlSqlGenerator as SQL against the content JSONB. They are in different repositories, and they drifted: see Query Provider Parity for what the drift cost, the shared corpus that now pins both, and the selectors on which they still disagree.


Reserved Qualifiers

namespace

Sets the search location (like a folder). Without an explicit scope, only immediate children are returned.

namespace:Systemorph              # Immediate children of Systemorph
namespace:Systemorph/Marketing    # Immediate children of Marketing

Add scope:descendants to search recursively:

namespace:Systemorph scope:descendants   # All items under Systemorph (recursive)

namespace:X never returns the node at path X itself. A node's namespace is its parent path — the node at X has namespace X-minus-last-segment (a top-level node such as a user root has namespace ""), so it can never satisfy namespace:X, with any scope. scope:subtree on a namespace therefore degrades to descendants at parse time (self + descendants of a namespace is just its descendants). This is guaranteed by the parser and pinned by tests (Parse_NamespaceWithSubtreeScope_DegradesToDescendants, NamespaceQuery_NeverReturnsTheNodeAtTheNamespacePath) — a user's home or a space's item list can never show the partition root itself. To include the base node, query path:X scope:subtree instead.

Multi-value namespace: — membership across namespaces

namespace:A|B|C matches nodes living in any of the listed namespaces — a single n.namespace IN (...) lookup, not N queries. It is exact membership: just the listed namespaces, with no ancestor/descendant graph walk.

namespace:rbuergi/Agent|AgenticPension/Agent|Agent nodeType:Agent
# Agent nodes whose namespace ∈ { rbuergi/Agent, AgenticPension/Agent, Agent }

This is the canonical agent-registry query (AgentPickerProjection.BuildAgentQuery): agents live in a dedicated /Agent sub-namespace per partition — the platform defaults in the bare Agent namespace, a space's own under {space}/Agent, a user's own under {user}/Agent — and the registry simply lists the relevant ones directly (no graph search). Models mirror this with /Model. A single namespace:X (no |) keeps its usual single-namespace semantics; the membership form is triggered only by the | alternation.

scope

Controls the traversal direction relative to namespace or path:

Value Description
descendants All descendants recursively (excludes self)
ancestors Parent hierarchy upward (excludes self)
hierarchy Ancestors + self + descendants
subtree Self + all descendants (with path:; on namespace: it degrades to descendants — see the note above)
ancestorsandself Self + all ancestors
nextLevel The next populated level below — the nearest real nodes, skipping empty intermediate namespace segments (alias: populated)

scope:nextLevel — the populated frontier

children returns nodes whose namespace is exactly the base path. But a node can sit several segments deep with no real node in between — e.g. a/b/node where a and a/b are pure namespace groupings, not nodes. children of the root misses it entirely.

scope:nextLevel returns the frontier: every node strictly below the base for which no other node sits between it and the base. Empty segments are skipped, so:

namespace:Org scope:nextLevel    # nearest real nodes below Org

This is the drill primitive for graph navigation (above = scope:ancestors, below = scope:nextLevel). On Postgres it is a single indexed anti-join — no per-child count probes. It is a within-partition scope: pin it to a namespace; an unscoped/cross-partition nextLevel degrades to descendants.

path

Sets the base path for search. Without a scope modifier, the default is an exact match:

path:Systemorph           # The exact Systemorph node
namespace:Systemorph      # Immediate children of Systemorph

Path Resolution — Longest-Prefix Match

The routing layer uses the query engine to answer "which MeshNode owns the longest matching prefix of this URL path?" The conceptual equivalent in C# is:

nodes.Where(node => requestedPath.StartsWith(node.Path))
     .OrderByDescending(node => node.Path.Length)
     .First();

The canonical query idiom — backend-agnostic, one round-trip on indexed backends:

path:foo/bar/baz scope:ancestorsandself sort:length(path)-desc limit:1

scope:ancestorsandself expands the candidate set to self and ancestors; sort:length(path)-desc limit:1 collapses it to the deepest match. Both clauses are required. Callers that want the full ancestor chain (breadcrumbs, parent navigation) simply omit limit:1.

sort

Specifies sort order. Default is ascending; append -desc for descending:

sort:name               # Name ascending
sort:name-desc          # Name descending
sort:lastModified-desc  # Most recently modified first

SQL-Function Selectors

Sort selectors accept a small allow-listed set of SQL-style functions — useful when ordering by a derived value rather than a raw column:

sort:length(path)-desc   # Longest path first
sort:lower(name)         # Case-insensitive name ascending
sort:upper(nodeType)     # Case-insensitive nodeType ascending

Allowed functions: length, lower, upper. Arbitrary SQL is not accepted.

The function-call form composes naturally with the rest of the query, as in the routing-layer example above.

limit

Caps the number of results returned:

limit:10    # At most 10 results
limit:50    # At most 50 results

source

Switches the data source that backs the query:

source:activity    # Only main nodes that HAVE Activity satellites (a change/activity feed),
                   # ordered by most recent activity first

When source:activity is set:

source:activity nodeType:Thread namespace:ACME scope:descendants   # Recently changed threads in ACME
source:activity nodeType:Document limit:10                         # Last 10 changed documents

There is a second source for the caller's own access recency:

source:accessed    # Only nodes the CALLER has opened, ordered by their last access time

source:accessed INNER-JOINs the caller's UserActivity satellites — the per-user access log. That log lives in the caller's own partition ({user}/_UserActivity routes by its first segment), so on the partitioned backends every branch of the cross-partition fan-out joins that ONE user_activities table; this is what makes "last accessed" work across partitions (opening acme/doc writes a row in your schema that matches acme's mesh_nodes by path-encoded id). Guarantees, pinned by tests:

The home catalog's Last accessed sort is the canonical consumer (namespace: is:main context:search -nodeType:User source:accessed sort:LastModified-desc + the namespace:{user} home-children leg).

is

Filters by node classification:

is:main    # Only main nodes (excludes satellite content such as comments and threads)

Satellite nodes exist in support of a main node — for example, comments on a document or threads started from a page. A main node has MainNode == Path (or null); a satellite node has MainNode pointing to its primary node's path.

namespace:ACME is:main                     # Main nodes directly under ACME
namespace:ACME scope:descendants is:main   # All main nodes under ACME (recursive)
is:main context:search                     # Main nodes visible in search

context

Filters by visibility context. Nodes (or their NodeType definitions) can declare contexts from which they should be excluded via the ExcludeFromContext property, enabling different views of the same data:

context:search    # Exclude nodes hidden from search
context:create    # Exclude nodes hidden from create menus

Nodes are inclusive by default. A node without ExcludeFromContext is visible in all contexts. A node with ExcludeFromContext: ["search"] is excluded only from context:search queries.

nodeType:NodeType context:create                      # NodeTypes visible in create menus
namespace:ACME scope:descendants context:search       # Searchable nodes under ACME

select

Projects results to include only the specified properties — ideal for autocomplete, dropdowns, and large-result queries where only a few fields are needed.

select:name                      # Single property
select:name,nodeType,icon        # Multiple properties (comma-separated)
namespace:Systemorph select:name,nodeType
nodeType:Story select:path,name sort:name limit:10

Always select: only the fields the consumer reads. Existence checks and "is-this-up-to-date?" polls need only (path, version), never the full node. Loading full Content for a subtree is the antipattern this qualifier eliminates.

🚨 select: decides whether Content is loaded at all

The projection has two different shapes depending on what the caller is typed on, and the difference is invisible at the call site:

Caller What a select: does
untyped (Query<object>, the MCP search tool) each row becomes a Dictionary<string, object> of exactly the named fields
typed on MeshNode (Query<MeshNode>, and therefore every workspace.GetQuery / hub.GetQuery) the row stays a MeshNode — no dictionary — but the SQL projection is still narrowed

On the typed path only ONE column is actually conditional: content. Every other field is projected regardless, so naming them is documentation rather than optimisation. But when content is not named, the storage adapter emits NULL::jsonb AS content (Postgres) or omits the field (Cosmos), and you get a fully-formed MeshNode whose Content is silently null.

That failure is indistinguishable from "this node has no content": no error, no warning, no empty result set — every node.Content is T / ContentAs<T>() downstream just returns null and the surface renders empty. It is the same silent-null class as a missing TypeRegistry entry.

# ✅ metadata-only consumer — content deliberately not loaded
namespace:Course/Intro scope:children nodeType:Module select:path,id,name,order

# ✅ content-bearing consumer — `content` named DELIBERATELY
path:{user}/_Memex/AiSettings nodeType:AiSettings select:path,id,name,nodeType,content

# ❌ reads ContentAs<ModuleConfiguration>() but never asked for content — always null
namespace:Course/Intro scope:children nodeType:Module select:path,name

Rule. Every GetQuery should carry a select:. If any consumer of that stream reads Content, content must be in the list. When you cannot prove the whole consumer chain is content-free, leave the query unprojected — the full node is the conservative default.

🚨 The projection is part of the cache key — so keep it stable per id. The synced-query cache is keyed by (id, query set): GetQuery(id, queries) answers the set you asked for, and two call sites that share an id but differ in their select: each get their own collection. It was keyed by id alone until issue #1311, and the discarded queries made a metadata-only reader starve a content reader of its content, intermittently, depending on render order — the same discard that froze a NodeType's source set on whichever declaration materialised first, so a newly added shared=@Other/Type/Source was ignored until the portal restarted.

A drift is therefore no longer a wrong answer, but it is still waste: every distinct query set opens its own subscription and stays resident for the life of the process. Keep the query strings byte-identical wherever an id is shared, and scope ids per module so unrelated readers never collide.


Combining Filters

Terms compose freely — every additional term narrows the result:

namespace:Systemorph nodeType:Project

nodeType:Story name:*claims* sort:lastModified-desc limit:20

namespace:ACME/ProductLaunch nodeType:Todo scope:descendants

Default Queries by Context

The query engine powers several built-in views. Understanding the default query each view issues makes it easy to extend or override them.

NodeType Catalog

When browsing a NodeType (e.g., Organization), the default query finds all instances:

nodeType:Organization

Instance Catalog

When browsing an instance (e.g., Systemorph), the default query shows its immediate children:

namespace:Systemorph

Add scope:descendants to go recursive.


Reading a single node's fields

There is no SelectAsync API. To read fields off a node at a known path, take the node from its stream — that is the authoritative, live read; a query would go through the lagged read-side index and return stale content right after a write:

workspace.GetMeshNodeStream("Systemorph/Marketing")
    .Where(node => node is not null)
    .Take(1)
    .Timeout(TimeSpan.FromSeconds(10))
    .Subscribe(node =>
    {
        var name = node.Name;
        var nodeType = node.NodeType;
        var icon = node.Icon;
    });

If you want a narrower payload out of a set query, use select: (below) — but note it drops content. See CQRS — Queries vs. Content Access.


Live Query Syntax Explorer

The cell below renders a quick-reference card for the most common query patterns — a handy cheat sheet while you build queries:

var patterns = new[]
{
    ("Field filter",        "nodeType:Organization"),
    ("Negation",            "-status:Archived"),
    ("Wildcard",            "name:Acme*"),
    ("Comparison",          "price:>100"),
    ("OR list",             "status:Active|Pending|Draft"),
    ("Namespace (folder)",  "namespace:ACME scope:descendants"),
    ("Longest-prefix",      "path:a/b/c scope:ancestorsandself sort:length(path)-desc limit:1"),
    ("Sort descending",     "sort:lastModified-desc"),
    ("Activity feed",       "source:activity nodeType:Thread limit:10"),
    ("Main nodes only",     "is:main context:search"),
    ("Select projection",   "nodeType:Story select:path,name limit:10"),
};

var rows = string.Join("\n", patterns.Select(p =>
    $"| `{p.Item1}` | `{p.Item2}` |"));

MeshWeaver.Layout.Controls.Markdown($"""
## Quick-Reference Card

| Pattern | Example |
|---|---|
{rows}
""")

Tips

  1. Case-insensitive — all comparisons ignore case.
  2. namespace:X means "folder X" — returns immediate children by default; add scope:descendants to go deep.
  3. Wildcards* matches anything; prefix, suffix, and contains patterns all work.
  4. Select only what you need — use select: to keep payloads small in large result sets.
  5. Known path ⇒ node stream, not a queryworkspace.GetMeshNodeStream(path) is authoritative and live; QueryAsync(path:X) is a stale-read bug.
  6. Vector search — free-floating text tokens (laptop nodeType:Story) trigger HNSW cosine-index search on Postgres backends when an IEmbeddingProvider is registered; structured-only queries stay on the regular SQL path.
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.