Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1ad0376
feat(stack-encrypt): let a target sealed under its own context opt in…
coderdan Oct 17, 2026
f41e258
feat(eql-bindings): query EQL v4 types through Stack Encrypt's typed …
coderdan Oct 17, 2026
fd55c20
docs(stack-encrypt,eql-bindings): say Answers needs LabelledTarget, a…
coderdan Oct 17, 2026
d827132
Merge pull request #1244 from cipherstash/feat/eql-v4-typed-queries
coderdan Oct 19, 2026
b6523a1
feat(eql): let an EQL v4 text search query carry only the terms its o…
coderdan Oct 18, 2026
6a1b2c0
feat(stack-encrypt)!: hand a target's query the index that answers it…
coderdan Oct 18, 2026
73583d2
feat(eql-bindings): produce TextSearch, its query carrying only its o…
coderdan Oct 19, 2026
213d998
test(golang): generate and seal a TextSearch field, each query carryi…
coderdan Oct 19, 2026
ba28168
feat(stack-encrypt)!: derive ore-rs 6-bit block ORE terms as a fifth …
coderdan Oct 19, 2026
243c561
feat(stack-encrypt): the JSON index, with node-local entries, a token…
coderdan Oct 19, 2026
b18cd82
feat(eql): the json_tree domain family for Stack Encrypt's JSON index…
coderdan Oct 19, 2026
adedb66
docs(plans): record where the JSON index implementation departs from …
coderdan Oct 19, 2026
358c0fd
Merge pull request #1274 from cipherstash/feat/eql-v4-text-search-opt…
coderdan Oct 19, 2026
a6619c4
feat(eql): compare ore-rs 6-bit block ORE in EQL v4, as ore_block_v2
coderdan Oct 19, 2026
9fa1312
feat(golang): read the ore_block index the engine reports
coderdan Oct 19, 2026
45a79a0
feat(eql-bindings): produce the block ORE EQL v4 types
coderdan Oct 19, 2026
1cf5f79
test(eql): let v4 own the JSON tree domains v3 does not in the coexis…
coderdan Oct 19, 2026
a05affa
Merge pull request #1280 from cipherstash/feat/eql-v4-block-ore
coderdan Oct 19, 2026
e4023f2
test(stack-encrypt): kill the JSON index mutants the in-diff gate found
coderdan Oct 20, 2026
81dee32
Merge remote-tracking branch 'origin/next' into feat/json-index-stack…
coderdan Oct 22, 2026
7d65ad9
refactor(stack-encrypt): derive the JSON header's Debug with OpaqueDebug
coderdan Oct 22, 2026
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
13 changes: 13 additions & 0 deletions .cargo/mutants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,19 @@ exclude_re = [
# (`entry_key`): its error is discarded (`.ok()`), so its message is never
# read at all.
'stack-encrypt/src/dynamic/record\.rs:\d+:\d+: replace entry_key::<impl Deserialize<\x27de> for Keys>::deserialize::<impl Visitor<\x27de> for V>::expecting -> fmt::Result with Ok\(Default::default\(\)\)$',
# The JSON index. `unhex` joins a high nibble shifted into the top four
# bits with a low nibble below 16: the bits never overlap, so `^` is `|`.
'stack-encrypt/src/json/mod\.rs:\d+:\d+: replace \| with \^ in unhex$',
# `JsonPath::root` is `Self::default()`.
'stack-encrypt/src/json/path\.rs:\d+:\d+: replace JsonPath::root -> Self with Default::default\(\)$',
# The sealed root's `None` token: with that arm gone, the root falls to
# the arm that wraps its parent's token, and the root has no parent, so
# it is `None` there too. `the_sealed_root_has_no_token_and_its_fields_are_heads`
# pins the value; the arm stays so the match says it.
'stack-encrypt/src/json/derive\.rs:\d+:\d+: delete match arm \(0, true\) in Walker<\x27_>::visit$',
# A document always has a root, so its terms are never empty: `false` is
# the value `is_empty` returns. The `true` replacement is still caught.
'stack-encrypt/src/json/derive\.rs:\d+:\d+: replace JsonTerms::is_empty -> bool with false$',
]

# Headroom over the measured baseline before a slow-but-correct mutant is
Expand Down
7 changes: 7 additions & 0 deletions .changeset/eql-json-tree-domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cipherstash/eql": minor
---

**EQL v4 gains the `json_tree` domain family: `public.eql_v4_json_tree`, an encrypted JSON document stored as one entry per node, with `@>` and `<@` that agree with PostgreSQL's own `jsonb @>` across array elements.** The family is new beside `public.eql_v4_json` and is not a port of the v3 SteVec surface: it is what Stack Encrypt's JSON index writes. A document's entries each carry a path selector `s` and a value selector `vs` (containment is a set comparison over the pair), the node's own ciphertext `c` (its label, kind and scalar value; a document's ciphertext is linear in its size, where a SteVec document repeats every subtree at every level), and a token chain (`p`, `w`, `o`) that lets core PostgreSQL walk the subtree under a node from one client-derived token: `eql_v4.json_tree_subtree(doc, token)`, `json_tree_subtree_at(doc, token, selector)`, `json_tree_children` and `json_tree_child_count`. By default the root is sealed, so no query can hand the server a token for the whole column. Containment projects a stored document into an element-nested shape from its group tags (`n`, `g`), `eql_v4.json_tree_query(doc)`, and compares it with the needle (`eql_v4.query_json_tree`) by native `jsonb @>`, so each needle array element must fit inside one stored element, which closes the cross-element false positives of selector-set containment (#1231) for this family; a GIN index on `(eql_v4.json_tree_query(col)::jsonb) jsonb_path_ops` serves it. `->` by selector returns an entry that opens on its own (`public.eql_v4_json_tree_entry`, with the header and ordinal grafted), and `eql_v4.query_json_tree_leaf` orders an extracted number or string leaf with `<`, `<=`, `>`, `>=`. Every other native jsonb operator on the column is blocked, as on the other EQL domains. EQL v4 only (`-- ONLY EQL VERSION: 4`); the v3 bundle is unchanged. The `eql-bindings` crate carries the hand-written `v4::json_tree` types with Stack Encrypt's `EncryptFrom` / `DecryptInto` for the document and the entry.

Why. Stack Encrypt had no JSON index: the operators (`Contains`, `ContainedBy`, `AtPath`) were declared and answered by nothing, and the v4 JSON wire format was unconstrained by anything shipped. This is the spike's Phase 1 and 2 (#1249), implemented against `docs/plans/2026-10-17-json-index-stack-encrypt-spike.md`, whose "Implementation notes" section records where the code deviates from the design.
2 changes: 1 addition & 1 deletion .changeset/eql-v4-bindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
'@cipherstash/eql': minor
---

**The `eql-bindings` crate has an `eql_bindings::v4` module, with the `stack-encrypt` feature: payload types for the EQL v4 domains.** EQL v4 is the v3 SQL installed under a second name (ADR-0002), for values written by Stack Encrypt, so the module exists only with that feature and its types derive Stack Encrypt's traits without a further gate. The module is generated from the same catalog as `v3`, with `eql_v4_*` domain names and `"v": 4`. `SchemaVersion` is now generic over the version it admits: `v3::SchemaVersion` is `SchemaVersion<3>` and `v4::SchemaVersion` is `SchemaVersion<4>`, each refusing the other's payloads, and plain `SchemaVersion` still means v3. Only v4 types derive Stack Encrypt's `EncryptFrom` and `DecryptInto`, and the Stack Encrypt target table (now `eql_bindings::v4::targets`) names v4 domains. Besides `TextEq`, Stack Encrypt now produces the storage-only types `Text`, `Integer`, `Bigint`, `Boolean`, `Real` and `Double`, and `TextMatch` (3-grams, `k = 6`, a 2048-bit filter, pinned as `eql_bindings::encryption::EqlMatch`; text shorter than three characters is refused, stored or queried, and only `stored @@ query` matches until #1171 is fixed), from the Rust primitives `i32`, `i64`, `bool`, `f32` and `f64`; the Go SDK's `encrypt/eql` table marks them producible. Stack Encrypt also produces the number equality types `IntegerEq`, `BigintEq`, `RealEq` and `DoubleEq`, whose `=` compares as PostgreSQL compares the plain types: an integer has one term whatever its width, and for floats `-0.0 = 0.0` and `NaN = NaN` (ADR-0002's semantic baseline). Every type it cannot produce yet names the issue that tracks its blocker. The by-name target entry points (`eql_bindings::encryption::targets::encrypt`, `decrypt` and `query`) exchange the plaintext as Stack Encrypt's `dynamic::ValueFrame`, one value as its transport frame, rather than a value tree. They, and the generated `v4::targets` table, need the new `targets` feature, which turns on `stack-encrypt` and Stack Encrypt's `dynamic` feature. `stack-encrypt` alone no longer builds Stack Encrypt's `dynamic` layer, so a Rust program using the typed v4 types through `encrypt_as` doesn't compile it.
**The `eql-bindings` crate has an `eql_bindings::v4` module, with the `stack-encrypt` feature: payload types for the EQL v4 domains.** EQL v4 is the v3 SQL installed under a second name (ADR-0002), for values written by Stack Encrypt, so the module exists only with that feature and its types derive Stack Encrypt's traits without a further gate. The module is generated from the same catalog as `v3`, with `eql_v4_*` domain names and `"v": 4`. `SchemaVersion` is now generic over the version it admits: `v3::SchemaVersion` is `SchemaVersion<3>` and `v4::SchemaVersion` is `SchemaVersion<4>`, each refusing the other's payloads, and plain `SchemaVersion` still means v3. Only v4 types derive Stack Encrypt's `EncryptFrom` and `DecryptInto`, and the Stack Encrypt target table (now `eql_bindings::v4::targets`) names v4 domains. Besides `TextEq`, Stack Encrypt now produces the storage-only types `Text`, `Integer`, `Bigint`, `Boolean`, `Real` and `Double`, and `TextMatch` (3-grams, `k = 6`, a 2048-bit filter, pinned as `eql_bindings::encryption::EqlMatch`; text shorter than three characters is refused, stored or queried, and only `stored @@ query` matches until #1171 is fixed), from the Rust primitives `i32`, `i64`, `bool`, `f32` and `f64`; the Go SDK's `encrypt/eql` table marks them producible. Stack Encrypt also produces the number equality types `IntegerEq`, `BigintEq`, `RealEq` and `DoubleEq`, whose `=` compares as PostgreSQL compares the plain types: an integer has one term whatever its width, and for floats `-0.0 = 0.0` and `NaN = NaN` (ADR-0002's semantic baseline). Every type it cannot produce yet names the issue that tracks its blocker. A Rust caller queries those types through Stack Encrypt's typed query chain: `cipher.query(op::Eq, v).on(&plan)` on a plan for `TextEq` or a number equality type returns the type's query twin, and `cipher.query(op::Matches, text).on(&plan)` on a plan for `TextMatch` returns a `MatchTwin<TextMatchQuery>` with `verify` and `target_limit`, the same bytes and answers as the data path the Go SDK uses (`eql_bindings::encryption::query`; an operator a type does not answer does not compile). The by-name target entry points (`eql_bindings::encryption::targets::encrypt`, `decrypt` and `query`) exchange the plaintext as Stack Encrypt's `dynamic::ValueFrame`, one value as its transport frame, rather than a value tree. They, and the generated `v4::targets` table, need the new `targets` feature, which turns on `stack-encrypt` and Stack Encrypt's `dynamic` feature. `stack-encrypt` alone no longer builds Stack Encrypt's `dynamic` layer, so a Rust program using the typed v4 types through `encrypt_as` doesn't compile it.

**The EQL v4 SQL bundle has range query domains and `eql_v4.between`, so a `BETWEEN` query is one operand.** The v4 bundle (`cipherstash-encrypt-v4.sql`) is built from this source and reaches users as the `eql-v4-install.sql` attached to each Go release; neither the npm package nor the crate ships it, and the v3 SQL they ship does not change. Every ordered family has one range query domain per order term, beside its single-bound ones: `eql_v4.query_integer_range_ore` beside `eql_v4.query_integer_ord_ore`, `eql_v4.query_integer_range_ope` beside `eql_v4.query_integer_ord_ope`, and the same for `smallint`, `bigint`, `date`, `timestamp`, `numeric`, `text`, `real` and `double`. A range is `{"v": 4, "i": …, "lo": <term>, "hi": <term>}`: each bound is the single-bound domain's `ob` (ORE) or `op` (OPE) value, in a `{v, i, lo, hi}` envelope. Stack Encrypt does not build that envelope yet; it will need to when the ordered types become producible. `eql_v4.between(column, range)` is true when the column's order term lies between the bounds, both included, and has an overload for every ordered column domain with the range of the term it stores (`_ord_ore` and `_search_ore` take the ORE range; `_ord`, `_ord_ope` and `_search` the OPE one). It is an inlinable SQL function, so a btree on the column's order term (`eql_v4.ord_term_ore(col)` or `eql_v4.ord_term(col)`) serves it as one index range scan. Write it schema-qualified (`BETWEEN` is an SQL keyword, so a bare `between(...)` does not parse), and cast the range to its exact domain (`$1::eql_v4.query_integer_range_ore`): an uncast `$1`, a `jsonb` parameter, or a range of another term or family fails with SQLSTATE 42725 ("function … is not unique"), unlike the single-bound operators, which resolve an untyped `$1`. An ORE range scan needs the `ore_block_256` btree operator class, which the installer creates only as a superuser; on an install without it (Supabase and other non-superuser roles), the ORE range domains are disabled like the single-bound ORE domains, and a value cast to one raises `feature_not_supported` naming the family's OPE range. In the crate, `eql_bindings::v4` (`stack-encrypt` feature) has a type for each range domain (`IntegerRangeOreQuery`, `TextRangeOpeQuery`, …, listed by `v4::all_range_query()`). EQL v3 has no range domains: its SQL and types are unchanged (ADR-0009, decision 7).

Expand Down
5 changes: 5 additions & 0 deletions .changeset/eql-v4-block-ore-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cipherstash/eql': minor
---

**EQL v4 compares ore-rs's 6-bit block ORE, and Stack Encrypt produces the block ORE types `IntegerOrdOre`, `SmallintOrdOre`, `BigintOrdOre`, `RealOrdOre`, `DoubleOrdOre`, `TextOrdOre` and `TextSearchOre`.** A v4 `ob` term is one ore-rs ciphertext: the fixed-width scheme for numbers, the chained scheme for text (padded to a power-of-two length bucket of at least 16 bytes). The v4 bundle names its block ORE objects `ore_block_v2` (`eql_v4_internal.ore_block_v2_term`, `compare_ore_block_v2_term`) and compares them with a new comparator that checks each term's shape, raises on malformed or mismatched terms, and runs a constant operation count for terms of one shape. The EQL v3 bundle is unchanged, byte for byte: its `ob` is still cipherstash-client's 8-bit block ORE under `ore_block_256`. See [U-002](../../docs/upgrading/v3.1.md#u-002-the-block-ore-term-is-ore-rss-6-bit-scheme): the comparator refuses the 8-bit terms the previous `eql_v4` bundle compared, so rows or operands holding them must be written again through Stack Encrypt.
5 changes: 5 additions & 0 deletions .changeset/eql-v4-text-search-optional-terms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cipherstash/eql': minor
---

**An EQL v4 text search query carries only the terms its operator reads.** `eql_v4.query_text_search` and `eql_v4.query_text_search_ore` now require at least one of their terms (`hm`, `op` or `ob`, `bf`) instead of all of them, so `col = $1` and `col < $1` work on text too short to cut a 3-gram, which has no match term. Each extractor over such a query raises (`invalid_parameter_value`) wherever it reads the operand when the query lacks the term it reads, or holds it as JSON `null` or an empty array, naming the domain, the term and the operators that compare it: `=` without `hm`, `<` without `op`, `@@` without `bf`. So a missing term raises rather than matching no row silently, or every row; the raise is at evaluation, so a generic plan that never reaches the predicate returns no row and no error. Stored `eql_v4_text_search` values still carry every term. The `eql_v3` domains are unchanged: the v3 bundle is byte for byte what it was. With it, **Stack Encrypt produces `TextSearch`** (`eql-bindings`, `stack-encrypt` feature): `TextSearchQuery`'s terms are `Option`s, built per index through `TextSearchEqualityQuery`, `TextSearchOrderQuery` and `TextSearchMatchQuery`, and the stored value carries every term: text too short for a 3-gram stores the empty filter (`"bf": []`, the new `v4::terms::SearchBloomFilter` field type), so `=` and `<` find it and no `@@` query does, where `TextMatch` still refuses it. `TextSearchQuery`'s `Option` terms and `TextSearch`'s `SearchBloomFilter` change the fields of the unreleased `v4` module only; no published `eql-bindings` has it. See [U-001](../../docs/upgrading/v3.1.md#u-001-a-text-search-query-carries-only-the-terms-its-operator-reads) for what a hand-built query must carry, the raise, and the rollback.
6 changes: 6 additions & 0 deletions .github/workflows/test-eql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,12 @@ jobs:
rustup component add --toolchain "${active_rust_toolchain}" rustfmt clippy
mise run test:crates

# The block ORE timing harness's ciphertext generator is a detached
# workspace with its own Cargo.lock that no job builds, so its lock is
# checked here rather than left to drift until someone runs the harness.
- name: Check the timing harness's Cargo.lock
run: cargo metadata --locked --format-version 1 --manifest-path tests/timing/ore_block_v2/ctgen/Cargo.toml > /dev/null

# The three encryption checks are mise tasks rather than raw cargo here,
# so `eql-suite-ci.test.mjs` counts them among the cargo tasks a root
# workflow must reach, and `eql-workflow-filters.test.mjs` derives the
Expand Down
20 changes: 18 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,18 @@ monorepo, which is where the silent failures are.
- **Changesets for `@cipherstash/eql` go in the repo-root `.changeset/`.** The
subtree's own `.changeset/` was deleted with the import; there is no second
one to put them in by mistake.
- **The JSON tree family is EQL v4 only and spans two crates.** The engine
half is `packages/stack-encrypt/src/json/` (the `Json` index, `JsonDocument`,
the query terms); the SQL is `packages/eql/src/v3/json_tree/` under
`-- ONLY EQL VERSION: 4`; the EQL types are hand-written in
`packages/eql/crates/eql-bindings/src/v4/json_tree.rs`; and the live
oracle, containment over the encrypted column against PostgreSQL's own
`jsonb @>` over the plaintexts, is `packages/eql/tests/encryption/tests/json_tree.rs`,
run by `mise run test:encryption:postgres`. The design and its
implementation notes are `docs/plans/2026-10-17-json-index-stack-encrypt-spike.md`.
A change to the derivations on one side (a PAE domain, the header layout,
the projection shape) is a change to the other: the Postgres suite is
what holds them together.

## Working on the Rust crates

Expand All @@ -695,8 +707,12 @@ exact pin would stop the suite sharing one `cts-common` with it.
- **Three Cargo workspaces, not one.** The root workspace (the six stack-*
crates and the three node binding crates), protect-ffi's and EQL's. The root
`Cargo.toml` excludes the other two, and they pin their own vitaminc. The
three fuzz crates and the two Go guests are detached workspaces too, each
with its own `Cargo.lock`.
three fuzz crates, the two Go guests and the EQL v4 block ORE timing
harness's ciphertext generator
(`packages/eql/tests/timing/ore_block_v2/ctgen`) are detached workspaces
too, each with its own `Cargo.lock`. None of them has a Dependabot entry.
CI builds the generator nowhere, so `test-eql.yml` checks its lock with
`cargo metadata --locked`, and `run.sh` builds it with `--locked`.
- **The toolchain is pinned in the root `mise.toml`:** Rust 1.94.1 (the
`stack-encrypt` `tests/ui` trybuild snapshots record its diagnostics) and
Go 1.26. EQL's `mise.toml` overrides the Rust pin in its folder.
Expand Down
Loading
Loading