Skip to content

Query language

JSON query DSL, v1

Every read surface (REST /v1/query, /v1/snapshot, gRPC Query/Snapshot, and the MCP findRecords/semanticSearch tools) compiles the same JSON document into one logical plan. This page is the field-by-field grammar; the fixture corpus is the grammar of record.

Normative sources

.ai/specs/core-engine/2026-07-02-query-language.md, the AST in core-engine/src/query/ast.rs, and the pinned fixture corpus at core-engine/tests/fixtures/query_corpus.json (every valid and invalid shape, run against core and both SDKs in CI). Where this page and the corpus disagree, the corpus wins.

Shape at a glance

A representative query
{
  "v": 1,
  "find": "RISK",
  "where": {
    "severity": { "$gte": 7 },
    "$or": [
      { "status": { "$eq": "open" } },
      { "$and": [{ "owner": { "$exists": true } }] }
    ]
  },
  "atValidTime": "2025-05-01T00:00:00Z",
  "atTxTime": "2025-06-01T00:00:00Z",
  "expand": [{ "type": "OWNED_BY", "direction": "out", "depth": 1 }],
  "vector": { "text": "supply chain delays", "model": "m1", "k": 20, "minScore": 0.5 },
  "limit": 100,
  "cursor": "opaque",
  "trace": false
}

Deserialization is strict (deny_unknown_fields): an unknown top-level field, an unknown operator, or the wrong type at any position is rejected with a JSON pointer to the exact spot, not a generic 400.

Top-level fields

Field Type Required Default Notes
v integer no 1 Grammar version. Anything other than 1 is QUERY_INVALID at /v.
find string yes , Label to scan. Empty string is rejected at /find.
where object no , Predicate tree. See Predicates.
atValidTime µs integer or RFC3339 string no now See Temporal coordinates.
atTxTime µs integer or RFC3339 string no now See Temporal coordinates.
expand array no [] Graph traversal steps. See Expand.
vector object no , Semantic scoring clause. See Vector.
limit integer no 100 1..=1000. See Limit and cursor.
cursor string no , Opaque continuation token from a previous page.
trace boolean no false Emit a per-stage execution trace.

The executor's logical plan runs the stages in a fixed order: TemporalResolve → Scan|IndexScan → Filter → Expand → VectorScore → Paginate → Trace.

Predicates (where)

Every predicate is an operator object keyed by property path, never a bare scalar:

{ "find": "RISK", "where": { "severity": { "$gte": 5 } } }
{ "find": "RISK", "where": { "severity": 5 } }
Response: 400 QUERY_INVALID
{
  "code": "QUERY_INVALID",
  "message": "/where/severity: expected an operator object like {\"$eq\": ...}",
  "request_id": "..."
}

Operators

Operator Meaning Operand
$eq Equal any scalar/array/object
$ne Not equal any scalar/array/object
$gt Strictly greater comparable value
$gte Greater or equal comparable value
$lt Strictly less comparable value
$lte Less or equal comparable value
$in Membership array, max 256 elements
$exists Property presence boolean

A property may carry multiple operators in one object (implicit AND): {"severity": {"$gte": 3, "$lte": 8}}.

Logical blocks: $and / $or

$and and $or take an array of predicate objects, non-empty, and nest up to depth 8:

{
  "find": "RISK",
  "where": {
    "$or": [
      { "status": { "$eq": "open" } },
      { "$and": [{ "sev": { "$gte": 7 } }, { "owner": { "$exists": true } }] }
    ]
  }
}

Only $and and $or are recognized logical keys; anything else starting with $ at that position (e.g. $nor) is unknown operator at that pointer.

Dotted paths

v1 supports one level of dotted-path predicates into Object values: "data.owner". Deeper paths are deferred (spec OQ-1) until schema inference tracks nested shapes.

{ "find": "RISK", "where": { "data.owner": { "$eq": "kim" } } }

Type semantics

Comparisons are type-strict against the engine's Value enum: Int and Float coerce against each other; comparing Str against Int is a validation error when the label's inferred schema knows the type, otherwise it is a runtime type mismatch that skips the row (and is counted in stats.typeMismatches, visible when trace: true).

Temporal coordinates

atValidTime and atTxTime each accept either a unix-microseconds integer or an RFC3339 string:

{ "find": "RISK", "atValidTime": 1750000000000000 }
{ "find": "RISK", "atValidTime": "2025-05-01T00:00:00Z" }

Omitting both means "current state." See The tritemporal model for what each axis means and worked examples of pinning one or both. An unparseable RFC3339 string is rejected at the field's pointer:

400 QUERY_INVALID
{ "code": "QUERY_INVALID", "message": "/atValidTime: invalid RFC3339 timestamp: ...", "request_id": "..." }

Expand

expand walks the graph from each primary result. Two forms:

{ "find": "RISK", "expand": ["OWNED_BY"] }

Shorthand means: this edge type, direction out, depth 1.

{
  "find": "RISK",
  "expand": [{ "type": "OWNED_BY", "direction": "out", "depth": 1 }]
}

type omitted means all edge types. direction defaults to out if omitted.

Field Values Default
type edge type string all types
direction out | in | both out
depth integer, 1..=3 1

Ceilings: at most 4 expand entries per query; depth outside 1..=3 is rejected at /expand/{i}/depth; more than 4 entries is rejected at /expand ("at most 4 expand entries allowed"). An unrecognized shape (bad direction value, or a stray field like dpeth) fails serde's untagged-enum match and reports /expand/{i}: data did not match any variant.

Expanded neighbors come back in the response's related array, grouped by which expand entry produced them, they are never merged into the primary records list.

Vector

vector adds semantic scoring, fused with any structured filter:

{ "find": "RISK", "vector": { "text": "supply chain delays", "model": "m1", "k": 20, "minScore": 0.5 } }
Field Type Required Notes
text string exactly one of text/near Embedded server-side; requires a configured embedding endpoint (501 UNIMPLEMENTED otherwise).
near array of float exactly one of text/near Pre-computed query vector.
model string yes Registered embedding model id.
k integer yes Neighbors to score, at least 1.
minScore float no Similarity floor, [0, 1].
alpha float no Per-query fusion balance override, [0, 1] (see the confidence-decay design in Architecture).

Validation:

  • Supplying both text and nearQUERY_INVALID at /vector ("not both").
  • Supplying neither → QUERY_INVALID at /vector ("required").
  • k: 0/vector/k ("at least 1").
  • minScore or alpha outside [0, 1]/vector/minScore or /vector/alpha.
  • An unknown field (e.g. temperature) → /vector/temperature: unknown field.

Scores surface as score on each primary record and each related node when a vector clause is present. Primary results are then ordered by score descending rather than key order.

Vector queries do not paginate

vector results are k-bounded and similarity-ordered. Supplying a cursor alongside vector is QUERY_INVALID at /cursor, and vector responses never emit nextCursor.

Limit and cursor

  • limit: integer, 1..=1000, default 100. Outside range → QUERY_INVALID at /limit ("limit must be 1..=1000").
  • cursor: an opaque, HMAC-signed continuation token from a previous page's nextCursor. It encodes the scan position, a scope hash (tenant-checked on resume), and a plan hash (the resumed query must be identical apart from the cursor). A tampered, foreign-tenant, or mismatched-plan cursor is rejected as BAD_CURSOR.

Trace

"trace": true asks the executor to attach a per-stage execution trace to the response (see stats in the REST API response shape: path, bitmapCandidates, rowsScanned, rowsFilteredOut, typeMismatches, rowsReturned, durationUs).

Ceilings reference

Ceiling Value Configurable
limit max 1000 no (v1 fixed)
where nesting depth ($and/$or) 8 no
$in elements 256 no
expand entries 4 no
expand depth 1..=3 no

These are server ceilings, not tenant-configurable in v1. The MCP tool surface layers its own, tighter ceilings on top (findRecords clamps expand depth to 2 and limit to the server's row cap, and drops cursor entirely), see MCP tools.

Worked examples from the corpus

Valid shapes

minimal
{ "find": "RISK" }
multiple operators on one property
{ "find": "RISK", "where": { "severity": { "$gte": 3, "$lte": 8 } } }
nested $or / $and
{
  "find": "RISK",
  "where": {
    "$or": [
      { "status": { "$eq": "open" } },
      { "$and": [{ "sev": { "$gte": 7 } }, { "owner": { "$exists": true } }] }
    ]
  }
}
both temporal coordinates
{ "find": "RISK", "atValidTime": 1750000000000000, "atTxTime": 1750000100000000 }
four expand entries (the ceiling)
{ "find": "RISK", "expand": ["A", "B", "C", "D"] }
vector with near + minScore
{ "find": "RISK", "vector": { "near": [0.1, 0.2, 0.3], "model": "m1", "k": 5, "minScore": 0.5 } }

Invalid shapes and their errors
Query Pointer Message contains
{"find": "RISK", "fnid": "typo"} /fnid "unknown field"
{"find": "RISK", "where": {"severity": {"$gtee": 7}}} /where/severity/$gtee "unknown operator"
{"find": "RISK", "where": {"$nor": [...]}} /where/$nor "unknown operator"
{"v": 2, "find": "RISK"} /v "unsupported grammar version"
{"limit": 10} (no find) / "missing field"
{"find": ""} /find "must not be empty"
{"find": "RISK", "where": 5} /where "expected an object"
{"find": "RISK", "where": {}} /where "empty predicate"
{"find": "RISK", "where": {"severity": 5}} /where/severity "operator object"
{"find": "RISK", "where": {"$and": {"a": {"$eq": 1}}}} /where/$and "expected an array"
{"find": "RISK", "where": {"$or": []}} /where/$or "at least one"
{"find": "RISK", "where": {"status": {"$in": "open"}}} /where/status/$in "expected an array"
{"find": "RISK", "where": {"owner": {"$exists": "yes"}}} /where/owner/$exists "expected a boolean"
{"find": "RISK", "expand": [{"type": "X", "depth": 0}]} /expand/0/depth "1..=3"
{"find": "RISK", "expand": [{"type": "X", "depth": 4}]} /expand/0/depth "1..=3"
{"find": "RISK", "expand": ["A","B","C","D","E"]} /expand "at most 4"
{"find": "RISK", "expand": [{"type": "X", "direction": "sideways"}]} /expand/0 "data did not match any variant"
{"find": "RISK", "vector": {"text": "a", "near": [0.1], "model": "m1", "k": 5}} /vector "not both"
{"find": "RISK", "vector": {"model": "m1", "k": 5}} /vector "required"
{"find": "RISK", "vector": {"text": "a", "k": 5}} /vector "missing field"
{"find": "RISK", "vector": {"text": "a", "model": "m1", "k": 0}} /vector/k "at least 1"
{"find": "RISK", "limit": 0} /limit "1..=1000"
{"find": "RISK", "limit": 1001} /limit "1..=1000"
{"find": "RISK", "atValidTime": "May 1st 2025"} /atValidTime "invalid RFC3339"
{"find": "RISK", "trace": "yes"} /trace "expected a boolean"

The full, current set (60+ valid, 40+ invalid) lives in core-engine/tests/fixtures/query_corpus.json and is run against core and both SDKs in CI, treat it as the source of truth over this table.

Query DSL by transport

Transport How the DSL travels
REST POST /v1/query and POST /v1/snapshot bodies (see REST API)
gRPC QueryRequest.query_json / SnapshotRequest.query_json, raw bytes (see gRPC API)
MCP findRecords and semanticSearch tool parameters, a constrained subset (see MCP tools)