Query: Question Classes and Idiom Cookbook
The query verb compiles GQL (Graph Query Language) and SPARQL strings to SQL SELECT statements over the knowledge graph (ADR-008). It is a structural pattern matcher, not a full-text or semantic search engine, and it is read-only: mutations always go through create, update, link, merge, and delete.
This guide has two parts: a catalogue of question classes mapped to the right verb (query, or something else), and a cookbook of verified idioms with copy-paste syntax and known gaps.
Routing: query vs. search vs. neighbors vs. traverse vs. context
query is one of five ways to read the graph. Reach for it only when the question is genuinely structural — a typed relation pattern, a property filter, or a bounded multi-hop path with a known relation. For everything else, a different verb is a better fit:
| You want to… | Use |
|---|---|
| Find records by topic or keyword, fuzzy or full-text | search(kind="entity", query="...") — kind="note" works the same way |
| Look up a known record by id | get(id="...") |
| See immediate connections of a known node | neighbors(node_id="...", direction="both") |
| Explore bounded multi-hop paths | traverse(roots=["..."], max_depth=N) — depth ≤10 with finite result/work/time budgets |
| Pull a budgeted, entity-anchored context bundle | context(query="...", hops=N, budget=N) — or anchor with entity_ids=[...] instead of query |
| Match a typed relation pattern, filter by property, or walk a fixed-relation bounded path | query(query="MATCH ...") or query(query="SELECT ...") |
| Create, update, link, merge, or delete anything | The corresponding KG verb — never query |
Rule of thumb: if the answer depends on relevance ranking or you don’t know the exact relation name, use search. If you already hold an id and want its neighborhood, use neighbors or traverse. Reach for query only once you can name the relation(s) and shape of the pattern you’re matching.
Question-class catalogue
Verdicts derived from the shipped parsers, AST validation, SQL compiler, the KG verb handlers, ADR-008, and live read-only checks against a production knowledge graph.
| Question class | Example question | Verdict | Idiom or boundary |
|---|---|---|---|
| Exact named-record lookup | Does this canonical name already exist? | YES via query | MATCH (n) WHERE n.name = "<exact name>" RETURN n.id, n.kind, n.name LIMIT 2 |
| Fuzzy discovery | What records concern recursive CTE traversal? | YES via search | query has no full-text or semantic operator; use search(kind="entity", query="..."). |
| Immediate adjacency | What directly annotates this known record? | YES via neighbors | neighbors(node_id="<uuid>", direction="incoming", relations=["annotates"]) |
| Targeted relation join | Which concepts were introduced by which documents? | YES via query | MATCH (c:concept)-[e:introduced_by]->(d:document) RETURN c.name, d.name, e.weight LIMIT 50 |
| Provenance path | What is the derivation path from an artifact to its source? | PARTIAL | Fixed derived_from chains, or a single variable-length edge, both work. A chain that mixes fixed and variable-length hops must be split into separate queries. |
| Supersession view | What replaces this concept? | YES via query | MATCH (old)-[e:supersedes]->(new) RETURN old.name, new.name, e.weight LIMIT 100 |
| Relation alternatives | Show records that extend or implement another record. | YES via query | Pipe-separated relation alternation inside one edge pattern — see the fenced example below the table (a literal pipe cannot be shown inside this table cell). |
| Bounded lineage | What is reachable by one to three extends hops? | YES via query | MATCH (a)-[:extends*1..3]->(b) RETURN a.name, b.name LIMIT 100 |
| General expansion | Expand this known entity two hops with context. | YES via traverse/context | traverse(roots=["<uuid>"], max_depth=2), or context(entity_ids=["<uuid>"], hops=2, budget=N). |
| Cross-substrate join | Which notes annotate concepts, documents, or other notes? | YES via query | MATCH (note:note)-[e:annotates]->(target) RETURN note.name, target.kind, target.name LIMIT 100 |
| Pack-note inventory | Which caller-visible comm messages exist? | YES via query | MATCH (m:message) RETURN m.id, m.kind LIMIT 100; equivalently, use MATCH (m:note) WHERE m.kind = "message" RETURN m.id, m.kind LIMIT 100. |
| Kind/type filtering | Which records use a given entity type? | YES via query | MATCH (n) WHERE n.entity_type IS NOT NULL RETURN n.entity_type, n.name LIMIT 100 |
| Property filtering | Which names contain a token, or belong to a set of kinds? | YES via query | WHERE n.name CONTAINS "<text>", STARTS WITH, or n.kind IN ["concept", "project"]. |
| Edge-weight filter | Which extends edges have weight at least 0.8? | YES via query | MATCH (a)-[e:extends]->(b) WHERE e.weight >= 0.8 RETURN a.name, e.weight, b.name LIMIT 100 |
| Deduplication candidates | Which pairs share a name or a similar description? | PARTIAL | A literal-name lookup works. Variable-to-variable comparison, grouping, and similarity scoring do not — use search to surface candidates instead. |
| Orphan detection | Which records have no graph edges? | NO | No negative pattern, NOT EXISTS, optional match, or anti-join exists. Approximate outside query with list plus neighbors. |
| Edge-density audit | Which entities have fewer than four edges? | PARTIAL | stats() gives a global edges_by_relation breakdown and query lists rows, but COUNT, GROUP BY, HAVING, and degree projection are all absent. |
| Aggregate inventory | How many concepts exist, by type? | PARTIAL | Filtered row listing and GQL SKIP paging work. Aggregate functions, grouping, distinct projection, and caller-defined ordering are absent. |
| Cycle / self-reachability | Which entities are in an extends cycle? | NO | Repeated node-variable bindings are rejected, so cycles and self-reachability cannot be expressed. |
| Branching join | Which parent has both an extends child and an implements child? | NO | GQL supports one alternating path; SPARQL rejects branched or disconnected WHERE blocks. |
| Cross-namespace audit | What belongs to another namespace? | NO (caller-controlled scope) | Namespace is a runtime CompileOptions input, not query text — a query that references namespace in its body is rejected. |
| Mutation via query | Create, update, link, merge, or delete through GQL/SPARQL. | NO (by design) | Write-shaped input is rejected outright. Use the corresponding KG verb. |
The relation-alternatives idiom, copy-paste form (alternation is a bare pipe between relation identifiers inside one edge pattern):
MATCH (a)-[e:extends|implements]->(b) RETURN a.name, e.relation, b.name LIMIT 100
Operational limits
- GQL accepts one connected, alternating path after
MATCH. It does not accept comma-separated patterns or branching self-joins. For a parallel-edge audit, returna.id, e.id, e.relation, b.idfrom one unlabeled-edge path and group endpoint IDs client-side without discarding the edge ID. RETURNaccepts comma-separated variables orvariable.propertyitems, but notASaliases. Output names remain variable-prefixed, such asa_id.- GQL supports either a chain of fixed-relation hops or exactly one variable-length edge per query — never both mixed in the same pattern. Minimum hop count is 1; maximum is 10. A bare
*(no bounds) means 1 through 5 hops, not zero-or-more. - A query that mixes a fixed hop with a variable-length hop in the same chain is rejected outright, not silently approximated. Split it into two queries.
- The query verb’s
page_sizehas minimum 1, defaults to 500, and is clamped to a hard cap of 10,000. GQLSKIPadvances through deterministically ordered matches. Omit query-textLIMITfor an exhaustive audit; aLIMITat or belowpage_sizeis a terminal caller-chosen bound. The legacy outerlimitargument remains a deprecated, mutually exclusive alias forpage_size. - Result columns are projection-derived and variable-prefixed (for example
a_name,e_relation). Returning a whole node variable expands it into its supported substrate columns. Variable-length paths add_depthand_total_weight(a running sum of edge weights along the path, not an average). - Pack-registered note kinds use the same GQL
kindfield as base kinds. A granular label such as:messageis shorthand for the stored kind;:noteselects the note substrate and can be combined withWHERE m.kind = "message".note_kindbelongs to the verb parameter surface and is not a GQL field. Both forms remain restricted to the caller’s authorized namespace scopes. - SPARQL variables project as whole nodes (full column expansion), unlike GQL’s dotted per-field projection. SPARQL
OFFSETis not supported.
Paging broad GQL results
The response always includes offset, effective page_size, has_more, and the compatibility alias truncated. A non-terminal GQL page also includes next_offset; set the next query’s SKIP to that exact value and keep the same page_size:
query(query="MATCH (a)-[r:depends_on]->(b) RETURN a.id, r.id, b.id", page_size=500)
# ... "has_more": true, "next_offset": 500
query(query="MATCH (a)-[r:depends_on]->(b) RETURN a.id, r.id, b.id SKIP 500", page_size=500)
next_offset advances by the number of rows actually emitted. It is omitted when has_more is false. The compiler orders fixed matches by their complete bound identities (including synthetic observation primary keys); recursive DISTINCT rows are ordered by depth, weight, and their complete projected value tuple. Repeated reads of unchanged data are therefore stable. This is offset paging, not a cross-call database snapshot: inserts or deletes that match the query can shift later pages.
Verified GQL idioms
Each row was issued as query(query="...") through the request tool against a production knowledge graph. Result shapes are column names and row counts only — no graph content is reproduced here.
| # | What it answers | Query string | Result shape / pitfall |
|---|---|---|---|
| 1 | Sample concept records | MATCH (n:concept) RETURN n LIMIT 2 | 2 rows; columns n_created_at, n_entity_type, n_id, n_kind, n_name, n_namespace, n_properties, n_updated_at. A whole-node variable expands to every supported column. |
| 2 | Provenance endpoints | MATCH (a)-[e:introduced_by]->(b) RETURN a.name, e.relation, b.name LIMIT 2 | 2 rows; a_name, b_name, e_relation. |
| 3 | Either extends or implements edge | The alternation idiom in the fenced block above, with LIMIT 2 | 2 rows; a_name, b_name, e_relation. Alternation uses a pipe inside one edge pattern. |
| 4 | Incoming extends edge | MATCH (a)<-[e:extends]-(b) RETURN a.name, e.weight, b.name LIMIT 2 | 2 rows; a_name, b_name, e_weight. |
| 5 | Undirected extends adjacency | MATCH (a)-[e:extends]-(b) RETURN a.name, e.relation, b.name LIMIT 2 | 2 rows; a_name, b_name, e_relation. May expose both orientations. |
| 6 | One- to two-hop lineage | MATCH (a)-[e:extends*1..2]->(b) RETURN a.name, b.name LIMIT 2 | 2 rows; _depth, _total_weight, a_name, b_name. Bounds are inclusive; maximum depth is 10. |
| 7 | Default variable-length lineage | MATCH (a)-[e:extends*]->(b) RETURN a.name, b.name LIMIT 2 | 2 rows; _depth, _total_weight, a_name, b_name. Bare * means 1 through 5 hops, not zero-or-more. |
| 8 | Weight threshold | MATCH (a)-[e:extends]->(b) WHERE e.weight >= 0 RETURN a.name, e.weight, b.name LIMIT 2 | 2 rows; a_name, b_name, e_weight. Edge predicates support relation and weight. |
| 9 | Kind equality filter | MATCH (n) WHERE n.kind = "concept" RETURN n.name, n.kind LIMIT 2 | 2 rows; n_kind, n_name. Escape inner literal quotes in the outer DSL string. |
| 10 | Case-insensitive substring | MATCH (n) WHERE n.name CONTAINS "ADR" RETURN n.name LIMIT 2 | 2 rows; n_name. CONTAINS escapes literal %, _, and \. |
| 11 | Prefix lookup | MATCH (n) WHERE n.name STARTS WITH "ADR" RETURN n.name LIMIT 2 | 2 rows; n_name. |
| 12 | Multi-value filter | MATCH (n) WHERE n.kind IN ["concept", "project"] RETURN n.name, n.kind LIMIT 2 | Live-verified: 2 rows; n_kind, n_name. IN requires a scalar list; null is illegal. |
| 13 | Indexed entity-type filter | MATCH (n) WHERE n.entity_type IS NOT NULL RETURN n.entity_type LIMIT 2 | 2 rows; n_entity_type. IS NOT NULL takes no right-hand literal. |
| 14 | WHERE ... OR ... | MATCH (n) WHERE n.kind = "concept" OR n.kind = "project" RETURN n.name, n.kind LIMIT 2 | 2 rows; n_kind, n_name. AND binds tighter than OR; parentheses are not available to force grouping. |
| 15 | Page size vs. query LIMIT | query(query="MATCH (n) RETURN n.name LIMIT 5", page_size=2) | 2 rows plus has_more: true and next_offset: 2; query LIMIT exceeds the effective page size, so the +1 sentinel detects another page. |
Parser-diagnosed dialect boundaries
These unsupported constructs receive specific parser errors rather than generic grammar or trailing-input failures:
| Form | Query string | Contract and alternative |
|---|---|---|
| Comma-separated patterns | MATCH (a)-[e1]->(b), (a)-[e2]->(b) RETURN a, b | QueryError::Unsupported names comma-separated MATCH patterns. For parallel-edge audits, use one path returning a.id, e.id, e.relation, b.id, then group endpoint IDs client-side while retaining each edge ID. |
RETURN aliases with AS | MATCH (a)-[e]->(b) RETURN a.name AS src, b.name AS dst LIMIT 5 | QueryError::Unsupported names AS. Use unaliased variable or variable.property projections; result names remain variable-prefixed. |
Rejected forms (live-checked)
Each is a verified read-only check against the running server. Errors are verbatim.
| Form | Query string | Observed error |
|---|---|---|
| GQL write | CREATE (n:concept) | query: unsupported feature: the query verb is read-only; to mutate the graph use: create, update, link, merge, delete |
| SPARQL update | INSERT DATA { ?a :extends ?b } | same read-only rejection as above |
| Mixed fixed and variable chain | MATCH (a)-[e:extends]->(b)-[f:extends*1..2]->(c) RETURN a, b, c | query: unsupported feature: variable-length patterns must be a single start_node -[*N..M]-> end_node (mixed fixed/variable chains are not yet implemented) |
| Zero-hop GQL range | MATCH (a)-[e:extends*0..2]->(b) RETURN a, b | query: unsupported feature: zero-hop ranges (min_hops = 0) not yet supported; use a minimum of 1 hop |
| Unknown relation | MATCH (a)-[e:bogus]->(b) RETURN a, b | query: validation error: unknown edge_relation: "bogus" (lists the 17 valid relations) |
| Repeated binding / cycle | MATCH (a)-[e:extends]->(a) RETURN a | query: unsupported feature: repeated node variable 'a' (cycle / self-reachability requires alias-equality predicates not yet implemented) |
| Branched SPARQL | SELECT ?a ?b ?c WHERE { ?a :extends ?b . ?a :implements ?c . } | query: unsupported feature: SPARQL WHERE block is branched or disconnected; only single-path patterns are supported |
| SPARQL zero-or-more | SELECT ?a WHERE { ?a :extends* ?b . } | query: unsupported feature: SPARQL '*' (zero-or-more hops) not yet supported; use '+' or '{min,max}' |
| Namespace from query text | MATCH (n) WHERE n.namespace = "local" RETURN n LIMIT 1 | query: validation error: namespace is set by CompileOptions, not query text |
| Incomplete float literal | MATCH (n) WHERE n.name = 1. RETURN n LIMIT 1 | query: parse error: float literal must have digits after '.' (e.g. '1.0', not '1.') |
SPARQL
SPARQL positive-path execution is verified end to end: a one-hop SELECT returns rows on the live MCP tool.
| What it answers | Query string | Notes |
|---|---|---|
Which endpoints have an extends edge | SELECT ?a ?b WHERE { ?a :extends ?b . } LIMIT 2 | 2 rows. SPARQL variables project as whole nodes — full column expansion (a_id, a_kind, a_name, a_properties, ...), unlike GQL’s dotted projection. |
Which concepts have an introduced_by source | SELECT ?a ?b WHERE { ?a a :concept . ?a :introduced_by ?b . } LIMIT 2 | Typed-node triple pattern (?a a :concept) combined with a relation triple. |
Which endpoints are one or two extends hops apart | SELECT ?a ?b WHERE { ?a :extends{1,2} ?b . } LIMIT 2 | Bounded variable-length path using the {min,max} form. |
Gaps
- Comma-separated GQL
MATCHpatterns andRETURN ... AS ...aliases are rejected with named unsupported-feature errors. Use one alternating path and unaliased projections; group returned rows client-side where needed. - Mixed fixed-plus-variable-length paths are rejected; split the query into separate calls instead.
- No anti-join, optional match, aggregate,
GROUP BY,DISTINCT, or caller-defined ordering exists. GQL usesSKIPrather than anOFFSETkeyword; SPARQLOFFSETremains unsupported. Orphan-detection and edge-density questions cannot be expressed in a single query. - A repeated node-variable binding is rejected, which blocks cycle and self-reachability queries.
- SPARQL is single-path only; a branched
WHEREblock is rejected.
See also
- GQL and SPARQL parsing — the concise grammar and unsupported-form contract.
- ADR-008: Query Layer Separation — why GQL and SPARQL share one SQL compilation target and no mutation path.
- Search and Retrieval — fuzzy, full-text, and hybrid retrieval when
queryisn’t the right tool. - Prompt Cookbook — verb patterns beyond
query. - AGENTS.md — additional GQL and SPARQL examples for agents.
Raw markdown for this page: /md/query-cookbook.md