Skip to content

eql-3.0.0

Latest

Choose a tag to compare

@github-actions github-actions released this 09 Jul 15:05
f54ba1f

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.sql

One 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, prefixed eql_v3_. They live in public because 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 through search_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(), and lints().
  • 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:

  1. Ordering works without superuser by default. _ord / text_search use OPE.
  2. 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 raises feature_not_supported (0A000) naming the domain and pointing at the alternatives. The constraint is NOT 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_jsonb

Query 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_json is a jsonb domain, so selector literals must be typed: col -> 'sel'::text. A bare col -> 'sel' resolves to the native jsonb operator, 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-bindings crate (payload structs, DomainPayload / QueryPayload enums, the domain inventory),
  • TypeScript bindings and JSON Schemas → the @cipherstash/eql npm 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_encrypted and 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 encryptindex migration machinery
  • GROUP BY / grouped_value on 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.