Repository navigation
EQL JSON containment (@>, <@) matches across array elements, so Contains is not exact yet #1231
Description
Background
EQL (Encrypt Query Language) is the SQL library we install into a customer's PostgreSQL database, as the eql_v3 schema today and eql_v4 next. It lets the database search encrypted values without decrypting them. For an encrypted JSON column, each stored document carries a SteVec index: a list with one entry per leaf of the document. Each entry has a selector, a keyed hash of the leaf's path, or of its path and value. A containment query (@>, "does this document contain that JSON?") encrypts the query document the same way, and the database compares selectors.
Stack Encrypt, the Rust encryption engine behind the Go SDK, declares JSON operators Contains (@>) and ContainedBy (<@) in #1209. It declares both as exact: the database's answer is the answer, and the library offers no verify step to check rows after decryption. We are keeping that declaration on purpose. This issue tracks the fact that the database does not yet deliver it.
Problem
What happens. EQL's containment is selector-set containment, not PostgreSQL's jsonb @>. A stored document "contains" a query document when every selector of the query is somewhere in the stored document (eql_v3.jsonb_document_contains, packages/eql/src/v3/json/functions.sql:298-334; packages/eql/docs/reference/json-support.md:214). Every element of an array shares one wildcard path ($.a[*]), so selectors cannot tell which array element a value came from (packages/eql/docs/plans/stevec-envelope-key-header-rfc.md:57).
A false positive (verified from the two facts above):
- Stored:
{"a":[{"x":1,"y":5},{"x":7,"y":2}]} - Query:
{"a":[{"x":1,"y":2}]} - The query's value selectors are
$.a[*].x = 1and$.a[*].y = 2. Both are in the stored document, so the database returns the row. - Plain
jsonb @>on the decrypted documents says false: no single element has bothx = 1andy = 2.
<@ has the same problem in reverse.
A possible false negative (plausible, not verified): in plain jsonb, '["a"]' @> '"a"' is true (a top-level array contains a top-level scalar). The query's selector sits at $, the stored element's at $[*]. Unless SteVec maps one onto the other, selector containment says false. A false negative is worse than a false positive, because no check after decryption can bring back a row the database never returned.
Who hits it. Anyone running a containment query on an encrypted JSON column with arrays of objects. This is today's behaviour in production through EQL v3, so it is a bug in what we ship now, not something #1209 introduces. #1209 only declares the operators; no Stack Encrypt index answers them yet.
Why nothing catches it. The EQL tests check containment against EQL's own selector rule, not against jsonb @> on the decrypted documents. The operator is declared exact, so callers are told the answer needs no checking.
Proposal
- Add a test that runs the same containment queries through EQL and through plain
jsonb @>/<@on the plaintext documents, and reports every disagreement. Start with the array-of-objects case above and the top-level-scalar case. That turns "plausible" into "verified" for the second case and pins the first. - Make the database answer agree with
jsonbcontainment for arrays. The selector would need to say which array element a value belongs to, without revealing more than today's index does. This is a SteVec and EQL change, and it has to work for EQL v4 too. - Until step 2 ships, document the gap where callers will read it: the
ContainsandContainedBydocs instack-encrypt(src/op/mod.rs), ADR-0009, and the EQL JSON reference. Say plainly that arrays of objects can return extra rows. - Keep the declaration exact. A step back to
Probabilistic(false positives filtered byverifyafter decryption) would hide a database bug inside the library, and would still not cover the false-negative case.
Relationship to other work
- feat(stack-encrypt): JSON operators Contains, ContainedBy and AtPath #1209 declares
ContainsandContainedByas exact. This issue is the follow-up agreed in its review. - EQL v3 Supabase: JSON containment/selector operands carry full storage ciphertext in GET query strings #654 is a different JSON containment issue (query operands carried in GET query strings on Supabase).
Activity
docs(plans): record why plain SHA-256 holds for the token chain, keep…
docs(plans): ship the JSON tree scheme as a new EQL v4 domain, not th…
docs(plans): seal the root by default so no query can hand the server…
docs(plans): record epoched tokens as the future bound on how long a …
docs(plans): native jsonb containment over an element-nested projecti…
coderdan commentedon Oct 18, 2026
The fix for the v4 side is designed in #1249 (a spike for a JSON index in Stack Encrypt, shipped as a new EQL v4 domain). Two findings from writing it, both reproduced on PostgreSQL 16 with a plaintext-selector simulation of the v3 flattening:
jsonb @>; scalars in arrays and single-constraint elements never do.'["a"]'::jsonb @> '"a"'is true in PostgreSQL and false under selector containment.The proposed fix groups entries by array element and lets native
jsonb @>over an element-nested projection enforce the same-element rule, with a second selector set for a root-scalar needle. It does not depend on the token chain, so it is portable to the v3 wire and SQL if the v3 bug is to be fixed too; the plan records that as separable rather than proposing it. Step 1 of this issue (the oracle test against the realeql_v3SQL) is Phase 0 of the spike.Generated by Claude Code