4. What is not possible — and why
These are rejected at plan time (fail-closed), with a message:
| Pattern | Why rejected |
|---|---|
MATCH (hc:HubSpotContact) RETURN hc |
Naked virtual scan — no anchor to probe. Virtual labels are reached only by traversing a declared join from a bound anchor; otherwise the engine would try to fetch every contact. The one exception is a label with exactly one declared everyone-door (§2), which the engine takes for it. |
MATCH (p:Person)-[:HAS_HUBSPOT_CONTACT]->(hc) with no predicate on p |
Unbound anchor — p matches every person; the fan-out is unbounded. Pin or filter the anchor. |
MATCH (i:Item)-[:MENTIONS]->(t:Tag) where Tag is brought only via TAGGED |
Brought child off its declared edge — a brought label is reachable only through the exact relationship its own brings: entry names, and only from the join whose target brought it. |
MATCH (`a b`:Repo), MATCH (`distinct`:Repo) |
A variable that needs backticks — variables are plain names. Refused by name with the rename to use, never as a syntax error the caller did not make. A word that reads back bare (count, match, all) is a legal name; column aliases and property keys may be quoted as usual. |
COUNT { MATCH (s:Bill) RETURN s UNION MATCH (s:Meeting) RETURN s } |
Two UNION branches inside a subquery binding the same new name — inside a CALL { }, COUNT { }, EXISTS { } or COLLECT { } body the branches share one scope, so a name bound afresh in two of them would be one variable read two ways. Refused naming it, with the rename that answers: MATCH (s:Bill) RETURN s AS item UNION MATCH (s2:Meeting) RETURN s2 AS item. A name may be shared across branches when it is imported or bound before the subquery, or when it is the returned column written as an alias (RETURN t AS s) in every branch but at most one. A statement-level UNION has no such limit: its branches are independent queries. |
MATCH (s:Shop) CALL { MATCH (s)-[:TAGGED]->(t) RETURN t.tag AS tag } RETURN tag |
A CALL { } body sharing a name with the statement around it without the two being one variable — an outer name the body does not import (including under CALL ()), or a name the body binds and does not return that the outer query uses again after it. The body’s s would be a second, unbound variable read as the caller’s. Refused naming it, with the import (CALL (s) { … }) or the rename (s2) that answers. CALL (*) is refused with the explicit import list the body needs. OPTIONAL CALL is refused as a form this engine cannot read. Every other read-only CALL { } is answered on every engine, its body scoped like the statement around it — uncorrelated, side by side, nested, importing (CALL (s) { … } or a leading WITH s), returning rows or an aggregate, or feeding a hop outside it; a fetched hop inside a body is fetched for the rows that reach it. |
MATCH (n) RETURN n.name, or a node left unnamed: MATCH (p:Person)-[:ABOUT]->() |
Unscopable node — a node is reached by the labels it names, so one that names none cannot be held to the asking principal. The lever is per node: a named node is told to take a label; a node the query left unnamed is told to take a name AND a label, because a label alone on (:Label) leaves nothing for the scope to be attached to and is refused in turn. |
| Anything the Cypher parser can’t parse | Fail closed — an unparseable query is rejected, never run unscoped. |
MATCH (n:!Topic), n IS !Topic, WHERE n:!Person |
Single-label negation — refused before it runs, naming the spellings that work: WHERE NOT n:Topic, or a combined label expression such as :Topic&!Person. A ! inside a string or comment is data. |
RETURN apoc.text.capitalize(p.name), CALL db.labels() |
A procedure or function library this engine does not provide — refused as an unknown function, naming it, not as a write. The refusal points at the built-in functions to use instead, and for schema questions at the schema the query guide shows. |
MATCH (p)-[:AUTHORED]->(r) WHERE count(r) > 1 RETURN p.name |
Aggregate in WHERE — refused before anything is fetched: compute it in a WITH and filter on its alias, WITH p, count(r) AS n WHERE n > 1. An aggregate inside a subquery’s own WITH/RETURN, and a COUNT { } subquery in WHERE, run. |
RETURN p.name AS passage, count(v) AS n ORDER BY n DESC, b.bookOrder |
A variable the projection dropped — after a WITH or RETURN that aggregates or is DISTINCT, ORDER BY may use only a projected alias or repeat a projected expression. Refused before any fetch, naming every out-of-scope variable at once. A realm view with this shape fails realm_validate; one already installed is reported against its realm at load, stays listed, and is refused with the defect named when called. |
x IS :: LIST<INTEGER>, x IS :: INTEGER | STRING, x IS NOT :: INTEGER |
A type predicate this surface cannot read faithfully — refused for what it is, quoting the type as written, with the rewrite to use; never reported as a syntax error. A plain x IS :: INTEGER (or IS :: INTEGER NOT NULL) runs on every surface, and a missing value is of every nullable type. |
… WITH x MATCH (t:Tag {k: 2}) … where t was bound before the WITH dropped it |
A dropped name re-bound with its own labels or properties, on a surface that cannot keep the two apart — refused with the rename to use ((t2:Tag {…})), rather than letting the second node match any node the caller can see. |
MATCH ((a:Person)-[:KNOWS]->(b:Person)){1,3}, -[:KNOWS]->{1,3} |
A quantified path pattern or quantified relationship over the caller’s own nodes, over a fetched label, or under a shortest-path selector — a repeated node cannot be scoped one repetition at a time, and a fetch is planned hop by hop. Refused naming the repetition, with the variable-length relationship of the same bounds and direction ((a)-[:KNOWS*1..3]->(b)), carrying an inner condition onto the path’s nodes; a longer repeated pattern is written out hop by hop. One over nodes that need no per-user scope, outside a selector, runs as written. A fixed quantifier {n} is read as {n,n}. |
MATCH p = ANY 2 (a)-[:R*1..5]->(b) |
The ANY k path selector — refused. SHORTEST k keeps the k shortest paths between each pair of ends, and ANY keeps one. |
A name a WITH drops is gone. A later MATCH that binds the same name binds a NEW variable,
with its own pins, joins and filters — nothing carries over from the first. In
MATCH (r:Repo {name:'widgets'})-[:HAS_ISSUE]->(x) WITH x MATCH (r)-[:HAS_PR]->(p) RETURN count(p)
the second r is every repository, so it is an unbound anchor: the answer is flagged
NEEDS_FILTER naming r, never widgets’ pull requests counted as if they were all of them. A name
the WITH carries — bare, WITH g AS h, or WITH * — stays the same variable, and UNWIND list AS x is not a re-binding.
A path selector keeps the paths it names. ALL alone is Cypher’s default and means every
path, never only the shortest. ANY SHORTEST, ALL SHORTEST, SHORTEST k, SHORTEST k GROUPS
and ANY select per pair of end nodes, shortest first: the selector picks among the pattern’s paths,
and the WHERE filters what it picked.
UNION and UNION ALL run, each branch scoped as its own query. A branch is held to the
asking principal exactly as a standalone query of that shape would be: every node it binds is scoped,
and every refusal above applies to it — an unlabelled node, a write, a procedure call in any branch
refuses the whole statement, with that branch’s own refusal. Cypher’s rules for combining branches
apply. Every branch returns the same column names, and a statement whose branches differ is refused
before it runs, naming each branch’s columns. UNION and UNION ALL cannot be mixed in one
statement. UNION removes duplicate rows by value equality; UNION ALL keeps them.
MATCH (b:Bill) WHERE b.due < date() RETURN b.title AS item, 'bill' AS kindUNION ALLMATCH (m:Meeting) WHERE m.at > datetime() RETURN m.subject AS item, 'meeting' AS kindA UNION also runs inside a subquery — a CALL { } body, and COUNT { }, EXISTS { } and
COLLECT { }. In a CALL each branch imports for itself, CALL (p) { MATCH (p)-[:OWES]->(b:Bill) RETURN b.title AS item UNION MATCH (p)-[:ATTENDS]->(m:Meeting) RETURN m.subject AS item }, and a
branch that reads an outer variable without importing it is refused as any CALL { } body is.
Inside a subquery the branches must name their own variables (the table above); a statement-level
UNION has no such limit.
Each statement-level branch is planned and fetched as its own query, so two branches that pin the
same variable name to different anchors each fetch their own rows: a virtual join in one branch is
never narrowed by a pin in another. A variable or alias literally named union that cannot be told
apart from the keyword may make the statement be refused rather than split in the wrong place; a
column alias named union, quoted or not (AS union, AS `union`), is read as a name.
A refusal names only what the query named. Every variable a refusal quotes is one the query wrote.
A node left unnamed is shown as (), as it was written, and a statement the ENGINE generated — a
join’s own anchor probe, quoted when it is that probe rather than the query that could not be scoped —
shows the nodes it named for itself as holes, ?1, ?2. So nothing in a refusal can be pasted back
into a query as a variable that exists, and a refusal’s lever is always one its reader can apply.
Strict schema validation also rejects a declared virtual relationship traversed from a label
outside its declared anchorLabel set, unless the recorded graph shape can answer that exact
relationship between the specified labels. Name a valid anchor or use a recorded edge; a matching
relationship name alone is not evidence that another anchor can fetch it.
A brings: entry naming a childType the realm does not declare is refused earlier still, when the
realm is validated — before any query reaches the planner. The remaining case is a non-event: a
producer that starts returning some extra child type cannot inject it, because materialization reads
only the records: paths the join declared. An unmodelled node has no way in, so there is nothing to
fail loud about; the risk brings: actually carries is the opposite one, a declared child arriving
without its id, which is reported, not swallowed.
And these run but are capped (never silently — see §9): a join that would ask its source for
more than maxAnchors distinct keys is refused before any call is made, and a materialization that
would create more than maxFanoutTotal nodes is truncated at the cap, each with a diagnostic.
Design principle. Every rejection has the same root: the engine must always know the fetch is bounded. A bound anchor, a declared shape, and hard caps are what make “splice a live API into a graph query” safe.

