Skip to content

feat(stack-drizzle): EQL v3 JSON selector-with-constraint querying#651

Merged
coderdan merged 6 commits into
mainfrom
feat/eql-v3-json-selector
Jul 15, 2026
Merged

feat(stack-drizzle): EQL v3 JSON selector-with-constraint querying#651
coderdan merged 6 commits into
mainfrom
feat/eql-v3-json-selector

Conversation

@coderdan

@coderdan coderdan commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Adds JSONPath selector-with-constraint querying for EQL v3 encrypted JSON to the Drizzle integration. Refs #623. Draft — pending live integration CI.

What it adds

ops.selector(col, '$.path').{eq,ne,gt,gte,lt,lte}(value) on a types.Json column. Complements contains (@>); its unique power is ordering at a path (col->'$.age' > 25), which containment can't express.

How it works (verified via FFI probes)

Emits an entry-vs-entry comparison:

eql_v3.<op>( eql_v3.jsonb_path_query_first(col, '<sel>'),
             eql_v3.jsonb_path_query_first('<needle>'::eql_v3_json, '<sel>') )
  • <sel> = encryptQuery('$.path', searchableJson) — the JSONPath-string query returns the ordering selector, which matches the stored op entry.
  • <needle> = a storage encrypt of {path: value} — its ste_vec entry at that selector carries c + op/hm. eq_term/ord_term read only hm/op, so the comparison is correct.

Why storage (interim), not a ciphertext-free query term

The clean ciphertext-free path is blocked upstream: encryptQuery can't mint a scalar/ordering query term against a ste_vec column — it only produces the hm equality needle. Probes confirmed the stored side already has the op term and the query_jsonb domain already permits it; the only gap is protect-ffi emitting an ordering needle → cipherstash/protectjs-ffi#137. Until that lands, the RHS is a storage needle, so the value's ciphertext appears in the WHERE clause (documented inline). When #137 ships, the RHS drops the ciphertext with no API change.

An earlier revision tried the ciphertext-free form and was caught red by the live integration job ("Index type not configured") — the storage form is what actually works.

Scope

Review fixes folded in

Promise.all for the two independent encrypts; throw (not silently mis-bind) when the selector term isn't a bare string; correct path normalization + empty-path rejection; dropped the fragile JS-type→query-domain inference.

Verification

  • Builds clean; 357 Drizzle unit tests pass; Biome clean.
  • SQL-build probe confirms the operand production no longer throws and emits the intended SQL.
  • Row-correctness is verified by the live integration job (integration/json-selector.integration.test.ts).

Remaining before ready

  • Live integration CI green (re-running on this push).
  • skills/stash-drizzle + skills/stash-encryption docs (they currently say this is "not yet implemented" — must be corrected; holding until CI green).

Refs #623 · #650 · #404 · cipherstash/protectjs-ffi#137
https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

Summary by CodeRabbit

  • New Features
    • Added JSONPath selector-with-constraint querying for encrypted JSON columns, including equality and range/order comparisons via selector(col, '$.path').
    • Supports dot-notation paths (e.g., $.profile.name) and exposes eq, ne, gt, gte, lt, lte.
    • Complements existing encrypted JSON containment (contains) behavior.
  • Bug Fixes
    • Added clear rejection behavior for unsupported array-index and wildcard JSONPath selectors.
  • Documentation
    • Updated operator and encrypted JSON docs to describe the new selector(...) query pattern, semantics, and current limitations.
  • Tests
    • Added integration coverage for selector constrained queries and expected failure cases.

Add ops.selector(col, '$.path') to the v3 Drizzle integration (#623): comparison
methods (eq/ne/gt/gte/lt/lte) bound to a JSONPath into a types.Json column,
emitting `eql_v3.<op>(eql_v3."->"(col,'<sel>'), eql_v3."->"('<needle>'::eql_v3_json,'<sel>'))`.
Its unique power over `contains` is ORDERING at a path (col->'$.age' > 21).

- Selector hash from encryptQuery(path, searchableJson) (string needle → ste_vec_selector).
- Comparison entry from a STORAGE encrypt of {path: value} — its ciphertext `c`
  is required by the eql_v3_jsonb_entry domain (a query term can't carry it).
- One new dialect fn (selectorEntry); reuses equality/comparison for the compare.
- v1: dot-notation object paths; array/wildcard rejected with a clear error.

Core needs no change. Supabase is a follow-up (#650) — blocked by PostgREST's
inability to cast a filter value.

Refs #623
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@coderdan, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1cbf2269-70fe-428f-889c-97a33981b0ed

📥 Commits

Reviewing files that changed from the base of the PR and between b5b18c3 and 36ecb3b.

📒 Files selected for processing (6)
  • .github/workflows/tests.yml
  • packages/stack-drizzle/__tests__/v3/operators.test-d.ts
  • packages/stack-drizzle/__tests__/v3/selector.test.ts
  • packages/stack-drizzle/integration/json-selector.integration.test.ts
  • packages/stack-drizzle/src/v3/operators.ts
  • skills/stash-encryption/SKILL.md
📝 Walkthrough

Walkthrough

Adds ops.selector(col, '$.path') to Drizzle v3 encrypted JSON operators, supporting equality and ordering comparisons for dot-notation JSONPath selectors. The change includes SQL dialect support, integration tests, documentation updates, and release metadata.

Changes

JSON selector comparisons

Layer / File(s) Summary
Selector operator implementation
packages/stack-drizzle/src/v3/operators.ts, packages/stack-drizzle/src/v3/sql-dialect.ts
Adds selector parsing, encrypted selector comparison construction, selectorEntry SQL generation, and public eq/ne/gt/gte/lt/lte operators.
Encrypted JSON integration validation
packages/stack-drizzle/integration/json-selector.integration.test.ts
Encrypts fixture documents and tests selector equality, ordering, unmatched values, and rejection of array or wildcard paths.
Documentation and release metadata
.changeset/eql-v3-json-selector.md, skills/stash-drizzle/SKILL.md, skills/stash-encryption/SKILL.md
Documents selector constraints, supported paths, limitations, capabilities, and package version changes.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Application
  participant DrizzleOperators
  participant EncryptionClient
  participant PostgreSQL
  Application->>DrizzleOperators: selector(column, '$.path').gt(value)
  DrizzleOperators->>EncryptionClient: encrypt selector and RHS data
  EncryptionClient-->>DrizzleOperators: encrypted selector entry data
  DrizzleOperators->>PostgreSQL: execute selector comparison SQL
  PostgreSQL-->>Application: matching rows
Loading

Possibly related issues

  • cipherstash/stack issue 623 — Directly covers EQL v3 JSONPath selector-with-constraint support.
  • cipherstash/stack issue 650 — Covers the Drizzle-side selector capability implemented here.
  • cipherstash/protectjs-ffi issue 137 — Relates to the storage-encrypted workaround for ordering needles.
  • cipherstash/encrypt-query-language issue 404 — Relates to the underlying database operators for JSON selector comparisons.
  • cipherstash/stack issue 423 — Relates to the SQL-level selector-entry extraction implemented here.

Suggested reviewers: tobyhede

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding EQL v3 JSON selector-with-constraint querying in stack-drizzle.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/eql-v3-json-selector

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 36ecb3b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@cipherstash/stack-drizzle Minor
stash Patch
@cipherstash/bench Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

coderdan added 3 commits July 15, 2026 08:55
…aint

Rework the selector path to be entirely ciphertext-free (the storage-payload RHS
was wrong — it leaked ciphertext into the WHERE clause). Both operands are query
terms; the comparison reduces to the encrypted term on each side:

  eql_v3.<term>(eql_v3.jsonb_path_query_first(col,'<sel>')) <cmp> eql_v3.<term>(<operand>)

where <term> is eq_term (=/<>) or ord_term (</<=/>/>=) — both collapse to
bytea (hmac_256 / ope_cllw) and compare natively.

- selector hash: encryptQuery(path, searchableJson) (string → ste_vec_selector).
- RHS: a scalar encryptQuery(value, { queryType: equality|orderAndRange }) term
  cast to eql_v3.query_<T>_<eq|ord>, T inferred from the leaf value's JS type.
- Drops the client `encrypt` requirement and the doc reconstruction entirely.

Assumes protect-ffi keys the scalar query term with the column's key (verified by
the live tests). The bundle lacks a (jsonb_entry, query_<T>) operator, so the
direct-function form is used; adding it upstream is an EQL follow-up.

Refs #623
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
The ciphertext-free approach was non-functional: encryptQuery can't mint a
scalar/ordering query term against a ste_vec (JSON) column, so the value call
threw "Index type not configured" (caught by CI's live integration job).

Verified via FFI probes: the stored ste_vec entry at the JSONPath selector
carries c + op (ordering) / hm (equality), and encryptQuery('$.path') returns
the selector that matches it. So the RHS is a STORAGE encryption of {path: value}
whose entry is extracted with jsonb_path_query_first and compared entry-vs-entry
(eq_term/ord_term read only hm/op). Ciphertext-free ordering needs a protect-ffi
change (cipherstash/protectjs-ffi#137); until then the value's ciphertext is in
the WHERE clause — documented inline.

Also from the code review: use Promise.all for the two independent encrypts;
throw (not silently mis-bind) if the selector term isn't a bare string; fix path
normalization (leading $/.) and reject the empty/root path; drop the fragile
JS-type→query-domain inference entirely.

Refs #623 · protect-ffi gap cipherstash/protectjs-ffi#137 · EQL operator #404
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Update stash-encryption + stash-drizzle skills for the new ops.selector(col,
'$.path').{eq,ne,gt,gte,lt,lte} surface — they previously said JSONPath selector
queries were 'not yet implemented'. Clarify that direct eq/gt/asc on a Json
column still throw; selector-with-constraint is the path-scoped form, with
ordering-at-a-path its unique power over containment. Skills ship in the stash
tarball, so this carries a stash patch changeset.

Refs #623
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan
coderdan marked this pull request as ready for review July 15, 2026 03:46
@coderdan
coderdan requested a review from a team as a code owner July 15, 2026 03:46
@coderdan
coderdan requested a review from tobyhede July 15, 2026 03:48

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/stack-drizzle/integration/json-selector.integration.test.ts`:
- Around line 68-76: Update the integration-test setup around TABLE_NAME to
remove the unconditional DROP TABLE operation and create the shared table
idempotently with CREATE TABLE IF NOT EXISTS. Preserve the existing RUN-specific
cleanup so setup deletes only rows belonging to the current test run.

In `@packages/stack-drizzle/src/v3/operators.ts`:
- Around line 632-643: Update the JSDoc for the selector-with-constraint flow in
packages/stack-drizzle/src/v3/operators.ts (lines 632-643) to describe the
interim storage-needle behavior and acknowledge that the encrypted RHS document
appears in generated SQL instead of calling the operation ciphertext-free. Add
the same limitation to the relevant guidance at skills/stash-drizzle/SKILL.md
(line 791) and skills/stash-encryption/SKILL.md (lines 691-700), ensuring each
skill file states that selector RHS ciphertext currently appears in the WHERE
clause and remains self-sufficient.
- Around line 115-124: Update the selector-document construction around the root
and nested objects in the segments traversal to use null-prototype objects,
preventing a `__proto__` path segment from invoking the prototype setter.
Preserve the existing cursor assignment and value placement behavior while
ensuring every intermediate and root object is created without inherited
properties.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 79f8944b-e042-4c46-919e-c36d9ba0417c

📥 Commits

Reviewing files that changed from the base of the PR and between a9f480f and f45963f.

📒 Files selected for processing (6)
  • .changeset/eql-v3-json-selector.md
  • packages/stack-drizzle/integration/json-selector.integration.test.ts
  • packages/stack-drizzle/src/v3/operators.ts
  • packages/stack-drizzle/src/v3/sql-dialect.ts
  • skills/stash-drizzle/SKILL.md
  • skills/stash-encryption/SKILL.md

Comment thread packages/stack-drizzle/src/v3/operators.ts Outdated
Comment thread packages/stack-drizzle/src/v3/operators.ts Outdated
- reconstructSelectorDocument: use null-prototype objects so a `__proto__` path
  segment becomes an own key instead of invoking the prototype setter (which
  would mis-serialize the needle → no matches).
- Fix stale selectorCompare JSDoc: it still described the reverted ciphertext-free
  approach ('No ciphertext, no storage encryption') and referenced the renamed
  selectorConstraint. Now documents the storage-needle interim consistently.
- Disclose the interim ciphertext-in-WHERE behavior in both stash-drizzle and
  stash-encryption skills (each must be self-sufficient).

Skipped: CodeRabbit's DROP-TABLE-IF-EXISTS suggestion — the sibling
json-contains integration test uses the same DROP+CREATE pattern and the RUN
discriminator isolates rows; hardening should be repo-wide, not one file.

Refs #623
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan

Copy link
Copy Markdown
Contributor Author

Addressed CodeRabbit's review in b5b18c3:

  • __proto__ prototype setter (correctness): reconstructSelectorDocument now builds the needle with null-prototype objects (Object.create(null)), so a $.__proto__.x segment becomes an own key instead of hitting the prototype setter. Verified: JSON.stringify{"__proto__":{...}} with the own key intact.
  • Stale selectorCompare JSDoc + skills disclosure: the doc header still described the reverted ciphertext-free approach ("No ciphertext, no storage encryption") and referenced the renamed selectorConstraint — fixed to document the storage-needle interim. Both stash-drizzle and stash-encryption skills now disclose that the comparison value is currently a storage-encrypted document (its encrypted value appears in the WHERE clause), pending EQL v3 ste_vec: encryptQuery can't mint an ordering (op) JSON query needle for selector-with-constraint protectjs-ffi#137.
  • Skipped — DROP TABLE in setup: the sibling json-contains.integration.test.ts uses the same DROP TABLE IF EXISTS + CREATE TABLE pattern, and the RUN discriminator isolates rows; the integration variants also run against separate databases. Diverging one file would break the convention — worth a repo-wide hardening pass separately if desired, not here.

Build clean, 357 unit tests pass, Biome clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@skills/stash-drizzle/SKILL.md`:
- Line 791: In the selector documentation sentence, update the EQL version label
from “v1 supports” to “v3 supports” while preserving the listed supported and
rejected JSONPath forms.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0f51e196-6852-4206-81fd-95c43469b3f6

📥 Commits

Reviewing files that changed from the base of the PR and between f45963f and b5b18c3.

📒 Files selected for processing (3)
  • packages/stack-drizzle/src/v3/operators.ts
  • skills/stash-drizzle/SKILL.md
  • skills/stash-encryption/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • skills/stash-encryption/SKILL.md
  • packages/stack-drizzle/src/v3/operators.ts

Comment thread skills/stash-drizzle/SKILL.md
Comment thread packages/stack-drizzle/src/v3/operators.ts
Comment thread packages/stack-drizzle/src/v3/operators.ts Outdated
Comment thread packages/stack-drizzle/src/v3/operators.ts Outdated
}

/**
* JSONPath selector-with-constraint on an `eql_v3_json` (`ste_vec`) column:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Source: Claude

selectorCompare JSDoc (operators.ts:632–647) is stale — describes the abandoned ciphertext-free design
and contradicts the implementation:

  • "entirely ciphertext-free" / "No ciphertext, no storage encryption" — false; the RHS is
    client.encrypt(...) (storage), as the inline comment at 666–673 correctly states.
  • value described as encryptQuery(..., {queryType}) cast to eql_v3.query_<T>_<eq|ord> — actually
    client.encrypt cast to public.eql_v3_json.
  • references v3Dialect.selectorConstraint, which doesn't exist (helper is selectorEntry).

Rewrite to match the storage-needle implementation (or drop it and keep the inline comment), and fix
the selectorConstraintselectorEntry reference.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction (edited after posting): My original review claimed finding #1 "breaks CI." That's wrong, and I've corrected it below. The type-double failure is real — I reproduced it by running pnpm --filter @cipherstash/stack-drizzle test:types — but it does not turn CI red, because CI only runs test:types for @cipherstash/stack and @cipherstash/test-kit (.github/workflows/tests.yml:110,117), never for stack-drizzle, and the Run Tests jobs use vitest run (no --typecheck). All of this PR's actual checks are green. Apologies for the overstatement. The rest of the review stands.


Reviewed with a multi-angle pass (line-by-line, removed-behavior, cross-file tracing, reuse/simplification/efficiency/altitude/conventions), each surviving candidate independently verified against the head commit, the core @cipherstash/stack helpers, and the vendored EQL v3 domain config.

Overall: really nice piece of work. The entry-vs-entry comparison design is clever, the interim storage-needle tradeoff is honestly reasoned and documented inline, and the operator/dialect code is unusually well-commented. The selValue shape guard, the empty-object match-all guard, and the concurrent-encrypt structure are all the right instincts. I verified two of my own worries away: the ste_vec-only capability gate is sufficient (the single types.Json domain hardcodes mode: 'compat', so every ste_vec entry always carries the op ordering term), and the production clients all expose encrypt, so the runtime path is sound.

I'm keeping this at request changes for the two confirmed items below — but note that with the CI framing corrected, none of these is a hard merge-blocker in the sense of turning CI red; they're a stale regression guard and a behavioral regression that are worth fixing in this PR rather than deferring.

Should fix in this PR

1. The type-double regression guard is broken under the package's own test:types (__tests__/v3/operators.test-d.ts:57). Adding the required encrypt(value, opts) member to OperandEncryptionClient (operators.ts:76, non-optional) means the { encryptQuery }-only double at lines 50-56 is no longer assignable, so expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith(double) fails to compile. Reproduced locally:

× accepts a minimal structural { encryptQuery } double
  → Property 'encrypt' is missing in type '{ encryptQuery … }' but required in type 'OperandEncryptionClient'.
  __tests__/v3/operators.test-d.ts:57

This is not CI-gated (see the correction above), so it won't block the merge — but it means the package's test:types is red and the M1 regression guard no longer does its job. Two knock-ons: the @ts-expect-error on the erased double (lines 67-73) is now satisfied by the missing-encrypt error instead of the intended encryptQuery-erasure error, silently defanging the #622 guard; and the runtime doubles in operators.test.ts:77 / bigint.test.ts:28 also omit encrypt (they pass today only because those paths never call it). Fix: add a minimal encrypt to the doubles and re-verify the erased assertion still fails for the erasure reason. Separately, it may be worth adding stack-drizzle to the CI test:types matrix so this guard is actually enforced.

2. The leaf-value-type guard was dropped and not re-established (operators.ts:654). Git history shows a prior revision rejected leaf values with no eq/ord query domain (else base = null // boolean has no eq/ord query domain, throwing no ordering query domain for a <type> leaf value). The storage-needle rewrite kept only requireNonNullOperand + path-syntax validation, so ops.selector(col, '$.flag').gt(true) or .gt({ nested: 1 }) now storage-encrypts the non-scalar/boolean leaf and emits an ordering comparison instead of the clean up-front EncryptionOperatorError the removed guard gave. Re-add a scalar-leaf check before encrypting the needle.

In plain English:

What the feature does: This PR lets you query encrypted JSON by a path — e.g. "find rows where the age field inside the JSON is greater than 21," written as ops.selector(users.doc, '$.age').gt(21). The value you compare against (here, 21) is the "leaf value."

What the guard used to do: An earlier version of this code checked the type of that value before doing anything with it. Some comparisons only make sense for ordered things like numbers and dates. Asking "is this field greater than true?" (a boolean) or "is this field greater than {nested: 1}?" (an object) is nonsense — booleans and objects don't have a "greater than" ordering. So the old code caught that early and threw a clear, friendly error saying, in effect, "you can't do an ordering comparison on this kind of value."

What changed: When the author reworked how the right-hand side gets encrypted (the "storage-needle rewrite"), that type check got deleted and never replaced. The only checks left are "the value isn't null" and "the path string is well-formed." Nothing looks at whether the value is a sensible thing to order.

Why that's a problem: Now if someone writes ops.selector(col, '$.flag').gt(true), the code doesn't stop them. It goes ahead, encrypts the value, builds the SQL, and sends it to the database. One of two bad things then happens:

  • The database rejects it with a cryptic, low-level error that mentions internal encryption types — the user has no idea it just means "you can't order a boolean."
  • Or worse, it runs and quietly returns the wrong rows.

Either way, the user gets a confusing failure late (at the database) instead of a clear message early (from the SDK), which is a worse experience than before.

The fix I suggested: Put back a check that the value is a scalar (number/date/string — something orderable) before encrypting it, so the SDK rejects gt(true) up front with a readable error, the way it used to.

One caveat: I confirmed this one against the actual git history (the deleted lines really are there), so I'm confident it's real. But I should be honest that I did not run it against a live database to confirm exactly which of the two bad outcomes happens (opaque error vs. wrong rows) — that distinction is my inference, not something I verified end to end.

3. ne and absent-path semantics are undefined, untested, and mis-documented (operators.ts:730, 745). For a row whose document lacks the queried path, eql_v3.jsonb_path_query_first returns SQL NULL, and ne routes straight into eql_v3.neq(NULL, needle) with no COALESCE/guard — under three-valued logic those rows are silently excluded from .ne(x), which is almost certainly not what a caller expects ("not equal to x" usually includes "has no x"). ne has zero test coverage (the integration suite exercises only eq/gt/gte/lt, and every seed row has $.age), and the jsdoc advertises it as plain col->'path' <> value with no NULL caveat. Please define the absent-path semantics, add a test that seeds a row missing the path, and document the behavior — or drop ne from v1 if the semantics aren't settled. (Like #2, the exact runtime NULL behavior is my inference from standard SQL three-valued logic; the live integration job is the definitive check.)

Follow-up (fine for subsequent PRs)

  • Share core's path helpers instead of forking them (operators.ts:85/102/111). parseSelectorSegments, jsonPathOf, and reconstructSelectorDocument re-implement core's parseJsonbPath, toJsonPath, and buildNestedObject (packages/stack/src/encryption/helpers/jsonb.ts, already re-exported from helpers/index.ts — they're just not on a public package subpath, which is the thing to fix). This is now the third copy of these (core, packages/protect, and here), and selector correctness depends on this normalization staying byte-identical to whatever core uses to build the stored ste_vec selectors. Notably the fork also drops core's FORBIDDEN_KEYS rejection (prototype/constructor), so $.constructor is accepted here but rejected by core. Prefer exporting these from @cipherstash/stack/adapter-kit (which stack-drizzle already imports) and deleting the fork.

  • Reject .. / malformed paths instead of silently collapsing (operators.ts:85). $..age (recursive descent) normalizes to $.age, $.a..b to $.a.b, and a stray leading space ( $.age) builds a garbage two-segment selector — all silently querying a different path than the user wrote and returning empty/wrong rows with no error. Since v1 already rejects array/wildcard with a clear message, extend the same treatment to .., empty interior segments, and non-$-anchored input (trim + validate).

  • Cache the value-independent selector hash (operators.ts:683). The LHS encryptQuery(jsonPathOf(segments), …) depends only on (column, path), not the value, yet it's re-encrypted on every predicate — so a bounded range .gt(21) + .lt(65) on the same path pays two identical selector round-trips, and N predicates on one path pay N. Memoize per (column, path) alongside the existing contextCache.

  • Make the ciphertext-in-WHERE caveat prominent where users actually look. The interim design puts the needle's plaintext-derived ciphertext into the WHERE clause (visible in query logs / pg_stat_statements). It's well-explained in the code comment, but the SKILL.md capability table flips Json to "selector-with-constraint queries" reading as fully done, with the caveat only in prose below. Surface the interim/leakage note in the table row itself, and (as you're already planning) hold the doc flip until live CI is green and ideally until #137 lands.

  • Minor: selectorCompare's operator label param is derivable from op + path; reconstructSelectorDocument's cursor loop reads more simply as a reduceRight; and the new as never casts (operators.ts:686/691/697/701) match the file's existing style but don't carry the // biome-ignore lint/plugin: <reason> justification the repo CLAUDE.md asks for on type-erasing assertions in src/.

To be clear on scope: the entry-vs-entry SQL correctness (whether the bundle ships eql_v3.<op>(eql_v3_jsonb_entry, eql_v3_jsonb_entry) overloads and whether the stored hm/op terms match the needle's) is only provable by the live integration job — which the PR correctly gates on, and which is green. My review covers the SDK layer above that.

Must-fixes:
- test:types was broken (operators.test-d.ts): adding the required `encrypt`
  member to OperandEncryptionClient made the `{ encryptQuery }`-only doubles
  unassignable. Add `encrypt` to both doubles — and to the `erased` double too,
  so it's rejected ONLY for the #622 encryptQuery-erasure reason (restoring the
  guard's teeth), not for a missing member.
- Re-add the scalar-leaf-type guard dropped in the storage rewrite: reject
  non-scalar leaf values (object/array → use contains()) and ordering a boolean,
  up front as EncryptionOperatorError instead of a deferred opaque DB error.
- Define + test + document `ne` absent-path semantics: a row lacking the path is
  now INCLUDED in `.ne(x)` (OR jsonb_path_query_first(...) IS NULL); eq/ordering
  keep SQL's exclude-on-missing. Integration test seeds a row with no `$.age`.

CI gap (root cause the type break slipped through): the adapter packages'
`test:types` was never wired into CI after the #627 split — only @cipherstash/stack
and test-kit ran. Add stack-drizzle + stack-supabase type-test steps to tests.yml
(both green today).

Toby: path-validation errors now surface as EncryptionOperatorError with column
context; add unit tests for the pure helpers (parseSelectorSegments,
reconstructSelectorDocument) + the selector guards.

Also from freshtonic: harden path parsing — reject `..`/malformed/empty-segment
paths (no longer silently query a different path) and prototype-pollution keys
(__proto__/prototype/constructor); surface the interim ciphertext-in-WHERE caveat
in the skill capability-table row.

Follow-ups (noted, not in this PR): share core's path helpers via adapter-kit;
memoize the value-independent selector hash per (column, path).

Refs #623 · protect-ffi gap cipherstash/protectjs-ffi#137
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan

Copy link
Copy Markdown
Contributor Author

Thanks both — really thorough. Addressed in 36ecb3b.

@freshtonic — must-fixes (all fixed)

  1. test:types break. Added encrypt to the { encryptQuery } doubles in operators.test-d.ts, and — per your knock-on — to the erased double too, so it's now rejected only for the encryptQuery-resolves-unknown erasure (the EQL v3 Drizzle: encrypt all query operands with encryptQuery (not client.encrypt) #622 guard has its teeth back), not for a missing member. test:types green locally.
    • Root cause it slipped past CI: you were right that test:types didn't catch it — but not because the double was fine; because CI never ran the adapter packages' type tests. After the Split the Drizzle and Supabase integrations into their own packages (stack-drizzle, stack-supabase) #627 split, only @cipherstash/stack + test-kit were wired into the test:types step; stack-drizzle and stack-supabase (both have .test-d.ts guards) were left out. Fixed the gap — added both to tests.yml (both green today), so this class of regression is caught going forward.
  2. Scalar-leaf-type guard restored. Non-scalar leaves (object/array → "use contains()") and ordering a boolean now throw EncryptionOperatorError up front, not a deferred DB error.
  3. ne absent-path semantics defined, documented, and tested: a row lacking the path is now included in .ne(x) (OR jsonb_path_query_first(...) IS NULL); eq/ordering keep SQL's exclude-on-missing. Integration suite seeds a noage row and asserts eq excludes / ne includes it.

Follow-ups: also did the path-hardening (../malformed/empty-segment rejection, __proto__/prototype/constructor forbidden keys) and the table-row ciphertext caveat now, since I was in the code. Left the helper-dedup-via-adapter-kit and the selector-hash memoization as noted follow-ups.

@tobyhede

  • Path-validation errors now surface as EncryptionOperatorError (with columnName/tableName/operator context), wrapped at the selectorCompare boundary so parseSelectorSegments stays a pure, testable function.
  • Added unit tests for the pure helpers (parseSelectorSegments, reconstructSelectorDocument) and the ops.selector guards — __tests__/v3/selector.test.ts (11 tests, no DB needed).
  • The stale JSDoc you quoted was fixed in the prior commit (b5b18c3).

368 unit tests + 7 type tests pass; Biome clean.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 36ecb3b0. All three items from my previous review are resolved, and I verified the fixes rather than eyeballing them — checked out the branch, installed deps, and ran the drizzle typecheck + unit suite locally; both Drizzle v3 integration jobs (postgres + supabase) are also green on this head. Approving.

Confirmed resolved

  1. Type-double regression guardoperators.test-d.ts now supplies encrypt on both doubles (and the erased double gets a correct encrypt so its @ts-expect-error is again driven only by the encryptQuery-erasure, restoring the #622 guard's teeth). You also wired stack-drizzle (and stack-supabase) into the CI test:types matrix — the deeper fix, since those .test-d.ts guards moved out of @cipherstash/stack in the #627 split and were running nowhere. I ran pnpm --filter @cipherstash/stack-drizzle test:types: 7/7 pass, no type errors (it failed before this commit).

  2. Leaf-value-type guardunsupportedLeafReason re-establishes the up-front check: non-scalars are rejected with "use contains()", and a boolean under an ordering op is rejected with a clear message, while eq/ne on a boolean is still allowed. Raised as an EncryptionOperatorError with column context before any encryption. Covered by selector.test.ts (rejects object/array leaf, rejects gt(true), allows eq(true)), which I ran green.

  3. ne / absent-path semantics — now emits (eql_v3.neq(...) OR leftEntry IS NULL), so a row whose document lacks the path is included in .ne(x) ("not equal" covers "has no value"), while eq/ordering keep SQL's default exclusion. Documented in the jsdoc, and — importantly — the live integration suite now seeds a noage row and asserts all three behaviors (eq excludes, ne includes, ordering excludes). The green integration jobs confirm end-to-end what I'd previously only inferred from three-valued-logic reasoning. Nicely, the (A OR B) fragment is self-parenthesising, so ops.not(...) over it stays correct.

You also folded in several follow-ups I'd flagged for "later": malformed-path rejection (.., stray/leading/trailing dots, whitespace trim — no more silent path collapse), prototype-pollution key rejection (__proto__/prototype/constructor, realigning with core's FORBIDDEN_KEYS), and the interim ciphertext-in-WHERE caveat now surfaced inline in the SKILL.md capability-table row. All unit-tested.

Still open (non-blocking, subsequent PRs)

  • Share core's path helpers via adapter-kit instead of the fork — you've left the parseSelectorSegments/jsonPathOf/reconstructSelectorDocument fork in place with a comment pointing at this follow-up, which is fine. The reason it's still worth doing: selector correctness depends on this normalization staying identical to whatever core uses to build the stored ste_vec selectors, and there are now three copies (core, packages/protect, here). Exporting the core helpers and deleting the fork removes the drift risk for good.
  • Cache the value-independent selector hash — the LHS encryptQuery(jsonPathOf(segments), …) depends only on (column, path), so N predicates on the same path still pay N identical selector round-trips; memoizing alongside contextCache would drop that to one.
  • Minor / worth a quick check: unsupportedLeafReason accepts bigint as a scalar leaf — worth confirming a bigint JSON leaf actually round-trips through the storage-needle encrypt (a plain JSON.stringify throws on bigint), otherwise it's exactly the kind of deferred error the guard exists to prevent. I did not verify this path, so treat it as a question, not a claim. Also cosmetic: selectorCompare's operator label arg is still derivable from op+path, and the new as never casts match the file's style but lack the // biome-ignore lint/plugin: <reason> the repo CLAUDE.md asks for.

Thanks for the thorough turnaround — and again, apologies for the "breaks CI" overstatement in my first pass; the type failure was real but, as you correctly noted, not CI-gated at the time. It is now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants