---
title: "Why does my vector search return nothing when I add a filter?"
description: "A filter applied after an HNSW walk can only remove rows, so a selective one comes back empty. Three measured SurrealDB fixes, plus the schema that avoids them."
url: https://surrealdb.com/blog/why-does-my-vector-search-return-nothing-when-i-add-a-filter-2
date: 2026-09-03
authors: "Martin Schaer"
---

# Why does my vector search return nothing when I add a filter?

![Why does my vector search return nothing when I add a filter?](https://cdn.surrealdb.com/sedtyvcefvzw6u7ri4ftbval.auto)

You build semantic search over your documents, it works, everyone is pleased.
Then you add the one line that scopes results to the caller, a tenant, an
organisation, a workspace, the signed-in user, and the same query starts
returning an empty array. The query still succeeds, so nothing in your logs
tells you anything is wrong.

The cause sits in how an approximate index works. An HNSW index does not scan
your table. It walks a graph of vectors and hands back the K it believes are
*globally* nearest to your query, knowing nothing about who owns them. A filter
applied after that walk can only remove rows from the list the index already
picked. It can never reach back into the index for the ones the walk passed
over.

Picture a table of 1,000 documents where one tenant owns ten of them and another
owns the rest. Ask for the ten nearest vectors to a query and, unless the small
tenant is remarkably lucky, all ten come back owned by the big one. Apply the
filter to that list and nothing survives:

```surql
SELECT id, vector::distance::knn() AS dist
FROM doc WHERE embedding <|10,64|> $q;
```

The query works beautifully in development, where most of the test data is
yours, and returns `[]` in production, where most of it belongs to somebody
else. It also makes no difference what is doing the scoping. A `PERMISSIONS`
clause, a `tenant_id` predicate, an `org`, a `workspace`, a language or status
column, a filter in your own application code: they all behave the same way.
What matters is **where in the query the filter lives**, because that is what
decides whether the index walk knows about it.

From there you get one of two outcomes, and neither is obvious. A filter the
query planner cannot see runs after the walk, so it silently returns too few
rows and often none at all. A filter the planner can see is pushed into the
walk, which returns the right rows but has to keep exploring until it finds K of
them that match, and that can be a thousand times slower.

Neither outcome is the one you wanted, and each has a real fix. This post
reproduces both failures on 1,000 rows and shows why the instinctive fix
(raising `K`) is the one that cannot work. It then measures the three that do,
and ends with the schema change that stops the problem existing at all.

---

## The short answer

One HNSW index holds rows belonging to different principals, so where you write
the filter decides which of the two failures you get. Put it in the same `WHERE`
clause as the KNN operator and the planner pushes it into the walk, so the
results are correct. The cost is that the walk keeps exploring until it finds K
matches, which at 0.1% selectivity takes 158 ms against 0.09 ms unfiltered. Put
it anywhere else,
a `PERMISSIONS` clause, a subquery, a view, or your own application code, and it
only sees rows the index has already chosen, so it can only remove them.

Raising K and EF does not solve either failure. Two fixes do, and the best
answer is both at once: try the index, then fall back to an exact scan of the
caller's own rows when it comes back short. That hybrid is four lines of
SurrealQL and stays
sub-millisecond for a tenant with 50 documents *and* for one with 50,000. And if
you are multi-tenant and have not shipped yet, there is a schema change that
makes the question disappear entirely.

Skip to [the hybrid](#fix-c-index-first-exact-scan-when-it-comes-back-short) if
you just want the code, or carry on to reproduce the failure first.

## What you'll need

Just the `surreal` binary:

**Bash**

```bash
curl -sSf https://install.surrealdb.com | sh
surreal start --user root --pass root --bind 127.0.0.1:8000 memory
```

**PowerShell**

```powershell
iwr https://windows.surrealdb.com -useb | iex
surreal start --user root --pass root --bind 127.0.0.1:8000 memory
```

| File | Purpose |
| --- | --- |
| [`schema.surql`](#schema-surql) | Two tenants, one document table with row-level security, the HNSW index |
| [`seed.surql`](#seed-surql) | 1,000 documents, 10 of them owned by the small tenant |
| [`search.surql`](#search-surql) | The hybrid `fn::search` |
| [`queries.surql`](https://gist.github.com/martinschaer/7a079b1284cf4c19744d802d07d5d669) | Every query in this post |
| [`bench.py`](https://gist.github.com/martinschaer/21b0a686484a7693a233feb45acbee46) | Rebuilds both databases, runs every strategy with and without permissions, asserts correctness, prints the tables |

---

## The setup

Two users, and a document table where each row belongs to one of them.

<!--![Schema diagram](schema.full)-->
![Schema diagram](https://cdn.surrealdb.com/w(1600)q(80)/tw949hepvn1f15soseclhf3d.auto)

<span id="schema-surql">**schema.surql**:</span>

```surql
DEFINE TABLE user SCHEMAFULL
    PERMISSIONS FOR select WHERE id = $auth;
DEFINE FIELD email ON user TYPE string;
DEFINE FIELD pass  ON user TYPE string;
DEFINE INDEX user_email ON user FIELDS email UNIQUE;

DEFINE ACCESS user ON DATABASE TYPE RECORD
    SIGNIN ( SELECT * FROM user WHERE email = $email AND crypto::argon2::compare(pass, $pass) )
    DURATION FOR TOKEN 15m, FOR SESSION 2h;

DEFINE TABLE doc SCHEMAFULL
    PERMISSIONS FOR select WHERE owner = $auth;
DEFINE FIELD owner     ON doc TYPE record<user>;
DEFINE FIELD title     ON doc TYPE string;
DEFINE FIELD embedding ON doc TYPE array<float, 8>;

DEFINE INDEX doc_embedding ON doc FIELDS embedding
    HNSW DIMENSION 8 DIST COSINE TYPE F64 EFC 150 M 12;

DEFINE INDEX doc_owner ON doc FIELDS owner;
```

Read `owner` as any column that narrows a search, not just one that scopes it.
`tenant` and `org` behave the same way, but so do `category = 'footwear'`,
`language = 'de'` and `status = 'published'`. What causes the trouble is never
ownership, it is that the filter rules out most of the table. This demo hands
the filtering to a `PERMISSIONS` clause because that is the harshest version of
the problem. The clause is invisible to the query planner *and* invisible to
`root`, so it survives every test you run with the credentials in your `.env`.
Every fix here
works the same way on a plain column predicate, and the section below measures
that with permissions switched off entirely.

Eight-float vectors are a demo convenience. Real embedding models return
hundreds or thousands of dimensions: 384 from `all-MiniLM-L6-v2`, 1,536 from
OpenAI's `text-embedding-3-small`, 3,072 from its large sibling. Eight keeps the
seed data on one screen and every distance below reproducible by hand. Set
`array<float, N>` and `DIMENSION N` to whatever your model emits and nothing
else in this post changes, except that each comparison costs more, which makes
the slow numbers slower and the case for the fixes stronger.

Two details in there that matter later.

**`owner = $auth`, not `owner = $auth.id`.** This one is permissions-specific;
skip it if your filter is an ordinary column. For a record access, `$auth` *is*
the record link of the signed-in user, so comparing a `record<user>` field
against it directly is free. Writing `$auth.id` instead makes SurrealDB fetch
the `user` row to read a field off it, which needs a select permission on
`user`. If that permission is missing, `$auth.id` quietly evaluates to `NONE`,
your permission clause becomes `owner = NONE`, and *every* row disappears. This
bites hardest on a table you never declared, because an implicitly created table
is `PERMISSIONS NONE`. A schema that failed to apply then hands you a
plausible-looking empty result set instead of an error.

**`DEFINE INDEX doc_owner`** is the difference between 0.17 ms and 45 ms later
on, and it is the one line to copy if you take nothing else from this post.

<span id="seed-surql">**seed.surql**:</span>

```surql
-- First let's create the users.
CREATE user:heavy SET email = 'heavy@example.com', pass = crypto::argon2::generate('heavy');
CREATE user:light SET email = 'light@example.com', pass = crypto::argon2::generate('light');

-- Now 1,000 documents, of which the small tenant owns every hundredth: ten rows,
-- 1% of the index:

FOR $i IN 0..1000 {
    CREATE type::record('doc', $i) SET
        owner = IF $i % 100 = 0 { user:light } ELSE { user:heavy },
        title = 'doc-' + <string> $i,
        embedding = [
            math::sin($i * 0.70 + 1), math::cos($i * 1.30 + 2),
            math::sin($i * 2.10 + 3), math::cos($i * 0.37 + 4),
            math::sin($i * 1.90 + 5), math::cos($i * 0.11 + 6),
            math::sin($i * 3.30 + 7), math::cos($i * 0.53 + 8)
        ];
};
```

The embeddings are trigonometric functions of the record index rather than
random, so every distance in this post reproduces exactly on your machine.

---

## Reproducing the failure

Sign in as each user and run the identical query. As `root`:

```surql
LET $q = [0.55, -0.31, 0.72, 0.19, -0.64, 0.28, 0.41, -0.83];

SELECT id, owner, vector::distance::knn() AS dist
FROM doc WHERE embedding <|5,64|> $q;
```

```surql
[
  { dist: 0.0795, id: doc:870, owner: user:heavy },
  { dist: 0.0851, id: doc:179, owner: user:heavy },
  { dist: 0.1469, id: doc:619, owner: user:heavy },
  { dist: 0.1648, id: doc:691, owner: user:heavy },
  { dist: 0.1648, id: doc:334, owner: user:heavy }
]
```

Five results, all owned by the big tenant, which is exactly what you would
expect when one user owns 99% of the rows. The same query as the small tenant:

```surql
[]
```

The query succeeded. The five nearest vectors in the database belong to someone
else, the permission clause removed all five, and what is left is nothing.

### The same thing with no permissions at all

If you have no row-level security, you are not safe: you are one line of
application code away from the same result. Here is the failure as `root`, which
bypasses `PERMISSIONS` entirely, with the filter written out as an ordinary
predicate in a second statement:

```surql
LET $hits = (SELECT id, owner FROM doc WHERE embedding <|10,64|> $q);
SELECT * FROM $hits WHERE owner = user:light;
```

```surql
[]
```

Zero rows again, this time with permissions out of the picture. The subquery
form fails the same way, and so
does the version everybody actually writes, where the KNN result comes back to
the client and gets filtered there:

```python
hits = db.query("SELECT id, owner FROM doc WHERE embedding <|10,64|> $q", {"q": q})
mine = [h for h in hits if h["owner"] == tenant]   # -> []
```

All three are the same mechanism. The walk produced the global top-10, and the
filter ran afterwards over those ten rows. `EXPLAIN` is where the difference
shows up. A filter the walk knows about is an attribute *of the `KnnScan`*:

```surql
{ operator: 'KnnScan',
  attributes: { index: 'doc_embedding', k: '10', ef: '64',
                dimension: '8', predicate: 'owner = (user:light)' } }
```

A filter that runs afterwards is a separate `Filter` node sitting above a
`KnnScan` that has no `predicate` at all:

```surql
{ operator: 'Filter',
  attributes: { predicate: 'owner = user:light' },
  children: [ { operator: 'SelectProject', children: [
              { operator: 'KnnScan',
                attributes: { index: 'doc_embedding', k: '10', ef: '64',
                              dimension: '8' } } ] } ] }
```

No `predicate` inside the `KnnScan` means your filter is a post-filter, whatever
it is written in. A `PERMISSIONS` clause is the sneakiest case of all: it shows
neither the `predicate` nor the `Filter` node, because it is applied outside the
plan altogether.

---

## Why raising K cannot fix it

The instinct is to over-fetch: ask for far more than you need and hope enough
survives the filter. Here is what that actually buys, as the small tenant, on a
table where they own 10 of 1,000 rows:

| K / EF | Rows returned | Latency |
| --- | --- | --- |
| `<\|5,64\|>` | 0 | 0.09 ms |
| `<\|50,64\|>` | 1 | 0.14 ms |
| `<\|100,100\|>` | 1 | 0.24 ms |
| `<\|200,200\|>` | 1 | 0.42 ms |
| `<\|500,500\|>` | 5 | 1.02 ms |
| `<\|1000,1000\|>` | 10 | 1.93 ms |

Two things to take from that table.

First, **K alone does nothing.** Going from `<|50,64|>` to `<|1000,64|>` still
returns one row, because `EF` (the size of the candidate list the walk keeps)
caps how much of the graph is ever visited. You have to raise both.

Second, and fatally: the only setting that returns all ten rows is
`<|1000,1000|>`, K and EF equal to the number of rows in the table. At that
point the "index lookup" is a full graph traversal that visits every vector, and
you have paid for an HNSW index in order to perform a scan. On a table 50× the
size, that stops being merely wasteful:

| K / EF, 50,000 rows | Rows returned | Latency |
| --- | --- | --- |
| `<\|10,64\|>` | 0 | 0.09 ms |
| `<\|100,100\|>` | 0 | 0.27 ms |
| `<\|1000,1000\|>` | 0 | 2.46 ms |

Still zero, now twenty-seven times slower. There is no value of K that makes this
correct, because the quantity you need to over-fetch by is *your filter's
selectivity*, and you do not know it at query time. A tenant who owns 0.1% of
the table needs K ≈ 1,000× the number of results they asked for; a tenant who
owns 0.001% needs a million. Oversampling turns a correctness bug into a
correctness bug that also gets slower.

---

## Fix A: restate the predicate so it goes *into* the walk

If your filter is a column you can name, put it in the same `WHERE` clause as
the KNN operator and the planner will push it into the traversal:

```surql
SELECT id, vector::distance::knn() AS dist
FROM doc WHERE embedding <|10,64|> $q AND owner = $auth;
```

Many multi-tenant readers are already here, and this is why their vector search
is slow rather than empty. `EXPLAIN` confirms the pushdown; look for
`predicate` *inside* the `KnnScan`:

```surql
{
  operator: 'KnnScan',
  attributes: { index: 'doc_embedding', k: '10', ef: '64',
                dimension: '8', predicate: 'owner = (user:light)' }
}
```

This returns all ten correct rows, at K=10, with no oversampling. It is a real
fix and worth knowing about.

It is also the slowest option in this post. A filtered graph walk has to keep
exploring until it has found K *matching* neighbours, so its cost scales inversely
with the filter's selectivity, and a per-tenant filter is about as selective as
filters get. At 50,000 rows with 0.1% visible, this query takes **158 ms**,
against 0.09 ms for the broken version. Note also that a filtered KNN can
legitimately return fewer than K rows when there are fewer than K matches to
find.

Fix A suits a filter that is *not* very selective: a role, a team, a language, a
status that most of the table shares. For per-tenant or per-user scoping it is
the wrong tool, and the two fixes below do much better.

---

## Fix B: forget the index, score the rows in the slice

If the caller can only see 50 rows, the fastest possible correct answer is to
compute 50 cosine distances:

```surql
SELECT id, title, 1 - vector::similarity::cosine(embedding, $q) AS dist
FROM doc WHERE owner = $auth
ORDER BY dist LIMIT 10;
```

Notice the `LIMIT 10`, which is the same as the `K` in `KnnScan`. This query
computes the distance for every row in the slice (in the example, 50 rows) and
sorts the top 10 by key.

With row-level permissions the predicate is `owner = $auth`; with a plain
scoping column it is `tenant = $tenant`. Nothing else about this query changes.

(SurrealDB exposes cosine as a [*similarity*](https://surrealdb.com/docs/learn/data-models/vector-search/similarity-search) in `[-1, 1]`, where higher is
closer. `1 - similarity` converts it to a distance so `ORDER BY ... ASC` reads
the same way as `vector::distance::knn()`.)

The `WHERE` clause is doing the heavy lifting, and it needs an index on the
filter column to exist. With `doc_owner` in place, `EXPLAIN` shows the query
reading only that slice and doing a bounded top-K sort over it:

```text
SelectProject
  Limit (10)
    SortTopKByKey (dist ASC, limit 10)
      Compute (dist = 1 - vector::similarity::cosine(...))
        IndexScan (index: doc_owner, access: = user:light)
```

At 50,000 rows with 50 visible, that is **0.17 ms**, the same order of
magnitude as the broken KNN query, and about nine hundred times faster than the
filtered walk in Fix A. Without the `doc_owner` index it is 7.7 ms, and without
the `WHERE` clause at all (letting `PERMISSIONS` do the filtering, so every row
gets scored) it is 45 ms. The index is the whole trick.

The catch is on the other end. This is a linear scan of the slice, so its cost
is the slice's row count:

| Tenant | Rows in the slice | Fix B latency |
| --- | --- | --- |
| small | 50 | 0.17 ms |
| large | 49,950 | 75 ms |

Roughly 1.5 µs per row scored. Fine for a tenant with thousands of documents,
not fine for one with millions, which is precisely the tenant for whom the
HNSW index works perfectly.

---

## Fix C: index first, exact scan when it comes back short

Neither fix is right for every caller, and the split is not something you can
settle when you write the query, because it depends on who is asking. The
practical answer is to let the query decide at runtime: try the index first, and
fall back to the exact scan when the filter has truncated the result.

<span id="search-surql">**search.surql**:</span>

```surql
DEFINE FUNCTION OVERWRITE fn::search($q: array<float>) {
    -- Fast path. Cheap, but the table's PERMISSIONS clause filters this
    -- *after* the HNSW walk, so the result can be silently truncated.
    LET $fast = (SELECT id, title, vector::distance::knn() AS dist
                 FROM doc WHERE embedding <|10,64|> $q);
    IF array::len($fast) >= 10 { RETURN $fast };

    -- Fallback: exact cosine over the rows in this caller's slice.
    -- `owner = $auth` is served by the doc_owner index, so this touches the
    -- caller's slice of the table, not all of it.
    RETURN (SELECT id, title, 1 - vector::similarity::cosine(embedding, $q) AS dist
            FROM doc WHERE owner = $auth ORDER BY dist LIMIT 10);
} PERMISSIONS FULL;
```

Both branches run with the caller's permissions, so neither can leak. The
`PERMISSIONS FULL` on the function is about who may *call* it, not what it may
see; without it, record users cannot invoke the function at all.

```surql
RETURN fn::search($q);
```

| Tenant | Rows in the slice | Path taken | Latency |
| --- | --- | --- | --- |
| small | 50 of 50,000 | index miss → exact scan | 0.34 ms |
| large | 49,950 of 50,000 | index hit | 0.16 ms |

Sub-millisecond for both, correct for both. The big tenant pays nothing for the
fallback because it never fires; the small tenant pays one wasted HNSW walk
(0.1 ms) for a correct answer.

### If your filter is a column, not a permission

In that case the fast path has to apply the filter itself, since nothing else
will. Oversample a little and filter the array in place:

```surql
LET $fast = (SELECT id, title, vector::distance::knn() AS dist
             FROM doc WHERE embedding <|20,64|> $q)[WHERE tenant = $tenant];
IF array::len($fast) >= 10 { RETURN array::slice($fast, 0, 10) };
```

That costs one extra pass over K rows instead of nothing, which at K=20 is
noise. The fallback branch is unchanged.

### Why the row count is a sound signal

The filter *removes* rows from the KNN result; it never reorders it and never adds to it. That holds whether the
filtering is done by a `PERMISSIONS` clause, a `Filter` node, or your client
code. So any row that comes back is genuinely one of the slice's nearest, and
any document in the slice closer than the farthest row returned would have been
inside the global top-K too. A truncated result is a correct *prefix* of the
right answer, just too short. That is why `count < k` is exactly the right
trigger, and why a full result needs no second-guessing beyond the approximation
error you already accept from any ANN index.

### Two things to tune for your data

Make the fast path's K a little larger than the
number of results you want, so a heavy tenant losing one or two rows to a
neighbour's document does not drop into the fallback unnecessarily. And if a
single slice grows past a few tens of thousands of rows, the fallback stops being
cheap for it, at which point the real answer is the next section.

---

## The structural answer: give each tenant its own index

Everything above is a workaround for one root cause: **one HNSW index holding
rows that belong to different principals.** If the index only ever contains rows
the caller may read, there is nothing left to post-filter.

SurrealDB gives you that for free, because a database is cheap and indexes are
per-database. Put each tenant in their own database (or namespace) and
`<|10,64|>` is simply correct. You have no predicate to restate, nothing to fall
back to, and selectivity stops being something you have to reason about. Every
tenant's index also stays small enough to be fast.

That is a bigger decision than a query rewrite, and it costs you cross-tenant
queries and a connection that has to route per tenant. It also only helps for
filters that partition your data: a filter on `status` or `language` still needs
Fix A or Fix C. But if you are building multi-tenant vector search and have not
shipped yet, this is the choice to make now rather than the one to work around
later.

---

## Gotchas worth knowing before you benchmark this

**Check `EXPLAIN`, not the result shape.** Every failure mode here returns
`status: OK` with a plausible-looking array. A `predicate` attribute inside the
`KnnScan`, or an `IndexScan` on your filter column, is the only confirmation that
the query is doing what you think.

**Wait for the HNSW index to finish building.** A bulk load returns before the
index is done, and querying too early gives you the worst of both worlds. The
KNN takes hundreds of milliseconds *and* returns fewer than K rows, even to
`root`. On 50,000 rows the settling took several seconds after the insert
returned. `INFO FOR INDEX` is how you check:

```surql
INFO FOR INDEX doc_embedding ON doc;
```

```surql
{ building: { initial: 50000, pending: 0, status: 'ready', updated: 0 } }
```

`status: 'ready'` means the index is complete; anything else (`'started'` while
a `CONCURRENTLY` build gets going, then a count of rows still to process) means
your timings are measuring a half-built graph. `bench.py` polls until a root KNN
is both fast and complete before it times anything; without that step its "plain
KNN" numbers came out 90× too slow.

**K and EF must be literal integers.** `<|$k, 64|>` is a parse error, including
inside a `DEFINE FUNCTION` body. Fix K in the function, or generate the
statement.

**The query vector must be a bound parameter.** Inlining a record field into the
operator (`WHERE embedding <|10,64|> doc:123.embedding`) silently skips the
index: you get null distances and rows in insertion order. Bind it first with
`LET`, then use the parameter.

And two more if your filter is a `PERMISSIONS` clause:

**A root user proves nothing.** Root and namespace users bypass `PERMISSIONS`
entirely, which is why this class of bug survives every test you run with the
credentials in your `.env`. Test as a record user or you are not testing it.

**Sign in once, reuse the token.** HTTP basic auth re-runs the signin query on
every request, and `crypto::argon2::compare` costs about 14 ms. That will swamp
anything you are trying to measure. Sign in at `/signin` and send the bearer
token.

---

## Reproduce it

```bash
surreal start --user root --pass root --bind 127.0.0.1:8000 memory &
python3 bench.py
```

[`bench.py`](https://gist.github.com/martinschaer/21b0a686484a7693a233feb45acbee46) has no dependencies. It rebuilds both databases from
[`schema.surql`](#schema-surql), waits for each HNSW index to settle, computes ground truth from
a root session. It then asserts, rather than merely reporting, three things:
that every strategy labelled correct returns exactly the ground-truth ranking,
that every plain KNN narrower than the whole table is truncated, and that every
post-filter comes back empty. If a future SurrealDB release changes any of this,
the script fails instead of quietly printing different numbers.

```markdown
50,000 docs in the HNSW index, 50 in the small tenant's slice (0.1%), asking for the nearest 10

as the tenant, filter in the table's PERMISSIONS clause

| Strategy                     | Rows | Correct | Median |
| ---------------------------- | ---- | ------- | ------ |
| plain KNN, K=10 EF=64        |    0 |      no |  0.26ms |
| plain KNN, K=100 EF=100      |    0 |      no |  0.46ms |
| plain KNN, K=1000 EF=1000    |    0 |      no |  2.69ms |
| filtered KNN, K=10 EF=64     |   10 |     yes | 174.85ms |
| exact scan, whole table      |   10 |     yes | 44.59ms |
| exact scan, owner-scoped     |   10 |     yes |  0.21ms |
| fn::search (hybrid)          |   10 |     yes |  0.49ms |

as root, no permissions anywhere, filter written as a column predicate

| Strategy                     | Rows | Correct | Median |
| ---------------------------- | ---- | ------- | ------ |
| post-filter, two statements  |    0 |      no |  0.60ms |
| post-filter, subquery        |    0 |      no |  0.54ms |
| filter in the KNN's WHERE    |   10 |     yes | 219.01ms |
| exact scan, owner-scoped     |   10 |     yes |  0.34ms |
```

Numbers are medians of nine runs against an in-memory 3.2.4 instance on an Apple
Silicon laptop, measured 2026-08-21; treat the ratios as the finding and the
absolutes as indicative.

---

## Further reading

- [Vector search reference](https://surrealdb.com/docs/surrealdb/reference-guide/vector-search): HNSW parameters, the KNN operator, distance functions
- [`DEFINE INDEX`](https://surrealdb.com/docs/surrealql/statements/define/indexes): HNSW tuning knobs (`EFC`, `M`, `M0`)
- [`DEFINE TABLE ... PERMISSIONS`](https://surrealdb.com/docs/surrealql/statements/define/table) and [record access](https://surrealdb.com/docs/surrealql/statements/define/access/record): how `$auth` is populated
- [`EXPLAIN`](https://surrealdb.com/docs/surrealql/statements/select#the-explain-clause): reading query plans
