Summary
A WHERE predicate that references both VLP endpoints (e.g. WHERE friend <> root, or its property form WHERE friend.id <> root.id) is placed in the recursive CTE's base case only, never on the recursive arm. On a cyclic graph this lets paths that return to the start node survive past the first hop.
Repro (LDBC SF1, standard schema)
-- Rows where the endpoint IS the root, despite the filter excluding them:
MATCH (root:Person {id: 14})-[:KNOWS*1..3]-(friend:Person)
WHERE friend <> root AND friend.id = 14
RETURN count(*) AS self_leak
*1..2 → self_leak = 0 ✓ (a 2-hop KNOWS path cannot return to root without reusing an edge, which edge-uniqueness already blocks)
*1..3 → self_leak = 52 ✗ (3-hop paths root → a → b → root survive)
The property form WHERE friend.id <> root.id produces the identical SQL and the identical 52-row leak, confirming this is independent of the #1100 bare-node-identity normalization (that fix reaches exact parity with this pre-existing path).
Root cause
In categorize_filters (src/render_plan/filter_pipeline.rs:258-262), a predicate referencing both start and end aliases is routed to start_filters, which the VLP CTE generator applies only in the base case (src/render_plan/cte_manager/mod.rs:1298-1302). The recursive arm re-derives end_node each hop but never re-applies the both-endpoint predicate, so a later hop can land back on the start node.
A both-endpoint identity filter (end != start) is hop-invariant and must be applied on every arm (base + recursive), like the per-hop end filters in #1003/#607.
Impact
- Only manifests at max_hops ≥ 3 on graphs with cycles (LDBC KNOWS is symmetric → cyclic).
- LDBC IC9/IC5 use
KNOWS*1..2 and are unaffected (verified self_leak = 0).
Acceptance
friend <> root on *1..N excludes root at every hop, on a cyclic fixture.
- Both bare-node and
.id property forms fixed together (same seam).
- Add a cyclic-fixture regression test (an acyclic graph cannot expose this — see the "cyclic oracle for edge-uniqueness" lesson).
Surfaced by the #1100 bare-node-identity fix (#1102) making the bare-alias form executable; the underlying leak predates it.
Summary
A WHERE predicate that references both VLP endpoints (e.g.
WHERE friend <> root, or its property formWHERE friend.id <> root.id) is placed in the recursive CTE's base case only, never on the recursive arm. On a cyclic graph this lets paths that return to the start node survive past the first hop.Repro (LDBC SF1, standard schema)
*1..2→self_leak = 0✓ (a 2-hop KNOWS path cannot return to root without reusing an edge, which edge-uniqueness already blocks)*1..3→self_leak = 52✗ (3-hop pathsroot → a → b → rootsurvive)The property form
WHERE friend.id <> root.idproduces the identical SQL and the identical 52-row leak, confirming this is independent of the #1100 bare-node-identity normalization (that fix reaches exact parity with this pre-existing path).Root cause
In
categorize_filters(src/render_plan/filter_pipeline.rs:258-262), a predicate referencing both start and end aliases is routed tostart_filters, which the VLP CTE generator applies only in the base case (src/render_plan/cte_manager/mod.rs:1298-1302). The recursive arm re-derivesend_nodeeach hop but never re-applies the both-endpoint predicate, so a later hop can land back on the start node.A both-endpoint identity filter (
end != start) is hop-invariant and must be applied on every arm (base + recursive), like the per-hop end filters in #1003/#607.Impact
KNOWS*1..2and are unaffected (verifiedself_leak = 0).Acceptance
friend <> rooton*1..Nexcludes root at every hop, on a cyclic fixture..idproperty forms fixed together (same seam).Surfaced by the #1100 bare-node-identity fix (#1102) making the bare-alias form executable; the underlying leak predates it.