Back to all guides
ContX IQ

Cypher for ContX IQ: Policy and Knowledge Query Patterns

How to write the Cypher used in CIQ policies and Knowledge Queries - the Neo4j-adapted dialect, its rules, and worked examples.

What is Cypher in ContX IQ?

Cypher is the graph pattern-matching language created for Neo4j. ContX IQ (CIQ) embeds a Neo4j-adapted subset of Cypher inside a policy's condition.cypher field. You use it to describe the contextual subgraph a request is allowed to touch: which nodes, which relationships, and how they connect.

The most important idea to internalize: in CIQ, Cypher only matches context. It does not return, create, or delete data. Reads, writes, and deletes are declared separately in the Knowledge Query and gated by the policy's allowed_reads, allowed_upserts, and allowed_deletes. Cypher names the pieces; the Knowledge Query decides what to do with them.

Schema example

This guide assumes you already know the CIQ component model (Policy → Knowledge Query → Execution). If not, read ContX IQ: Context-Aware Data Queries and Policies first. For a gentle introduction to graph traversal and Cypher basics, see Why use a Graph Database?

Where do I write Cypher?

Cypher lives in exactly one place: the condition.cypher string of a policy.

{
	"meta": { "policy_version": "1.0-ciq" },
	"subject": { "type": "Person" },
	"condition": {
		"cypher": "MATCH (subject:Person)-[r:LIKES]->(track:Track)",
		"filter": [ ... ]
	},
	"allowed_reads": { "nodes": ["track.property.title"], "relationships": ["r"] }
}

The Knowledge Query then references the variables you named in the Cypher (subject, track, r) - it never re-declares the pattern.

Which policy version does this cover?

Everything in this guide targets CIQ v1 - policy_version: "1.0-ciq", set in the policy's meta. This is the stable, default dialect and the one you should use.

A v2 dialect - 2.0 (Beta testing only) - is also accepted by the runtime and available on request; it is the engine intended to become the next stable version. v2 is not a new query language. The external API and authentication, the full filter operator set and value types, every Cypher construct in this guide (MATCH / OPTIONAL MATCH / WHERE / WITH, aggregates, variable-length paths, inline $params, trust_score and _Application handling), and the Knowledge Query and execution shapes are all identical to v1. Every example in this guide is valid on both versions.

v2 changes little, in two areas. First, stricter validation when a policy is created - it rejects two patterns that v1 quietly accepted:

  • No references to anonymous nodes. Any node referenced from a filter, an allowed_* list, or the Knowledge Query must be named in the Cypher. Referencing an unnamed node - e.g. the (:Track) in MATCH (subject:Person)-[:LIKES]->(:Track) - is rejected; write (track:Track). (A node always needs a label in both versions; v2 additionally forbids referencing an unnamed one.)
  • No untyped relationships in writes or deletes. A relationship feeding allowed_upserts or allowed_deletes must declare its type in the Cypher ([r:PLAYED_AT], not bare [r]). An untyped relationship is still fine when used only in reads.

Both rules are good practice on v1 too, so the patterns in this guide already satisfy them - a v1 policy written this way creates unchanged under v2. Second, one filter extension: v2 lets the filter attribute side also be an @ reference (v1 allows that only on the value side). The two dialects can coexist in one project once v2 is enabled.

How is CIQ Cypher different from standard Neo4j Cypher?

CIQ Cypher is the read/traverse core of Cypher, with the result and mutation clauses removed and replaced by declarative JSON. Keep these adaptations in mind:

Standard Cypher In CIQ
RETURN chooses output No RETURN. Output is chosen by the Knowledge Query's nodes, relationships, and aggregate_values - and must be allow-listed in policy allowed_reads.
CREATE / MERGE / SET Not written in Cypher. Declared in the Knowledge Query's upsert_nodes / upsert_relationships, gated by policy allowed_upserts.
DELETE / REMOVE Not written in Cypher. Declared in the Knowledge Query's delete_nodes / delete_relationships, gated by policy allowed_deletes.
Any node can be a starting point Exactly one node must be the subject - the variable named subject, with a label matching subject.type. A policy has a single subject type.
Anonymous nodes are common Any node or relationship you reference later must be named (give it a variable). Anonymous parts can only be used as connective glue.
Parameters are $x bound by the driver Parameters are $x placeholders resolved at execution from input_params. $token.* (token claims) and $_appId (application identity) are reserved.
Filtering via WHERE Inline WHERE works, but execution-time parameters and token claims are best expressed in the structured filter array (see below).

What Cypher syntax is supported?

CIQ supports the pattern-matching and projection core. Treat this as the practical surface; validate edge cases by creating the policy through the Config API (it rejects unsupported or invalid constructs).

Feature Example
MATCH MATCH (subject:Person)-[r:LIKES]->(track:Track)
OPTIONAL MATCH OPTIONAL MATCH (subject)-[:CREATED]->(p:Playlist)
Multiple comma / multi-clause patterns (cartesian + join) MATCH (subject:Person), (venue:Venue)
WHERE with comparisons and boolean logic WHERE subject.property.karaoke_confidence >= venue.property.min_confidence
WITH (projection / pipelining; exposes aggregates) WITH subject, COLLECT(track.external_id) AS liked
Relationship direction -[r]-> outgoing, <-[r]- incoming, -[r]- either
Relationship type and alternation [:SUBSCRIBED_TO|CREATED]
Variable-length paths (bound the depth - see the timeout note) [rels:INVOKES*1..5], [:MEMBER_OF*1..3]
Inline property maps (venue:Venue {name: $venue_name}), (cp:Property {type:'role'})
Multiple labels and re-using a bound variable (n:Label1:Label2); close a loop with an already-bound name, e.g. ...<-[:CREATED]-(subject)
Aggregate functions COUNT(...), COUNT(DISTINCT ...), COLLECT(...), SUM / AVG / MIN / MAX
Predicate & list functions ALL / ANY / NONE, list comprehension [r IN rels | endNode(r).external_id], endNode() / startNode()

How do Cypher variables flow into a Knowledge Query?

The variable names in condition.cypher are the contract between the policy and everything downstream. A name you bind in Cypher becomes the handle used by:

  • Policy allowed_reads / allowed_upserts / allowed_deletes - the allow-list of what may be read, written, or removed.
  • Knowledge Query nodes / relationships / aggregate_values - what this query actually returns.
  • Knowledge Query upsert_* / delete_* - the variables to mutate.

A read variable must be allow-listed in the policy and requested in the query. If the Cypher binds track but allowed_reads.nodes omits it, the query cannot return it.

What attribute naming conventions are used?

Pattern Description Example
<var>.property.<attr> A property value on a node track.property.title
<var>.external_id The external id of a node or relationship subject.external_id
<var>.property.<attr>.metadata.<meta> Metadata attached to a property ln.property.value.metadata.source
<var>.trust_score.<field> A Trust Score signal on a node (see the Trust Score guide) subject.trust_score._final_score
<var>.* / <var>.property.* Wildcard - all of a node's properties subject.*
$token.<claim> A claim on the requestor's token $token.sub, $token.acr, $token.iat, $token.scope
$<param> An execution-time parameter from input_params $venue_name
$_appId Reserved - auto-filled with the calling Application's external_id subject.external_id = $_appId

Worked examples (music dataset)

Each example shows the policy condition (Cypher + filter), the matching Knowledge Query, and the execution input_params. They use the IndyKite music sandbox dataset (Artists, Tracks, Albums, Playlists, Venues, People).

1. Subject only - read your own profile

The simplest pattern: match just the subject and pin it to the caller's token via a filter. No relationships needed.

MATCH (subject:Person)
// policy condition
"condition": {
	"cypher": "MATCH (subject:Person)",
	"filter": [
		{ "operator": "=", "attribute": "subject.external_id", "value": "$token.sub" }
	]
}

// knowledge query - read selected profile properties { “nodes”: [“subject.property.firstname”, “subject.property.email”, “subject.property.city”], “relationships”: [] }

// execution - no params; identity comes from the token { “id”: “kq-person-own-profile-read”, “input_params”: {} }

The $token.sub filter guarantees a Person can only ever resolve their own node, even though the Cypher matches the label generically.

2. One hop - a Person's liked tracks

Name the relationship (r) so the query can both filter on the connected node and return the edge.

MATCH (subject:Person)-[r:LIKES]->(track:Track)
"condition": {
	"cypher": "MATCH (subject:Person)-[r:LIKES]->(track:Track)",
	"filter": [
		{ "operator": "AND", "operands": [
			{ "operator": "=", "attribute": "subject.external_id", "value": "$token.sub" },
			{ "operator": "=", "attribute": "track.external_id", "value": "$track_external_id" }
		]}
	]
}

// knowledge query { “nodes”: [“track.property.title”, “track.property.popularity”], “relationships”: [“r”] }

// execution { “id”: “kq-person-liked-tracks-read”, “input_params”: { “track_external_id”: “track-1” } }

3. _Application subject with parameters

When the subject is _Application, bind it and constrain it with $_appId (auto-filled). A second MATCH brings in the data graph; a named edge (r) lets you read and later mutate it.

MATCH (subject:_Application) MATCH (track:Track)-[r:PLAYED_AT]->(venue:Venue)
"condition": {
	"cypher": "MATCH (subject:_Application) MATCH (track:Track)-[r:PLAYED_AT]->(venue:Venue)",
	"filter": [
		{ "operator": "AND", "operands": [
			{ "attribute": "venue.property.name", "operator": "=", "value": "$venue_name" },
			{ "attribute": "subject.external_id", "operator": "=", "value": "$_appId" }
		]}
	]
}

// read query { “nodes”: [“track.property.title”, “track.property.loudness”, “track.property.energy”], “relationships”: [“r”] }

// execution - X-IK-ClientKey only; $_appId is implicit, never sent { “id”: “kq-app-venue-playable-tracks-read”, “input_params”: { “venue_name”: “Shower-Concert-Hall” } }

4. Writing and deleting reuse the same Cypher variables

The same policy supports mutation Knowledge Queries - no new Cypher. To create a relationship, reference the node variables as source/target; to delete, reference the named edge. The policy must allow these via allowed_upserts / allowed_deletes.

// upsert: link track -> venue with a new PLAYED_AT edge (gated by allowed_upserts)
{
	"upsert_relationships": [
		{ "name": "newPlayedAt", "source": "track", "target": "venue", "type": "PLAYED_AT" }
	],
	"nodes": ["track.external_id", "venue.external_id"],
	"relationships": ["newPlayedAt"]
}

// delete: remove the matched PLAYED_AT edge r (gated by allowed_deletes) { “delete_relationships”: [“r”], “nodes”: [“track.external_id”] }

Updating a node works the same way - reference the Cypher variable by name in upsert_nodes (e.g. set new properties on subject or playlist). Protected properties cannot be deleted: _service, create_time, external_id, id, type, update_time.

What the write/delete returns. The response shape depends on what the query reads, not on whether it writes or deletes. The examples above list read-back fields (nodes / relationships), so they return that data. A write or delete that lists nothing to read (empty nodes, relationships, aggregate_values) instead returns a single count of affected rows under the reserved result key: { "data": [ { "aggregate_values": { "result": 3 } } ] }. To get the changed entities back, list them to read. This is the same in both versions.

5. Mixed direction and multi-hop

Patterns can change direction mid-chain. Here the caller's playlist is reached outgoing (CREATED), and its tracks point into the playlist (<-[pf:PART_OF]-).

MATCH (subject:Person)-[r:CREATED]->(playlist:Playlist)<-[pf:PART_OF]-(track:Track)
"filter": [
	{ "operator": "AND", "operands": [
		{ "operator": "=", "attribute": "subject.external_id", "value": "$token.sub" },
		{ "operator": "=", "attribute": "playlist.external_id", "value": "$playlist_external_id" }
	]}
]

// knowledge query - read the incoming edge pf and track titles { “nodes”: [“playlist.property.name”, “track.property.title”, “track.external_id”], “relationships”: [“pf”] }

// execution { “id”: “kq-person-playlist-tracks-read”, “input_params”: { “playlist_external_id”: “playlist-100” } }

6. Relationship alternation and undirected hops

A relationship segment can match any of several types (alternation with |) and can be undirected (-[...]-, matching either direction). This policy lets a Person read playlists created by a family member, where "family" is any of three relationship types in either direction.

MATCH (subject:Person)-[:MARRIED_TO|PARTNERS|PARENT_OF]-(family:Person)-[:CREATED]->(playlist:Playlist)
"condition": {
	"cypher": "MATCH (subject:Person)-[:MARRIED_TO|PARTNERS|PARENT_OF]-(family:Person)-[:CREATED]->(playlist:Playlist)",
	"filter": [
		{ "operator": "=", "attribute": "subject.external_id", "value": "$token.sub" }
	]
}

// knowledge query { “nodes”: [“family.property.firstname”, “playlist.property.name”, “playlist.property.mood”], “relationships”: [] }

// execution - identity comes from the token, no params { “id”: “kq-person-family-playlists”, “input_params”: {} }

An inline WHERE is also fine for static thresholds, e.g. ... WHERE subject.property.karaoke_confidence >= 0.8.

7. Aggregation with WITH and aggregate_values

Use WITH to project aggregates, then expose them via the Knowledge Query's aggregate_values. This _Application policy walks a variable-length INVOKES chain from a workflow to a target agent (anchored by the agent's indexed external_id) and collects the agent ids. The depth is bounded (*1..5) so the traversal stays fast - see Will my query time out? below.

MATCH (subject:_Application)
MATCH (wf:Workflow)-[rels:INVOKES*1..5]->(a:Agent {external_id: $agent_id})
WHERE ALL(r IN rels WHERE r.workflow_name = wf.external_id AND endNode(r):Agent)
WITH subject, wf.external_id AS workflow, [r IN rels | endNode(r).external_id] AS agent_list
// the aliases created by WITH become the readable aggregates
"allowed_reads": { "nodes": [], "relationships": [], "aggregate_values": ["workflow", "agent_list"] }

// knowledge query returns only the aggregates { “aggregate_values”: [“workflow”, “agent_list”] }

// execution { “id”: “kq-agent-invocation-chain”, “input_params”: { “agent_id”: “agent-42” } }

Whatever you alias in the final WITH (... AS workflow, ... AS agent_list) is what aggregate_values can read - this is how aggregate results leave the query, since there is no RETURN.

8. OPTIONAL MATCH and counting

OPTIONAL MATCH includes data that may or may not exist (it yields null rather than dropping the row), and chained WITH steps build several aggregates. This _Application policy reports, per venue, how many people will attend and how many playlists are approved. Note the identity is pinned in the filter here; you can equally pin it inline, e.g. MATCH (subject:Person) WHERE subject.external_id = $token.sub.

MATCH (subject:_Application) MATCH (venue:Venue)
OPTIONAL MATCH (venue)<-[:WILL_ATTEND]-(attendee:Person)
WITH subject, venue, COUNT(attendee) AS attendeeCount
OPTIONAL MATCH (playlist:Playlist)-[:APPROVED_FOR]->(venue)
WITH subject, venue, attendeeCount, COUNT(playlist) AS playlistCount
"filter": [
	{ "operator": "AND", "operands": [
		{ "operator": "=", "attribute": "subject.external_id", "value": "$_appId" },
		{ "operator": "=", "attribute": "venue.property.name", "value": "$venue_name" }
	]}
]

// knowledge query - venue facts plus the two computed counts { “nodes”: [“venue.property.name”, “venue.property.description”], “aggregate_values”: [“attendeeCount”, “playlistCount”] }

// execution { “id”: “kq-app-venue-attendance-stats”, “input_params”: { “venue_name”: “Shower-Concert-Hall” } }

What can a filter express?

A filter is a tree of conditions, used in the policy condition and, optionally, in the Knowledge Query. A leaf has an attribute, an operator, and (for most operators) a value. A branch uses AND / OR / NOT with nested operands and no attribute/value.

Operators

Operator Meaning
AND / ORCombine nested operands (1 or more).
NOTNegate exactly one nested operand.
= / <>Equal / not equal.
> / < / >= / <=Numeric or datetime comparison.
INAttribute is in the supplied array value.
=~Attribute matches a regular-expression value.
STARTS WITH / ENDS WITHString prefix / suffix match.
CONTAINSThe value is present as a whitespace-separated token within the attribute's string (membership in a space-separated list, e.g. OAuth scope).
IS NULL / IS NOT NULLAttribute is absent/null or present/non-null. No value.

Value forms

  • Raw scalar - string, number, or boolean (e.g. 0.8, "Upbeat", true).
  • Array - for IN (e.g. ["track-1", "track-2"]).
  • $param - an execution-time parameter supplied in input_params (string values 1-256 chars).
  • $token.<claim> - a claim from the caller's token ($token.sub, $token.acr, $token.iat, $token.scope, ...).
  • $_appId - reserved; the calling Application's external_id, auto-filled for _Application subjects.
  • Typed value object - { "type": "datetime", "value": "2026-01-15T00:00:00Z" } or { "type": "datetime", "value": "$control_date" }. type defaults to any; use datetime for RFC 3339 timestamps so comparisons are date-aware.
  • @reference - a reference to another matched attribute, by name, instead of a constant. This is how you compare one property to another (see below).

Escaping. The leading $ and @ are special. To use a literal string that actually starts with one of them, escape it: \$rawValue or \@user.

Branches nest arbitrarily (AND of ORs, etc.). A token_filter is a sibling of filter that only references $token.* values and can attach step-up advice (error, error_description) returned via the Www-Authenticate header when the token is insufficient. See the ContX IQ guide for the full filter, typed-value, and token_filter schema.

Comparing one property to another (@ references)

By default a filter compares an attribute to a constant (or a $param / $token value). To compare it to another attribute matched by the Cypher - a property-to-property comparison - prefix that operand with @ and give the attribute's full name. It is the structured-filter equivalent of an inline WHERE a.x <= b.y.

// a Track may PLAY at a Venue only if its loudness is within the venue's limit
// cypher: MATCH (subject:Track)-[r:PLAYED_AT]->(venue:Venue)
{ "attribute": "subject.property.loudness", "operator": "<=", "value": "@venue.property.max_loudness" }

The @ name must resolve to a variable and attribute that the Cypher actually binds (any of the attribute forms above - @venue.property.max_loudness, @other.external_id, @contract.trust_score._final_score, @subject.property.email.metadata.source).

Version note. Property-to-property comparison with @ works in both versions; they differ only in which operand may hold the reference. In v1, only the value side may be an @ reference (the attribute side is the property being tested). In v2 (Beta), both the attribute and the value may be @ references, so you can compare two referenced attributes directly.

Filter vs inline WHERE - which should I use?

  • Use the filter array for execution-time parameters ($param), token claims ($token.*), and the $_appId binding. It is the structured, validated way to express partial filters and supports nested AND / OR / NOT, typed values, and step-up token_filter advice.
  • Use inline WHERE for static, structural constraints inside the pattern - fixed thresholds, label predicates, list predicates (ALL / ANY / NONE), and relationship conditions that are part of the traversal logic.

Will my query time out?

Execution enforces a per-query timeout. The default is tuned for fast, interactive queries; a read-only query that legitimately needs longer can set batch_read: true in the Knowledge Query to use the extended limit (5 minutes / 300 seconds - see batch_read). The right fix is almost always to make the matched subgraph smaller, not to wait longer.

  • Anchor on an indexed identity. Constrain the subject with subject.external_id = $token.sub (users) or $_appId (applications), or pin a node by external_id (inline {external_id: $x} or a filter). Traversal then starts from one node instead of a full label scan. Examples 1-6 and 8 all do this.
  • Bound every variable-length path. Write [:INVOKES*1..5], never a bare [:INVOKES*] - an unbounded * can traverse the whole graph and loop on cycles, the most common cause of timeouts. This is the only traversal cost in example 7, which is why it bounds the depth.
  • Filter to specific entities, not whole labels. Prefer playlist.external_id = $playlist_external_id or venue.property.name = $venue_name over matching every Playlist or Venue. When a pattern does start from a label (e.g. MATCH (venue:Venue) in example 8), make sure a filter narrows it before any expansion or aggregation.
  • Aggregate over bounded subgraphs. COUNT / COLLECT are cheap over one anchored entity's neighbours (example 8 counts a single venue's attendees). Aggregating across an entire label is what warrants batch_read.
  • Page large result sets. Use page_size and page_token on the execute call to fetch results in chunks rather than one oversized response.

Every example in this guide is anchored and bounded, so each returns quickly on a realistically sized graph. Reach for batch_read only when a read is intentionally broad.

Checklist: authoring a CIQ Cypher

Before submitting a policy, verify each of these. Most rejections at create time trace back to one of them.

  • Exactly one subject. The pattern binds a node to the variable subject, and its label matches subject.type. One subject type per policy - need two? Write two policies.
  • Name everything you reference. Every node/relationship used in filter, allowed_*, or the Knowledge Query must have a Cypher variable. Anonymous nodes (e.g. (:Track)) can only connect the pattern.
  • No RETURN, CREATE, MERGE, SET, or DELETE in the Cypher. Results come from the Knowledge Query; mutations from upsert_* / delete_* gated by allowed_*.
  • Allow-list every read. Each variable/attribute the query returns must appear in allowed_reads (and aggregates in allowed_reads.aggregate_values).
  • Parameters line up. Each $x used must be supplied in input_params at execution (string values 1-256 chars). Do not send $_appId for _Application subjects - it is auto-filled. Bind the identity (subject.external_id = $token.sub for users) so a query cannot resolve other people's data.
  • Expose aggregates via WITH ... AS alias and list the alias in aggregate_values.
  • Type your relationships for writes. If a named relationship feeds allowed_upserts/allowed_deletes, give it an explicit type in the Cypher (e.g. [r:PLAYED_AT], not [r]). Required in v2, recommended in v1.
  • System-managed elements are off-limits. A Knowledge Query cannot create, update, or delete the reserved _Application identity node, and cannot create system-internal node types. Protected properties cannot be deleted: _service, create_time, external_id, id, type, update_time.
  • Keep it inside the timeout. Anchor on an indexed identity, bound variable-length paths (*1..N), and filter to specific entities so the matched subgraph stays small. Set batch_read: true only for intentionally large reads. See Will my query time out?
  • Validate by creating it. Create the policy and query through the public REST Config API - POST /configs/v1/authorization-policies and POST /configs/v1/knowledge-queries (or use Terraform). The API rejects syntax and reference errors immediately. Full reference: openapi.indykite.com.

Quick recipe: from requirement to policy

  1. State the access in one sentence - "an Application can read tracks playable at a named venue", or "a Person can view a family member's playlist".
  2. Pick the subject - Person (user token) or _Application (service). This sets subject.type and the auth model.
  3. Draw the path - write the MATCH from subject to the target, naming each node/edge you will read or mutate; set directions (->, <-, -).
  4. Constrain it - identity ($token.sub / $_appId) and selectors ($param) in the filter array; static thresholds inline in WHERE.
  5. Declare capabilities - fill allowed_reads / allowed_upserts / allowed_deletes with the variables from step 3.
  6. Write the Knowledge Query - choose nodes / relationships / aggregate_values to return, or upsert_* / delete_* to mutate.
  7. Execute - POST /contx-iq/v1/execute with the query id/name and input_params.

Next Steps