EQL 3.0 replaces the untyped eql_v2_encrypted composite column type with a family of typed, per-capability encrypted domain types, and removes the eql_v2 schema entirely. The database no longer owns the encryption configuration model — the client (CipherStash Proxy / ProtectJS) does — and EQL ships as a single self-contained SQL surface plus first-class Rust and TypeScript packages generated from one catalog.
This is a major, breaking release. Nothing in eql_v2 survives.
Install
curl -fsSLO https://github.com/cipherstash/encrypt-query-language/releases/download/eql-3.0.0/cipherstash-encrypt.sql
psql -f cipherstash-encrypt.sqlOne artifact installs everywhere — superuser and managed Postgres alike. An uninstaller ships alongside it.
Wire types and the matching DDL are published in lockstep at the same version:
| Package | Version |
|---|---|
@cipherstash/eql (npm) |
3.0.0 |
eql-bindings (crates.io) |
3.0.0 |
| SQL installer | this release |
Each language package bundles the exact SQL installer it was generated against, so you pin types and DDL together.
Upgrading from 2.x? Start with the 3.0 upgrade guide — this release removes the entire eql_v2 schema.
The core change: typed columns instead of one opaque type
In v2 every encrypted column was eql_v2_encrypted, regardless of what it held or how it could be searched. Capability was a property of the payload, discovered at query time.
In v3 capability is a property of the type. A column is declared as the domain matching exactly what it supports, and the domain's CHECK enforces that the payload actually carries the required index terms — so a missing term fails on insert or cast, not later at query time.
CREATE TABLE users (
age public.eql_v3_integer_ord, -- equality + ordering + MIN/MAX
email public.eql_v3_text_eq, -- equality only
bio public.eql_v3_text_match, -- bloom containment only
is_admin public.eql_v3_boolean, -- encrypted at rest, not searchable
profile public.eql_v3_json -- searchable encrypted JSONB
);Capability suffixes
| Suffix | Operators | Backing index term |
|---|---|---|
| (none) | — (storage only) | c |
_eq |
= <> |
hm (HMAC-256) |
_ord |
= <> < <= > >=, MIN/MAX |
op (CLLW-OPE) |
_ord_ope |
as _ord (byte-identical twin) |
op |
_ord_ore |
as _ord |
ob (block-ORE) |
_match |
@> <@ |
bf (bloom filter) |
_search |
all of the above | hm + op + bf |
_search_ore |
all of the above | hm + ob + bf |
On the integer-like families the order term is exact, so _ord / _ord_ore get equality from op / ob directly. On text the order term is not equality-lossless, so text_ord and text_ord_ore additionally carry hm and always route = / <> through the exact HMAC.
51 column-type domains across 10 scalar families — smallint, integer, bigint, real, double, numeric, date, timestamp (each: storage / _eq / _ord / _ord_ope / _ord_ore), text (adds _match, _search, _search_ore), and boolean (storage-only) — plus eql_v3_json and eql_v3_jsonb_entry.
boolean is deliberately storage-only: a two-value column has so little cardinality that even an HMAC equality index would leak the plaintext distribution.
Every operator also has a callable function equivalent (eql_v3.eq, eql_v3.lt, eql_v3.contains, …). Supabase/PostgREST exposes the database through RPC that calls functions, not operators — WHERE col = $1 isn't expressible there, but eql_v3.eq(col, $1) is. A CI gate (v3_operator_equivalents_tests.rs) fails if any supported operator loses its wrapper.
Schema layout
Three namespaces, split along a deliberate line:
public— the column-type domains, prefixedeql_v3_. They live inpublicbecause dropping an EQL-owned schema must never drop application columns. The prefix means EQL types no longer shadow built-in names (integer,text,json), so an unqualified reference can't resolve to the wrong type throughsearch_path, and future EQL versions can coexist in one database.eql_v3— the public API: query-operand domains, index-term extractors (eq_term/ord_term/ord_term_ore/match_term), comparison wrappers, aggregates, the JSONB access surface,version(), andlints().eql_v3_internal— internal only: the SEM index-term types (hmac_256,bloom_filter,ope_cllw,ore_block_256), operator blockers, aggregate state functions, and CHECK validators. Hidden so they don't clutter type pickers that enumerate every type in every visible schema.
EQL never grants permissions automatically. The installer issues no GRANT/REVOKE; access is strictly opt-in. See docs/reference/permissions.md.
Ordering: CLLW-OPE by default, block-ORE opt-in
The biggest operational change. _ord is now backed by CLLW-OPE (op term), not block-ORE.
eql_v3.ord_term(col) returns eql_v3_internal.ope_cllw, a domain over bytea. Because it's a bytea domain it inherits the default btree operator class, so a plain functional index engages structurally, and the whole comparison chain stays inlinable:
CREATE INDEX ON users (eql_v3.ord_term(age));Block-ORE needs a hand-written operator class on a composite type, and CREATE OPERATOR CLASS requires superuser. On cloud-hosted Supabase and most managed Postgres that class silently didn't exist — CREATE INDEX succeeded by binding record_ops, then never engaged. Ordered queries fell back to seq scans with no error.
So v3 does two things:
- Ordering works without superuser by default.
_ord/text_searchuse OPE. - Block-ORE fails loudly when unavailable. If the installer can't create the ORE operator class, it capability-detects the skip and poisons all 20 ORE-carrying domains with an always-raising
CHECK. First cast or insert raisesfeature_not_supported(0A000) naming the domain and pointing at the alternatives. The constraint isNOT VALID, so ORE data written under an earlier superuser install stays readable. Superuser installs are unchanged.
Block-ORE remains fully supported behind _ord_ore / text_search_ore, extracted via eql_v3.ord_term_ore(col).
Query-operand domains
Every term-bearing domain has a paired eql_v3.query_<name> twin (40 of them) carrying {v, i, <terms>} — the storage envelope minus the ciphertext c, enforced by the domain CHECK.
WHERE age = $1::eql_v3.query_integer_ord
WHERE profile @> $1::eql_v3.query_jsonbQuery operands travel through PostgREST query strings, URL logs, proxies, and SQL logs. They should carry index terms only — never a decryptable ciphertext. A c-bearing operand is rejected.
Encrypted JSONB (SteVec)
public.eql_v3_json documents are searchable without decryption: containment (@>, <@), path access (->, ->>, jsonb_path_query, jsonb_array_*), entry equality on extracted leaves, and entry-level ordered range.
Entries carry hm XOR op. Entry ordering extracts eql_v3.ord_term(entry) — the same bytea-domain path as the scalars, so ordered JSONB indexes also work without superuser. The old oc (CLLW-ORE) term, its composite type, comparator, operators, and operator class are removed; oc-bearing documents must be re-encrypted.
-- containment
CREATE INDEX ON users USING GIN (eql_v3.to_ste_vec_query(profile) jsonb_path_ops);
-- ordered leaf
CREATE INDEX ON users (eql_v3.ord_term(profile -> 'age'::text));Comparisons are leaf-level only. Root-document =, <, > and every native jsonb operator reachable through domain fallback (?, ?|, @?, #>, ||, …) raise rather than silently falling through to plaintext-jsonb semantics.
Typed operands.
eql_v3_jsonis ajsonbdomain, so selector literals must be typed:col -> 'sel'::text. A barecol -> 'sel'resolves to the nativejsonboperator, because PostgreSQL reduces a domain to its base type for unknown-typed literals. The Proxy interface passes typed parameters.
Wire format: v: 3
Every v3 domain CHECK pins VALUE->>'v' = '3'. The bindings accept exactly 3 and reject legacy 2 at the type boundary. A client emitting v: 2 cannot insert into a v3 column.
eql_bindings::from_v2 / from_v2_query convert v2.3 payloads to v3, failing closed (MissingTerm, UnconvertibleOreTerm) rather than guessing — the target domain is explicit input, since every v2 index term is optional on the wire and capability can't be inferred.
One catalog, three languages
Scalar domains are defined as rows in eql-domains::CATALOG (Rust). cargo run -p eql-codegen materialises from it:
- the SQL domain/operator/aggregate surface,
- the Rust
eql-bindingscrate (payload structs,DomainPayload/QueryPayloadenums, the domain inventory), - TypeScript bindings and JSON Schemas → the
@cipherstash/eqlnpm package.
Adding a scalar type is one catalog row. Drift between the three is CI-gated (types:check). The previous Python codegen toolchain is gone — building and testing EQL no longer needs Python.
Both language packages bundle the exact SQL installer they were generated against (eql_bindings::sql, or the npm package's ./sql subpath export), so consumers pin wire types and DDL together. Releases are cut in lockstep from Changesets; from 3.0.0 the changelog is packages/eql/CHANGELOG.md.
Removed
The eql_v2 schema and everything in it, with no v3 replacement for:
eql_v2_encryptedand its operators (=,<>,LIKE/ILIKE,@>/<@, ORE comparisons)- database-side configuration management —
eql_v2_configuration,add_search_config,add_column,migrate_config,diff_config,create_encrypted_columns - the
encryptindexmigration machinery GROUP BY/grouped_valueon the encrypted column type- operator-class-on-column indexing
Searchable-encryption capability is provided by the v3 domain families. Configuration is now owned by the client.
Build variants are gone too: one artifact, release/cipherstash-encrypt.sql (+ uninstaller), under the name the combined build used — existing install URLs keep working. The Supabase and Protect variants no longer exist because there is now a single surface that installs everywhere.
Breaking changes
Full detail and migration recipes in docs/upgrading/v3.0.md:
| Change | |
|---|---|
| U-001 | Payloads carry v: 3 |
| U-002 | Query operands are eql_v3.query_<name> |
| U-003 | Non-superuser installs disable the ORE domains |
| U-004 | SteVec ordering is CLLW-OPE (op, not oc) |
| U-005 | _ord is backed by CLLW-OPE — existing _ord indexes must be rebuilt |
| U-006 | text_search is backed by CLLW-OPE |
Two behaviour changes ride along with U-005: text_ord now accepts the empty string (OPE encodes "" to a well-formed term; block-ORE produced an unorderable ob: []), and real/double _ord columns now distinguish -0.0 from +0.0. A float column needing IEEE ±0.0 semantics should be typed _ord_ore, which canonicalizes. Both splits are pinned by known_failure markers against #387.
Testing
18 SQLx suites, plus a property-test harness asserting SQL operator results agree with a plaintext oracle across a generated input space — a pure-Rust catalog suite, a fixture suite over committed real ciphertexts, and an e2e suite (behind the proptest-e2e feature) that batch-encrypts fresh plaintexts through ZeroKMS every run.
Dedicated gates cover the things that silently rot: eql_v2-freeness of the artifact and its dependency closure, public/internal schema placement, operator↔function equivalence, uninstall completeness, privilege behaviour, and Rust/TS/JSON-Schema drift.
Two performance regressions found during the release benches were fixed by converting hot functions the planner can never inline from LANGUAGE sql to plpgsql (#353, #354), restoring or beating v2 numbers on ORE ordered scans and the JSONB needle cast.
Full changelog
Per-change detail, assembled from the changesets in each PR: packages/eql/CHANGELOG.md.
Those entries record the branch's history, so some describe intermediate states that later changes superseded — the surface described above is what 3.0.0 actually ships.