Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ content/stack/reference/drizzle/latest/
content/stack/reference/drizzle/v*/
.tmp-*
.env.local

# EQL release manifest extracted by generate-eql-docs.ts (see generate-eql-api-docs.ts)
.eql-manifest.release.json
22 changes: 11 additions & 11 deletions content/docs/reference/eql/booleans.mdx
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
---
title: Booleans
description: "Encrypted booleans are storage-only by design: eql_v3.bool stores and decrypts, carries no index terms, and blocks every comparison."
description: "Encrypted booleans are storage-only by design: public.boolean stores and decrypts, carries no index terms, and blocks every comparison."
type: reference
components: [eql]
verifiedAgainst:
eql: "3.0.0"
---

Every scalar type has a storage-only variant — for `bool` it's the only one. EQL ships `eql_v3.bool` and nothing else: there is no `bool_eq` and no `bool_ord`. An encrypted boolean column can be stored, decrypted, and null-checked; it cannot be filtered, sorted, grouped, or joined on.
Every scalar type has a storage-only variant — for `bool` it's the only one. EQL ships `public.boolean` and nothing else: there is no `bool_eq` and no `bool_ord`. An encrypted boolean column can be stored, decrypted, and null-checked; it cannot be filtered, sorted, grouped, or joined on.

## Why there are no query variants

A two-value column has too little cardinality for any searchable index to be safe. An equality term over `true` / `false` would partition the table into two visible buckets — leaking the value distribution (and, with any outside knowledge, the values themselves) outright. Rather than ship an index term that can't keep its promise, EQL omits the query variants entirely. See [Searchable encryption](/concepts/searchable-encryption) for the general analysis of what index terms reveal.

## What works, what raises

`eql_v3.bool` follows the bare-variant contract described in [Core concepts](/reference/eql/core-concepts#variants-declare-capability): it carries no index terms, so `IS NULL` / `IS NOT NULL` are the only predicates that work. Every comparison operator routes to a blocker and raises — the [fail-loud behavior](/reference/eql/core-concepts#unsupported-operations-fail-loudly) shared by all encrypted variants:
`public.boolean` follows the bare-variant contract described in [Core concepts](/reference/eql/core-concepts#variants-declare-capability): it carries no index terms, so `IS NULL` / `IS NOT NULL` are the only predicates that work. Every comparison operator routes to a blocker and raises — the [fail-loud behavior](/reference/eql/core-concepts#unsupported-operations-fail-loudly) shared by all encrypted variants:

```sql
-- ❌ Raises: operator = is not supported for eql_v3.bool
SELECT * FROM users WHERE is_active = $1::eql_v3.bool;
-- ❌ Raises: operator = is not supported for public.boolean
SELECT * FROM users WHERE is_active = $1::public.boolean;

-- ✅ Works: NULL columns are not encrypted
SELECT * FROM users WHERE is_active IS NOT NULL;
Expand All @@ -32,16 +32,16 @@ Query on other columns, decrypt the boolean in your application, and filter ther
```sql
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email eql_v3.text_eq, -- exact lookup
created_at eql_v3.timestamp_ord, -- range queries, ORDER BY
is_active eql_v3.bool -- storage only (by design)
email public.text_eq, -- exact lookup
created_at public.timestamp_ord, -- range queries, ORDER BY
is_active public.boolean -- storage only (by design)
);
```

```sql
-- Narrow the result set with the columns that do carry index terms…
SELECT id, email, is_active FROM users
WHERE created_at >= $1::eql_v3.timestamp_ord;
WHERE created_at >= $1::public.timestamp_ord;
-- …then decrypt is_active in the client and filter on the plaintext.
```

Expand All @@ -51,12 +51,12 @@ If a boolean genuinely needs to be a server-side predicate, that is a data-model

## Storing without querying

`bool` is the forced case of a pattern available to every scalar type: the bare variant `eql_v3.<T>` (for example `eql_v3.int4`, `eql_v3.text`, `eql_v3.timestamp`) is storage-and-decryption only. It carries no index terms, and every comparison operator raises — use it for columns you only ever store and decrypt, so the database holds no searchable material for them at all.
`bool` is the forced case of a pattern available to every scalar type: the bare variant `public.<T>` (for example `public.integer`, `public.text`, `public.timestamp`) is storage-and-decryption only. It carries no index terms, and every comparison operator raises — use it for columns you only ever store and decrypt, so the database holds no searchable material for them at all.

For every type other than `bool`, storage-only is a choice you can walk back. If you later need to query, retype the column as a query variant — or, if the payloads already carry the needed term (the client decides which terms travel in the payload), cast at the call site:

```sql
SELECT * FROM readings WHERE value::eql_v3.int4_ord > $1::eql_v3.int4_ord;
SELECT * FROM readings WHERE value::public.integer_ord > $1::public.integer_ord;
```

The variant families and what each one enables are covered in [Core concepts](/reference/eql/core-concepts); the per-type specifics live in [Numbers](/reference/eql/numbers), [Dates & times](/reference/eql/dates-and-times), and [Text](/reference/eql/text).
66 changes: 41 additions & 25 deletions content/docs/reference/eql/core-concepts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,32 @@ Everything in the EQL reference builds on four ideas: columns are typed as **dom

## Variants declare capability

EQL ships its searchable-encryption surface as PostgreSQL **domains in the `eql_v3` schema**, all backed by `jsonb`. Each scalar type generates a *family* of domain variants, and the variant you type a column as fixes its query capability. Each domain carries a `CHECK` constraint that validates the encrypted payload on insert, so a malformed or wrong-version value is rejected at write time rather than surfacing at query time.
EQL ships its searchable-encryption surface as PostgreSQL **domains in the `public` schema**, all backed by `jsonb` (the functions behind them live in `eql_v3` — [The three schemas](#the-three-schemas) explains the split). Each scalar type generates a *family* of domain variants, and the variant you type a column as fixes its query capability. Each domain carries a `CHECK` constraint that validates the encrypted payload on insert, so a malformed or wrong-version value is rejected at write time rather than surfacing at query time.

There is no database-side configuration table. Earlier EQL versions tracked encryption config in the database (`config_add_table`, `config_add_column`, and friends) — those are gone in v3. The searchable surface of a column is fixed by the domain variant you type it as, and which index terms travel in a value's payload is decided by the encryption client (the [Stack SDK](/reference/stack) or [CipherStash Proxy](/reference/proxy)). The domain makes the matching operators resolve; the term in the payload is what makes them answer.

For any scalar type `<T>`, the family looks like this:

| Domain variant | Capability |
| --- | --- |
| `eql_v3.<T>` | Storage and decryption only. |
| `eql_v3.<T>_eq` | Equality: `=`, `<>`, `IN`, `GROUP BY`, `DISTINCT`, equijoins. |
| `eql_v3.<T>_ord` | Comparisons (`<` … `>=`), `BETWEEN`, `ORDER BY`, `MIN` / `MAX` — plus equality. |
| `eql_v3.<T>_ord_ore` | As `<T>_ord`, with the ORE mechanism pinned — see [SEM specifiers](#sem-specifiers). |
| `eql_v3.text_match` (text only) | Free-text token containment: `@>` / `<@`. |
| `eql_v3.text_search` (text only) | Equality + ordering + token containment. |
| `public.<T>` | Storage and decryption only. |
| `public.<T>_eq` | Equality: `=`, `<>`, `IN`, `GROUP BY`, `DISTINCT`, equijoins. |
| `public.<T>_ord` | Comparisons (`<` … `>=`), `BETWEEN`, `ORDER BY`, `MIN` / `MAX` — plus equality. |
| `public.<T>_ord_ore` | As `<T>_ord`, with the ORE mechanism pinned — see [SEM specifiers](#sem-specifiers). |
| `public.text_match` (text only) | Free-text token containment: `@>` / `<@`. |
| `public.text_search` (text only) | Equality + ordering + token containment. |

Two things worth calling out:

- **The bare variant blocks everything.** `eql_v3.<T>` carries no index term. Querying it with any comparison operator raises an "operator not supported" exception. Use it for columns you only ever store and decrypt — [Booleans](/reference/eql/booleans) covers this pattern in full.
- **The bare variant blocks everything.** `public.<T>` carries no index term. Querying it with any comparison operator raises an "operator not supported" exception. Use it for columns you only ever store and decrypt — [Booleans](/reference/eql/booleans) covers this pattern in full.
- **Which index term backs each capability** is an implementation detail of the payload — covered in [Anatomy of an encrypted value](#anatomy-of-an-encrypted-value) below.

### SEM specifiers

A trailing mechanism suffix — the `_ore` in `_ord_ore` — is a **SEM specifier**: it pins *which* searchable-encryption mechanism implements the capability, rather than just declaring the capability itself.

- `eql_v3.<T>_ord` declares *orderable* and leaves the mechanism to EQL's default — currently ORE (order-revealing encryption).
- `eql_v3.<T>_ord_ore` declares *orderable via ORE, explicitly*. Today the two are byte-identical surfaces backed by the same term.
- `public.<T>_ord` declares *orderable* and leaves the mechanism to EQL's default — currently ORE (order-revealing encryption).
- `public.<T>_ord_ore` declares *orderable via ORE, explicitly*. Today the two are byte-identical surfaces backed by the same term.

The distinction earns its keep as mechanisms multiply: the EQL v3 release adds an **OPE** (order-preserving encryption) specifier for every orderable type — including `text` — at which point pinning a specifier documents and freezes a column's mechanism choice, while unspecified variants track the default. Each type page lists its available specifiers under an "SEM specifiers" heading.

Expand All @@ -45,13 +45,29 @@ Declaring a table is just typing each column as the variant it needs:
```sql
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email eql_v3.text_eq, -- equality only
salary eql_v3.int4_ord, -- equality + range + ORDER BY
created_at eql_v3.timestamp_ord
email public.text_eq, -- equality only
salary public.integer_ord, -- equality + range + ORDER BY
created_at public.timestamp_ord
);
```

Every scalar type — `int2`, `int4`, `int8`, `numeric`, `float4`, `float8`, `date`, `timestamp`, `text`, and `bool` in EQL 3.0.0 — ships some subset of this family. The per-category pages list exactly which variants each type has and how to choose between them: [Numbers](/reference/eql/numbers), [Dates & times](/reference/eql/dates-and-times), [Text](/reference/eql/text), and [Booleans](/reference/eql/booleans). Encrypted JSON documents use a separate domain, `eql_v3.json`, with its own operator surface — see [JSON](/reference/eql/json).
Every scalar type — `int2`, `int4`, `int8`, `numeric`, `float4`, `float8`, `date`, `timestamp`, `text`, and `bool` in EQL 3.0.0 — ships some subset of this family. The per-category pages list exactly which variants each type has and how to choose between them: [Numbers](/reference/eql/numbers), [Dates & times](/reference/eql/dates-and-times), [Text](/reference/eql/text), and [Booleans](/reference/eql/booleans). Encrypted JSON documents use a separate domain, `public.json`, with its own operator surface — see [JSON](/reference/eql/json).

## The three schemas

EQL spreads its surface across three PostgreSQL schemas, and the split is what makes EQL **upgradable in place**:

| Schema | Holds | Do you call it? |
| --- | --- | --- |
| `public` | The encrypted **domain types** — every `public.<T>` variant you type a column as. | Referenced in your table DDL. |
| `eql_v3` | All **user-callable functions and operators** — the searchable-encryption API (`eql_v3.eq_term`, `eql_v3.jsonb_path_query`, the encrypted `=` / `<` / `@>` operators, `eql_v3.version()`). | Yes — this is the API. |
| `eql_v3_internal` | The **implementation functions** the domains and operators are built from. | No — never call these directly. |

**Why the types live in `public`.** Your columns are typed as `public.integer_ord`, `public.text_eq`, and so on — never `eql_v3.*`. Keeping the types in the unversioned `public` schema means an EQL upgrade never rewrites your table definitions: the logic ships in a *versioned* schema (`eql_v3` today, a future `eql_v4` alongside it tomorrow) while the type names your schema depends on stay put. `eql_v2` was removed wholesale in 3.0.0 without any `public.*` type changing.

**Why `eql_v3` is versioned.** The schema name encodes the major API version and is itself part of the public contract — a breaking change introduces a new `eql_vN` schema *beside* the old one rather than mutating it, so you migrate on your own timeline. Everything in `eql_v3` is fair game to call.

**Why `eql_v3_internal` is off-limits.** These are the building blocks — term comparators, unsupported-operator blockers, cast helpers — that the operators and domain `CHECK` constraints wire together. They carry no stability guarantee and can change or disappear between releases. If you're reaching for `eql_v3_internal.*`, there's a `public` type or an `eql_v3` function that does what you want.

## Anatomy of an encrypted value

Expand All @@ -77,17 +93,17 @@ A `k` discriminator (`"ct"` for a scalar ciphertext, `"sv"` for a JSON document)

### Index-term keys

Alongside the envelope, a payload carries the index terms for its column's capability. Each key is backed by a SEM (searchable encrypted metadata) type in the `eql_v3` schema:
Alongside the envelope, a payload carries the index terms for its column's capability. Each key is backed by a SEM (searchable encrypted metadata) type in the `eql_v3_internal` schema:

| Key | SEM type | Wire shape | Enables | Reveals |
| --- | --- | --- | --- | --- |
| `hm` | `eql_v3.hmac_256` (domain over `text`) | Hex string (HMAC-SHA-256) | `=`, `<>` on `_eq` and `text_search` domains | Whether two values are equal — nothing else |
| `ob` | `eql_v3.ore_block_256` (composite: array of `bytea` block terms) | Array of hex-encoded ORE blocks (block count varies by scalar width) | `<`, `<=`, `>`, `>=`, `ORDER BY` on `_ord` / `_ord_ore` domains — and `=` / `<>`, since ORE comparison collapses to equality | The relative order of two values |
| `bf` | `eql_v3.bloom_filter` (domain over `smallint[]`) | Array of set bit positions (**signed** 16-bit — large filters emit negative positions) | `@>` / `<@` token containment on `_match` domains | Probabilistic token overlap between values |
| `hm` | `eql_v3_internal.hmac_256` (domain over `text`) | Hex string (HMAC-SHA-256) | `=`, `<>` on `_eq` and `text_search` domains | Whether two values are equal — nothing else |
| `ob` | `eql_v3_internal.ore_block_256` (composite: array of `bytea` block terms) | Array of hex-encoded ORE blocks (block count varies by scalar width) | `<`, `<=`, `>`, `>=`, `ORDER BY` on `_ord` / `_ord_ore` domains — and `=` / `<>`, since ORE comparison collapses to equality | The relative order of two values |
| `bf` | `eql_v3_internal.bloom_filter` (domain over `smallint[]`) | Array of set bit positions (**signed** 16-bit — large filters emit negative positions) | `@>` / `<@` token containment on `_match` domains | Probabilistic token overlap between values |

The capability is encoded as **required keys**: the payload for an `eql_v3.text_eq` column must carry `hm`; an `eql_v3.int4_ord` payload must carry `ob` (and only `ob`); a `text_match` payload must carry `bf`; a `text_search` payload carries all three. A payload missing its term key fails the domain `CHECK` — and fails to deserialize in the client bindings.
The capability is encoded as **required keys**: the payload for a `public.text_eq` column must carry `hm`; a `public.integer_ord` payload must carry `ob` (and only `ob`); a `text_match` payload must carry `bf`; a `text_search` payload carries all three. A payload missing its term key fails the domain `CHECK` — and fails to deserialize in the client bindings.

A scalar payload for an `eql_v3.text_search` column (lookup + ordering + free-text match, so all three terms are required):
A scalar payload for a `public.text_search` column (lookup + ordering + free-text match, so all three terms are required):

```json
{
Expand Down Expand Up @@ -124,10 +140,10 @@ The `eql_v3` domains are backed by `jsonb`. When an operand has no known type
SELECT * FROM users WHERE email = $1;

-- ✅ Right: typed operand — the encrypted `=` resolves.
SELECT * FROM users WHERE email = $1::eql_v3.text_eq;
SELECT * FROM users WHERE email = $1::public.text_eq;
```

Always type the operand: a typed parameter (`$1::eql_v3.text_eq`) or an explicit cast (`'…'::eql_v3.int4_ord`). The [Stack SDK](/reference/stack) and [CipherStash Proxy](/reference/proxy) type bound parameters automatically — raw SQL must do it by hand.
Always type the operand: a typed parameter (`$1::public.text_eq`) or an explicit cast (`'…'::public.integer_ord`). The [Stack SDK](/reference/stack) and [CipherStash Proxy](/reference/proxy) type bound parameters automatically — raw SQL must do it by hand.

This is the one place where a mistake is *silent*. Everything else fails loudly:

Expand All @@ -136,9 +152,9 @@ This is the one place where a mistake is *silent*. Everything else fails loudly:
Unsupported operators are not silent no-ops. Every operator that a variant doesn't support is still *defined* — it routes to a blocker function that raises an `operator … is not supported` exception. A mis-typed query fails loudly instead of silently returning wrong results:

```sql
-- salary is eql_v3.int8_eq (equality only)
SELECT * FROM users WHERE salary > $1::eql_v3.int8_eq;
-- ERROR: operator > is not supported for eql_v3.int8_eq
-- salary is public.bigint_eq (equality only)
SELECT * FROM users WHERE salary > $1::public.bigint_eq;
-- ERROR: operator > is not supported for public.bigint_eq
```

A `NULL` operand still raises — the blockers are deliberately not `STRICT`, so PostgreSQL can't skip the check. (A SQL `NULL` column value is not encrypted, so `IS NULL` / `IS NOT NULL` themselves always work, on every variant.)
Expand Down
Loading